diff --git a/app/build.gradle b/app/build.gradle index cf51265..def73b6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -53,4 +53,18 @@ dependencies { ksp 'androidx.room:room-compiler:2.7.2' implementation 'androidx.paging:paging-runtime:3.3.6' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + + // HTTP webhook delivery (offline-resilient via WorkManager constraints + backoff). + implementation 'androidx.work:work-runtime-ktx:2.10.0' + + testImplementation 'org.robolectric:robolectric:4.14.1' + testImplementation 'androidx.test.ext:junit:1.2.1' + testImplementation 'androidx.test:core:1.6.1' + testImplementation 'androidx.work:work-testing:2.10.0' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1' + + androidTestImplementation 'androidx.work:work-testing:2.10.0' + androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + androidTestImplementation 'androidx.test.ext:junit:1.2.1' } \ No newline at end of file diff --git a/app/schemas/de.jl.notificationlog.data.AppDatabase/7.json b/app/schemas/de.jl.notificationlog.data.AppDatabase/7.json new file mode 100644 index 0000000..c7fac3e --- /dev/null +++ b/app/schemas/de.jl.notificationlog.data.AppDatabase/7.json @@ -0,0 +1,298 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "7dd2b4e5b630d7ec6722adf02f8bbb9f", + "entities": [ + { + "tableName": "notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `package` TEXT NOT NULL, `time` INTEGER NOT NULL, `title` TEXT NOT NULL, `text` TEXT NOT NULL, `progress` INTEGER NOT NULL, `progress_max` INTEGER NOT NULL, `progress_indeterminate` INTEGER NOT NULL, `is_oldest_version` INTEGER NOT NULL, `is_newest_version` INTEGER NOT NULL, `duplicate_group_id` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressMax", + "columnName": "progress_max", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressIndeterminate", + "columnName": "progress_indeterminate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isOldestVersion", + "columnName": "is_oldest_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isNewestVersion", + "columnName": "is_newest_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duplicateGroupId", + "columnName": "duplicate_group_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "notifications_index_time", + "unique": false, + "columnNames": [ + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_index_time` ON `${TABLE_NAME}` (`time`)" + }, + { + "name": "notifications_index_app_and_time", + "unique": false, + "columnNames": [ + "package", + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_index_app_and_time` ON `${TABLE_NAME}` (`package`, `time`)" + }, + { + "name": "notifications_oldest_index_time", + "unique": false, + "columnNames": [ + "is_oldest_version", + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_oldest_index_time` ON `${TABLE_NAME}` (`is_oldest_version`, `time`)" + }, + { + "name": "notifications_oldest_index_app_and_time", + "unique": false, + "columnNames": [ + "is_oldest_version", + "package", + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_oldest_index_app_and_time` ON `${TABLE_NAME}` (`is_oldest_version`, `package`, `time`)" + }, + { + "name": "notifications_newest_index_time", + "unique": false, + "columnNames": [ + "is_newest_version", + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_newest_index_time` ON `${TABLE_NAME}` (`is_newest_version`, `time`)" + }, + { + "name": "notifications_newest_index_app_and_time", + "unique": false, + "columnNames": [ + "is_newest_version", + "package", + "time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_newest_index_app_and_time` ON `${TABLE_NAME}` (`is_newest_version`, `package`, `time`)" + }, + { + "name": "notifications_index_duplicate_group", + "unique": false, + "columnNames": [ + "duplicate_group_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_index_duplicate_group` ON `${TABLE_NAME}` (`duplicate_group_id`)" + }, + { + "name": "notifications_index_app_duplicate_group", + "unique": false, + "columnNames": [ + "package", + "duplicate_group_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `notifications_index_app_duplicate_group` ON `${TABLE_NAME}` (`package`, `duplicate_group_id`)" + } + ] + }, + { + "tableName": "active_notifications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_package_name` TEXT NOT NULL, `system_id` INTEGER NOT NULL, `system_tag` TEXT NOT NULL, `previous_notification_item_id` INTEGER NOT NULL, FOREIGN KEY(`previous_notification_item_id`) REFERENCES `notifications`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appPackageName", + "columnName": "app_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "systemId", + "columnName": "system_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "systemTag", + "columnName": "system_tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "previousNotificationItemId", + "columnName": "previous_notification_item_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "active_notifications_query_index", + "unique": true, + "columnNames": [ + "app_package_name", + "system_id", + "system_tag" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `active_notifications_query_index` ON `${TABLE_NAME}` (`app_package_name`, `system_id`, `system_tag`)" + }, + { + "name": "active_notification_previous_notification_item_index", + "unique": true, + "columnNames": [ + "previous_notification_item_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `active_notification_previous_notification_item_index` ON `${TABLE_NAME}` (`previous_notification_item_id`)" + } + ], + "foreignKeys": [ + { + "table": "notifications", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "previous_notification_item_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_webhook_deliveries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `notification_id` INTEGER NOT NULL, FOREIGN KEY(`notification_id`) REFERENCES `notifications`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notification_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "pending_webhook_deliveries_index_notification_id", + "unique": false, + "columnNames": [ + "notification_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `pending_webhook_deliveries_index_notification_id` ON `${TABLE_NAME}` (`notification_id`)" + } + ], + "foreignKeys": [ + { + "table": "notifications", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "notification_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7dd2b4e5b630d7ec6722adf02f8bbb9f')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/de/jl/notificationlog/webhook/WebhookE2ETest.kt b/app/src/androidTest/java/de/jl/notificationlog/webhook/WebhookE2ETest.kt new file mode 100644 index 0000000..bb5471d --- /dev/null +++ b/app/src/androidTest/java/de/jl/notificationlog/webhook/WebhookE2ETest.kt @@ -0,0 +1,95 @@ +package de.jl.notificationlog.webhook + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import de.jl.notificationlog.data.AppDatabase +import de.jl.notificationlog.util.Configuration +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + +/** + * Real-device E2E: real Room (with migration applied), real WorkManager + * (TestDriver to flip constraints), real HttpURLConnection against a local + * MockWebServer. + */ +@RunWith(AndroidJUnit4::class) +class WebhookE2ETest { + + private lateinit var server: MockWebServer + private lateinit var context: Context + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + server = MockWebServer().apply { start() } + + Configuration.with(context).apply { + webhookEnabled = true + webhookUrl = server.url("/topic").toString() + webhookBearerToken = "abc" + } + + // Clear pending rows from previous runs + val db = AppDatabase.with(context) + while (true) { + val n = db.pendingWebhookDelivery().peekOldestNotificationSync() ?: break + val pid = db.pendingWebhookDelivery().findPendingIdByNotificationSync(n.id) ?: break + db.pendingWebhookDelivery().deleteSync(pid) + } + // Note: Application.onCreate already initialized real WorkManager. We use it as-is and + // skip NetworkType.CONNECTED constraint in the test request — the worker code itself + // (drain loop, HTTP, deletion) is what we are exercising end-to-end. + } + + @After + fun teardown() { + server.shutdown() + } + + @Test + fun realWorkManagerDeliversNotificationViaConstraint() { + val db = AppDatabase.with(context) + val notifId = db.notification().insertSyncHandlePossibleDuplicate( + packageName = "com.test.e2e", time = System.currentTimeMillis(), + title = "E2E", text = "body", + progress = 0, progressMax = 0, progressIndeterminate = false, + isOldestVersion = true, isNewestVersion = true + ) + db.pendingWebhookDelivery().enqueueSync(notifId) + server.enqueue(MockResponse().setResponseCode(200)) + + val request = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(context).enqueue(request).result.get() + + val info = waitForWorker(request.id) + assertEquals(WorkInfo.State.SUCCEEDED, info.state) + + val req = server.takeRequest(5, TimeUnit.SECONDS) + assertTrue("server got the POST", req != null) + assertEquals("E2E", req!!.getHeader("Title")) + assertEquals("body", req.body.readUtf8()) + assertEquals(0, db.pendingWebhookDelivery().countSync()) + } + + private fun waitForWorker(id: java.util.UUID): WorkInfo { + val wm = WorkManager.getInstance(context) + val deadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < deadline) { + val info = wm.getWorkInfoById(id).get() + if (info != null && info.state.isFinished) return info + Thread.sleep(200) + } + return wm.getWorkInfoById(id).get()!! + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 809270a..b1a54f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ xmlns:tools="http://schemas.android.com/tools"> + + + diff --git a/app/src/main/java/de/jl/notificationlog/data/dao/PendingWebhookDeliveryDao.kt b/app/src/main/java/de/jl/notificationlog/data/dao/PendingWebhookDeliveryDao.kt new file mode 100644 index 0000000..3b3d026 --- /dev/null +++ b/app/src/main/java/de/jl/notificationlog/data/dao/PendingWebhookDeliveryDao.kt @@ -0,0 +1,33 @@ +package de.jl.notificationlog.data.dao + +import androidx.room.Dao +import androidx.room.Query +import de.jl.notificationlog.data.item.NotificationItem + +@Dao +abstract class PendingWebhookDeliveryDao { + @Query("INSERT INTO pending_webhook_deliveries (notification_id) VALUES (:notificationId)") + abstract fun enqueueSync(notificationId: Long): Long + + @Query(""" + SELECT n.* FROM pending_webhook_deliveries p + JOIN notifications n ON n.id = p.notification_id + ORDER BY p.id ASC + LIMIT 1 + """) + abstract fun peekOldestNotificationSync(): NotificationItem? + + @Query(""" + SELECT id FROM pending_webhook_deliveries + WHERE notification_id = :notificationId + ORDER BY id ASC + LIMIT 1 + """) + abstract fun findPendingIdByNotificationSync(notificationId: Long): Long? + + @Query("DELETE FROM pending_webhook_deliveries WHERE id = :pendingId") + abstract fun deleteSync(pendingId: Long) + + @Query("SELECT COUNT(*) FROM pending_webhook_deliveries") + abstract fun countSync(): Int +} diff --git a/app/src/main/java/de/jl/notificationlog/data/item/PendingWebhookDelivery.kt b/app/src/main/java/de/jl/notificationlog/data/item/PendingWebhookDelivery.kt new file mode 100644 index 0000000..94a4a52 --- /dev/null +++ b/app/src/main/java/de/jl/notificationlog/data/item/PendingWebhookDelivery.kt @@ -0,0 +1,30 @@ +package de.jl.notificationlog.data.item + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "pending_webhook_deliveries", + indices = [ + Index(name = "pending_webhook_deliveries_index_notification_id", value = ["notification_id"]) + ], + foreignKeys = [ + ForeignKey( + entity = NotificationItem::class, + parentColumns = ["id"], + childColumns = ["notification_id"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ] +) +data class PendingWebhookDelivery( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") + val id: Long, + @ColumnInfo(name = "notification_id") + val notificationId: Long +) diff --git a/app/src/main/java/de/jl/notificationlog/notification/NotificationParser.kt b/app/src/main/java/de/jl/notificationlog/notification/NotificationParser.kt index 7eb79d9..9726949 100644 --- a/app/src/main/java/de/jl/notificationlog/notification/NotificationParser.kt +++ b/app/src/main/java/de/jl/notificationlog/notification/NotificationParser.kt @@ -3,6 +3,7 @@ package de.jl.notificationlog.notification import android.app.Notification import android.content.Context import android.os.Build +import android.os.Bundle import android.os.Parcel import android.os.Parcelable import android.text.TextUtils @@ -20,14 +21,14 @@ object NotificationParser { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val extras = notification.extras val title = extras.getString(Notification.EXTRA_TITLE)?.toString() - val text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString() + val text = extractRichText(extras) val progress = extras.getInt(Notification.EXTRA_PROGRESS) val progressMax = extras.getInt(Notification.EXTRA_PROGRESS_MAX) val progressIndeterminate = extras.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE) return NotificationData( title = title ?: "", - text = text ?: "", + text = text, progress = progress, progressMax = progressMax, progressIndeterminate = progressIndeterminate @@ -37,6 +38,49 @@ object NotificationParser { } } + /** + * EXTRA_TEXT alone is often a one-line summary ("(2 new messages)") while the actual + * body sits in a style-specific extra. Pick the richest payload available, in this order: + * + * 1. MessagingStyle (Signal, Telegram, WhatsApp, …) — EXTRA_MESSAGES + EXTRA_HISTORIC_MESSAGES, + * flattened "sender: text" per line. This is what users actually want to see. + * 2. BigTextStyle (Gmail, long bodies) — EXTRA_BIG_TEXT, the full body the OS shows when + * the notification is expanded. + * 3. InboxStyle (grouped messages) — EXTRA_TEXT_LINES, joined with newlines. + * 4. Plain EXTRA_TEXT — the upstream behaviour, kept as final fallback. + */ + private fun extractRichText(extras: Bundle): String { + extractMessages(extras)?.let { if (it.isNotBlank()) return it } + extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() + ?.takeIf { it.isNotBlank() }?.let { return it } + extractTextLines(extras)?.let { if (it.isNotBlank()) return it } + return extras.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty() + } + + private fun extractMessages(extras: Bundle): String? { + val combined = mutableListOf() + @Suppress("DEPRECATION") (extras.getParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES))?.let { combined.addAll(it) } + @Suppress("DEPRECATION") (extras.getParcelableArray(Notification.EXTRA_MESSAGES))?.let { combined.addAll(it) } + if (combined.isEmpty()) return null + val sb = StringBuilder() + for (p in combined) { + val b = p as? Bundle ?: continue + // Bundle keys per Notification.MessagingStyle.Message.toBundle() in framework. + val text = b.getCharSequence("text")?.toString() ?: continue + val sender = b.getCharSequence("sender")?.toString().orEmpty() + if (sb.isNotEmpty()) sb.append('\n') + if (sender.isNotEmpty()) sb.append(sender).append(": ") + sb.append(text) + } + return if (sb.isEmpty()) null else sb.toString() + } + + private fun extractTextLines(extras: Bundle): String? { + val lines = extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES) ?: return null + if (lines.isEmpty()) return null + return lines.joinToString("\n") + } + private fun parseOld(notification: Notification, context: Context): NotificationData { val notificationIdDetector = NotificationIds.with(context) val strings = getStringsFromRemoteViews(notification.contentView) diff --git a/app/src/main/java/de/jl/notificationlog/service/NotificationListenerService.kt b/app/src/main/java/de/jl/notificationlog/service/NotificationListenerService.kt index ceaf114..c1b7e2d 100644 --- a/app/src/main/java/de/jl/notificationlog/service/NotificationListenerService.kt +++ b/app/src/main/java/de/jl/notificationlog/service/NotificationListenerService.kt @@ -17,6 +17,10 @@ class NotificationListenerService : android.service.notification.NotificationLis override fun onListenerConnected() { super.onListenerConnected() - NotificationSaveUtil.restoreClickHandlers(activeNotifications.toList(), this) + val active = activeNotifications.toList() + NotificationSaveUtil.restoreClickHandlers(active, this) + // After a reboot the OS does not re-fire onNotificationPosted for what's already in the + // shade; without this replay the webhook would silently miss the current state. + NotificationSaveUtil.replayActiveForWebhook(active, this) } } diff --git a/app/src/main/java/de/jl/notificationlog/service/NotificationSaveUtil.kt b/app/src/main/java/de/jl/notificationlog/service/NotificationSaveUtil.kt index 318cc09..1033d3d 100644 --- a/app/src/main/java/de/jl/notificationlog/service/NotificationSaveUtil.kt +++ b/app/src/main/java/de/jl/notificationlog/service/NotificationSaveUtil.kt @@ -2,8 +2,11 @@ package de.jl.notificationlog.service import android.annotation.TargetApi import android.app.Notification +import android.app.Notification.FLAG_FOREGROUND_SERVICE +import android.app.Notification.FLAG_ONGOING_EVENT import android.content.Context import android.os.Build +import android.util.Log import android.service.notification.StatusBarNotification import de.jl.notificationlog.data.AppDatabase import de.jl.notificationlog.data.item.ActiveNotificationItem @@ -11,6 +14,7 @@ import de.jl.notificationlog.data.item.NotificationItem import de.jl.notificationlog.notification.NotificationParser import de.jl.notificationlog.util.Configuration import de.jl.notificationlog.util.PendingIntentHolder +import de.jl.notificationlog.webhook.WebhookConfig import java.util.concurrent.Executors object NotificationSaveUtil { @@ -23,25 +27,40 @@ object NotificationSaveUtil { val item = NotificationParser.parse(notification, context) val database = AppDatabase.with(context) + val config = Configuration.with(context) + val webhookEnabled = config.webhookEnabled + val webhookEligible = webhookEnabled && !shouldSkipForWebhook(notification, config) saveThread.submit { - val notificationId = database.notification().insertSyncHandlePossibleDuplicate( - packageName = packageName, - time = System.currentTimeMillis(), - title = item.title, - text = item.text, - progress = item.progress, - progressMax = item.progressMax, - progressIndeterminate = item.progressIndeterminate, - isOldestVersion = true, - isNewestVersion = true - ) + database.runInTransaction { + val notificationId = database.notification().insertSyncHandlePossibleDuplicate( + packageName = packageName, + time = System.currentTimeMillis(), + title = item.title, + text = item.text, + progress = item.progress, + progressMax = item.progressMax, + progressIndeterminate = item.progressIndeterminate, + isOldestVersion = true, + isNewestVersion = true + ) - // save click action - PendingIntentHolder.save( - savedNotificationId = notificationId, - contentIntent = notification.contentIntent - ) + if (webhookEligible && + database.notification().getDuplicateGroupIdSync(notificationId) == notificationId) { + // group_id == id ⇒ first occurrence of this content for this app + // (insertSyncHandlePossibleDuplicate joins identical bodies into a group; + // we only POST the first row of each group, suppressing identical reposts). + database.pendingWebhookDelivery().enqueueSync(notificationId) + } + + // save click action + PendingIntentHolder.save( + savedNotificationId = notificationId, + contentIntent = notification.contentIntent + ) + } + + if (webhookEligible) WebhookConfig.enqueue(context) } } @@ -53,6 +72,9 @@ object NotificationSaveUtil { val item = NotificationParser.parse(notification.notification, context) val database = AppDatabase.with(context) + val config = Configuration.with(context) + val webhookEnabled = config.webhookEnabled + val webhookEligible = webhookEnabled && !shouldSkipForWebhook(notification.notification, config) saveThread.submit { database.runInTransaction { @@ -88,6 +110,11 @@ object NotificationSaveUtil { ) ) + if (webhookEligible && + database.notification().getDuplicateGroupIdSync(notificationId) == notificationId) { + database.pendingWebhookDelivery().enqueueSync(notificationId) + } + // save click action PendingIntentHolder.save( savedNotificationId = notificationId, @@ -119,6 +146,11 @@ object NotificationSaveUtil { lastNotificationId = notificationId ) + if (webhookEligible && + database.notification().getDuplicateGroupIdSync(notificationId) == notificationId) { + database.pendingWebhookDelivery().enqueueSync(notificationId) + } + // save click action PendingIntentHolder.save( savedNotificationId = notificationId, @@ -126,9 +158,30 @@ object NotificationSaveUtil { ) } } + + if (webhookEligible) WebhookConfig.enqueue(context) } } + /** + * When `webhookSkipOngoing` is set (default), suppress webhook delivery for notifications + * that an app marked as "ongoing" or that the system marked as belonging to a foreground + * service. These tick many times per second (download progress, step counters, music + * players, torrent throughput, voice recorders…) and would otherwise spam the webhook. + * The notification still goes into the local log — only HTTP delivery is skipped. + * + * We OR both flags because some apps call startForeground() without setOngoing(true), so + * only FLAG_FOREGROUND_SERVICE ends up set; conversely setOngoing(true) without a + * foreground service only sets FLAG_ONGOING_EVENT. + */ + private fun shouldSkipForWebhook(notification: Notification, config: Configuration): Boolean { + if (!config.webhookSkipOngoing) return false + val mask = FLAG_ONGOING_EVENT or FLAG_FOREGROUND_SERVICE + val skip = (notification.flags and mask) != 0 + if (skip) Log.d("NotificationSaveUtil", "skip-webhook (flags=0x${notification.flags.toString(16)})") + return skip + } + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) fun saveNotificationRemoved(notification: StatusBarNotification, context: Context) { saveNotificationRemoved( @@ -151,6 +204,54 @@ object NotificationSaveUtil { } } + /** + * Re-emit every notification currently visible in the shade to the webhook. + * + * Triggered by NotificationListenerService.onListenerConnected, which fires on listener + * (re)connect — most importantly after a device reboot, because the OS does not re-fire + * onNotificationPosted for notifications that were already visible. Without this replay + * the webhook subscriber would silently miss the post-reboot state of the shade. + * + * Honours the same per-app filter and skip-ongoing toggle as live posts; deduplicates + * within the snapshot so a single Title:Body never POSTs twice in one replay. Bypasses + * the across-time duplicate_group_id dedup that saveNotificationPosted uses, because the + * point of the replay IS to re-send notifications that already have rows from before the + * reboot. + */ + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) + fun replayActiveForWebhook(active: List, context: Context) { + val config = Configuration.with(context) + if (!config.webhookEnabled) return + val database = AppDatabase.with(context) + saveThread.submit { + val seen = HashSet() + for (sbn in active) { + val pkg = sbn.packageName + if (!config.shouldLogNotifications(pkg)) continue + if (shouldSkipForWebhook(sbn.notification, config)) continue + val item = NotificationParser.parse(sbn.notification, context) + if (item.isEmpty) continue + val fp = "$pkg|${item.title}|${item.text}" + if (!seen.add(fp)) continue + database.runInTransaction { + val notificationId = database.notification().insertSyncHandlePossibleDuplicate( + packageName = pkg, + time = System.currentTimeMillis(), + title = item.title, + text = item.text, + progress = item.progress, + progressMax = item.progressMax, + progressIndeterminate = item.progressIndeterminate, + isOldestVersion = true, + isNewestVersion = true + ) + database.pendingWebhookDelivery().enqueueSync(notificationId) + } + } + if (seen.isNotEmpty()) WebhookConfig.enqueue(context) + } + } + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) fun restoreClickHandlers(statusBarNotifications: List, context: Context) { val database = AppDatabase.with(context) diff --git a/app/src/main/java/de/jl/notificationlog/ui/settings/SettingsActivity.kt b/app/src/main/java/de/jl/notificationlog/ui/settings/SettingsActivity.kt index 70a7f50..e16f229 100644 --- a/app/src/main/java/de/jl/notificationlog/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/de/jl/notificationlog/ui/settings/SettingsActivity.kt @@ -11,6 +11,7 @@ import de.jl.notificationlog.databinding.ActivitySettingsBinding import de.jl.notificationlog.ui.App import de.jl.notificationlog.ui.AppsUtil import de.jl.notificationlog.ui.CheckAuthActivity +import de.jl.notificationlog.webhook.WebhookConfig class SettingsActivity : CheckAuthActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -20,6 +21,34 @@ class SettingsActivity : CheckAuthActivity() { setSupportActionBar(binding.toolbar) + binding.content.webhookEnabledSwitch.isChecked = configuration.webhookEnabled + binding.content.webhookUrl.setText(configuration.webhookUrl) + binding.content.webhookBearerToken.setText(configuration.webhookBearerToken) + + binding.content.webhookEnabledSwitch.setOnCheckedChangeListener { _, checked -> + configuration.webhookEnabled = checked + if (checked) WebhookConfig.enqueue(this) + } + binding.content.webhookUrl.addTextChangedListener(object: TextWatcher { + override fun afterTextChanged(s: Editable?) { + configuration.webhookUrl = s?.toString() ?: "" + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + }) + binding.content.webhookBearerToken.addTextChangedListener(object: TextWatcher { + override fun afterTextChanged(s: Editable?) { + configuration.webhookBearerToken = s?.toString() ?: "" + } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + }) + + binding.content.webhookSkipOngoingSwitch.isChecked = configuration.webhookSkipOngoing + binding.content.webhookSkipOngoingSwitch.setOnCheckedChangeListener { _, checked -> + configuration.webhookSkipOngoing = checked + } + binding.content.modeRadioGroup.check(when (configuration.isWhitelistMode) { true -> R.id.mode_whitelist false -> R.id.mode_blacklist diff --git a/app/src/main/java/de/jl/notificationlog/util/Configuration.kt b/app/src/main/java/de/jl/notificationlog/util/Configuration.kt index 83c7061..71dcc4e 100644 --- a/app/src/main/java/de/jl/notificationlog/util/Configuration.kt +++ b/app/src/main/java/de/jl/notificationlog/util/Configuration.kt @@ -23,6 +23,10 @@ class Configuration(context: Application) { private const val NOTIFICATION_KEEPING_DAYS = "notification_keeping_days" private const val HIDE_DUPLICATES = "hide_duplicates" private const val REQUIRE_AUTH = "require_auth" + private const val WEBHOOK_ENABLED = "webhook_enabled" + private const val WEBHOOK_URL = "webhook_url" + private const val WEBHOOK_BEARER_TOKEN = "webhook_bearer_token" + private const val WEBHOOK_SKIP_ONGOING = "webhook_skip_ongoing" private var instance: Configuration? = null private val lock = Object() @@ -155,6 +159,22 @@ class Configuration(context: Application) { .apply() } + var webhookEnabled: Boolean + get() = preferences.getBoolean(WEBHOOK_ENABLED, false) + set(value) = preferences.edit().putBoolean(WEBHOOK_ENABLED, value).apply() + + var webhookUrl: String + get() = preferences.getString(WEBHOOK_URL, "") ?: "" + set(value) = preferences.edit().putString(WEBHOOK_URL, value).apply() + + var webhookBearerToken: String + get() = preferences.getString(WEBHOOK_BEARER_TOKEN, "") ?: "" + set(value) = preferences.edit().putString(WEBHOOK_BEARER_TOKEN, value).apply() + + var webhookSkipOngoing: Boolean + get() = preferences.getBoolean(WEBHOOK_SKIP_ONGOING, true) // safe default: drop foreground-service spam + set(value) = preferences.edit().putBoolean(WEBHOOK_SKIP_ONGOING, value).apply() + enum class AppSorting { Alphabetically, NewestFirst, diff --git a/app/src/main/java/de/jl/notificationlog/webhook/WebhookConfig.kt b/app/src/main/java/de/jl/notificationlog/webhook/WebhookConfig.kt new file mode 100644 index 0000000..75295ca --- /dev/null +++ b/app/src/main/java/de/jl/notificationlog/webhook/WebhookConfig.kt @@ -0,0 +1,33 @@ +package de.jl.notificationlog.webhook + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import java.util.concurrent.TimeUnit + +object WebhookConfig { + const val UNIQUE_WORK_NAME = "webhook-delivery" + const val INITIAL_BACKOFF_SECONDS = 30L + + fun enqueue(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + INITIAL_BACKOFF_SECONDS, + TimeUnit.SECONDS + ) + .build() + + WorkManager.getInstance(context.applicationContext) + .enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.KEEP, request) + } +} diff --git a/app/src/main/java/de/jl/notificationlog/webhook/WebhookWorker.kt b/app/src/main/java/de/jl/notificationlog/webhook/WebhookWorker.kt new file mode 100644 index 0000000..0f4689c --- /dev/null +++ b/app/src/main/java/de/jl/notificationlog/webhook/WebhookWorker.kt @@ -0,0 +1,152 @@ +package de.jl.notificationlog.webhook + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import de.jl.notificationlog.data.AppDatabase +import de.jl.notificationlog.data.item.NotificationItem +import de.jl.notificationlog.ui.AppsUtil +import de.jl.notificationlog.util.Configuration +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import android.util.Base64 + +class WebhookWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val config = Configuration.with(applicationContext) + if (!config.webhookEnabled) { + // disabled at runtime — drop pending so they don't pile up forever + AppDatabase.with(applicationContext).pendingWebhookDelivery().let { dao -> + while (true) { + val n = dao.peekOldestNotificationSync() ?: break + val pid = dao.findPendingIdByNotificationSync(n.id) ?: break + dao.deleteSync(pid) + } + } + return@withContext Result.success() + } + + val url = config.webhookUrl + if (url.isBlank()) { + return@withContext Result.success() + } + val parsedUrl = try { + URL(url) + } catch (e: Exception) { + Log.w(TAG, "Invalid webhook URL: $url", e) + return@withContext Result.success() + } + + val token = config.webhookBearerToken + val dao = AppDatabase.with(applicationContext).pendingWebhookDelivery() + + while (true) { + val notification = dao.peekOldestNotificationSync() ?: break + val pendingId = dao.findPendingIdByNotificationSync(notification.id) ?: break + + when (val outcome = post(parsedUrl, token, notification)) { + Outcome.Success, Outcome.ClientError -> dao.deleteSync(pendingId) + Outcome.Retry -> { + Log.d(TAG, "Retrying webhook delivery later: ${outcome}") + return@withContext Result.retry() + } + } + } + + Result.success() + } + + private enum class Outcome { Success, ClientError, Retry } + + private fun post(url: URL, bearerToken: String, item: NotificationItem): Outcome { + var conn: HttpURLConnection? = null + return try { + conn = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + doOutput = true + connectTimeout = 15_000 + readTimeout = 30_000 + setRequestProperty("Content-Type", "text/plain; charset=utf-8") + setRequestProperty("Title", encodeHeader(buildTitle(item))) + setRequestProperty("Tags", encodeHeader(item.packageName)) + if (bearerToken.isNotBlank()) { + setRequestProperty("Authorization", "Bearer $bearerToken") + } + } + conn.outputStream.use { it.write(item.text.toByteArray(Charsets.UTF_8)) } + val code = conn.responseCode + when { + code in 200..299 -> Outcome.Success + // 408 Request Timeout and 429 Too Many Requests are transient — proxies in + // front of ntfy (Cloudflare, nginx) emit these when the origin is overloaded + // or restarting. Retry rather than drop. + code == 408 || code == 429 -> { + Log.w(TAG, "Webhook ${code} (transient) — will retry") + Outcome.Retry + } + code in 400..499 -> { + Log.w(TAG, "Webhook 4xx (dropping notification ${item.id}): $code") + Outcome.ClientError + } + else -> { + Log.w(TAG, "Webhook ${code} for notification ${item.id} — will retry") + Outcome.Retry + } + } + } catch (e: IOException) { + Log.w(TAG, "Webhook IO error — will retry", e) + Outcome.Retry + } catch (e: Exception) { + // Unknown error — be defensive: prefer the chance of duplicate delivery over + // silently losing the notification. WorkManager will keep retrying with backoff. + Log.e(TAG, "Webhook unexpected error — will retry", e) + Outcome.Retry + } finally { + conn?.disconnect() + } + } + + /** + * Compose the Title sent to the webhook: `: `. The app label + * (resolved via PackageManager) is prepended so subscribers know which app the notification + * came from — `Tags` already carries the package name for automation/grepping. Falls back + * to bare title when the notification's title is itself the label, and to packageName when + * the app is no longer installed. + */ + private fun buildTitle(item: NotificationItem): String { + val label = AppsUtil.getAppTitle(item.packageName, applicationContext) + val title = item.title.trim() + return when { + title.isBlank() -> label + title == label -> label + else -> "$label: $title" + } + } + + /** + * HTTP headers must be ASCII (RFC 7230). For values with non-ASCII characters we use + * RFC 2047 encoded-word `=?utf-8?B??=`, which ntfy.sh decodes back to UTF-8. + * Pure-ASCII values pass through verbatim (after CR/LF strip) to keep the wire format + * readable in the common case. + */ + private fun encodeHeader(value: String): String { + val cleaned = value.replace('\r', ' ').replace('\n', ' ') + if (cleaned.isEmpty()) return "-" + val needsEncoding = cleaned.any { it.code !in 0x20..0x7E } + if (!needsEncoding) return cleaned + val b64 = Base64.encodeToString(cleaned.toByteArray(Charsets.UTF_8), Base64.NO_WRAP) + return "=?utf-8?B?$b64?=" + } + + companion object { + private const val TAG = "WebhookWorker" + } +} diff --git a/app/src/main/res/layout/content_settings.xml b/app/src/main/res/layout/content_settings.xml index 21da0bc..9262ce8 100644 --- a/app/src/main/res/layout/content_settings.xml +++ b/app/src/main/res/layout/content_settings.xml @@ -15,6 +15,56 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + + + + + + + + + Request authentication Sign in to view the notifications + + + Forward notifications via HTTP (ntfy.sh-compatible) + Webhook URL (e.g. https://ntfy.sh/your-topic) + Bearer token (optional) + Skip \"ongoing\" notifications + Recommended. When off, apps with foreground services (step counter, download progress, torrent throughput, music players) can flood the webhook with many POSTs per second. diff --git a/app/src/test/java/de/jl/notificationlog/notification/NotificationParserTest.kt b/app/src/test/java/de/jl/notificationlog/notification/NotificationParserTest.kt new file mode 100644 index 0000000..a48f84b --- /dev/null +++ b/app/src/test/java/de/jl/notificationlog/notification/NotificationParserTest.kt @@ -0,0 +1,115 @@ +package de.jl.notificationlog.notification + +import android.app.Notification +import android.os.Bundle +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], manifest = Config.NONE, application = android.app.Application::class) +class NotificationParserTest { + private val ctx = ApplicationProvider.getApplicationContext() + + @Test + fun `plain EXTRA_TEXT is returned verbatim (upstream behaviour)`() { + val n = Notification().apply { + extras = Bundle().apply { + putString(Notification.EXTRA_TITLE, "App") + putCharSequence(Notification.EXTRA_TEXT, "hi") + } + } + val r = NotificationParser.parse(n, ctx) + assertEquals("App", r.title) + assertEquals("hi", r.text) + } + + @Test + fun `EXTRA_BIG_TEXT wins over EXTRA_TEXT (long bodies, gmail-style)`() { + val n = Notification().apply { + extras = Bundle().apply { + putString(Notification.EXTRA_TITLE, "Mail") + putCharSequence(Notification.EXTRA_TEXT, "summary line") + putCharSequence(Notification.EXTRA_BIG_TEXT, "Long\nmulti-line\nbody") + } + } + val r = NotificationParser.parse(n, ctx) + assertEquals("Long\nmulti-line\nbody", r.text) + } + + @Test + fun `EXTRA_TEXT_LINES wins over EXTRA_TEXT (inbox-style grouped msgs)`() { + val n = Notification().apply { + extras = Bundle().apply { + putCharSequence(Notification.EXTRA_TEXT, "(3 new)") + putCharSequenceArray(Notification.EXTRA_TEXT_LINES, + arrayOf("line A", "line B", "line C")) + } + } + assertEquals("line A\nline B\nline C", NotificationParser.parse(n, ctx).text) + } + + @Test + fun `MessagingStyle EXTRA_MESSAGES wins (Signal Telegram WhatsApp)`() { + val msg1 = Bundle().apply { + putCharSequence("text", "hello there") + putCharSequence("sender", "Alice") + } + val msg2 = Bundle().apply { + putCharSequence("text", "general kenobi") + putCharSequence("sender", "Bob") + } + val n = Notification().apply { + extras = Bundle().apply { + putString(Notification.EXTRA_TITLE, "Group chat") + putCharSequence(Notification.EXTRA_TEXT, "(2 new)") + putCharSequence(Notification.EXTRA_BIG_TEXT, "should be ignored") + putParcelableArray(Notification.EXTRA_MESSAGES, arrayOf(msg1, msg2)) + } + } + assertEquals("Alice: hello there\nBob: general kenobi", + NotificationParser.parse(n, ctx).text) + } + + @Test + fun `historic messages prepended to current messages`() { + val historic = Bundle().apply { + putCharSequence("text", "earlier message") + putCharSequence("sender", "Alice") + } + val current = Bundle().apply { + putCharSequence("text", "newer message") + putCharSequence("sender", "Alice") + } + val n = Notification().apply { + extras = Bundle().apply { + putParcelableArray(Notification.EXTRA_HISTORIC_MESSAGES, + arrayOf(historic)) + putParcelableArray(Notification.EXTRA_MESSAGES, + arrayOf(current)) + } + } + assertEquals("Alice: earlier message\nAlice: newer message", + NotificationParser.parse(n, ctx).text) + } + + @Test + fun `MessagingStyle without sender (you-message) renders bare text`() { + val msg = Bundle().apply { putCharSequence("text", "self note") } + val n = Notification().apply { + extras = Bundle().apply { + putParcelableArray(Notification.EXTRA_MESSAGES, arrayOf(msg)) + } + } + assertEquals("self note", NotificationParser.parse(n, ctx).text) + } + + @Test + fun `falls back to empty string when no body extras present`() { + val n = Notification().apply { extras = Bundle().apply { putString(Notification.EXTRA_TITLE, "T") } } + assertEquals("", NotificationParser.parse(n, ctx).text) + } +} diff --git a/app/src/test/java/de/jl/notificationlog/webhook/WebhookWorkerTest.kt b/app/src/test/java/de/jl/notificationlog/webhook/WebhookWorkerTest.kt new file mode 100644 index 0000000..a457356 --- /dev/null +++ b/app/src/test/java/de/jl/notificationlog/webhook/WebhookWorkerTest.kt @@ -0,0 +1,332 @@ +package de.jl.notificationlog.webhook + +import android.content.Context +import android.preference.PreferenceManager +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.ListenableWorker +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.testing.WorkManagerTestInitHelper +import de.jl.notificationlog.data.AppDatabase +import de.jl.notificationlog.data.item.NotificationItem +import de.jl.notificationlog.data.item.PendingWebhookDelivery +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.lang.reflect.Field +import java.util.concurrent.TimeUnit + +/** + * JVM tests for the webhook delivery path. Drives WebhookWorker directly via + * TestListenableWorkerBuilder against a real in-memory Room DB and a MockWebServer. + * No emulator required. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], manifest = Config.NONE, application = android.app.Application::class) +class WebhookWorkerTest { + + private lateinit var server: MockWebServer + private lateinit var context: Context + private lateinit var db: AppDatabase + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + server = MockWebServer().apply { start() } + + // Reset AppDatabase singleton between tests by reflection (the upstream class + // exposes only `with(context)`; we want a fresh in-memory DB per test). + resetAppDatabaseSingleton() + injectInMemoryDatabase() + + // Reset prefs + PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit() + + // WorkManager init for any code path that touches it (we drive worker manually) + WorkManagerTestInitHelper.initializeTestWorkManager( + context, + Configuration.Builder() + .setExecutor(SynchronousExecutor()) + .setTaskExecutor(SynchronousExecutor()) + .build() + ) + } + + @After + fun teardown() { + server.shutdown() + db.close() + resetAppDatabaseSingleton() + } + + private fun setSingleton(value: AppDatabase?) { + // Kotlin lifts companion's private var to a static field on the outer class. + val instanceField: Field = AppDatabase::class.java.getDeclaredField("instance") + .apply { isAccessible = true } + instanceField.set(null, value) + } + + private fun resetAppDatabaseSingleton() = setSingleton(null) + + private fun injectInMemoryDatabase() { + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + setSingleton(db) + } + + private fun enable(url: String, token: String = "") { + de.jl.notificationlog.util.Configuration.with(context).apply { + webhookEnabled = true + webhookUrl = url + webhookBearerToken = token + } + } + + private fun insertNotification(title: String, text: String, pkg: String = "com.example.app"): Long { + val id = db.notification().insertSyncHandlePossibleDuplicate( + packageName = pkg, time = System.currentTimeMillis(), + title = title, text = text, + progress = 0, progressMax = 0, progressIndeterminate = false, + isOldestVersion = true, isNewestVersion = true + ) + db.pendingWebhookDelivery().enqueueSync(id) + return id + } + + private fun runWorker(): ListenableWorker.Result = + runBlocking { TestListenableWorkerBuilder(context).build().doWork() } + + /** Decode the Title header back to plain text whether it was sent as RFC 2047 or verbatim. */ + private fun decodeTitle(value: String?): String? { + if (value == null) return null + val match = Regex("""^=\?utf-8\?B\?([A-Za-z0-9+/=]+)\?=$""").matchEntire(value) ?: return value + return String(android.util.Base64.decode(match.groupValues[1], android.util.Base64.NO_WRAP), + Charsets.UTF_8) + } + + @Test + fun `2xx success deletes pending row and sends headers + body`() { + enable(server.url("/topic").toString(), token = "secret") + insertNotification("hello", "world") + server.enqueue(MockResponse().setResponseCode(200)) + + val result = runWorker() + + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(0, db.pendingWebhookDelivery().countSync()) + val req: RecordedRequest = server.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("POST", req.method) + assertEquals("/topic", req.path) + // Robolectric has no PackageManager entry for "com.example.app" so the label + // resolution falls back to the package name itself, prepended to the title. + assertEquals("com.example.app: hello", decodeTitle(req.getHeader("Title"))) + assertEquals("com.example.app", req.getHeader("Tags")) + assertEquals("Bearer secret", req.getHeader("Authorization")) + assertEquals("world", req.body.readUtf8()) + } + + @Test + fun `5xx returns retry and keeps pending row`() { + enable(server.url("/t").toString()) + insertNotification("a", "b") + server.enqueue(MockResponse().setResponseCode(500)) + + val result = runWorker() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(1, db.pendingWebhookDelivery().countSync()) + } + + @Test + fun `408 Request Timeout retries (transient)`() { + enable(server.url("/t").toString()) + insertNotification("a", "b") + server.enqueue(MockResponse().setResponseCode(408)) + + val result = runWorker() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(1, db.pendingWebhookDelivery().countSync()) + } + + @Test + fun `429 Too Many Requests retries (transient)`() { + enable(server.url("/t").toString()) + insertNotification("a", "b") + server.enqueue(MockResponse().setResponseCode(429)) + + val result = runWorker() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(1, db.pendingWebhookDelivery().countSync()) + } + + @Test + fun `4xx drops pending row to avoid permanent stall`() { + enable(server.url("/t").toString()) + insertNotification("a", "b") + server.enqueue(MockResponse().setResponseCode(403)) + + val result = runWorker() + + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(0, db.pendingWebhookDelivery().countSync()) + } + + @Test + fun `bearer token absent when blank`() { + enable(server.url("/t").toString(), token = "") + insertNotification("a", "b") + server.enqueue(MockResponse().setResponseCode(200)) + + runWorker() + val req = server.takeRequest(2, TimeUnit.SECONDS)!! + assertNull(req.getHeader("Authorization")) + } + + @Test + fun `FIFO order across multiple pending notifications`() { + enable(server.url("/t").toString()) + insertNotification("first", "1") + insertNotification("second", "2") + insertNotification("third", "3") + repeat(3) { server.enqueue(MockResponse().setResponseCode(200)) } + + runWorker() + + val titles = (1..3).map { decodeTitle(server.takeRequest(2, TimeUnit.SECONDS)!!.getHeader("Title")) } + assertEquals(listOf("com.example.app: first", "com.example.app: second", "com.example.app: third"), titles) + assertEquals(0, db.pendingWebhookDelivery().countSync()) + } + + @Test + fun `partial drain on 5xx — first delivered, second retried`() { + enable(server.url("/t").toString()) + insertNotification("ok", "1") + insertNotification("fail", "2") + server.enqueue(MockResponse().setResponseCode(200)) + server.enqueue(MockResponse().setResponseCode(500)) + + val result = runWorker() + + assertEquals(ListenableWorker.Result.retry(), result) + assertEquals(1, db.pendingWebhookDelivery().countSync()) + // Next time worker runs (network returned), the second one should still be peekable. + val pending = db.pendingWebhookDelivery().peekOldestNotificationSync() + assertNotNull(pending) + assertEquals("fail", pending!!.title) + } + + @Test + fun `recovery after restart — second worker run drains rows left by first`() { + enable(server.url("/t").toString()) + insertNotification("a", "1") + insertNotification("b", "2") + // First run: server is offline / errors out + server.enqueue(MockResponse().setResponseCode(500)) + runWorker() + assertEquals(2, db.pendingWebhookDelivery().countSync()) + + // "Restart" — fresh worker instance, server back up + server.enqueue(MockResponse().setResponseCode(200)) + server.enqueue(MockResponse().setResponseCode(200)) + val result = runWorker() + + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(0, db.pendingWebhookDelivery().countSync()) + } + + @Test + fun `disabled at runtime drains and drops queue`() { + // Pre-populate while enabled, then disable + enable(server.url("/t").toString()) + insertNotification("a", "1") + insertNotification("b", "2") + de.jl.notificationlog.util.Configuration.with(context).webhookEnabled = false + + val result = runWorker() + + assertEquals(ListenableWorker.Result.success(), result) + assertEquals(0, db.pendingWebhookDelivery().countSync()) + // No HTTP requests issued + assertEquals(0, server.requestCount) + } + + @Test + fun `non-ASCII title is RFC 2047 base64 encoded`() { + enable(server.url("/t").toString()) + // "działa" — Polish ł + ó-style chars + insertNotification(title = "działa żółć", text = "ascii body") + server.enqueue(MockResponse().setResponseCode(200)) + + runWorker() + val req = server.takeRequest(2, TimeUnit.SECONDS)!! + // RFC 2047 encoded-word: =?utf-8?B??= (label-prefixed) + assertEquals("com.example.app: działa żółć", decodeTitle(req.getHeader("Title"))) + assertEquals("ascii body", req.body.readUtf8()) + } + + @Test + fun `pure ASCII title passes through verbatim (no encoded-word wrapper)`() { + enable(server.url("/t").toString()) + insertNotification(title = "Plain title", text = "x") + server.enqueue(MockResponse().setResponseCode(200)) + + runWorker() + val req = server.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("com.example.app: Plain title", req.getHeader("Title")) + } + + @Test + fun `CR LF in title is stripped to spaces`() { + enable(server.url("/t").toString()) + insertNotification(title = "line1\r\nline2", text = "x") + server.enqueue(MockResponse().setResponseCode(200)) + + runWorker() + val req = server.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("com.example.app: line1 line2", req.getHeader("Title")) + } + + @Test + fun `blank notification title uses just the app label`() { + enable(server.url("/t").toString()) + insertNotification(title = "", text = "body only") + server.enqueue(MockResponse().setResponseCode(200)) + + runWorker() + val req = server.takeRequest(2, TimeUnit.SECONDS)!! + assertEquals("com.example.app", decodeTitle(req.getHeader("Title"))) + assertEquals("body only", req.body.readUtf8()) + } + + @Test + fun `blank URL is no-op success`() { + de.jl.notificationlog.util.Configuration.with(context).apply { + webhookEnabled = true + webhookUrl = "" + } + insertNotification("a", "1") + + val result = runWorker() + + assertEquals(ListenableWorker.Result.success(), result) + // Pending row stays (because the worker doesn't drain when URL is blank — user + // might be mid-edit; we don't want to lose data while they configure it) + assertEquals(1, db.pendingWebhookDelivery().countSync()) + } +}