diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteReceiver.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteReceiver.kt index 558fd0a0cd4..e7ad4c2b5b2 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteReceiver.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteReceiver.kt @@ -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, + 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 + 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) val notificationManagerCompat = NotificationManagerCompat.from(context) @@ -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) } } diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorker.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorker.kt new file mode 100644 index 00000000000..65e7d57c4de --- /dev/null +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorker.kt @@ -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, + eventDataValues: Array, + ) { + 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() + .setInputData(data) + // We want the event to be sent right away if it is possible + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .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() + 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() + } + } +} diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationFunctions.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationFunctions.kt index faf15eaa6ec..ef8279dce2c 100755 --- a/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationFunctions.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/notifications/NotificationFunctions.kt @@ -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 @@ -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) } diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorkerTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorkerTest.kt new file mode 100644 index 00000000000..512a917a648 --- /dev/null +++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/notifications/NotificationDeleteWorkerTest.kt @@ -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) { + 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