Skip to content

Commit eecb59e

Browse files
committed
Use Worker to do heavy work instead of Receiver for NotificationDelete
1 parent 4a84f0f commit eecb59e

File tree

4 files changed

+269
-55
lines changed

4 files changed

+269
-55
lines changed
Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,60 @@
11
package io.homeassistant.companion.android.common.notifications
22

3+
import android.app.PendingIntent
34
import android.content.BroadcastReceiver
45
import android.content.Context
56
import android.content.Intent
6-
import android.os.Build
77
import androidx.core.app.NotificationManagerCompat
8-
import dagger.hilt.android.AndroidEntryPoint
9-
import io.homeassistant.companion.android.common.data.servers.ServerManager
108
import io.homeassistant.companion.android.common.util.cancelGroupIfNeeded
11-
import io.homeassistant.companion.android.database.notification.NotificationDao
12-
import javax.inject.Inject
13-
import kotlinx.coroutines.CoroutineScope
14-
import kotlinx.coroutines.Dispatchers
15-
import kotlinx.coroutines.Job
16-
import kotlinx.coroutines.launch
17-
import timber.log.Timber
189

19-
@AndroidEntryPoint
2010
class NotificationDeleteReceiver : BroadcastReceiver() {
2111
companion object {
22-
const val EXTRA_DATA = "EXTRA_DATA"
23-
const val EXTRA_NOTIFICATION_GROUP = "EXTRA_NOTIFICATION_GROUP"
24-
const val EXTRA_NOTIFICATION_GROUP_ID = "EXTRA_NOTIFICATION_GROUP_ID"
25-
const val EXTRA_NOTIFICATION_DB = "EXTRA_NOTIFICATION_DB"
26-
}
27-
28-
private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job())
29-
30-
@Inject
31-
lateinit var serverManager: ServerManager
12+
private const val EXTRA_DATA_KEYS = "EXTRA_DATA_KEYS"
13+
private const val EXTRA_DATA_VALUES = "EXTRA_DATA_VALUES"
14+
private const val EXTRA_NOTIFICATION_GROUP = "EXTRA_NOTIFICATION_GROUP"
15+
private const val EXTRA_NOTIFICATION_GROUP_ID = "EXTRA_NOTIFICATION_GROUP_ID"
16+
private const val EXTRA_NOTIFICATION_DB = "EXTRA_NOTIFICATION_DB"
3217

33-
@Inject
34-
lateinit var notificationDao: NotificationDao
18+
/**
19+
* Creates a [PendingIntent] that fires a notification cleared event when triggered.
20+
*
21+
* @param context The context to use for creating the intent.
22+
* @param data The event data to send to the Home Assistant server.
23+
* @param messageId The unique ID for the PendingIntent request code.
24+
* @param group The notification group name, if any.
25+
* @param groupId The notification group ID.
26+
* @param databaseId The database ID of the notification.
27+
*/
28+
fun createDeletePendingIntent(
29+
context: Context,
30+
data: Map<String, String>,
31+
messageId: Int,
32+
group: String?,
33+
groupId: Int,
34+
databaseId: Long?,
35+
): PendingIntent {
36+
val deleteIntent = Intent(context, NotificationDeleteReceiver::class.java).apply {
37+
putExtra(EXTRA_DATA_KEYS, data.keys.toTypedArray())
38+
putExtra(EXTRA_DATA_VALUES, data.values.toTypedArray())
39+
putExtra(EXTRA_NOTIFICATION_GROUP, group)
40+
putExtra(EXTRA_NOTIFICATION_GROUP_ID, groupId)
41+
putExtra(EXTRA_NOTIFICATION_DB, databaseId)
42+
}
43+
return PendingIntent.getBroadcast(
44+
context,
45+
messageId,
46+
deleteIntent,
47+
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
48+
)
49+
}
50+
}
3551

36-
@Suppress("UNCHECKED_CAST")
3752
override fun onReceive(context: Context, intent: Intent) {
38-
val hashData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
39-
intent.getSerializableExtra(EXTRA_DATA, HashMap::class.java)
40-
} else {
41-
@Suppress("DEPRECATION")
42-
intent.getSerializableExtra(EXTRA_DATA)
43-
} as HashMap<String, *>
53+
val eventDataKeys = intent.getStringArrayExtra(EXTRA_DATA_KEYS) ?: emptyArray()
54+
val eventDataValues = intent.getStringArrayExtra(EXTRA_DATA_VALUES) ?: emptyArray()
4455
val group = intent.getStringExtra(EXTRA_NOTIFICATION_GROUP)
4556
val groupId = intent.getIntExtra(EXTRA_NOTIFICATION_GROUP_ID, -1)
57+
val databaseId = intent.getLongExtra(EXTRA_NOTIFICATION_DB, 0)
4658

4759
val notificationManagerCompat = NotificationManagerCompat.from(context)
4860

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

54-
ioScope.launch {
55-
try {
56-
val databaseId = intent.getLongExtra(EXTRA_NOTIFICATION_DB, 0)
57-
val serverId = notificationDao.get(databaseId.toInt())?.serverId ?: ServerManager.SERVER_ID_ACTIVE
58-
59-
serverManager.integrationRepository(serverId).fireEvent("mobile_app_notification_cleared", hashData)
60-
Timber.d("Notification cleared event successful!")
61-
} catch (e: Exception) {
62-
Timber.e(e, "Issue sending event to Home Assistant")
63-
}
64-
}
66+
NotificationDeleteWorker.enqueue(context, databaseId, eventDataKeys, eventDataValues)
6567
}
6668
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package io.homeassistant.companion.android.common.notifications
2+
3+
import android.content.Context
4+
import androidx.work.CoroutineWorker
5+
import androidx.work.Data
6+
import androidx.work.OneTimeWorkRequestBuilder
7+
import androidx.work.WorkManager
8+
import androidx.work.WorkerParameters
9+
import dagger.hilt.EntryPoint
10+
import dagger.hilt.EntryPoints
11+
import dagger.hilt.InstallIn
12+
import dagger.hilt.components.SingletonComponent
13+
import io.homeassistant.companion.android.common.data.servers.ServerManager
14+
import io.homeassistant.companion.android.database.notification.NotificationDao
15+
import kotlinx.coroutines.CancellationException
16+
import timber.log.Timber
17+
18+
/**
19+
* Worker that fires the "mobile_app_notification_cleared" event to the Home Assistant server.
20+
*/
21+
internal class NotificationDeleteWorker(context: Context, params: WorkerParameters) :
22+
CoroutineWorker(context.applicationContext, params) {
23+
24+
companion object {
25+
private const val KEY_DATABASE_ID = "database_id"
26+
private const val KEY_EVENT_DATA_KEYS = "event_data_keys"
27+
private const val KEY_EVENT_DATA_VALUES = "event_data_values"
28+
29+
/**
30+
* A bug in the AndroidX Hilt compiler that caused a StackOverflow in our codebase
31+
* tracked in https://github.com/google/dagger/issues/4702 forces us to use an entry point.
32+
*/
33+
@EntryPoint
34+
@InstallIn(SingletonComponent::class)
35+
internal interface NotificationDeleteWorkerEntryPoint {
36+
fun serverManager(): ServerManager
37+
fun notificationDao(): NotificationDao
38+
}
39+
40+
/**
41+
* Enqueues work to fire the notification delete event to the Home Assistant server.
42+
*
43+
* @param context The context to use for obtaining [WorkManager].
44+
* @param databaseId The database ID of the notification that was cleared.
45+
* @param eventDataKeys The keys of the event data to send to the server.
46+
* @param eventDataValues The values of the event data to send to the server, matching [eventDataKeys] by index.
47+
*/
48+
internal fun enqueue(
49+
context: Context,
50+
databaseId: Long,
51+
eventDataKeys: Array<String?>,
52+
eventDataValues: Array<String?>,
53+
) {
54+
val data = Data.Builder()
55+
.putLong(KEY_DATABASE_ID, databaseId)
56+
.putStringArray(KEY_EVENT_DATA_KEYS, eventDataKeys)
57+
.putStringArray(KEY_EVENT_DATA_VALUES, eventDataValues)
58+
.build()
59+
60+
val request = OneTimeWorkRequestBuilder<NotificationDeleteWorker>()
61+
.setInputData(data)
62+
.build()
63+
64+
WorkManager.getInstance(context).enqueue(request)
65+
}
66+
}
67+
68+
override suspend fun doWork(): Result {
69+
val databaseId = inputData.getLong(KEY_DATABASE_ID, 0)
70+
val keys = inputData.getStringArray(KEY_EVENT_DATA_KEYS) ?: return Result.failure()
71+
val values = inputData.getStringArray(KEY_EVENT_DATA_VALUES) ?: return Result.failure()
72+
73+
val entryPoints = EntryPoints.get(applicationContext, NotificationDeleteWorkerEntryPoint::class.java)
74+
val serverManager = entryPoints.serverManager()
75+
val notificationDao = entryPoints.notificationDao()
76+
77+
return try {
78+
val eventData = keys.zip(values).toMap()
79+
val serverId = notificationDao.get(databaseId.toInt())?.serverId ?: ServerManager.SERVER_ID_ACTIVE
80+
serverManager.integrationRepository(serverId).fireEvent("mobile_app_notification_cleared", eventData)
81+
Timber.d("Notification cleared event successful")
82+
Result.success()
83+
} catch (e: CancellationException) {
84+
throw e
85+
} catch (e: Exception) {
86+
Timber.e(e, "Issue sending notification cleared event to Home Assistant")
87+
Result.failure()
88+
}
89+
}
90+
}

common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationFunctions.kt

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ package io.homeassistant.companion.android.common.notifications
22

33
import android.app.NotificationChannel
44
import android.app.NotificationManager
5-
import android.app.PendingIntent
65
import android.content.Context
7-
import android.content.Intent
86
import android.graphics.Color
97
import android.graphics.PorterDuff
108
import android.graphics.PorterDuffColorFilter
@@ -276,17 +274,14 @@ fun handleDeleteIntent(
276274
groupId: Int,
277275
databaseId: Long?,
278276
) {
279-
val deleteIntent = Intent(context, NotificationDeleteReceiver::class.java).apply {
280-
putExtra(NotificationDeleteReceiver.EXTRA_DATA, HashMap(data))
281-
putExtra(NotificationDeleteReceiver.EXTRA_NOTIFICATION_GROUP, group)
282-
putExtra(NotificationDeleteReceiver.EXTRA_NOTIFICATION_GROUP_ID, groupId)
283-
putExtra(NotificationDeleteReceiver.EXTRA_NOTIFICATION_DB, databaseId)
284-
}
285-
val deletePendingIntent = PendingIntent.getBroadcast(
286-
context,
287-
messageId,
288-
deleteIntent,
289-
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
277+
builder.setDeleteIntent(
278+
NotificationDeleteReceiver.createDeletePendingIntent(
279+
context = context,
280+
data = data,
281+
messageId = messageId,
282+
group = group,
283+
groupId = groupId,
284+
databaseId = databaseId,
285+
),
290286
)
291-
builder.setDeleteIntent(deletePendingIntent)
292287
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package io.homeassistant.companion.android.common.notifications
2+
3+
import android.content.Context
4+
import androidx.work.Data
5+
import androidx.work.ListenableWorker
6+
import androidx.work.WorkerParameters
7+
import dagger.hilt.EntryPoints
8+
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
9+
import io.homeassistant.companion.android.common.data.servers.ServerManager
10+
import io.homeassistant.companion.android.common.notifications.NotificationDeleteWorker.Companion.NotificationDeleteWorkerEntryPoint
11+
import io.homeassistant.companion.android.database.notification.NotificationDao
12+
import io.homeassistant.companion.android.database.notification.NotificationItem
13+
import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
14+
import io.mockk.coEvery
15+
import io.mockk.coVerify
16+
import io.mockk.every
17+
import io.mockk.mockk
18+
import io.mockk.mockkStatic
19+
import kotlinx.coroutines.test.runTest
20+
import org.junit.jupiter.api.Assertions.assertEquals
21+
import org.junit.jupiter.api.BeforeEach
22+
import org.junit.jupiter.api.Test
23+
import org.junit.jupiter.api.extension.ExtendWith
24+
25+
@ExtendWith(ConsoleLogExtension::class)
26+
class NotificationDeleteWorkerTest {
27+
28+
private val serverManager: ServerManager = mockk()
29+
private val notificationDao: NotificationDao = mockk()
30+
private val integrationRepository: IntegrationRepository = mockk(relaxed = true)
31+
private val context: Context = mockk()
32+
private val workerParams: WorkerParameters = mockk(relaxed = true)
33+
34+
@BeforeEach
35+
fun setup() {
36+
every { context.applicationContext } returns context
37+
coEvery { serverManager.integrationRepository(any()) } returns integrationRepository
38+
39+
mockkStatic(EntryPoints::class)
40+
every {
41+
EntryPoints.get(any(), NotificationDeleteWorkerEntryPoint::class.java)
42+
} returns mockk {
43+
every { serverManager() } returns serverManager
44+
every { notificationDao() } returns notificationDao
45+
}
46+
}
47+
48+
@Test
49+
fun `Given valid input when doWork then fire event and return success`() = runTest {
50+
val eventData = mapOf("action" to "cleared", "tag" to "test-tag")
51+
val databaseId = 42L
52+
val serverId = 5
53+
setupWorkerInput(databaseId = databaseId, eventData = eventData)
54+
coEvery { notificationDao.get(databaseId.toInt()) } returns notificationItem(serverId = serverId)
55+
56+
val worker = NotificationDeleteWorker(context, workerParams)
57+
val result = worker.doWork()
58+
59+
assertEquals(ListenableWorker.Result.success(), result)
60+
coVerify(exactly = 1) {
61+
serverManager.integrationRepository(serverId)
62+
integrationRepository.fireEvent("mobile_app_notification_cleared", eventData)
63+
}
64+
}
65+
66+
@Test
67+
fun `Given notification not in database when doWork then use active server and return success`() = runTest {
68+
val eventData = mapOf("action" to "cleared")
69+
val databaseId = 99L
70+
setupWorkerInput(databaseId = databaseId, eventData = eventData)
71+
coEvery { notificationDao.get(databaseId.toInt()) } returns null
72+
73+
val worker = NotificationDeleteWorker(context, workerParams)
74+
val result = worker.doWork()
75+
76+
assertEquals(ListenableWorker.Result.success(), result)
77+
coVerify(exactly = 1) {
78+
serverManager.integrationRepository(ServerManager.SERVER_ID_ACTIVE)
79+
integrationRepository.fireEvent("mobile_app_notification_cleared", eventData)
80+
}
81+
}
82+
83+
@Test
84+
fun `Given missing event data when doWork then return failure`() = runTest {
85+
every { workerParams.inputData } returns Data.Builder()
86+
.putLong("database_id", 1L)
87+
.build()
88+
89+
val worker = NotificationDeleteWorker(context, workerParams)
90+
val result = worker.doWork()
91+
92+
assertEquals(ListenableWorker.Result.failure(), result)
93+
coVerify(exactly = 0) { integrationRepository.fireEvent(any(), any()) }
94+
}
95+
96+
@Test
97+
fun `Given server throws when doWork then return failure`() = runTest {
98+
val eventData = mapOf("action" to "cleared")
99+
val databaseId = 42L
100+
setupWorkerInput(databaseId = databaseId, eventData = eventData)
101+
coEvery { notificationDao.get(databaseId.toInt()) } returns notificationItem(serverId = 1)
102+
coEvery { integrationRepository.fireEvent(any(), any()) } throws IllegalStateException("Server unavailable")
103+
104+
val worker = NotificationDeleteWorker(context, workerParams)
105+
val result = worker.doWork()
106+
107+
assertEquals(ListenableWorker.Result.failure(), result)
108+
}
109+
110+
private fun setupWorkerInput(databaseId: Long, eventData: Map<String, String>) {
111+
every { workerParams.inputData } returns Data.Builder()
112+
.putLong("database_id", databaseId)
113+
.putStringArray("event_data_keys", eventData.keys.toTypedArray())
114+
.putStringArray("event_data_values", eventData.values.toTypedArray())
115+
.build()
116+
}
117+
118+
private fun notificationItem(serverId: Int): NotificationItem =
119+
NotificationItem(
120+
id = 1,
121+
received = 0L,
122+
message = "test",
123+
data = "{}",
124+
source = "test",
125+
serverId = serverId,
126+
)
127+
}

0 commit comments

Comments
 (0)