Skip to content

Introduce NotificationDeleteWorker#6460

Draft
TimoPtr wants to merge 2 commits intomainfrom
feature/notification_delete_worker
Draft

Introduce NotificationDeleteWorker#6460
TimoPtr wants to merge 2 commits intomainfrom
feature/notification_delete_worker

Conversation

@TimoPtr
Copy link
Copy Markdown
Member

@TimoPtr TimoPtr commented Feb 18, 2026

Summary

BroadcastReceiver should remain small since it is meant to have a short life we should migrate to the worker API. This is the first PR to showcase how we could do it for the NotificationDelete.

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

Any other notes

It is based on #6459

@jpelgrom
Copy link
Copy Markdown
Member

Following from my comment #6459 (comment):

I think this instance should be adjusted to avoid hitting 30s in the first place. WorkManager is for deferrable tasks - events like these should fire immediately.

@TimoPtr
Copy link
Copy Markdown
Member Author

TimoPtr commented Feb 18, 2026

Following from my comment #6459 (comment):

I think this instance should be adjusted to avoid hitting 30s in the first place. WorkManager is for deferrable tasks - events like these should fire immediately.

The issue is that even if we reduce the timeout, if I'm not wrong frireEvent might try the first Url fail and try the second one if there are any and even maybe a third one? Internal/cloud/external?

Base automatically changed from revert/go_async_6396 to main February 19, 2026 08:09
@TimoPtr TimoPtr force-pushed the feature/notification_delete_worker branch from eecb59e to 840b363 Compare February 19, 2026 10:14
Copilot AI review requested due to automatic review settings February 19, 2026 10:14
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.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request migrates notification deletion handling from a long-lived BroadcastReceiver pattern to WorkManager by introducing NotificationDeleteWorker. The change addresses Android's recommendation that BroadcastReceivers should have short lifetimes (less than 30 seconds), moving the network call to fire the notification cleared event to a CoroutineWorker which is better suited for potentially long-running background tasks.

Changes:

  • Introduced NotificationDeleteWorker to handle notification cleared events asynchronously via WorkManager
  • Refactored NotificationDeleteReceiver to delegate work to the worker after handling immediate UI operations (canceling empty notification groups)
  • Changed data serialization from HashMap to separate key/value arrays to work with WorkManager's Data API
  • Simplified handleDeleteIntent in NotificationFunctions.kt to use the new createDeletePendingIntent method

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorker.kt New CoroutineWorker that fires notification cleared events to Home Assistant server using Hilt EntryPoint pattern for dependency injection
common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteReceiver.kt Refactored to extract PendingIntent creation to a companion function and delegate event firing to NotificationDeleteWorker
common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationFunctions.kt Simplified to use the new createDeletePendingIntent helper method, removing direct PendingIntent creation
common/src/test/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorkerTest.kt Comprehensive unit tests covering success cases, failure cases, null handling, and error scenarios

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.
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.

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.
Comment on lines +26 to +127
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,
)
} No newline at end of file
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.
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.
@TimoPtr TimoPtr marked this pull request as draft March 26, 2026 09:56
@TimoPtr
Copy link
Copy Markdown
Member Author

TimoPtr commented Mar 26, 2026

@jpelgrom I don't know yet what to do for these ANR I'm not sure there is a "right" way

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants