Merge pull request #1983 from vitorpamplona/claude/always-on-notification-service-NuW9I

Add always-on notification service for real-time Nostr relay connections
This commit is contained in:
Vitor Pamplona
2026-04-22 09:54:57 -04:00
committed by GitHub
22 changed files with 1521 additions and 46 deletions
+299
View File
@@ -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<Boolean>`) 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
+3
View File
@@ -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
+34
View File
@@ -43,6 +43,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls -->
@@ -52,6 +53,12 @@
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Always-on notification service: restart after reboot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Always-on notification service: battery optimization exemption -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -263,6 +270,33 @@
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".service.notifications.NotificationRelayService"
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Persistent real-time messaging relay connection for Nostr protocol. Maintains WebSocket connections to user-configured inbox relays for immediate notification delivery of direct messages, zaps, and mentions." />
</service>
<receiver
android:name=".service.notifications.BootCompletedReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<receiver
android:name=".service.notifications.ServiceWatchdogManager$WatchdogReceiver"
android:exported="false" />
<receiver
android:name=".service.notifications.AutoRestartReceiver"
android:exported="false" />
<receiver
android:name=".service.notifications.PokeyReceiver"
android:exported="true"
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerRes
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.resourceCacheInit
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -98,6 +100,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.transform
@@ -391,6 +394,9 @@ class AppModules(
)
}
// Manages always-on notification service lifecycle
val alwaysOnNotificationServiceManager = AlwaysOnNotificationServiceManager(appContext, applicationIOScope)
// Organizes cache clearing
val trimmingService by
lazy {
@@ -488,6 +494,17 @@ class AppModules(
// registers to receive events
pokeyReceiver.register(appContext)
// Watch for account login and start/stop always-on notification service
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
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()
}
@@ -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(),
@@ -170,6 +170,7 @@ class AccountSettings(
var hideDeleteRequestDialog: Boolean = false,
var hideBlockAlertDialog: Boolean = false,
var hideNIP17WarningDialog: Boolean = false,
val alwaysOnNotificationService: MutableStateFlow<Boolean> = 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
// ---
@@ -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)
}
}
@@ -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<NotificationCatchUpWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.KEEP,
request,
)
}
}
@@ -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)
}
}
}
}
@@ -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)
}
}
}
}
}
@@ -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,7 +89,34 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
class EventNotificationConsumer(
private val applicationContext: Context,
) {
suspend fun consume(event: GiftWrapEvent) {
companion object {
private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes
}
/**
* 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 <T> 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.
@@ -221,7 +249,8 @@ class EventNotificationConsumer(
}
}
suspend fun findAccountAndConsume(event: Event) {
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()
@@ -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<NotificationCatchUpWorker>(
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<NotificationCatchUpWorker>()
.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()
}
}
}
@@ -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)
}
}
@@ -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)
}
}
}
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
@@ -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))
}
}
}
}
}
@@ -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)
}
+13
View File
@@ -925,6 +925,19 @@
<string name="call_settings_turn_username">Username</string>
<string name="call_settings_turn_credential">Credential</string>
<string name="always_on_notif_channel_name" translatable="false">Relay Connection Service</string>
<string name="always_on_notif_channel_description">Keeps connections to your inbox relays active for real-time notifications</string>
<string name="always_on_notif_title">Amethyst Notifications Active</string>
<string name="always_on_notif_connected">Connected to %1$d inbox relays</string>
<string name="always_on_notif_connecting">Connecting to inbox relays\u2026</string>
<string name="always_on_notif_stop">Pause</string>
<string name="always_on_notif_setting_title">Always-on notification service</string>
<string name="always_on_notif_setting_description">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.</string>
<string name="battery_optimization_title">Battery optimization active</string>
<string name="battery_optimization_description">Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications.</string>
<string name="battery_optimization_fix_now">Fix now</string>
<string name="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string>
@@ -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 <T> LifecycleAwareKeyDataSourceSubscription(
state: T,
dataSource: ComposeSubscriptionManager<T>,
) {
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
}
}
}
}
+2
View File
@@ -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" }