diff --git a/PULL_NOTIFICATION.md b/PULL_NOTIFICATION.md new file mode 100644 index 000000000..ef71d19e2 --- /dev/null +++ b/PULL_NOTIFICATION.md @@ -0,0 +1,299 @@ +# Always-On Notification Service + +Amethyst's always-on notification service maintains persistent WebSocket connections to the +user's inbox relays and DM relays, ensuring real-time delivery of DMs, zaps, mentions, and +other notifications without depending on an external push server. + +## Why + +The existing push notification system (`push.amethyst.social`) can only monitor relays it +knows about. Private, paid, or obscure inbox relays get missed entirely. The only way to +guarantee 100% notification coverage is for the device itself to maintain connections to +the user's NIP-65 inbox relays and NIP-17 DM relays. + +## Architecture + +``` +NIP-65 notification inbox relays ──WebSocket──┐ + ├──> [NotificationRelayService] +NIP-17 DM inbox relays ───────────WebSocket──┘ | + v + EventNotificationConsumer + | + v + Android Notification +``` + +The service shares the **same `NostrClient` instance** as the UI. This is the key design +decision. When the app is in the foreground, both the UI and the service are collecting +the `relayServices` flow. The `AccountFilterAssembler` subscription from the Compose UI +tree stays active as long as the Activity exists (even when stopped/backgrounded), +keeping notification and DM relay connections alive. When the app returns to the +foreground, the UI piggybacks on the already-open connections. **Zero reconnection, zero +dropped messages.** + +``` +BACKGROUND MODE (service running): + inbox-relay-1 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows] + inbox-relay-2 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows] + dm-relay-1 ────WebSocket──> [AccountFilterAssembler: gift wraps] + (outbox relays disconnected — no Home/Video/Discovery subscriptions) + +APP FOREGROUNDS: + inbox-relay-1 ──WebSocket──> [Same connection] <── Home/Discovery subs resume + inbox-relay-2 ──WebSocket──> [Same connection] <── Home/Discovery subs resume + dm-relay-1 ────WebSocket──> [Same connection] <── ChatroomList subs resume + outbox-relay-3 ──WebSocket──> [New connection] <── Home/Video feed relay +``` + +## Subscription Architecture + +### What the service does + +The service does NOT create its own relay subscriptions. It only keeps the +`RelayProxyClientConnector` alive by collecting `relayServices`. The actual subscriptions +come from the `AccountFilterAssembler` in the Compose tree (`LoggedInPage`), which +stays active as long as the Activity exists and covers: + +- **Metadata** — user profile, relay lists, mute lists, follows (via `AccountMetadataEoseManager`) +- **Follows** — follow list changes that affect notification filtering (via `AccountFollowsLoaderSubAssembler`) +- **Notifications** — mentions, zaps, reactions on NIP-65 inbox relays (via `AccountNotificationsEoseFromInboxRelaysManager`) +- **Gift wraps** — NIP-59 encrypted DMs on NIP-17 DM relays (via `AccountGiftWrapsEoseManager`) +- **Drafts** — draft events (via `AccountDraftsEoseManager`) + +This is critical because notification filtering depends on follow lists, mute lists, +and relay configurations. If the service maintained its own isolated subscriptions, +it would miss follow list changes and display notifications from muted users. + +### What pauses on background + +Heavy feed subscriptions use `LifecycleAwareKeyDataSourceSubscription` which subscribes +on `ON_START` and unsubscribes on `ON_STOP`. When the app backgrounds: + +| Subscription | Behavior | Why | +|-------------|----------|-----| +| `AccountFilterAssembler` | **Stays active** | Needed for notifications, DMs, follow/mute list changes | +| `HomeFilterAssembler` | **Pauses** | Home feed data with nobody viewing wastes bandwidth | +| `VideoFilterAssembler` | **Pauses** | Video feed data with nobody viewing wastes bandwidth | +| `DiscoveryFilterAssembler` | **Pauses** | Discovery feed data with nobody viewing wastes bandwidth | +| `ChatroomListFilterAssembler` | **Pauses** | Chatroom list updates with nobody viewing waste bandwidth | + +When the feed subscriptions pause, the relay pool automatically disconnects outbox relays +that no longer have any active subscriptions. Only inbox and DM relays stay connected +(because `AccountFilterAssembler` still has active subscriptions on them). + +## Foreground Service + +`NotificationRelayService` is a foreground service with `specialUse` type: + +- **No time limit**: Unlike `dataSync` (6-hour limit on Android 15), `specialUse` has no + timeout restriction. **Why it matters:** A notification service must run indefinitely. + The `dataSync` type would force the service to stop after 6 cumulative hours per 24-hour + period, making it useless for always-on notifications. +- **BOOT_COMPLETED safe**: Can be started from boot receivers on Android 15+, unlike + `dataSync` which is restricted. **Why it matters:** Without this, the service couldn't + auto-restart after a reboot on modern Android. +- **START_STICKY**: Android will restart the service if it's killed by the system. +- **Persistent notification**: Shows "Connected to N inbox relays" with a Pause action. + Uses `IMPORTANCE_LOW` so it's silent and unobtrusive. + +### ForegroundServiceStartNotAllowedException + +On Android 12+, starting a foreground service from the background can throw +`ForegroundServiceStartNotAllowedException`. The service catches this gracefully and stops +itself rather than crashing the app. + +**Why it matters:** Without this catch, if the watchdog alarm or WorkManager tries to +restart the service while the app lacks the background-start exemption (e.g., battery +optimization is active), the app would crash with an unhandled exception. + +### Redundant startForeground() + +`startForeground()` is called from both `onCreate()` and `onStartCommand()` as a safety +net. In rare edge cases, `onStartCommand()` can fire before `onCreate()` completes +(observed in ntfy issue #1520). The `foregroundStarted` flag prevents double invocation. + +**Why it matters:** If `startForeground()` isn't called within 5 seconds of +`startForegroundService()`, the app crashes with an ANR. The redundant call ensures the +foreground notification is posted regardless of which lifecycle method runs first. + +## 8-Layer Auto-Restart Defense + +Android (and OEM battery optimizers) will aggressively try to kill background services. +The notification service uses 8 independent mechanisms to stay alive. Each addresses a +specific kill vector that the others don't cover: + +### Layer 1: START_STICKY + +**What:** When Android kills the service due to memory pressure, `START_STICKY` tells the +system to recreate it with a null intent. + +**Why needed:** This is the baseline restart mechanism provided by Android. However, it's +unreliable in practice — many OEMs (Xiaomi MIUI, Huawei EMUI, Samsung One UI, Oppo +ColorOS) override this behavior and prevent sticky service restarts. That's why we need +the remaining 7 layers. + +### Layer 2: onTaskRemoved() Alarm + +**What:** When the user swipes the app from recents, schedules a 1-second alarm to restart +the service. + +**Why needed:** On stock Android, swiping from recents only removes the task but leaves +the foreground service running. However, many OEMs treat swipe-from-recents as a force +stop, killing the foreground service. `START_STICKY` won't help because some OEMs block +sticky restarts after a task removal. The alarm bypasses this by scheduling the restart +through `AlarmManager`, which is a separate system that OEM modifications rarely touch. + +### Layer 3: onDestroy() Broadcast + +**What:** When the service is destroyed for any reason, it broadcasts to +`AutoRestartReceiver`, which enqueues a one-time WorkManager task with a network +connectivity constraint. + +**Why needed:** This catches the gap between `START_STICKY` and `onTaskRemoved()`. +If the system kills the service during normal operation (not from recents), `START_STICKY` +should restart it — but if the OEM blocks that restart, the broadcast fires a WorkManager +task as a backup. WorkManager is harder for OEMs to suppress because it's part of +Google Play Services infrastructure. + +### Layer 4: AlarmManager Watchdog (5 minutes) + +**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5 +minutes. The receiver checks if the service should be running and restarts it. + +**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail +(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't +delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to +wake the device from sleep, ensuring the check happens even in Doze. + +### Layer 5: WorkManager Periodic Catch-Up (15 minutes) + +**What:** Runs every 15 minutes with a network connectivity constraint. Ensures relay +connections are active and restarts the foreground service if needed. + +**Why needed:** WorkManager survives process death and device reboots — it's the most +persistent scheduling mechanism on Android. Even if the app process is completely dead, +WorkManager (backed by JobScheduler) will eventually wake it. The 15-minute interval is +WorkManager's minimum, ensuring regular catch-up even if the foreground service has been +dead for a while. + +### Layer 6: Network-Available One-Time Worker + +**What:** When `AutoRestartReceiver` fires, it enqueues a one-time WorkManager task that +runs as soon as network connectivity is available. + +**Why needed:** If the service dies during a network outage, there's no point restarting it +immediately (the relays won't connect). This worker waits for connectivity and restarts +then, rather than waiting up to 15 minutes for the next periodic worker. This is especially +important after airplane mode, tunnel/elevator scenarios, or switching between WiFi and +cellular. + +### Layer 7: Boot and Package Receivers + +**What:** `BootCompletedReceiver` restarts the service after device reboot +(`BOOT_COMPLETED`, `QUICKBOOT_POWERON`) and app update (`MY_PACKAGE_REPLACED`). + +**Why needed:** After a reboot, no services are running — `START_STICKY` doesn't apply +across reboots. The boot receiver is the only way to restart. After an app update, the +old process is killed and the new version's services don't auto-start. Without +`MY_PACKAGE_REPLACED`, users would need to manually open the app after every Play Store +update to restore notifications. + +### Layer 8: FCM / UnifiedPush (existing) + +**What:** The existing push notification system continues to work alongside the always-on +service. + +**Why needed:** FCM is the only mechanism that survives a force stop (because Google Play +Services handles delivery outside the app's process). If the user explicitly force-stops +Amethyst from Settings, all 7 layers above are disabled. Only FCM can still deliver +notifications until the user opens the app again. + +## WakeLock During Notification Processing + +`EventNotificationConsumer` acquires a `PARTIAL_WAKE_LOCK` with a 10-minute timeout when +processing incoming notifications. + +**Why needed:** When a notification event arrives from a relay, the app needs to decrypt +NIP-59 gift wraps, verify signatures, look up accounts, resolve display names, load +profile pictures, and construct the Android notification. In Doze mode, the CPU can sleep +between alarm windows. Without a WakeLock, the CPU could sleep mid-processing, causing the +notification to be delayed or lost. The 10-minute timeout is generous to handle slow +decryption (especially with external signers) while preventing indefinite wake locks from +battery drain. + +## Battery Optimization Exemption + +The service works best when the app is exempted from Android's battery optimizations (Doze). + +**Why needed:** Even with a foreground service, Android can restrict network access during +Doze maintenance windows. The battery optimization exemption tells Android that this app's +network activity is user-expected and should not be deferred. Without it, relay connections +may be broken during Doze, causing missed notifications that only arrive when the device +exits Doze (which can be hours for a stationary, charging device). + +`BatteryOptimizationHelper` checks the exemption status and provides a method to launch +the system settings dialog. When the always-on service is enabled but the app isn't +whitelisted, the settings screen shows a warning banner with a "Fix now" button. + +Messaging apps are explicitly listed as a valid use case for this exemption in Google Play +policy. + +## Coordinator + +`AlwaysOnNotificationServiceManager` watches the account's `alwaysOnNotificationService` +setting (a `MutableStateFlow`) and activates or deactivates all layers in +response: + +``` +Setting ON → Start foreground service + Schedule WorkManager + Schedule watchdog alarm +Setting OFF → Stop foreground service + Cancel WorkManager + Cancel watchdog alarm +``` + +The manager is initialized in `AppModules` and watches the account state. When a user +logs in, it starts watching their setting. When they log out, it stops. + +## Settings + +The always-on notification service is **opt-in** (off by default). Users enable it from +**App Settings** with a toggle switch. The setting is persisted per-account in +`AccountSettings.alwaysOnNotificationService` and saved to `EncryptedSharedPreferences`. + +## Files + +| File | Purpose | +|------|---------| +| `NotificationRelayService.kt` | Foreground service, keeps relay connections alive, auto-restart | +| `LifecycleAwareKeyDataSourceSubscription.kt` | Pauses heavy feed subs when app backgrounds | +| `BootCompletedReceiver.kt` | Restart on boot and app update | +| `AutoRestartReceiver.kt` | Restart via WorkManager when service is destroyed | +| `NotificationCatchUpWorker.kt` | Periodic and on-demand catch-up worker | +| `ServiceWatchdogManager.kt` | AlarmManager-based health monitor | +| `AlwaysOnNotificationServiceManager.kt` | Coordinates all layers based on setting | +| `BatteryOptimizationHelper.kt` | Battery optimization check and exemption request | +| `EventNotificationConsumer.kt` | WakeLock wrapper for notification processing | +| `AccountSettings.kt` | `alwaysOnNotificationService` setting | +| `LocalPreferences.kt` | Setting persistence | +| `AppSettingsScreen.kt` | Toggle UI and battery optimization banner | +| `AndroidManifest.xml` | Permissions, service, and receiver declarations | + +## Permissions + +| Permission | Purpose | +|------------|---------| +| `FOREGROUND_SERVICE` | Run the foreground service | +| `FOREGROUND_SERVICE_SPECIAL_USE` | Declare `specialUse` service type | +| `RECEIVE_BOOT_COMPLETED` | Restart on boot | +| `WAKE_LOCK` | Keep CPU awake during notification processing | +| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Request Doze exemption | + +## Inspiration + +This implementation draws from battle-tested patterns in: + +- **ntfy** (millions of users) — `onTaskRemoved()` alarm, `onDestroy()` broadcast restart, + `ForegroundServiceStartNotAllowedException` handling, redundant `startForeground()`, + battery optimization guidance, WakeLock during processing +- **Pokey** (Nostr notification app) — `specialUse` foreground service type, + `MY_PACKAGE_REPLACED` restart +- **Signal** — Hybrid FCM + persistent WebSocket architecture diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 7a7b41eae..76fd56406 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -280,6 +280,9 @@ dependencies { // Biometrics implementation libs.androidx.biometric.ktx + // Background Work + implementation libs.androidx.work.runtime.ktx + // Websockets API implementation libs.okhttp implementation libs.okhttpCoroutines diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 986175094..9f41d69f2 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -43,6 +43,7 @@ + @@ -52,6 +53,12 @@ + + + + + + @@ -263,6 +270,33 @@ android:resource="@xml/file_paths" /> + + + + + + + + + + + + + + + + + if (state is AccountState.LoggedIn) { + alwaysOnNotificationServiceManager.watchAccount(state.account) + } else { + alwaysOnNotificationServiceManager.stop() + } + } + } + // initializes diskcache on an IO thread. applicationIOScope.launch { // Prepares video cache later @@ -500,6 +517,7 @@ class AppModules( pokeyReceiver.unregister(appContext) BackgroundMedia.removeBackgroundControllerAndReleaseIt() PlaybackServiceClient.shutdown() + alwaysOnNotificationServiceManager.stop() applicationIOScope.cancel("Application onTerminate $appContext") accountsCache.clear() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index d0c49b844..efcfad223 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -136,6 +136,7 @@ private object PrefKeys { const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later + const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val TOR_SETTINGS = "tor_settings" const val USE_PROXY = "use_proxy" const val PROXY_PORT = "proxy_port" @@ -409,6 +410,7 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) + putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) // migrating from previous design remove(PrefKeys.USE_PROXY) @@ -513,6 +515,7 @@ object LocalPreferences { val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) + val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) @@ -636,6 +639,7 @@ object LocalPreferences { hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, + alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), backupUserMetadata = latestUserMetadata.await(), backupContactList = latestContactList.await(), backupNIP65RelayList = latestNip65RelayList.await(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 9022e400d..10521231f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -170,6 +170,7 @@ class AccountSettings( var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, + val alwaysOnNotificationService: MutableStateFlow = MutableStateFlow(false), var backupUserMetadata: MetadataEvent? = null, var backupContactList: ContactListEvent? = null, var backupDMRelayList: ChatMessageRelayListEvent? = null, @@ -215,6 +216,17 @@ class AccountSettings( fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null + // --- + // Always-on Notification Service + // --- + + fun toggleAlwaysOnNotificationService(): Boolean { + val newValue = !alwaysOnNotificationService.value + alwaysOnNotificationService.tryEmit(newValue) + saveAccountSettings() + return newValue + } + // --- // Zaps and Reactions // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt new file mode 100644 index 000000000..37fc34447 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.Context +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Coordinates all 5 layers of the always-on notification system: + * + * L1 - NotificationRelayService (foreground service with persistent WebSocket) + * L2 - FCM/UnifiedPush (existing push system, wakeup trigger) + * L3 - NotificationCatchUpWorker (WorkManager, 15-min periodic catch-up) + * L4 - BootCompletedReceiver (restart on boot) + * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) + * + * When enabled, all layers activate. When disabled, all layers deactivate. + * The manager watches the account's alwaysOnNotificationService setting + * and reacts to changes in real time. + */ +class AlwaysOnNotificationServiceManager( + private val context: Context, + private val scope: CoroutineScope, +) { + companion object { + private const val TAG = "AlwaysOnNotifManager" + } + + private var watchJob: Job? = null + private var wasEnabled = false + + /** + * Starts watching the given account's always-on setting. + * When the setting changes, all layers are started or stopped accordingly. + * On initial load with false, nothing happens (no-op for users who never enabled it). + */ + fun watchAccount(account: Account) { + watchJob?.cancel() + wasEnabled = false + watchJob = + scope.launch { + account.settings.alwaysOnNotificationService.collectLatest { enabled -> + if (enabled) { + wasEnabled = true + enableAllLayers() + } else if (wasEnabled) { + disableAllLayers() + } + } + } + } + + fun stop() { + watchJob?.cancel() + watchJob = null + } + + private fun enableAllLayers() { + Log.d(TAG, "Enabling all notification service layers") + + // L1: Start foreground service + NotificationRelayService.start(context) + + // L3: Schedule periodic catch-up worker + NotificationCatchUpWorker.schedule(context) + + // L5: Start watchdog alarm + ServiceWatchdogManager.schedule(context) + + // L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest + } + + private fun disableAllLayers() { + Log.d(TAG, "Disabling all notification service layers") + + // L1: Stop foreground service + NotificationRelayService.stop(context) + + // L3: Cancel periodic catch-up worker + NotificationCatchUpWorker.cancel(context) + + // L5: Cancel watchdog alarm + ServiceWatchdogManager.cancel(context) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AutoRestartReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AutoRestartReceiver.kt new file mode 100644 index 000000000..219fc95b9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AutoRestartReceiver.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.vitorpamplona.quartz.utils.Log + +/** + * Receives a broadcast from NotificationRelayService.onDestroy() and + * enqueues a one-time WorkManager task to restart the service. + * + * This catches kills that START_STICKY might miss (e.g., OEM battery + * optimizers, aggressive memory reclaim). The WorkManager task requires + * network connectivity, so the service won't restart until the device + * has an active connection. + * + * Pattern inspired by ntfy's AutoRestartReceiver. + */ +class AutoRestartReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "AutoRestartReceiver" + private const val WORK_NAME = "notification_service_restart" + } + + override fun onReceive( + context: Context, + intent: Intent?, + ) { + if (intent?.action != NotificationRelayService.ACTION_AUTO_RESTART) return + + Log.d(TAG, "Received auto-restart broadcast, enqueuing restart work") + + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.KEEP, + request, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt new file mode 100644 index 000000000..220c2eec5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings +import com.vitorpamplona.quartz.utils.Log + +/** + * Helper for checking and requesting battery optimization exemption. + * + * When the always-on notification service is enabled, the app needs to be + * exempted from battery optimizations (Doze) to maintain reliable relay + * connections. Without the exemption, Android may restrict network access + * and defer alarms even for foreground services. + * + * Messaging apps are explicitly listed as a valid use case for this exemption + * in Google Play policy. + */ +object BatteryOptimizationHelper { + private const val TAG = "BatteryOptimization" + + fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + /** + * Launches the system dialog to request battery optimization exemption. + * Falls back to the general battery settings page if the direct intent fails. + */ + fun requestBatteryOptimizationExemption(context: Context) { + try { + val intent = + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + Log.w(TAG, "Direct battery exemption request failed, opening settings", e) + try { + val fallback = + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(fallback) + } catch (e2: Exception) { + Log.e(TAG, "Failed to open battery settings", e2) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BootCompletedReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BootCompletedReceiver.kt new file mode 100644 index 000000000..40a9a2ab9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BootCompletedReceiver.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.vitorpamplona.quartz.utils.Log + +/** + * Restarts the NotificationRelayService after device reboot or app update. + * + * Handles: + * - BOOT_COMPLETED / QUICKBOOT_POWERON: restart after device reboot + * - MY_PACKAGE_REPLACED: restart after app update (without this, the service + * stays dead until the user manually opens the app or reboots) + * + * The specialUse foreground service type is allowed to start from BOOT_COMPLETED + * on Android 15+, unlike dataSync which is restricted. + */ +class BootCompletedReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "BootCompletedReceiver" + } + + override fun onReceive( + context: Context, + intent: Intent, + ) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, + "android.intent.action.QUICKBOOT_POWERON", + Intent.ACTION_MY_PACKAGE_REPLACED, + -> { + Log.d(TAG) { "Received ${intent.action}, checking if notification service should start" } + if (NotificationRelayService.isEnabled(context)) { + Log.d(TAG, "Starting notification relay service") + NotificationRelayService.start(context) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 5c7ac4748..0268dedd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.notifications import android.app.NotificationManager import android.content.Context import android.graphics.drawable.BitmapDrawable +import android.os.PowerManager import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat import coil3.ImageLoader @@ -88,28 +89,55 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo=" class EventNotificationConsumer( private val applicationContext: Context, ) { - suspend fun consume(event: GiftWrapEvent) { - Log.d(TAG, "New Notification Arrived") + companion object { + private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes + } - // PushNotification Wraps don't include a receiver. - // Test with all logged in accounts - var matchAccount = false - LocalPreferences.allSavedAccounts().forEach { - if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) { - LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc -> - Log.d(TAG) { "New Notification Testing if for ${it.npub}" } - try { - val account = Amethyst.instance.accountsCache.loadAccount(acc) - consumeIfMatchesAccount(event, account) - matchAccount = true - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + /** + * Acquires a partial WakeLock during notification processing to ensure + * the CPU stays awake long enough to decrypt, process, and dispatch + * the notification, even in Doze mode. + */ + private inline fun withWakeLock(block: () -> T): T { + val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = + powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "amethyst:notification_processing", + ) + wakeLock.acquire(WAKELOCK_TIMEOUT_MS) + try { + return block() + } finally { + if (wakeLock.isHeld) { + wakeLock.release() + } + } + } + + suspend fun consume(event: GiftWrapEvent) = + withWakeLock { + Log.d(TAG, "New Notification Arrived") + + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + var matchAccount = false + LocalPreferences.allSavedAccounts().forEach { + if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) { + LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc -> + Log.d(TAG) { "New Notification Testing if for ${it.npub}" } + try { + val account = Amethyst.instance.accountsCache.loadAccount(acc) + consumeIfMatchesAccount(event, account) + matchAccount = true + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + } } } } } - } private suspend fun consumeIfMatchesAccount( pushWrappedEvent: GiftWrapEvent, @@ -221,30 +249,31 @@ class EventNotificationConsumer( } } - suspend fun findAccountAndConsume(event: Event) { - Log.d(TAG, "New Notification Arrived") - val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) } - val npubs = users.map { it.pubkeyNpub() }.toSet() + suspend fun findAccountAndConsume(event: Event) = + withWakeLock { + Log.d(TAG, "New Notification Arrived") + val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) } + val npubs = users.map { it.pubkeyNpub() }.toSet() - // PushNotification Wraps don't include a receiver. - // Test with all logged in accounts - var matchAccount = false - LocalPreferences.allSavedAccounts().forEach { - if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) { - LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings -> - Log.d(TAG) { "New Notification Testing if for ${it.npub}" } - try { - val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) - consumeNotificationEvent(event, account) - matchAccount = true - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + var matchAccount = false + LocalPreferences.allSavedAccounts().forEach { + if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) { + LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings -> + Log.d(TAG) { "New Notification Testing if for ${it.npub}" } + try { + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + consumeNotificationEvent(event, account) + matchAccount = true + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + } } } } } - } private suspend fun unwrapAndConsume( event: Event, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt new file mode 100644 index 000000000..610e57a85 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +/** + * WorkManager periodic worker that catches up on missed notifications. + * + * This runs every 15 minutes (WorkManager's minimum interval) and ensures + * relay connections are active. It acts as a safety net: + * + * - If the foreground service was killed by an OEM battery optimizer, this + * worker will briefly restore connections and pull missed events. + * - If the foreground service is running fine, this worker is essentially a no-op + * since the connections are already live and filters are active. + * + * The worker collects relayServices to ensure connections are active, + * waits briefly for data to flow, then exits. The foreground service + * (if alive) handles the persistent connection. + */ +class NotificationCatchUpWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + companion object { + private const val TAG = "NotificationCatchUpWorker" + private const val WORK_NAME = "notification_catch_up" + private const val CATCH_UP_DURATION_MS = 30_000L + + fun schedule(context: Context) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + PeriodicWorkRequestBuilder( + 15, + TimeUnit.MINUTES, + ).setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + Log.d(TAG, "Scheduled periodic catch-up work") + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_ON_NETWORK) + Log.d(TAG, "Cancelled periodic catch-up work") + } + + private const val WORK_NAME_ON_NETWORK = "notification_catch_up_on_network" + + /** + * Enqueues a one-time catch-up task that runs as soon as network + * connectivity is available. Call this when the service detects + * network loss to ensure it restarts immediately when network returns, + * rather than waiting for the next periodic worker. + */ + fun scheduleOnNetworkAvailable(context: Context) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_ON_NETWORK, + ExistingWorkPolicy.KEEP, + request, + ) + Log.d(TAG, "Scheduled one-time catch-up on network available") + } + } + + override suspend fun doWork(): Result { + Log.d(TAG, "Starting notification catch-up") + + return try { + // If the foreground service should be running but isn't, restart it + if (NotificationRelayService.isEnabled(applicationContext)) { + NotificationRelayService.start(applicationContext) + } + + val appModules = Amethyst.instance + + // Collecting relayServices ensures connections are active. + // If the foreground service is alive, connections are already up + // and this is essentially free. If it was killed, this briefly + // restores them. + withTimeoutOrNull(CATCH_UP_DURATION_MS) { + // Trigger connection by collecting the first emission + appModules.relayProxyClientConnector.relayServices.first() + + // Give the relay subscriptions time to receive pending events + delay(CATCH_UP_DURATION_MS - 5_000) + } + + Log.d(TAG, "Notification catch-up completed") + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Notification catch-up failed", e) + Result.retry() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt new file mode 100644 index 000000000..a9f386356 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.AlarmManager +import android.app.ForegroundServiceStartNotAllowedException +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.os.SystemClock +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * A foreground service that maintains persistent WebSocket connections to the user's + * inbox relays for real-time notification delivery. + * + * This service keeps the shared NostrClient alive by collecting the relayServices flow. + * It does NOT create its own relay subscriptions — instead, it relies on the + * AccountFilterAssembler subscription from the Compose UI tree, which stays active + * as long as the Activity is alive (even when stopped/backgrounded). That subscription + * covers notifications, gift wraps, metadata, follows, relay lists, and drafts. + * + * Heavy feed subscriptions (Home, Video, Discovery, ChatroomList) are managed + * separately with lifecycle awareness — they pause when the app backgrounds + * and resume when it foregrounds. This prevents bandwidth waste on feeds + * nobody is viewing. + * + * Auto-restart mechanisms (inspired by ntfy): + * - START_STICKY: Android restarts killed services + * - onTaskRemoved(): 1-second alarm restart when swiped from recents + * - onDestroy(): broadcast to AutoRestartReceiver for WorkManager restart + * - AlarmManager watchdog (external, ServiceWatchdogManager) + * - WorkManager catch-up (external, NotificationCatchUpWorker) + * - BOOT_COMPLETED + MY_PACKAGE_REPLACED receivers (external, BootCompletedReceiver) + */ +class NotificationRelayService : Service() { + companion object { + private const val TAG = "NotificationRelayService" + private const val CHANNEL_ID = "notification_relay_service" + private const val NOTIFICATION_ID = 9832 + + private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" + private const val ACTION_STOP = "com.vitorpamplona.amethyst.STOP_NOTIFICATION_SERVICE" + + const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" + + fun start(context: Context) { + val intent = + Intent(context, NotificationRelayService::class.java).apply { + action = ACTION_START + } + try { + ContextCompat.startForegroundService(context, intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service", e) + } + } + + fun stop(context: Context) { + context.stopService(Intent(context, NotificationRelayService::class.java)) + } + + fun isEnabled(context: Context): Boolean = + try { + Amethyst.instance.sessionManager + .loggedInAccount() + ?.settings + ?.alwaysOnNotificationService + ?.value == true + } catch (e: Exception) { + false + } + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var relayServiceCollectorJob: Job? = null + private var connectedRelayCount = 0 + private var foregroundStarted = false + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service created") + createNotificationChannel() + initializeForeground() + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + when (intent?.action) { + ACTION_STOP -> { + Log.d(TAG, "Stopping service") + stopSelf() + return START_NOT_STICKY + } + + else -> { + Log.d(TAG, "Starting service") + // Safety: also call startForeground from onStartCommand in case + // onCreate didn't complete before onStartCommand fired (ntfy #1520) + initializeForeground() + startRelayConnection() + } + } + return START_STICKY + } + + /** + * Called when the user swipes the app from recents. Some OEMs kill the + * foreground service at this point. Schedule an alarm to restart in 1 second. + */ + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (!isEnabled(this)) return + + Log.d(TAG, "Task removed, scheduling restart alarm") + val restartIntent = + Intent(this, NotificationRelayService::class.java).apply { + action = ACTION_START + } + val pendingIntent = + PendingIntent.getService( + this, + 2, + restartIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT, + ) + val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent, + ) + } + + /** + * Called when the service is being destroyed. Send a broadcast to + * AutoRestartReceiver which will enqueue a WorkManager task to restart. + * This catches kills that START_STICKY might miss. + */ + override fun onDestroy() { + Log.d(TAG, "Service destroyed") + relayServiceCollectorJob?.cancel() + scope.cancel() + + if (isEnabled(this)) { + Log.d(TAG, "Broadcasting auto-restart request") + val intent = + Intent(this, AutoRestartReceiver::class.java).apply { + action = ACTION_AUTO_RESTART + } + sendBroadcast(intent) + } + + super.onDestroy() + } + + private fun initializeForeground() { + if (foregroundStarted) return + try { + val notification = buildNotification(0) + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } else { + 0 + }, + ) + foregroundStarted = true + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + e is ForegroundServiceStartNotAllowedException + ) { + Log.w(TAG, "Foreground service start not allowed, stopping self") + stopSelf() + } else { + Log.e(TAG, "Failed to start foreground", e) + } + } + } + + /** + * Keeps the relay infrastructure alive. The service collects two flows: + * + * 1. relayServices: Keeps the RelayProxyClientConnector active (connectivity, + * Tor, network changes). Without this, the client disconnects 30s after + * the UI stops collecting. + * + * 2. connectedRelaysFlow: Updates the persistent notification with relay count. + * + * The service does NOT create its own relay subscriptions. Instead, it relies on + * the AccountFilterAssembler subscription that lives in the Compose tree (LoggedInPage). + * That subscription stays active as long as the Activity exists (even when stopped) + * and covers: metadata, follows, notifications (inbox relays), gift wraps (DM relays), + * drafts, and relay list changes. Since the service keeps the client connected, + * those subscriptions remain active on the relays. + */ + private fun startRelayConnection() { + relayServiceCollectorJob?.cancel() + relayServiceCollectorJob = + scope.launch { + launch { + Amethyst.instance.relayProxyClientConnector.relayServices.collectLatest { + Log.d(TAG) { "Relay services state updated: $it" } + } + } + + launch { + Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> + val count = relays.size + if (count != connectedRelayCount) { + connectedRelayCount = count + updateNotification(count) + } + } + } + } + } + + private fun updateNotification(connectedRelays: Int) { + val notification = buildNotification(connectedRelays) + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun buildNotification(connectedRelays: Int): Notification { + val contentText = + if (connectedRelays > 0) { + getString(R.string.always_on_notif_connected, connectedRelays) + } else { + getString(R.string.always_on_notif_connecting) + } + + val openAppIntent = + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val stopIntent = + Intent(this, NotificationRelayService::class.java).apply { + action = ACTION_STOP + } + val stopPendingIntent = + PendingIntent.getService( + this, + 1, + stopIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.always_on_notif_title)) + .setContentText(contentText) + .setSmallIcon(R.drawable.amethyst) + .setContentIntent(pendingIntent) + .addAction(0, getString(R.string.always_on_notif_stop), stopPendingIntent) + .setOngoing(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + private fun createNotificationChannel() { + val channel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.always_on_notif_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.always_on_notif_channel_description) + setShowBadge(false) + } + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt new file mode 100644 index 000000000..dc9fd2ccd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.SystemClock +import com.vitorpamplona.quartz.utils.Log + +/** + * Uses AlarmManager to periodically check if the NotificationRelayService is still alive + * and restart it if needed. + * + * This serves as a watchdog: OEM battery optimizations (Xiaomi MIUI, Huawei EMUI, + * Samsung One UI) may kill the foreground service despite it being "foreground". + * The alarm fires every 5 minutes and checks if the service should be running. + */ +class ServiceWatchdogManager { + companion object { + private const val TAG = "ServiceWatchdogManager" + private const val WATCHDOG_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes + + fun schedule(context: Context) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, WatchdogReceiver::class.java) + val pendingIntent = + PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + alarmManager.setInexactRepeating( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, + WATCHDOG_INTERVAL_MS, + pendingIntent, + ) + Log.d(TAG, "Watchdog alarm scheduled") + } + + fun cancel(context: Context) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, WatchdogReceiver::class.java) + val pendingIntent = + PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_NO_CREATE, + ) + if (pendingIntent != null) { + alarmManager.cancel(pendingIntent) + Log.d(TAG, "Watchdog alarm cancelled") + } + } + } + + class WatchdogReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent?, + ) { + Log.d(TAG, "Watchdog fired, checking service state") + if (NotificationRelayService.isEnabled(context)) { + NotificationRelayService.start(context) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt index 4fbf4176e..dc778c521 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssemblerSubscription.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -45,5 +45,5 @@ fun ChatroomListFilterAssemblerSubscription( ChatroomListState(accountViewModel.account) } - KeyDataSourceSubscription(state, dataSource) + LifecycleAwareKeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt index f0cd98b46..54a518e70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -46,5 +46,5 @@ fun DiscoveryFilterAssemblerSubscription( DiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) } - KeyDataSourceSubscription(state, dataSource) + LifecycleAwareKeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt index 13ced4050..bdec0fcb9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -50,5 +50,5 @@ fun HomeFilterAssemblerSubscription( ) } - KeyDataSourceSubscription(state, dataSource) + LifecycleAwareKeyDataSourceSubscription(state, dataSource) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 5e7c8caca..9a18876d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -31,8 +31,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -42,11 +46,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType @@ -58,6 +64,7 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType +import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer @@ -89,7 +96,7 @@ fun SettingsScreen( }, ) { Column(Modifier.padding(it)) { - SettingsScreen(accountViewModel.settings.uiSettingsFlow) + SettingsScreen(accountViewModel.settings.uiSettingsFlow, accountViewModel) } } } @@ -103,7 +110,10 @@ fun SettingsScreenPreview() { } @Composable -fun SettingsScreen(sharedPrefs: UiSettingsFlow) { +fun SettingsScreen( + sharedPrefs: UiSettingsFlow, + accountViewModel: AccountViewModel? = null, +) { Column( Modifier .fillMaxSize() @@ -124,6 +134,9 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { GalleryChoice(sharedPrefs) AiWritingHelpChoice(sharedPrefs) PushNotificationSettingsRow(sharedPrefs) + if (accountViewModel != null) { + AlwaysOnNotificationServiceChoice(accountViewModel) + } } } @@ -495,3 +508,68 @@ fun SettingsRow( } } } + +@Composable +fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) { + val enabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + + SettingsRow( + R.string.always_on_notif_setting_title, + R.string.always_on_notif_setting_description, + ) { + Switch( + checked = enabled, + onCheckedChange = { + accountViewModel.account.settings.toggleAlwaysOnNotificationService() + }, + ) + } + + if (enabled) { + BatteryOptimizationBanner() + } +} + +@Composable +fun BatteryOptimizationBanner() { + val context = LocalContext.current + val isExempt = + remember { + BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) + } + + if (!isExempt) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.battery_optimization_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.battery_optimization_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button( + onClick = { + BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) + }, + ) { + Text(stringRes(R.string.battery_optimization_fix_now)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt index ab49de65f..dabf9ec72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/VideoFilterAssemblerSubscription.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @Composable @@ -46,5 +46,5 @@ fun VideoFilterAssemblerSubscription( VideoQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope) } - KeyDataSourceSubscription(state, filterAssembler) + LifecycleAwareKeyDataSourceSubscription(state, filterAssembler) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index a87b2df76..182e677bb 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -925,6 +925,19 @@ Username Credential + Relay Connection Service + Keeps connections to your inbox relays active for real-time notifications + Amethyst Notifications Active + Connected to %1$d inbox relays + Connecting to inbox relays\u2026 + Pause + Always-on notification service + Keeps a persistent connection to your inbox relays for instant notification delivery. Shows an ongoing notification. Uses more battery but ensures you never miss a message. + + Battery optimization active + Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications. + Fix now + Notify: Join Conversation diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt new file mode 100644 index 000000000..bbd9bc1c1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/subscriptions/LifecycleAwareKeyDataSourceSubscription.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.relayClient.subscriptions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager + +/** + * A lifecycle-aware version of [KeyDataSourceSubscription] that subscribes + * when the lifecycle reaches STARTED and unsubscribes when it reaches STOPPED. + * + * Use this for heavy feed subscriptions (home, video, discovery, chatroom list) + * that should NOT run when the app is in the background. When an always-on + * notification service keeps the relay client connected, these subscriptions + * would otherwise leak bandwidth on feeds nobody is viewing. + * + * Lightweight subscriptions that should always run (account metadata, notifications, + * gift wraps) should continue using the regular [KeyDataSourceSubscription]. + */ +@Composable +fun LifecycleAwareKeyDataSourceSubscription( + state: T, + dataSource: ComposeSubscriptionManager, +) { + val lifecycleOwner = LocalLifecycleOwner.current + var isStarted by remember { mutableStateOf(false) } + + DisposableEffect(state, lifecycleOwner) { + val observer = + LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + if (!isStarted) { + dataSource.subscribe(state) + isStarted = true + } + } + + Lifecycle.Event.ON_STOP -> { + if (isStarted) { + dataSource.unsubscribe(state) + isStarted = false + } + } + + else -> {} + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + // If already started (e.g., recomposition while visible), subscribe immediately + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + dataSource.subscribe(state) + isStarted = true + } + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + if (isStarted) { + dataSource.unsubscribe(state) + isStarted = false + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31c83765f..bba9c6fae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,7 @@ commonsImaging = "1.0.0-alpha6" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" +workRuntime = "2.10.1" androidxCamera = "1.6.0" androidxCollection = "1.6.0" androidxExifinterface = "1.4.2" @@ -190,6 +191,7 @@ androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "sqlite" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } androidx-sqlite-bundled-jvm = { module = "androidx.sqlite:sqlite-bundled-jvm", version.ref = "sqlite" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }