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
9 changes: 6 additions & 3 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
# Privacy Policy

Last updated: October 5, 2026.
Last updated: October 15, 2026.

Message487 forwards selected notifications and new incoming SMS to a webhook you configure.
Its core SMS feature transfers new incoming SMS from Android to your n8n workflow so you can
view them on a computer.
Both capture sources are off by default. Notifications require Android notification access and
an explicit selection of source apps. SMS forwarding requires the receive-SMS permission.
selection of source apps. Optional automatic selection of newly detected launcher apps is off by default;
when enabled, it runs when you open or return to Message487 and preserves manual exclusions.
SMS forwarding requires the receive-SMS permission.
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, duplicate-filter preference, pause state and a random installation ID in private preferences. These
selected and previously detected package names, the automatic-selection preference, 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.

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ Guides: [Install from APK](docs/en/apk-installation.md) · [n8n Cloud](docs/en/n
3. In **Sources**, enable notification forwarding, grant notification access in Android settings,
and select applications. Add a package manually if it has no launcher icon.
**Select all applications** selects the entire available list, regardless of the search filter;
manually added selections are preserved. Newly installed apps must be selected separately.
manually added selections are preserved. Newly installed apps must be selected separately unless
you enable **Automatically select new apps** (off by default). It selects newly detected launcher apps when you open or return to Message487,
preserving existing choices and manual exclusions. Apps without a launcher icon still require manual selection.
4. Enable SMS forwarding separately and grant SMS permission. Only new incoming SMS are read;
existing history is not imported. Multipart SMS are combined into one event.

Expand Down
43 changes: 36 additions & 7 deletions app/src/main/java/life/andre/message487/ConnectionViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
val permissions = mutablePermissions.asStateFlow()
private val mutableApps = MutableStateFlow<List<AppSource>>(emptyList())
val apps = mutableApps.asStateFlow()
private var refreshingApps = false

init {
refreshPermissions()
Expand All @@ -66,19 +67,41 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
}
}

private fun installedApps(): List<AppSource> {
val application = getApplication<Application>()
val pm = application.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
@Suppress("DEPRECATION")
val packages = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName }
return packages.distinct().filter { it != application.packageName }
.map { AppSourceResolver(pm).resolve(it) }.sortedBy { it.name.lowercase() }
}

private fun refreshApps() {
if (refreshingApps) return
refreshingApps = true
viewModelScope.launch {
mutableApps.value = withContext(Dispatchers.IO) {
val pm = application.packageManager
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
@Suppress("DEPRECATION")
val packages = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName }
packages.distinct().filter { it != application.packageName }
.map { AppSourceResolver(pm).resolve(it) }.sortedBy { it.name.lowercase() }
try {
mutableApps.value = withContext(Dispatchers.IO) {
installedApps().also { apps ->
graph.settings.discoverPackages(apps.map { it.packageName }.toSet(), getApplication<Application>().packageName)
}
}
} catch (cancelled: kotlinx.coroutines.CancellationException) {
throw cancelled
} catch (error: Exception) {
graph.diagnostics.record(DiagnosticEvent.LOCAL_OPERATION_FAILED, error = error)
mutableState.value = state.value.copy(notice = R.string.local_error)
} finally {
refreshingApps = false
}
}
}

fun refreshPermissions() {
refreshApps()
val app = getApplication<Application>()
mutablePermissions.value = PermissionState(
notifications = app.packageName in NotificationManagerCompat.getEnabledListenerPackages(app),
Expand Down Expand Up @@ -112,6 +135,12 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
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 autoSelectNewApps(enabled: Boolean) = action {
val baseline = if (enabled) installedApps().map { it.packageName }.toSet() else emptySet()
graph.settings.update {
it.copy(autoSelectNewApps = enabled, knownPackages = it.knownPackages + baseline)
}
}
fun selectAllPackages() = action {
val packages = apps.value.map { it.packageName }.toSet() - getApplication<Application>().packageName
graph.settings.update { it.copy(packages = it.packages + packages) }
Expand Down
18 changes: 18 additions & 0 deletions app/src/main/java/life/andre/message487/ForwardingSettings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ data class ForwardingSettings(
val sms: Boolean = false,
val paused: Boolean = false,
val packages: Set<String> = emptySet(),
val autoSelectNewApps: Boolean = false,
internal val knownPackages: Set<String> = emptySet(),
val captureFailed: Boolean = false,
val authToken: String = "",
val smsDeduplication: Boolean = true,
Expand Down Expand Up @@ -56,9 +58,23 @@ class SettingsStore(
sms = preferences.getBoolean("sms", false),
paused = preferences.getBoolean("paused", false),
packages = preferences.getStringSet("packages", emptySet()).orEmpty().toSet(),
autoSelectNewApps = preferences.getBoolean("auto_select_new_apps", false),
knownPackages = preferences.getStringSet("known_packages", emptySet()).orEmpty().toSet(),
captureFailed = preferences.getBoolean("capture_failed", false),
)

@Synchronized
fun discoverPackages(packages: Set<String>, ownPackage: String) {
val discovered = packages - ownPackage
if (state.value.knownPackages.containsAll(discovered)) return
update {
it.copy(
knownPackages = it.knownPackages + discovered,
packages = if (it.autoSelectNewApps) it.packages + (discovered - it.knownPackages) else it.packages,
)
}
}

@Synchronized
fun update(transform: (ForwardingSettings) -> ForwardingSettings): ForwardingSettings {
val next = transform(mutableState.value).let {
Expand All @@ -78,6 +94,8 @@ class SettingsStore(
.putBoolean("notification_deduplication", next.notificationDeduplication)
.remove("deduplication")
.putInt("deduplication_window_seconds", next.deduplicationWindowSeconds)
.putBoolean("auto_select_new_apps", next.autoSelectNewApps)
.putStringSet("known_packages", next.knownPackages)
.putBoolean("paused", next.paused).putStringSet("packages", next.packages)
.putBoolean("capture_failed", next.captureFailed).commit()
) throw IOException("Could not save settings")
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/life/andre/message487/SourcesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ internal fun SourcesScreen(settings: ForwardingSettings, permissions: Permission
item {
SectionTitle(stringResource(R.string.selected_apps, settings.packages.size), stringResource(R.string.add_package)) { addPackage = true }
SupportingText(stringResource(R.string.app_selection_short))
SourceSwitch(Icons.Outlined.Apps, R.string.auto_select_new_apps, R.string.auto_select_new_apps_hint,
settings.autoSelectNewApps, !busy, model::autoSelectNewApps)
OutlinedButton(
onClick = { if (allSelected) model.clearPackageSelection() else model.selectAllPackages() },
enabled = !busy && apps.isNotEmpty(),
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values-ru/strings.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<resources>
<string name="auto_select_new_apps">Автоматически выбирать новые приложения</string>
<string name="auto_select_new_apps_hint">По умолчанию выключено. При открытии или возврате в Message487 новые приложения со значком запуска выбираются для пересылки уведомлений. Текущий выбор сохраняется; снятый вручную выбор не возвращается. Избегайте циклов пересылки.</string>
<string name="select_all_apps">Выбрать все приложения</string>
<string name="clear_app_selection">Снять выбор</string>
<string name="back">Назад</string>
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<resources>
<string name="auto_select_new_apps">Automatically select new apps</string>
<string name="auto_select_new_apps_hint">Off by default. When you open or return to Message487, newly detected apps with launcher icons are selected for notification forwarding. Existing choices stay unchanged; apps you deselect stay excluded. Avoid forwarding loops.</string>
<string name="select_all_apps">Select all applications</string>
<string name="clear_app_selection">Clear selection</string>
<string name="back">Back</string>
Expand Down
27 changes: 27 additions & 0 deletions app/src/test/java/life/andre/message487/ScreenInteractionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,33 @@ class ScreenInteractionTest {
assertTrue(application.diagnostics.readTail().contains(DiagnosticEvent.LOG_CLEARED.name))
}

@Test fun `auto select switch preserves existing choices and selects newly discovered launcher apps`() {
compose.waitUntil(10_000) { model.apps.value.size == 2 }
compose.onNode(hasText(application.getString(R.string.sources_tab)) and hasClickAction()).performClick()
compose.onNode(hasScrollToNodeAction()).performScrollToNode(
hasContentDescription(application.getString(R.string.auto_select_new_apps)))
icon(R.string.auto_select_new_apps).performScrollTo().assertIsOff().performClick()
compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
assertTrue(SettingsStore(application).state.value.autoSelectNewApps)
assertTrue(model.settings.value.packages.isEmpty())
val intent = android.content.Intent(android.content.Intent.ACTION_MAIN).addCategory(android.content.Intent.CATEGORY_LAUNCHER)
val info = android.content.pm.ResolveInfo().apply {
activityInfo = android.content.pm.ActivityInfo().apply { packageName = "example.new"; name = "example.new.MainActivity" }
}
org.robolectric.Shadows.shadowOf(application.packageManager).addResolveInfoForIntent(intent, info)
compose.runOnIdle { model.refreshPermissions() }
compose.waitUntil(10_000) { compose.waitForIdle(); model.apps.value.any { it.packageName == "example.new" } }
assertEquals(setOf("example.new"), model.settings.value.packages)
compose.runOnIdle { model.selectPackage("example.new", false) }
compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
compose.runOnIdle { model.refreshPermissions() }
compose.waitForIdle()
assertTrue(model.settings.value.packages.isEmpty())
icon(R.string.auto_select_new_apps).performScrollTo().assertIsOn().performClick()
compose.waitUntil(10_000) { compose.waitForIdle(); !model.state.value.busy }
assertFalse(SettingsStore(application).state.value.autoSelectNewApps)
}

@Test fun `select all preserves manual packages excludes self and clear removes selection`() {
compose.waitForIdle()
assertSame(application.graph.settings.state, model.settings)
Expand Down
30 changes: 30 additions & 0 deletions app/src/test/java/life/andre/message487/SettingsStoreTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,36 @@ class SettingsStoreTest {
override fun decrypt(value: ByteArray) = String(value).reversed()
}

@Test fun `new app selection is opt in persistent and respects manual exclusions`() {
context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit()
val store = SettingsStore(context, cipher)
assertFalse(store.state.value.autoSelectNewApps)
store.discoverPackages(setOf("existing.app", "own.app"), "own.app")
assertTrue(store.state.value.packages.isEmpty())
store.update { it.copy(autoSelectNewApps = true, packages = setOf("manual.hidden")) }
store.discoverPackages(setOf("existing.app", "new.app", "own.app"), "own.app")
assertEquals(setOf("manual.hidden", "new.app"), store.state.value.packages)
assertFalse("own.app" in store.state.value.knownPackages)
store.update { it.copy(packages = it.packages - "new.app") }
val reopened = SettingsStore(context, cipher)
assertTrue(reopened.state.value.autoSelectNewApps)
reopened.discoverPackages(setOf("existing.app", "new.app"), "own.app")
assertEquals(setOf("manual.hidden"), reopened.state.value.packages)
// Clearing selection must not make known apps new again, even after reinstall.
reopened.update { it.copy(packages = emptySet()) }
reopened.discoverPackages(emptySet(), "own.app")
reopened.discoverPackages(setOf("new.app"), "own.app")
assertTrue(reopened.state.value.packages.isEmpty())
reopened.update { it.copy(autoSelectNewApps = false) }
reopened.discoverPackages(setOf("while.off"), "own.app")
assertTrue(reopened.state.value.packages.isEmpty())
reopened.update { it.copy(autoSelectNewApps = true) }
reopened.discoverPackages(setOf("while.off", "later.app"), "own.app")
assertEquals(setOf("later.app"), SettingsStore(context, cipher).state.value.packages)
assertFalse(reopened.state.value.notifications)
assertFalse(reopened.state.value.acceptsPackage("later.app", "own.app"))
}

@Test fun `deduplication defaults on and opt out survives restart`() {
context.getSharedPreferences("connection", Context.MODE_PRIVATE).edit().clear().commit()
val store = SettingsStore(context, cipher)
Expand Down
Loading