diff --git a/PRIVACY.md b/PRIVACY.md index f2ef408..8b8e98f 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -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. diff --git a/README.md b/README.md index 6f0c59e..1e1644f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt index 21bc4f1..501e5b4 100644 --- a/app/src/main/java/life/andre/message487/ConnectionViewModel.kt +++ b/app/src/main/java/life/andre/message487/ConnectionViewModel.kt @@ -49,6 +49,7 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati val permissions = mutablePermissions.asStateFlow() private val mutableApps = MutableStateFlow>(emptyList()) val apps = mutableApps.asStateFlow() + private var refreshingApps = false init { refreshPermissions() @@ -66,19 +67,41 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati } } } + } + + private fun installedApps(): List { + val application = getApplication() + 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().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() mutablePermissions.value = PermissionState( notifications = app.packageName in NotificationManagerCompat.getEnabledListenerPackages(app), @@ -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().packageName graph.settings.update { it.copy(packages = it.packages + packages) } diff --git a/app/src/main/java/life/andre/message487/ForwardingSettings.kt b/app/src/main/java/life/andre/message487/ForwardingSettings.kt index e7b9e09..2d4b799 100644 --- a/app/src/main/java/life/andre/message487/ForwardingSettings.kt +++ b/app/src/main/java/life/andre/message487/ForwardingSettings.kt @@ -15,6 +15,8 @@ data class ForwardingSettings( val sms: Boolean = false, val paused: Boolean = false, val packages: Set = emptySet(), + val autoSelectNewApps: Boolean = false, + internal val knownPackages: Set = emptySet(), val captureFailed: Boolean = false, val authToken: String = "", val smsDeduplication: Boolean = true, @@ -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, 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 { @@ -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") diff --git a/app/src/main/java/life/andre/message487/SourcesScreen.kt b/app/src/main/java/life/andre/message487/SourcesScreen.kt index eef4ab0..149dcdf 100644 --- a/app/src/main/java/life/andre/message487/SourcesScreen.kt +++ b/app/src/main/java/life/andre/message487/SourcesScreen.kt @@ -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(), diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f3f703d..574c170 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,4 +1,6 @@ + Автоматически выбирать новые приложения + По умолчанию выключено. При открытии или возврате в Message487 новые приложения со значком запуска выбираются для пересылки уведомлений. Текущий выбор сохраняется; снятый вручную выбор не возвращается. Избегайте циклов пересылки. Выбрать все приложения Снять выбор Назад diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f157f3..cc45c00 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ + Automatically select new apps + 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. Select all applications Clear selection Back diff --git a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt index b45b0cf..bb72407 100644 --- a/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt +++ b/app/src/test/java/life/andre/message487/ScreenInteractionTest.kt @@ -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) diff --git a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt index 9a25717..cf8df2e 100644 --- a/app/src/test/java/life/andre/message487/SettingsStoreTest.kt +++ b/app/src/test/java/life/andre/message487/SettingsStoreTest.kt @@ -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)