Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Application>().packageName
Expand Down
15 changes: 12 additions & 3 deletions app/src/main/java/life/andre/message487/ForwardingSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ data class ForwardingSettings(
val packages: Set<String> = 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)
Expand All @@ -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(),
Expand All @@ -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")
Expand Down
38 changes: 30 additions & 8 deletions app/src/main/java/life/andre/message487/Outbox.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -52,13 +52,22 @@ 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) {
if (oldVersion < 2) {
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) {
Expand All @@ -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
}
}
Expand All @@ -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 {
Expand Down
25 changes: 23 additions & 2 deletions app/src/main/java/life/andre/message487/SourcesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@
<string name="app_version">Версия %1$s · %2$s</string>
<string name="privacy_policy">Политика конфиденциальности</string>
<string name="no_browser">Не удалось открыть политику конфиденциальности: браузер недоступен.</string>
<string name="deduplication_enabled">Не отправлять дубли</string>
<string name="deduplication_hint">Пропускать сообщения с одинаковыми приложением, исходным временем и текстом. Отключите, чтобы пересылать и повторные сообщения.</string>
<string name="sms_deduplication">Не отправлять дубли SMS</string>
<string name="notification_deduplication">Не отправлять дубли уведомлений</string>
<string name="deduplication_hint">Пропускать сообщения с одинаковыми источником и текстом в выбранном окне времени.</string>

<string name="updates">Обновления приложения</string>
<string name="update_installed">Установленная версия: %1$s</string>
Expand Down Expand Up @@ -154,4 +155,6 @@
<string name="update_notification_open">Обновить</string>
<string name="update_skip">Пропустить версию</string>
<string name="update_disable">Отключить автопроверку</string>
<string name="deduplication_window">Окно времени (± секунд)</string>
<string name="deduplication_window_hint">0 — точное совпадение времени. Одинаковые новые сообщения внутри окна тоже пропускаются.</string>
</resources>
7 changes: 5 additions & 2 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@
<string name="app_version">Version %1$s · %2$s</string>
<string name="privacy_policy">Privacy policy</string>
<string name="no_browser">No browser is available to open the privacy policy.</string>
<string name="deduplication_enabled">Skip duplicate messages</string>
<string name="deduplication_hint">Skip messages with exactly the same app, original timestamp and text. Turn off to forward repeated messages too.</string>
<string name="sms_deduplication">Skip duplicate SMS</string>
<string name="notification_deduplication">Skip duplicate notifications</string>
<string name="deduplication_hint">Skip messages with the same source and exact text within the selected time window.</string>

<string name="updates">App updates</string>
<string name="update_installed">Installed version: %1$s</string>
Expand Down Expand Up @@ -154,4 +155,6 @@
<string name="update_notification_open">Update</string>
<string name="update_skip">Skip this version</string>
<string name="update_disable">Disable auto-checks</string>
<string name="deduplication_window">Time window (± seconds)</string>
<string name="deduplication_window_hint">0 compares exact timestamps. Identical new messages inside the window are also skipped.</string>
</resources>
Loading
Loading