diff --git a/PRIVACY.md b/PRIVACY.md index 12ca997..f2ef408 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -23,7 +23,8 @@ Captured event bodies, including message text, notification titles and SMS sende a private SQLite outbox encrypted with AES-GCM and a key held in Android Keystore. Each queued request includes its original destination, encrypted authentication token and confirmation mode. Delivery metadata (event ID, source display name, type, timestamps, state, attempt count and HTTP result) is stored without -additional application-level encryption. Message duplicate detection stores SHA-256 hashes of source package, original timestamp and text, +additional application-level encryption. Message duplicate detection stores SHA-256 hashes of source package and text together with original event timestamps +(and retains hashes from the older exact-timestamp filter), including when filtering is disabled. These hashes remain after delivery or journal deletion until app data is cleared or the app is uninstalled. They are not a substitute for encryption against guesses of known content. diff --git a/README.md b/README.md index 46e20c9..460dfaa 100644 --- a/README.md +++ b/README.md @@ -56,14 +56,17 @@ forwards only the content the system exposes. ## Delivery and data -**Skip duplicate messages** in **Sources** is on by default. SMS and notifications are -compared by source package, exact original timestamp and exact text (including whitespace). +**Skip duplicate SMS** and **Skip duplicate notifications** in **Sources** are independently +enabled by default and share the time window. SMS and notifications are +compared by source package and exact text (including whitespace), with original timestamps +inside the configured ± time window. Set the window in seconds in Sources; 0 requires exact timestamps. Titles and SMS senders are not part of this key. SMS uses the system source `android`. -A different timestamp counts as a new message, even with identical text. Turn the switch off +Messages outside the inclusive window count as new. Identical genuine messages inside it are also skipped. Turn the corresponding switch off to forward repeated captures. Manual connection tests are exempt; network retries keep their event ID. -Deduplication hashes survive restarts, delivery and journal deletion until app data is cleared. -They are recorded even while the switch is off so re-enabling it covers those captures too. -On upgrade, this history starts with newly captured events; existing queued events are preserved. +Deduplication hashes and original timestamps survive restarts, delivery and journal deletion until app data is cleared. +They are recorded even while the corresponding switch is off so re-enabling it covers those captures too. +Existing queued events are preserved on upgrade. Older exact-timestamp hashes still suppress exact matches; +time-window matching applies to captures recorded by the new filter. The n8n confirmation mode requires JSON with `status: "accepted"` and the matching `event_id`. This is our fixture contract, not a built-in n8n response. Turn off **n8n confirmation** for a diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index 3e41129..21bc4f1 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -106,7 +106,11 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati fun sendTest() = action(R.string.test_queued) { graph.enqueueTest() } fun notifications(enabled: Boolean) = action { graph.settings.update { it.copy(notifications = enabled) } } - fun deduplication(enabled: Boolean) = action { graph.settings.update { it.copy(deduplication = enabled) } } + fun deduplicationWindow(seconds: Int) = action { + graph.settings.update { it.copy(deduplicationWindowSeconds = seconds) } + } + fun smsDeduplication(enabled: Boolean) = action { graph.settings.update { it.copy(smsDeduplication = enabled) } } + fun notificationDeduplication(enabled: Boolean) = action { graph.settings.update { it.copy(notificationDeduplication = enabled) } } fun sms(enabled: Boolean) = action { graph.settings.update { it.copy(sms = enabled) } } fun selectAllPackages() = action { val packages = apps.value.map { it.packageName }.toSet() - getApplication().packageName diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt index f67c061..e7b9e09 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -17,8 +17,12 @@ data class ForwardingSettings( val packages: Set = emptySet(), val captureFailed: Boolean = false, val authToken: String = "", - val deduplication: Boolean = true, + val smsDeduplication: Boolean = true, + val notificationDeduplication: Boolean = true, + val deduplicationWindowSeconds: Int = 1, ) { + init { require(deduplicationWindowSeconds >= 0) } + override fun toString(): String = "ForwardingSettings(redacted)" fun ready(): Boolean = deviceCode.isNotBlank() && validWebhookUrl(url, BuildConfig.DEBUG) && validAuthToken(authToken) @@ -41,7 +45,9 @@ class SettingsStore( cipher.decrypt(android.util.Base64.decode(it, android.util.Base64.NO_WRAP)) }.orEmpty() }.getOrDefault(""), - deduplication = preferences.getBoolean("deduplication", true), + smsDeduplication = preferences.getBoolean("sms_deduplication", preferences.getBoolean("deduplication", true)), + notificationDeduplication = preferences.getBoolean("notification_deduplication", preferences.getBoolean("deduplication", true)), + deduplicationWindowSeconds = preferences.getInt("deduplication_window_seconds", 1), url = preferences.getString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), deviceId = preferences.getString("device_id", "").orEmpty(), deviceCode = preferences.getString("device_code", "android-device").orEmpty(), @@ -68,7 +74,10 @@ class SettingsStore( .putString("url", next.url).putString("device_id", next.deviceId) .putString("device_code", next.deviceCode).putBoolean("require_ack", next.requireAck) .putBoolean("notifications", next.notifications).putBoolean("sms", next.sms) - .putBoolean("deduplication", next.deduplication) + .putBoolean("sms_deduplication", next.smsDeduplication) + .putBoolean("notification_deduplication", next.notificationDeduplication) + .remove("deduplication") + .putInt("deduplication_window_seconds", next.deduplicationWindowSeconds) .putBoolean("paused", next.paused).putStringSet("packages", next.packages) .putBoolean("capture_failed", next.captureFailed).commit() ) throw IOException("Could not save settings") diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt index bb90e8e..2b5ff53 100644 --- a/app/src/main/java/life/andre/message487/Outbox.kt +++ b/app/src/main/java/life/andre/message487/Outbox.kt @@ -40,7 +40,7 @@ fun deliveryQueueState(result: DeliveryResult): QueueState = when (result.status } else QueueState.BLOCKED } -class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 2) { +class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 3) { private val mutableRevision = MutableStateFlow(0L) val revision = mutableRevision.asStateFlow() @@ -52,6 +52,7 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe )""") db.execSQL("CREATE INDEX events_state ON events(state)") createDeduplicationTable(db) + createWindowTable(db) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -59,6 +60,14 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe createDeduplicationTable(db) db.execSQL("DROP TABLE IF EXISTS notifications") } + if (oldVersion < 3) createWindowTable(db) + } + + private fun createWindowTable(db: SQLiteDatabase) { + db.execSQL("""CREATE TABLE message_windows ( + fingerprint TEXT NOT NULL, occurred_at INTEGER NOT NULL, + PRIMARY KEY (fingerprint, occurred_at) + )""") } private fun createDeduplicationTable(db: SQLiteDatabase) { @@ -70,14 +79,27 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe val db = writableDatabase db.beginTransaction() try { - // JSON preserves field boundaries; normalize equivalent timestamp representations. // Test requests are deliberate user actions and are never content-deduplicated. - val fingerprint = if (event.messageType in setOf("sms", "notification")) digest( - org.json.JSONArray().put(event.source.packageName) - .put(java.time.Instant.parse(event.occurredAt).toString()).put(event.text).toString() + val captured = event.messageType in setOf("sms", "notification") + val occurredAt = if (captured) java.time.Instant.parse(event.occurredAt).toEpochMilli() else 0L + val fingerprint = if (captured) digest( + org.json.JSONArray().put(event.source.packageName).put(event.text).toString() ) else null - if (settings.deduplication && fingerprint != null) { - db.rawQuery("SELECT 1 FROM message_fingerprints WHERE fingerprint = ?", arrayOf(fingerprint)).use { + val deduplication = when (event.messageType) { + "sms" -> settings.smsDeduplication + "notification" -> settings.notificationDeduplication + else -> false + } + if (deduplication && fingerprint != null) { + val window = settings.deduplicationWindowSeconds.toLong() * 1000 + db.rawQuery( + "SELECT 1 FROM message_windows WHERE fingerprint = ? AND occurred_at BETWEEN ? AND ?", + arrayOf(fingerprint, (occurredAt - window).toString(), (occurredAt + window).toString()) + ).use { if (it.moveToFirst()) return false } + // Old hashes cannot reveal their content; retain exact matching for pre-upgrade history. + val legacy = digest(org.json.JSONArray().put(event.source.packageName) + .put(java.time.Instant.parse(event.occurredAt).toString()).put(event.text).toString()) + db.rawQuery("SELECT 1 FROM message_fingerprints WHERE fingerprint = ?", arrayOf(legacy)).use { if (it.moveToFirst()) return false } } @@ -95,7 +117,7 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe put("payload", cipher.encrypt(envelope)) }) if (fingerprint != null) { - db.execSQL("INSERT OR IGNORE INTO message_fingerprints (fingerprint) VALUES (?)", arrayOf(fingerprint)) + db.execSQL("INSERT OR IGNORE INTO message_windows (fingerprint, occurred_at) VALUES (?, ?)", arrayOf(fingerprint, occurredAt)) } db.setTransactionSuccessful() } finally { diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt index a03a66f..eef4ab0 100644 --- a/app/src/main/java/life/andre/message487/SourcesScreen.kt +++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt @@ -95,8 +95,29 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission } item { Panel { - SourceSwitch(Icons.Outlined.FilterList, R.string.deduplication_enabled, R.string.deduplication_hint, - settings.deduplication, !busy, model::deduplication) + SourceSwitch(Icons.Outlined.Sms, R.string.sms_deduplication, R.string.deduplication_hint, + settings.smsDeduplication, !busy, model::smsDeduplication) + SourceSwitch(Icons.Outlined.Notifications, R.string.notification_deduplication, R.string.deduplication_hint, + settings.notificationDeduplication, !busy, model::notificationDeduplication) + var window by remember(settings.deduplicationWindowSeconds) { + mutableStateOf(settings.deduplicationWindowSeconds.toString()) + } + val seconds = window.toIntOrNull()?.takeIf { it >= 0 } + OutlinedTextField( + value = window, + onValueChange = { value -> + window = value + value.toIntOrNull()?.takeIf { it >= 0 }?.let(model::deduplicationWindow) + }, + label = { Text(stringResource(R.string.deduplication_window)) }, + supportingText = { Text(stringResource(R.string.deduplication_window_hint)) }, + isError = seconds == null, + enabled = !busy && (settings.smsDeduplication || settings.notificationDeduplication), + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) } } item { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 22c5a37..3be4aae 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -123,8 +123,9 @@ Версия %1$s · %2$s Политика конфиденциальности Не удалось открыть политику конфиденциальности: браузер недоступен. - Не отправлять дубли - Пропускать сообщения с одинаковыми приложением, исходным временем и текстом. Отключите, чтобы пересылать и повторные сообщения. + Не отправлять дубли SMS + Не отправлять дубли уведомлений + Пропускать сообщения с одинаковыми источником и текстом в выбранном окне времени. Обновления приложения Установленная версия: %1$s @@ -154,4 +155,6 @@ Обновить Пропустить версию Отключить автопроверку + Окно времени (± секунд) + 0 — точное совпадение времени. Одинаковые новые сообщения внутри окна тоже пропускаются. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4336245..bd66a59 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,8 +123,9 @@ Version %1$s · %2$s Privacy policy No browser is available to open the privacy policy. - Skip duplicate messages - Skip messages with exactly the same app, original timestamp and text. Turn off to forward repeated messages too. + Skip duplicate SMS + Skip duplicate notifications + Skip messages with the same source and exact text within the selected time window. App updates Installed version: %1$s @@ -154,4 +155,6 @@ Update Skip this version Disable auto-checks + Time window (± seconds) + 0 compares exact timestamps. Identical new messages inside the window are also skipped. diff --git a/app/src/test/java/life/andre/message487/OutboxTest.kt b/app/src/test/java/life/andre/message487/OutboxTest.kt index 590f860..4ee6034 100644 --- a/app/src/test/java/life/andre/message487/OutboxTest.kt +++ b/app/src/test/java/life/andre/message487/OutboxTest.kt @@ -89,27 +89,63 @@ class OutboxTest { val duplicate = original.copy(eventId = "duplicate", title = "Different title", sender = "Different sender") assertFalse(outbox.enqueue(duplicate, settings)) assertFalse(outbox.enqueue(duplicate.copy(occurredAt = "2026-09-09T10:00:00.000Z"), settings)) + assertFalse(outbox.enqueue(duplicate.copy(occurredAt = "2026-09-09T09:59:59Z"), settings)) assertTrue(outbox.enqueue(duplicate.copy(eventId = "app", source = AppSource("other.app", "Chat")), settings)) - assertTrue(outbox.enqueue(duplicate.copy(eventId = "time", occurredAt = "2026-09-09T10:00:00.001Z"), settings)) + assertTrue(outbox.enqueue(duplicate.copy(eventId = "time", occurredAt = "2026-09-09T10:00:01.001Z"), settings)) assertTrue(outbox.enqueue(duplicate.copy(eventId = "text", text = original.text + " "), settings)) assertEquals(3, outbox.pendingCount()) } + @Test fun `window is inclusive symmetric configurable and does not slide on rejected captures`() { + for (type in listOf("sms", "notification")) { + for (window in listOf(0, 1, 3)) { + val boundary = window * 1000L + for (offset in listOf(-boundary - 1, -boundary, 0L, boundary, boundary + 1).distinct()) { + val options = settings.copy(deduplicationWindowSeconds = window) + val original = event().copy(messageType = type, text = "$type-$window-$offset", + occurredAt = "2026-09-09T10:00:00Z") + assertTrue(outbox.enqueue(original, options)) + val duplicate = original.copy(eventId = java.util.UUID.randomUUID().toString(), + occurredAt = java.time.Instant.parse(original.occurredAt).plusMillis(offset).toString()) + assertEquals(kotlin.math.abs(offset) > window * 1000L, outbox.enqueue(duplicate, options)) + } + } + } + val original = event().copy(text = "non-sliding", occurredAt = "2026-09-09T10:00:00Z") + assertTrue(outbox.enqueue(original, settings)) + assertFalse(outbox.enqueue(original.copy(eventId = "suppressed", occurredAt = "2026-09-09T10:00:00.900Z"), settings)) + assertTrue(outbox.enqueue(original.copy(eventId = "new", occurredAt = "2026-09-09T10:00:01.800Z"), settings)) + } + @Test fun `opt out allows sms and notification duplicates but preserves event id idempotency`() { for (type in listOf("sms", "notification")) { - val original = event().copy(messageType = type) + val original = event().copy(messageType = type, text = type) assertTrue(outbox.enqueue(original, settings)) val duplicate = original.copy(eventId = "duplicate-$type") assertFalse(outbox.enqueue(duplicate, settings)) - assertTrue(outbox.enqueue(duplicate, settings.copy(deduplication = false))) - assertFalse(outbox.enqueue(duplicate, settings.copy(deduplication = false))) + assertTrue(outbox.enqueue(duplicate, settings.copy(smsDeduplication = false, notificationDeduplication = false))) + assertFalse(outbox.enqueue(duplicate, settings.copy(smsDeduplication = false, notificationDeduplication = false))) assertFalse(outbox.enqueue(original.copy(eventId = "reenabled-$type"), settings)) } } + @Test fun `sms and notification switches only control their own source type`() { + for (smsEnabled in listOf(false, true)) { + for (notificationsEnabled in listOf(false, true)) { + val options = settings.copy(smsDeduplication = smsEnabled, notificationDeduplication = notificationsEnabled) + for (type in listOf("sms", "notification")) { + val original = event().copy(messageType = type, text = "$type-$smsEnabled-$notificationsEnabled") + assertTrue(outbox.enqueue(original, options)) + val enabled = if (type == "sms") smsEnabled else notificationsEnabled + assertEquals(!enabled, outbox.enqueue(original.copy(eventId = java.util.UUID.randomUUID().toString()), options)) + } + } + } + } + @Test fun `events captured while opted out are remembered when reenabled and tests are exempt`() { val original = event() - assertTrue(outbox.enqueue(original, settings.copy(deduplication = false))) + assertTrue(outbox.enqueue(original, settings.copy(smsDeduplication = false, notificationDeduplication = false))) assertFalse(outbox.enqueue(original.copy(eventId = "reenabled"), settings)) val test = original.copy(eventId = "test-1", messageType = "test") assertTrue(outbox.enqueue(test, settings)) @@ -121,6 +157,7 @@ class OutboxTest { outbox.enqueue(original, settings) outbox.writableDatabase.apply { execSQL("DROP TABLE message_fingerprints") + execSQL("DROP TABLE message_windows") execSQL("CREATE TABLE notifications (notification_key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)") version = 1 } @@ -128,7 +165,27 @@ class OutboxTest { outbox = Outbox(context, codec) assertEquals(original.text, JSONObject(outbox.beginAttempt(original.eventId)!!.request.json).getString("text")) assertTrue(outbox.enqueue(event(), settings)) - assertEquals(2, outbox.readableDatabase.version) + assertEquals(3, outbox.readableDatabase.version) + } + + @Test fun `version two migration preserves queued payloads and old exact history`() { + val original = event().copy(occurredAt = "2026-09-09T10:00:00Z") + assertTrue(outbox.enqueue(original, settings)) + val legacy = digest(org.json.JSONArray().put(original.source.packageName) + .put(original.occurredAt).put(original.text).toString()) + outbox.writableDatabase.apply { + execSQL("DROP TABLE message_windows") + execSQL("INSERT INTO message_fingerprints (fingerprint) VALUES (?)", arrayOf(legacy)) + version = 2 + } + outbox.close() + outbox = Outbox(context, codec) + assertEquals(original.text, JSONObject(outbox.beginAttempt(original.eventId)!!.request.json).getString("text")) + assertFalse(outbox.enqueue(original.copy(eventId = "legacy-duplicate"), settings)) + val fresh = original.copy(eventId = "fresh", occurredAt = "2026-09-09T10:01:00Z") + assertTrue(outbox.enqueue(fresh, settings)) + assertFalse(outbox.enqueue(fresh.copy(eventId = "fresh-duplicate", occurredAt = "2026-09-09T10:01:00.500Z"), settings)) + assertEquals(3, outbox.readableDatabase.version) } @Test fun `invalid acknowledgement blocks automatic delivery until manual retry`() { @@ -147,7 +204,7 @@ class OutboxTest { val waiting = event("waiting") outbox.enqueue(waiting, settings) repeat(105) { - val event = event() + val event = event().copy(text = "Message $it") outbox.enqueue(event, settings) val attempt = outbox.beginAttempt(event.eventId)!! outbox.finish(event.eventId, attempt.token, DeliveryResult(event.eventId, DeliveryStatus.HTTP_SUCCESS, 204)) diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index ed676db..4bbb6ea 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -60,13 +60,22 @@ class ScreenInteractionTest { @Test fun `duplicate filter can be disabled and reenabled from sources`() { compose.onNode(hasText(compose.activity.getString(R.string.sources_tab)) and hasClickAction()).performClick() - icon(R.string.deduplication_enabled).performScrollTo().assertIsOn().performClick() + icon(R.string.sms_deduplication).performScrollTo().assertIsOn().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertFalse(SettingsStore(application).state.value.deduplication) - icon(R.string.deduplication_enabled).assertIsOff().performClick() + assertFalse(SettingsStore(application).state.value.smsDeduplication) + icon(R.string.sms_deduplication).assertIsOff().performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } - assertTrue(SettingsStore(application).state.value.deduplication) - icon(R.string.deduplication_enabled).assertIsOn() + assertTrue(SettingsStore(application).state.value.smsDeduplication) + icon(R.string.sms_deduplication).assertIsOn() + icon(R.string.notification_deduplication).performScrollTo().assertIsOn().performClick() + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } + assertFalse(SettingsStore(application).state.value.notificationDeduplication) + assertTrue(SettingsStore(application).state.value.smsDeduplication) + node(R.string.deduplication_window).performScrollTo().performTextReplacement("3") + compose.waitUntil(10_000) { + compose.waitForIdle() + SettingsStore(application).state.value.deduplicationWindowSeconds == 3 + } } @Test fun `tabs navigate and diagnostics back returns to previous screen`() { @@ -158,7 +167,9 @@ class ScreenInteractionTest { compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } assertEquals(setOf("manual.hidden"), SettingsStore(application).state.value.packages) compose.onNode(hasText(compose.activity.getString(R.string.sources_tab)) and hasClickAction()).performClick() - node(R.string.select_all_apps).performScrollTo().performClick() + compose.onNode(hasScrollToNodeAction()).performScrollToNode( + hasText(compose.activity.getString(R.string.select_all_apps))) + node(R.string.select_all_apps).performClick() compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } assertEquals(setOf("manual.hidden", "example.alpha", "example.beta"), SettingsStore(application).state.value.packages) node(R.string.clear_app_selection).performScrollTo().performClick() diff --git a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt index 92ced8b..9a25717 100644 --- a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt +++ b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt @@ -21,12 +21,32 @@ class SettingsStoreTest { @Test fun `deduplication defaults on and opt out survives restart`() { context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit() val store = SettingsStore(context, cipher) - assertTrue(store.state.value.deduplication) - store.update { it.copy(deduplication = false) } + assertTrue(store.state.value.smsDeduplication) + assertTrue(store.state.value.notificationDeduplication) + assertEquals(1, store.state.value.deduplicationWindowSeconds) + store.update { it.copy(smsDeduplication = false, deduplicationWindowSeconds = 3) } val reopened = SettingsStore(context, cipher) - assertFalse(reopened.state.value.deduplication) - reopened.update { it.copy(deduplication = true) } - assertTrue(SettingsStore(context, cipher).state.value.deduplication) + assertFalse(reopened.state.value.smsDeduplication) + assertTrue(reopened.state.value.notificationDeduplication) + assertEquals(3, reopened.state.value.deduplicationWindowSeconds) + assertThrows(IllegalArgumentException::class.java) { reopened.update { it.copy(deduplicationWindowSeconds = -1) } } + reopened.update { it.copy(smsDeduplication = true) } + assertTrue(SettingsStore(context, cipher).state.value.smsDeduplication) + } + + @Test fun `old shared switch migrates without overriding separate switches`() { + val prefs = context.getSharedPreferences("connection", Context.MODE_PRIVATE) + for (enabled in listOf(false, true)) { + prefs.edit().clear().putBoolean("deduplication", enabled).commit() + val store = SettingsStore(context, cipher) + assertEquals(enabled, store.state.value.smsDeduplication) + assertEquals(enabled, store.state.value.notificationDeduplication) + store.update { it.copy(notificationDeduplication = !enabled) } + val reopened = SettingsStore(context, cipher) + assertEquals(enabled, reopened.state.value.smsDeduplication) + assertEquals(!enabled, reopened.state.value.notificationDeduplication) + assertFalse(prefs.contains("deduplication")) + } } @Test fun `token survives restart through injected encryption and is redacted`() { diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 9fcf410..e36784f 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -154,7 +154,7 @@ a reason; emulator SMS does not establish carrier delivery. | C02 | Plain/big text, InboxStyle, empty content, ongoing/group summary, text update. Excluded ongoing/summary/self notifications stay excluded. Existing notifications are not replayed when access is granted. Do not promise content Android redacts. | | C03 | Select/unselect, manually add launcherless packages, search and Select all. Bulk selection covers the whole list and preserves manual entries; newly installed apps are not automatically selected. Persist across restart; avoid downstream apps that create feedback loops. | | C04 | Receive synthetic SMS from an authorized test number on a phone. Verify sender/text/time and source `android`; no historical import, default-SMS role or reply. Test multipart, Cyrillic/Unicode and distinct SMS; record actual segmentation/timestamps. | -| C05 | Deduplication on/off/on, restart and upgrade. Identity is package + original timestamp + exact text; a changed timestamp is a new event. Title/sender changes alone do not change identity. Use controlled fixtures/JVM evidence and separate available runtime controls; two commands with different timestamps do not prove suppression. | +| C05 | SMS and notification deduplication: both switches default on; test all four combinations independently, toggling, restart and migration of the old shared switch on upgrade. Identity is package + exact text, with an inclusive configurable ± time window around the original timestamp. Check both boundaries and just outside them, zero (exact matching), saving the window across restart, and changing its value. Identical real messages inside the window are also skipped. Title/sender changes alone do not change identity. Use controlled fixtures/JVM evidence and separate available runtime controls; two commands with different timestamps do not prove suppression. | | C06 | Disable source/pause during capture/delivery. No new captures; pause also delays queue. An in-flight request may finish. Resume delivers queued events without importing events missed while paused. Repeated manual connection tests are not message-deduplicated. | ### Webhook, authentication and outbox diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 3cc51b7..4dd161b 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -164,7 +164,7 @@ SHA-256 APK с разными подписями не заменяют эту п | C02 | Обычный текст, big text, строки InboxStyle, пустое содержимое, ongoing и group summary; обновление текста уведомления. Исключённые ongoing/summary/свои уведомления не пересылаются. Уже существующее уведомление при выдаче доступа не воспроизводится. Не обещать доступ к скрытому Android содержимому. | | C03 | Выбор/снятие приложения, ручной пакет без launcher, поиск и «Выбрать все». Массовый выбор действует на весь список, сохраняет ручные пакеты; установленное позже приложение не выбирается само. UI-перезапуск сохраняет выбор. Не выбирайте приложение, получающее downstream сообщения: возможен цикл. | | C04 | На телефоне получите синтетическую SMS с другого разрешённого тестового номера. Проверьте sender/text/occurred_at и source `android`; история SMS не импортируется, приложение не становится default SMS и не отправляет ответ. Отдельно — длинная multipart SMS, кириллица/Unicode и две разные SMS. Зафиксируйте сегментацию/время, а не только длину строки. | -| C05 | Дедупликация включена/выключена/включена, перезапуск и обновление. Ключ — source package + исходный timestamp + точный text; другой timestamp означает новое событие даже при одинаковом тексте. Изменение title/sender само по себе не меняет ключ. Управляемую идентичность события проверяйте фикстурой/JVM и отдельно runtime с доступными контролями; не выводите PASS из двух команд с разным временем. | +| C05 | Дедупликация SMS и уведомлений: оба переключателя включены по умолчанию; проверьте независимо все четыре сочетания, выключение/включение, перезапуск и перенос старой общей настройки при обновлении. Ключ — source package + точный text, время сравнивается в настраиваемом окне ± с включёнными границами. Проверьте обе границы и значения сразу за ними, нулевое окно (точное время), сохранение настройки после перезапуска и изменение окна. Одинаковые настоящие сообщения внутри окна тоже пропускаются. Изменение title/sender само по себе не меняет ключ. Управляемую идентичность события проверяйте фикстурой/JVM и отдельно runtime с доступными контролями; не выводите PASS из двух команд с разным временем. | | C06 | Отключение источника и пауза при захвате/отправке. Нет новых событий после отключения/паузы; пауза также задерживает старую очередь. Уже выполняющийся запрос может завершиться. После Resume старые события доставляются, пропущенные во время паузы не импортируются. Две ручные проверки подключения не должны подавляться дедупликацией сообщений. | ### Веб-хук, авторизация и очередь