diff --git a/PRIVACY.md b/PRIVACY.md index 125255a..ac5d695 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -10,7 +10,7 @@ The app does not read existing SMS history, send SMS, or reply to notifications. ## On your device The app stores your webhook URL, editable device code, confirmation preference, enabled sources, -selected package names, pause state and a random installation ID in private preferences. These +selected package names, duplicate-filter preference, pause state and a random installation ID in private preferences. These preferences do not have additional application-level encryption. A webhook URL may itself contain a secret, so treat it as sensitive. Android cloud backup and device transfer are disabled for app data. @@ -21,8 +21,10 @@ 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. Notification duplicate detection stores hashes of keys -and contents; these hashes are not a substitute for encryption against guesses of known content. +additional application-level encryption. Message duplicate detection stores SHA-256 hashes of source package, original timestamp and text, +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. The journal shows delivery metadata and does not display message contents. Confirmed events have their encrypted payload removed from the active database record; only recent delivery metadata is diff --git a/README.md b/README.md index d3f758f..d4580f1 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,15 @@ 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). +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 +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. + 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 custom webhook that acknowledges with HTTP 2xx. Neither response proves delivery to a downstream diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ceff620..2349762 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,8 +31,8 @@ android { applicationId = "life.andre.message487" minSdk = 26 targetSdk = 36 - versionCode = 4 - versionName = "0.0.4" + versionCode = 5 + versionName = "0.0.5" buildConfigField("String", "GIT_COMMIT_HASH", "\"$gitCommitHash\"") } signingConfigs { diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index d1a7cef..3e41129 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -106,6 +106,7 @@ 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 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 e791480..f67c061 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -17,6 +17,7 @@ data class ForwardingSettings( val packages: Set = emptySet(), val captureFailed: Boolean = false, val authToken: String = "", + val deduplication: Boolean = true, ) { override fun toString(): String = "ForwardingSettings(redacted)" @@ -40,6 +41,7 @@ class SettingsStore( cipher.decrypt(android.util.Base64.decode(it, android.util.Base64.NO_WRAP)) }.orEmpty() }.getOrDefault(""), + deduplication = preferences.getBoolean("deduplication", true), url = preferences.getString("url", BuildConfig.DEFAULT_WEBHOOK_URL).orEmpty(), deviceId = preferences.getString("device_id", "").orEmpty(), deviceCode = preferences.getString("device_code", "android-device").orEmpty(), @@ -66,6 +68,7 @@ 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("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/MessageGraph.kt b/app/src/main/java/life/andre/message487/MessageGraph.kt index be56dfd..9d39ada 100644 --- a/app/src/main/java/life/andre/message487/MessageGraph.kt +++ b/app/src/main/java/life/andre/message487/MessageGraph.kt @@ -4,7 +4,6 @@ import android.app.Application import android.content.Context import java.security.MessageDigest import java.time.Instant -import java.util.UUID import java.util.concurrent.Executors import java.io.File import life.andre.message487.diagnostics.CrashHandler @@ -66,24 +65,22 @@ class MessageGraph internal constructor(private val context: Application) { occurredAt = Instant.ofEpochMilli(notification.postedAt).toString(), messageType = "notification", text = notification.text, title = notification.title, ) - enqueue(event, config, digest(notification.key), digest(notification.title + "\u0000" + notification.text)) + enqueue(event, config) } fun captureSms(sender: String, text: String, timestamp: Long) { val config = settings.state.value if (config.paused || !config.sms || !config.ready()) return - val identity = "${config.deviceId}\u0000$sender\u0000$timestamp\u0000$text" val event = MessageEvent( config.deviceId, config.deviceCode, sources.resolve("android"), - eventId = UUID.nameUUIDFromBytes(identity.toByteArray(Charsets.UTF_8)).toString(), occurredAt = Instant.ofEpochMilli(timestamp).toString(), messageType = "sms", text = text, sender = sender, ) enqueue(event, config) } - private fun enqueue(event: MessageEvent, config: ForwardingSettings, key: String? = null, fingerprint: String? = null) { - if (outbox.enqueue(event, config, key, fingerprint)) { + private fun enqueue(event: MessageEvent, config: ForwardingSettings) { + if (outbox.enqueue(event, config)) { diagnostics.record(DiagnosticEvent.EVENT_QUEUED, type = event.messageType) scheduler.schedule(event.eventId) } else diagnostics.record(DiagnosticEvent.DUPLICATE_SKIPPED, type = event.messageType) diff --git a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt index bc89e31..3bed54f 100644 --- a/app/src/main/java/life/andre/message487/NotificationCaptureService.kt +++ b/app/src/main/java/life/andre/message487/NotificationCaptureService.kt @@ -59,10 +59,4 @@ class NotificationCaptureService : NotificationListenerService() { } catch (error: Exception) { graph.captureFailed(error) } } - override fun onNotificationRemoved(sbn: StatusBarNotification) { - val graph = MessageGraph.get(this) - graph.captureExecutor.execute { - try { graph.outbox.forgetNotification(digest(sbn.key)) } catch (error: Exception) { graph.captureFailed(error) } - } - } } diff --git a/app/src/main/java/life/andre/message487/Outbox.kt b/app/src/main/java/life/andre/message487/Outbox.kt index 0fc1eef..bb90e8e 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, 1) { +class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHelper(context, "outbox.db", null, 2) { private val mutableRevision = MutableStateFlow(0L) val revision = mutableRevision.asStateFlow() @@ -51,21 +51,34 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe outcome TEXT, http_code INTEGER, payload BLOB, attempt_token TEXT )""") db.execSQL("CREATE INDEX events_state ON events(state)") - db.execSQL("CREATE TABLE notifications (notification_key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)") + createDeduplicationTable(db) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { - error("Missing forward migration from $oldVersion to $newVersion") + if (oldVersion < 2) { + createDeduplicationTable(db) + db.execSQL("DROP TABLE IF EXISTS notifications") + } + } + + private fun createDeduplicationTable(db: SQLiteDatabase) { + db.execSQL("CREATE TABLE message_fingerprints (fingerprint TEXT PRIMARY KEY)") } @Synchronized - fun enqueue(event: MessageEvent, settings: ForwardingSettings, notificationKey: String? = null, fingerprint: String? = null): Boolean { + fun enqueue(event: MessageEvent, settings: ForwardingSettings): Boolean { val db = writableDatabase db.beginTransaction() try { - if (notificationKey != null) { - db.rawQuery("SELECT fingerprint FROM notifications WHERE notification_key = ?", arrayOf(notificationKey)).use { - if (it.moveToFirst() && it.getString(0) == fingerprint) return false + // 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() + ) else null + if (settings.deduplication && fingerprint != null) { + db.rawQuery("SELECT 1 FROM message_fingerprints WHERE fingerprint = ?", arrayOf(fingerprint)).use { + if (it.moveToFirst()) return false } } db.rawQuery("SELECT id FROM events WHERE id = ?", arrayOf(event.eventId)).use { @@ -81,12 +94,8 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe put("state", QueueState.PENDING.name) put("payload", cipher.encrypt(envelope)) }) - if (notificationKey != null) { - db.insertWithOnConflict("notifications", null, ContentValues().apply { - put("notification_key", notificationKey) - put("fingerprint", requireNotNull(fingerprint)) - }, SQLiteDatabase.CONFLICT_REPLACE) - db.execSQL("DELETE FROM notifications WHERE rowid NOT IN (SELECT rowid FROM notifications ORDER BY rowid DESC LIMIT 2000)") + if (fingerprint != null) { + db.execSQL("INSERT OR IGNORE INTO message_fingerprints (fingerprint) VALUES (?)", arrayOf(fingerprint)) } db.setTransactionSuccessful() } finally { @@ -96,11 +105,6 @@ class Outbox(context: Context, private val cipher: PayloadCipher) : SQLiteOpenHe return true } - @Synchronized - fun forgetNotification(key: String) { - writableDatabase.delete("notifications", "notification_key = ?", arrayOf(key)) - } - @Synchronized fun pendingIds(): List = readableDatabase.rawQuery( "SELECT id FROM events WHERE state IN ('PENDING', 'SENDING', 'RETRY') ORDER BY created_at", null diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt index 7a3188f..a03a66f 100644 --- a/app/src/main/java/life/andre/message487/SourcesScreen.kt +++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt @@ -93,6 +93,12 @@ 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) + } + } item { SectionTitle(stringResource(R.string.selected_apps, settings.packages.size), stringResource(R.string.add_package)) { addPackage = true } SupportingText(stringResource(R.string.app_selection_short)) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 3dd17ed..1717359 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -123,4 +123,6 @@ Версия %1$s · %2$s Политика конфиденциальности Не удалось открыть политику конфиденциальности: браузер недоступен. + Не отправлять дубли + Пропускать сообщения с одинаковыми приложением, исходным временем и текстом. Отключите, чтобы пересылать и повторные сообщения. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33f2ff8..ac1e9cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,4 +123,6 @@ 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. diff --git a/app/src/test/java/life/andre/message487/OutboxTest.kt b/app/src/test/java/life/andre/message487/OutboxTest.kt index 336fb11..590f860 100644 --- a/app/src/test/java/life/andre/message487/OutboxTest.kt +++ b/app/src/test/java/life/andre/message487/OutboxTest.kt @@ -78,17 +78,59 @@ class OutboxTest { } } - @Test fun `notification duplicates survive restart while changed or reposted content is captured`() { - assertTrue(outbox.enqueue(event(), settings, "key", "content")) + @Test fun `deduplication uses app timestamp and text and survives delivery deletion and restart`() { + val original = event().copy(occurredAt = "2026-09-09T10:00:00Z") + assertTrue(outbox.enqueue(original, settings)) + val attempt = outbox.beginAttempt(original.eventId)!! + outbox.finish(original.eventId, attempt.token, DeliveryResult(original.eventId, DeliveryStatus.ACCEPTED, 200)) + outbox.delete(original.eventId) outbox.close() outbox = Outbox(context, codec) - assertFalse(outbox.enqueue(event(), settings, "key", "content")) - assertTrue(outbox.enqueue(event(), settings, "key", "changed")) - outbox.forgetNotification("key") - assertTrue(outbox.enqueue(event(), settings, "key", "changed")) + 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)) + 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 = "text", text = original.text + " "), settings)) assertEquals(3, outbox.pendingCount()) } + @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) + 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))) + assertFalse(outbox.enqueue(original.copy(eventId = "reenabled-$type"), settings)) + } + } + + @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))) + assertFalse(outbox.enqueue(original.copy(eventId = "reenabled"), settings)) + val test = original.copy(eventId = "test-1", messageType = "test") + assertTrue(outbox.enqueue(test, settings)) + assertTrue(outbox.enqueue(test.copy(eventId = "test-2"), settings)) + } + + @Test fun `version one migration preserves queued payloads`() { + val original = event() + outbox.enqueue(original, settings) + outbox.writableDatabase.apply { + execSQL("DROP TABLE message_fingerprints") + execSQL("CREATE TABLE notifications (notification_key TEXT PRIMARY KEY, fingerprint TEXT NOT NULL)") + version = 1 + } + outbox.close() + 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) + } + @Test fun `invalid acknowledgement blocks automatic delivery until manual retry`() { val event = event() outbox.enqueue(event, settings) @@ -116,15 +158,16 @@ class OutboxTest { assertFalse(outbox.enqueue(waiting, settings)) } - @Test fun `failed persistence does not advance notification deduplication`() { + @Test fun `failed persistence does not advance message deduplication`() { + val original = event() val broken = Outbox(context, object : PayloadCipher { override fun encrypt(value: String): ByteArray = throw java.io.IOException("Storage failure") override fun decrypt(value: ByteArray): String = error("Unused") }) broken.use { - assertThrows(java.io.IOException::class.java) { it.enqueue(event(), settings, "key", "content") } + assertThrows(java.io.IOException::class.java) { it.enqueue(original, settings) } } - assertTrue(outbox.enqueue(event(), settings, "key", "content")) + assertTrue(outbox.enqueue(original, settings)) assertEquals(1, outbox.pendingCount()) } } diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index c03fc56..ed676db 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -58,6 +58,17 @@ class ScreenInteractionTest { application.diagnostics.close() } + @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() + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } + assertFalse(SettingsStore(application).state.value.deduplication) + icon(R.string.deduplication_enabled).assertIsOff().performClick() + compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy } + assertTrue(SettingsStore(application).state.value.deduplication) + icon(R.string.deduplication_enabled).assertIsOn() + } + @Test fun `tabs navigate and diagnostics back returns to previous screen`() { for (destination in listOf(R.string.sources_tab, R.string.journal, R.string.connection_nav)) { compose.onNode(hasText(compose.activity.getString(destination)) and hasClickAction()).performClick() diff --git a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt index da2506d..92ced8b 100644 --- a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt +++ b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt @@ -18,6 +18,17 @@ class SettingsStoreTest { override fun decrypt(value: ByteArray) = String(value).reversed() } + @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) } + val reopened = SettingsStore(context, cipher) + assertFalse(reopened.state.value.deduplication) + reopened.update { it.copy(deduplication = true) } + assertTrue(SettingsStore(context, cipher).state.value.deduplication) + } + @Test fun `token survives restart through injected encryption and is redacted`() { val store = SettingsStore(context, cipher) store.update { it.copy(authToken = "private-token", url = "https://example.test/hook") } diff --git a/docs/en/project-context.md b/docs/en/project-context.md index e6072ae..edc9b89 100644 --- a/docs/en/project-context.md +++ b/docs/en/project-context.md @@ -45,8 +45,9 @@ Compatibility with that protocol was not agreed as a requirement for the new app ## Capture and delivery NotificationListenerService uses a package selection; SMS_RECEIVED uses RECEIVE_SMS and -goAsync. Both sources default to off. Existing SMS history is not read. Unchanged notification -updates are suppressed; changed content creates an event. Group summaries, ongoing notifications +goAsync. Both sources default to off. Existing SMS history is not read. Duplicate SMS and notifications +are suppressed by source package, exact original timestamp and text. The Sources switch is on +by default and allows opting out. Group summaries, ongoing notifications and Message487's own notifications are excluded. Events are persisted in SQLite before delivery; request bodies use AES-GCM with Android @@ -59,8 +60,9 @@ attempts; a running request may complete. Backup and device transfer exclude app Limitations: capture depends on Android, and the process may die before local persistence. WorkManager does not promise immediate delivery. Sensitive-notification restrictions are not -bypassed. Physical erasure of SQLite pages is not guaranteed. SMS deduplication uses sender, -time, text and installation; it does not replace server-side deduplication. +bypassed. Physical erasure of SQLite pages is not guaranteed. Deduplication hashes persist until app data is cleared, independently of journal cleanup. +The history starts with new captures after upgrading to database version 2. This does not replace +server-side deduplication by event ID. ## Product direction diff --git a/docs/ru/project-context.md b/docs/ru/project-context.md index 9ade22c..f4240a9 100644 --- a/docs/ru/project-context.md +++ b/docs/ru/project-context.md @@ -44,8 +44,9 @@ ## Захват и доставка Реализованы NotificationListenerService с выбором пакетов и SMS_RECEIVED с RECEIVE_SMS и goAsync. -Оба источника выключены по умолчанию. История SMS не читается. Неизменившиеся обновления -уведомления подавляются; изменившиеся создают новое событие. Сводки групп, постоянные уведомления +Оба источника выключены по умолчанию. История SMS не читается. Дубли SMS и уведомлений +подавляются по пакету приложения, точному исходному времени и тексту. Фильтр включён по умолчанию; +его можно отключить на экране источников. Сводки групп, постоянные уведомления и собственные уведомления Message487 исключены. Событие сохраняется в SQLite до отправки; тело запроса зашифровано AES-GCM с Android Keystore. @@ -59,8 +60,9 @@ HTTP-ошибки требуют ручного повтора. Неподтве Ограничения: захват зависит от Android, процесс может завершиться до локального сохранения; WorkManager не гарантирует немедленную доставку. Системные ограничения на чувствительные -уведомления не обходятся. Физическая очистка страниц SQLite не гарантируется. Дедупликация SMS -основана на отправителе, времени, тексте и установке; она не заменяет серверную дедупликацию. +уведомления не обходятся. Физическая очистка страниц SQLite не гарантируется. Хеши дедупликации +сохраняются до очистки данных приложения независимо от удаления записей журнала. После обновления +БД до версии 2 история начинается с новых событий. Это не заменяет серверную дедупликацию по event_id. ## Направление продукта diff --git a/fastlane/metadata/android/en-US/changelogs/5.txt b/fastlane/metadata/android/en-US/changelogs/5.txt new file mode 100644 index 0000000..c6b55a3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/5.txt @@ -0,0 +1 @@ +Duplicate filtering is enabled by default for SMS and notifications, using app, exact timestamp and text. Disable it in Sources to forward repeated messages. Duplicate history survives restarts and journal cleanup. diff --git a/fastlane/metadata/android/ru-RU/changelogs/5.txt b/fastlane/metadata/android/ru-RU/changelogs/5.txt new file mode 100644 index 0000000..68bb405 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/changelogs/5.txt @@ -0,0 +1 @@ +Дедупликация SMS и уведомлений включена по умолчанию: проверяются приложение, точное время и текст. Её можно отключить на экране источников. История проверки сохраняется после перезапуска и очистки журнала.