Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,48 +1,60 @@
package io.homeassistant.companion.android.common.notifications

import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.util.cancelGroupIfNeeded
import io.homeassistant.companion.android.database.notification.NotificationDao
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import timber.log.Timber

@AndroidEntryPoint
class NotificationDeleteReceiver : BroadcastReceiver() {
companion object {
const val EXTRA_DATA = "EXTRA_DATA"
const val EXTRA_NOTIFICATION_GROUP = "EXTRA_NOTIFICATION_GROUP"
const val EXTRA_NOTIFICATION_GROUP_ID = "EXTRA_NOTIFICATION_GROUP_ID"
const val EXTRA_NOTIFICATION_DB = "EXTRA_NOTIFICATION_DB"
}

private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job())

@Inject
lateinit var serverManager: ServerManager
private const val EXTRA_DATA_KEYS = "EXTRA_DATA_KEYS"
private const val EXTRA_DATA_VALUES = "EXTRA_DATA_VALUES"
private const val EXTRA_NOTIFICATION_GROUP = "EXTRA_NOTIFICATION_GROUP"
private const val EXTRA_NOTIFICATION_GROUP_ID = "EXTRA_NOTIFICATION_GROUP_ID"
private const val EXTRA_NOTIFICATION_DB = "EXTRA_NOTIFICATION_DB"

@Inject
lateinit var notificationDao: NotificationDao
/**
* Creates a [PendingIntent] that fires a notification cleared event when triggered.
*
* @param context The context to use for creating the intent.
* @param data The event data to send to the Home Assistant server.
* @param messageId The unique ID for the PendingIntent request code.
* @param group The notification group name, if any.
* @param groupId The notification group ID.
* @param databaseId The database ID of the notification.
*/
fun createDeletePendingIntent(
context: Context,
data: Map<String, String>,
messageId: Int,
group: String?,
groupId: Int,
databaseId: Long?,
): PendingIntent {
val deleteIntent = Intent(context, NotificationDeleteReceiver::class.java).apply {
putExtra(EXTRA_DATA_KEYS, data.keys.toTypedArray())
putExtra(EXTRA_DATA_VALUES, data.values.toTypedArray())
putExtra(EXTRA_NOTIFICATION_GROUP, group)
putExtra(EXTRA_NOTIFICATION_GROUP_ID, groupId)
putExtra(EXTRA_NOTIFICATION_DB, databaseId)
}
return PendingIntent.getBroadcast(
context,
messageId,
deleteIntent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}

@Suppress("UNCHECKED_CAST")
override fun onReceive(context: Context, intent: Intent) {
val hashData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getSerializableExtra(EXTRA_DATA, HashMap::class.java)
} else {
@Suppress("DEPRECATION")
intent.getSerializableExtra(EXTRA_DATA)
} as HashMap<String, *>
val eventDataKeys = intent.getStringArrayExtra(EXTRA_DATA_KEYS) ?: emptyArray()
val eventDataValues = intent.getStringArrayExtra(EXTRA_DATA_VALUES) ?: emptyArray()
val group = intent.getStringExtra(EXTRA_NOTIFICATION_GROUP)
val groupId = intent.getIntExtra(EXTRA_NOTIFICATION_GROUP_ID, -1)
val databaseId = intent.getLongExtra(EXTRA_NOTIFICATION_DB, 0)
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When databaseId is null, putExtra will store null, but getLongExtra will return the default value of 0 regardless of whether null was stored or the key is missing. This means you cannot distinguish between a missing database ID and an actual ID of 0. Consider using hasExtra() to check if the key exists before retrieving the value, or handle the null case explicitly by storing a sentinel value like -1 for missing database IDs.

Copilot uses AI. Check for mistakes.

val notificationManagerCompat = NotificationManagerCompat.from(context)

Expand All @@ -51,16 +63,6 @@ class NotificationDeleteReceiver : BroadcastReceiver() {
// Then only the empty group is left and needs to be cancelled
notificationManagerCompat.cancelGroupIfNeeded(group, groupId)

ioScope.launch {
try {
val databaseId = intent.getLongExtra(EXTRA_NOTIFICATION_DB, 0)
val serverId = notificationDao.get(databaseId.toInt())?.serverId ?: ServerManager.SERVER_ID_ACTIVE

serverManager.integrationRepository(serverId).fireEvent("mobile_app_notification_cleared", hashData)
Timber.d("Notification cleared event successful!")
} catch (e: Exception) {
Timber.e(e, "Issue sending event to Home Assistant")
}
}
NotificationDeleteWorker.enqueue(context, databaseId, eventDataKeys, eventDataValues)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package io.homeassistant.companion.android.common.notifications

import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.notification.NotificationDao
import kotlinx.coroutines.CancellationException
import timber.log.Timber

/**
* Worker that fires the "mobile_app_notification_cleared" event to the Home Assistant server.
*/
internal class NotificationDeleteWorker(context: Context, params: WorkerParameters) :
CoroutineWorker(context.applicationContext, params) {

companion object {
private const val KEY_DATABASE_ID = "database_id"
private const val KEY_EVENT_DATA_KEYS = "event_data_keys"
private const val KEY_EVENT_DATA_VALUES = "event_data_values"

/**
* A bug in the AndroidX Hilt compiler that caused a StackOverflow in our codebase
* tracked in https://github.com/google/dagger/issues/4702 forces us to use an entry point.
*/
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface NotificationDeleteWorkerEntryPoint {
fun serverManager(): ServerManager
fun notificationDao(): NotificationDao
}

/**
* Enqueues work to fire the notification delete event to the Home Assistant server.
*
* @param context The context to use for obtaining [WorkManager].
* @param databaseId The database ID of the notification that was cleared.
* @param eventDataKeys The keys of the event data to send to the server.
* @param eventDataValues The values of the event data to send to the server, matching [eventDataKeys] by index.
*/
internal fun enqueue(
context: Context,
databaseId: Long,
eventDataKeys: Array<String?>,
eventDataValues: Array<String?>,
) {
val data = Data.Builder()
.putLong(KEY_DATABASE_ID, databaseId)
.putStringArray(KEY_EVENT_DATA_KEYS, eventDataKeys)
.putStringArray(KEY_EVENT_DATA_VALUES, eventDataValues)
.build()

val request = OneTimeWorkRequestBuilder<NotificationDeleteWorker>()
.setInputData(data)
// We want the event to be sent right away if it is possible
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Marks the WorkRequest as important to the user. In this case, WorkManager provides an additional signal to the OS that this work is important.
Note that although the execution time of this work won't be counted against your app's quota while your app is in the foreground, if the expedited work continues in the background, you are susceptible to quota. However, power management restrictions, such as Battery Saver and Doze, are less likely to affect expedited work. Because of this, expedited work is best suited for short tasks which need to start immediately and are important to the user or user-initiated.

.build()

WorkManager.getInstance(context).enqueue(request)
}
}

override suspend fun doWork(): Result {
val databaseId = inputData.getLong(KEY_DATABASE_ID, 0)
val keys = inputData.getStringArray(KEY_EVENT_DATA_KEYS) ?: return Result.failure()
val values = inputData.getStringArray(KEY_EVENT_DATA_VALUES) ?: return Result.failure()

val entryPoints = EntryPoints.get(applicationContext, NotificationDeleteWorkerEntryPoint::class.java)
val serverManager = entryPoints.serverManager()
val notificationDao = entryPoints.notificationDao()

return try {
val eventData = keys.zip(values).toMap()
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no validation that keys and values arrays have the same length before calling zip. If the arrays have different lengths, zip will silently truncate to the shorter array's length, potentially losing event data. Consider adding a check to return Result.failure() if the lengths don't match, or log a warning if this is intentional behavior.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type safety issue: getStringArray returns Array<String>? where elements can be null according to Android's API contract, even though the original data from Map.keys.toTypedArray() contains only non-null strings. When calling keys.zip(values).toMap(), null elements would create a Map<String?, String?>, but fireEvent expects Map<String, Any> with non-nullable keys and values. The code should filter out nulls before creating the map or use mapNotNull and filterNotNull to ensure type safety, for example: val eventData = keys.filterNotNull().zip(values.filterNotNull()).toMap().

Suggested change
val eventData = keys.zip(values).toMap()
val eventData = keys
.zip(values)
.mapNotNull { (key, value) ->
if (key != null && value != null) {
key to value
} else {
null
}
}
.toMap()

Copilot uses AI. Check for mistakes.
val serverId = notificationDao.get(databaseId.toInt())?.serverId ?: ServerManager.SERVER_ID_ACTIVE
serverManager.integrationRepository(serverId).fireEvent("mobile_app_notification_cleared", eventData)
Timber.d("Notification cleared event successful")
Result.success()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.e(e, "Issue sending notification cleared event to Home Assistant")
Result.failure()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ package io.homeassistant.companion.android.common.notifications

import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
Expand Down Expand Up @@ -276,17 +274,14 @@ fun handleDeleteIntent(
groupId: Int,
databaseId: Long?,
) {
val deleteIntent = Intent(context, NotificationDeleteReceiver::class.java).apply {
putExtra(NotificationDeleteReceiver.EXTRA_DATA, HashMap(data))
putExtra(NotificationDeleteReceiver.EXTRA_NOTIFICATION_GROUP, group)
putExtra(NotificationDeleteReceiver.EXTRA_NOTIFICATION_GROUP_ID, groupId)
putExtra(NotificationDeleteReceiver.EXTRA_NOTIFICATION_DB, databaseId)
}
val deletePendingIntent = PendingIntent.getBroadcast(
context,
messageId,
deleteIntent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
builder.setDeleteIntent(
NotificationDeleteReceiver.createDeletePendingIntent(
context = context,
data = data,
messageId = messageId,
group = group,
groupId = groupId,
databaseId = databaseId,
),
)
builder.setDeleteIntent(deletePendingIntent)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package io.homeassistant.companion.android.common.notifications

import android.content.Context
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoints
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.common.notifications.NotificationDeleteWorker.Companion.NotificationDeleteWorkerEntryPoint
import io.homeassistant.companion.android.database.notification.NotificationDao
import io.homeassistant.companion.android.database.notification.NotificationItem
import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

@ExtendWith(ConsoleLogExtension::class)
class NotificationDeleteWorkerTest {

private val serverManager: ServerManager = mockk()
private val notificationDao: NotificationDao = mockk()
private val integrationRepository: IntegrationRepository = mockk(relaxed = true)
private val context: Context = mockk()
private val workerParams: WorkerParameters = mockk(relaxed = true)

@BeforeEach
fun setup() {
every { context.applicationContext } returns context
coEvery { serverManager.integrationRepository(any()) } returns integrationRepository

mockkStatic(EntryPoints::class)
every {
EntryPoints.get(any(), NotificationDeleteWorkerEntryPoint::class.java)
} returns mockk {
every { serverManager() } returns serverManager
every { notificationDao() } returns notificationDao
}
}

@Test
fun `Given valid input when doWork then fire event and return success`() = runTest {
val eventData = mapOf("action" to "cleared", "tag" to "test-tag")
val databaseId = 42L
val serverId = 5
setupWorkerInput(databaseId = databaseId, eventData = eventData)
coEvery { notificationDao.get(databaseId.toInt()) } returns notificationItem(serverId = serverId)

val worker = NotificationDeleteWorker(context, workerParams)
val result = worker.doWork()

assertEquals(ListenableWorker.Result.success(), result)
coVerify(exactly = 1) {
serverManager.integrationRepository(serverId)
integrationRepository.fireEvent("mobile_app_notification_cleared", eventData)
}
}

@Test
fun `Given notification not in database when doWork then use active server and return success`() = runTest {
val eventData = mapOf("action" to "cleared")
val databaseId = 99L
setupWorkerInput(databaseId = databaseId, eventData = eventData)
coEvery { notificationDao.get(databaseId.toInt()) } returns null

val worker = NotificationDeleteWorker(context, workerParams)
val result = worker.doWork()

assertEquals(ListenableWorker.Result.success(), result)
coVerify(exactly = 1) {
serverManager.integrationRepository(ServerManager.SERVER_ID_ACTIVE)
integrationRepository.fireEvent("mobile_app_notification_cleared", eventData)
}
}

@Test
fun `Given missing event data when doWork then return failure`() = runTest {
every { workerParams.inputData } returns Data.Builder()
.putLong("database_id", 1L)
.build()

val worker = NotificationDeleteWorker(context, workerParams)
val result = worker.doWork()

assertEquals(ListenableWorker.Result.failure(), result)
coVerify(exactly = 0) { integrationRepository.fireEvent(any(), any()) }
}

@Test
fun `Given server throws when doWork then return failure`() = runTest {
val eventData = mapOf("action" to "cleared")
val databaseId = 42L
setupWorkerInput(databaseId = databaseId, eventData = eventData)
coEvery { notificationDao.get(databaseId.toInt()) } returns notificationItem(serverId = 1)
coEvery { integrationRepository.fireEvent(any(), any()) } throws IllegalStateException("Server unavailable")

val worker = NotificationDeleteWorker(context, workerParams)
val result = worker.doWork()

assertEquals(ListenableWorker.Result.failure(), result)
}

private fun setupWorkerInput(databaseId: Long, eventData: Map<String, String>) {
every { workerParams.inputData } returns Data.Builder()
.putLong("database_id", databaseId)
.putStringArray("event_data_keys", eventData.keys.toTypedArray())
.putStringArray("event_data_values", eventData.values.toTypedArray())
.build()
}

private fun notificationItem(serverId: Int): NotificationItem =
NotificationItem(
id = 1,
received = 0L,
message = "test",
data = "{}",
source = "test",
serverId = serverId,
)
}
Comment on lines +26 to +127
Copy link

Copilot AI Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for the case where the keys and values arrays have different lengths. Since zip truncates to the shorter array's length, this edge case should be tested to ensure it's handled correctly (or documented as intended behavior).

Copilot uses AI. Check for mistakes.