diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index 5a7960c004..42e59a2bb1 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -384,6 +384,17 @@
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." />
+
+
+
+
+
+
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
index 92a2454665..22d08179ab 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt
@@ -1104,11 +1104,13 @@ class AppModules(
}
}
- // Watch for account login and start/stop always-on notification service
+ // Watch for account login and start/stop always-on notification service.
+ // The manager gates on the global master switch + each account's participation
+ // (not the active account), so it only needs to run while someone is logged in.
applicationIOScope.launch {
sessionManager.accountContent.collectLatest { state ->
if (state is AccountState.LoggedIn) {
- alwaysOnNotificationServiceManager.watchAccount(state.account)
+ alwaysOnNotificationServiceManager.start()
} else {
alwaysOnNotificationServiceManager.stop()
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
index b437a6e838..a6783c51da 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt
@@ -71,6 +71,7 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -94,6 +95,11 @@ data class AccountInfo(
private object PrefKeys {
const val CURRENT_ACCOUNT = "currently_logged_in_account"
+
+ // Global (non-account) master switch for the always-on notification service.
+ // When off, the service is suppressed for every account regardless of each
+ // account's own participation flag. Persisted so it survives restarts/crashes.
+ const val NOTIFICATION_SERVICE_ENABLED = "notification_service_enabled"
const val SAVED_ACCOUNTS = "all_saved_accounts"
const val NOSTR_PRIVKEY = "nostr_privkey"
const val NOSTR_PUBKEY = "nostr_pubkey"
@@ -205,6 +211,37 @@ object LocalPreferences {
private val savedAccountsMutex = Mutex()
private val cachedAccounts: MutableMap = mutableMapOf()
+ // Global master switch for the always-on notification service ("Background
+ // notification service"). Default ON: existing users keep current behavior, and
+ // per-account participation decides who actually stays active.
+ //
+ // Stored in PLAIN (non-encrypted) SharedPreferences on purpose. It is a non-sensitive
+ // global boolean, and — unlike encryptedPreferences(), which asserts non-main — plain
+ // prefs can be read synchronously on ANY thread. The restart-layer gate
+ // (NotificationRelayService.isEnabled) is synchronous and runs in fresh processes (boot
+ // receiver, WorkManager), so it MUST read the persisted value without a suspend hop;
+ // otherwise a saved OFF would be missed on cold boot and the service would resurrect.
+ // The flow is lazily seeded from disk once (synchronous, main-safe) and is thereafter
+ // the source of truth, so there is no async hydrate that could clobber a user toggle.
+ private fun globalSettingsPrefs(): SharedPreferences =
+ Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
+
+ private val notificationServiceEnabled: MutableStateFlow by lazy {
+ MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
+ }
+
+ fun notificationServiceEnabledFlow(): StateFlow = notificationServiceEnabled
+
+ fun isNotificationServiceEnabled(): Boolean = notificationServiceEnabled.value
+
+ fun setNotificationServiceEnabled(enabled: Boolean) {
+ // In-memory update is the source of truth (main-safe); plain-prefs edit{} persists
+ // asynchronously via apply(), also main-safe. No suspend/hydrate hop, so no window
+ // where a late disk read can clobber this write.
+ notificationServiceEnabled.value = enabled
+ globalSettingsPrefs().edit { putBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, enabled) }
+ }
+
suspend fun currentAccount(): String? {
if (currentAccount == null) {
currentAccount =
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
index b124ba8ca7..58c11797f9 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt
@@ -22,14 +22,18 @@ package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
-import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
/**
@@ -41,16 +45,23 @@ import kotlinx.coroutines.launch
* 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.
+ * Two switches gate the system:
*
- * While enabled, every saved writable account is kept loaded in
- * [AccountCacheState] so GiftWraps addressed to any of them (delivered via
- * open relay subscriptions) get unwrapped by the owning account's
- * `newNotesPreProcessor`. Without this, wraps for non-active accounts would
- * sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber
- * able to decrypt them.
+ * - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the
+ * "Background notification service" toggle / Quick Settings tile). When off, every
+ * layer is torn down and nothing restarts, regardless of any account's setting —
+ * this is the battery-saver "airplane mode". Persisted, so an explicit off survives
+ * restarts and crashes.
+ * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService],
+ * "Keep this account active in the background"). While the master is on, the service
+ * runs as long as **at least one** writable account participates.
+ *
+ * While the master is on, every saved writable account is kept loaded in
+ * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps
+ * addressed to any of them (delivered via open relay subscriptions) get unwrapped by
+ * the owning account's `newNotesPreProcessor`. Without this, wraps for non-active
+ * accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no
+ * subscriber able to decrypt them.
*/
class AlwaysOnNotificationServiceManager(
private val context: Context,
@@ -68,22 +79,52 @@ class AlwaysOnNotificationServiceManager(
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).
+ * Starts watching the global master switch and the participation flags of every
+ * loaded writable account. The service layers run while the master is on AND at
+ * least one account participates; the master overrides everything when off.
+ *
+ * Idempotent: safe to call again on account switch/login — it restarts the watch.
*/
- fun watchAccount(account: Account) {
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun start() {
watchJob?.cancel()
wasEnabled = false
watchJob =
scope.launch {
- account.settings.alwaysOnNotificationService.collectLatest { enabled ->
- if (enabled) {
- wasEnabled = true
- enableAllLayers()
- } else if (wasEnabled) {
- disableAllLayers()
+ localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled ->
+ if (!masterEnabled) {
+ // Global airplane mode: suppress every layer regardless of
+ // per-account participation, and stop keeping accounts loaded.
+ if (wasEnabled) {
+ disableServiceLayers()
+ wasEnabled = false
+ }
+ stopMultiAccountPreload()
+ return@collectLatest
}
+
+ // Master on: keep every writable account loaded so its participation
+ // flag is observable and its gift wraps can decrypt, then run the
+ // service only while at least one account is participating.
+ startMultiAccountPreload()
+ accountsCache.accounts
+ .flatMapLatest { accounts ->
+ val flags = accounts.values.map { it.settings.alwaysOnNotificationService }
+ if (flags.isEmpty()) {
+ flowOf(false)
+ } else {
+ combine(flags) { values -> values.any { it } }
+ }
+ }.distinctUntilChanged()
+ .collectLatest { anyParticipating ->
+ if (anyParticipating) {
+ wasEnabled = true
+ enableServiceLayers()
+ } else if (wasEnabled) {
+ disableServiceLayers()
+ wasEnabled = false
+ }
+ }
}
}
}
@@ -93,10 +134,15 @@ class AlwaysOnNotificationServiceManager(
watchJob = null
preloadJob?.cancel()
preloadJob = null
+ // Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm
+ // and periodic worker stay scheduled and would resurrect the service for a
+ // logged-out user (nobody participating).
+ disableServiceLayers()
+ wasEnabled = false
}
- private fun enableAllLayers() {
- Log.d(TAG, "Enabling all notification service layers")
+ private fun enableServiceLayers() {
+ Log.d(TAG, "Enabling notification service layers")
// L1: Start foreground service
NotificationRelayService.start(context)
@@ -108,12 +154,10 @@ class AlwaysOnNotificationServiceManager(
ServiceWatchdogManager.schedule(context)
// L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest
-
- startMultiAccountPreload()
}
- private fun disableAllLayers() {
- Log.d(TAG, "Disabling all notification service layers")
+ private fun disableServiceLayers() {
+ Log.d(TAG, "Disabling notification service layers")
// L1: Stop foreground service
NotificationRelayService.stop(context)
@@ -123,8 +167,6 @@ class AlwaysOnNotificationServiceManager(
// L5: Cancel watchdog alarm
ServiceWatchdogManager.cancel(context)
-
- stopMultiAccountPreload()
}
/**
@@ -154,7 +196,7 @@ class AlwaysOnNotificationServiceManager(
/**
* Cancels the preload collector and releases every cached account except the
- * currently active one, so users with the setting off return to single-account
+ * currently active one, so users with the master off return to single-account
* memory/battery footprint.
*/
private fun stopMultiAccountPreload() {
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
index c255ea4b36..2f48e5466b 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt
@@ -37,6 +37,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.pluralStringRes
@@ -117,13 +118,18 @@ class NotificationRelayService : Service() {
context.stopService(Intent(context, NotificationRelayService::class.java))
}
+ // Gate for the restart layers (watchdog, boot, catch-up worker, onResume): the
+ // global master must be on, and — matching the manager's rule — at least one loaded
+ // writable account must be participating. An empty cache (logged out, or accounts
+ // not yet loaded) gates OFF, so the layers never resurrect the service for a
+ // logged-out user; on cold boot the account loads headlessly and the manager starts
+ // the service itself once a participant exists.
fun isEnabled(context: Context): Boolean =
try {
- Amethyst.instance.sessionManager
- .loggedInAccount()
- ?.settings
- ?.alwaysOnNotificationService
- ?.value == true
+ LocalPreferences.isNotificationServiceEnabled() &&
+ Amethyst.instance.accountsCache.accounts.value.values.any {
+ it.settings.alwaysOnNotificationService.value
+ }
} catch (e: Exception) {
false
}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt
new file mode 100644
index 0000000000..7c9b4ea907
--- /dev/null
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationServiceTileService.kt
@@ -0,0 +1,141 @@
+/*
+ * 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.os.Build
+import android.service.quicksettings.Tile
+import android.service.quicksettings.TileService
+import com.vitorpamplona.amethyst.Amethyst
+import com.vitorpamplona.amethyst.LocalPreferences
+import com.vitorpamplona.amethyst.R
+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
+
+/**
+ * Quick Settings tile that temporarily enables or disables the always-on background
+ * notification service — a battery-saver "airplane mode" — without digging through
+ * Settings.
+ *
+ * The tile toggles the **global master** switch ([LocalPreferences.setNotificationServiceEnabled]),
+ * not any per-account setting: when off, [AlwaysOnNotificationServiceManager] tears down
+ * every layer for all accounts; when on, each account's own "keep active in the
+ * background" flag decides who participates. The master is persisted, so an explicit off
+ * survives restarts and crashes.
+ *
+ * The switch is global, so the tile does not depend on a logged-in account. It only
+ * needs `Amethyst.instance` (main process) to reach [LocalPreferences]; during the brief
+ * cold-start window before that is built — or in the `:napplet` process, which never
+ * hosts this tile — it renders as unavailable and the next `onStartListening` recovers.
+ */
+class NotificationServiceTileService : TileService() {
+ companion object {
+ private const val TAG = "NotifServiceTile"
+ }
+
+ private var scope: CoroutineScope? = null
+ private var watchJob: Job? = null
+
+ /** True once `Amethyst.instance` (and therefore [LocalPreferences]) is reachable. */
+ private fun preferencesReady(): Boolean =
+ try {
+ Amethyst.instance
+ true
+ } catch (e: Exception) {
+ false
+ }
+
+ override fun onStartListening() {
+ super.onStartListening()
+ // onStartListening/onStopListening are balanced by the framework, but cancel
+ // any stale collector defensively so a re-entrant start can't leak a scope.
+ watchJob?.cancel()
+ scope?.cancel()
+
+ refreshTile()
+ if (!preferencesReady()) return
+
+ val newScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
+ scope = newScope
+ // Keep the tile in sync when the master is flipped elsewhere (the Settings
+ // toggle) while the shade is open.
+ watchJob =
+ newScope.launch {
+ LocalPreferences.notificationServiceEnabledFlow().collectLatest {
+ refreshTile()
+ }
+ }
+ }
+
+ override fun onStopListening() {
+ watchJob?.cancel()
+ watchJob = null
+ scope?.cancel()
+ scope = null
+ super.onStopListening()
+ }
+
+ override fun onClick() {
+ super.onClick()
+ if (!preferencesReady()) {
+ Log.w(TAG, "Preferences not ready; ignoring tile click")
+ refreshTile()
+ return
+ }
+ LocalPreferences.setNotificationServiceEnabled(!LocalPreferences.isNotificationServiceEnabled())
+ refreshTile()
+ }
+
+ private fun refreshTile() {
+ val tile = qsTile ?: return
+
+ if (!preferencesReady()) {
+ tile.state = Tile.STATE_UNAVAILABLE
+ } else {
+ val enabled = LocalPreferences.isNotificationServiceEnabled()
+ tile.state = if (enabled) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ tile.subtitleCompat(
+ getString(
+ if (enabled) {
+ R.string.always_on_notif_tile_subtitle_on
+ } else {
+ R.string.always_on_notif_tile_subtitle_off
+ },
+ ),
+ )
+ }
+
+ // Label and icon come from the manifest (),
+ // which is the tile's default — no need to reset them on every refresh.
+ tile.updateTile()
+ }
+
+ private fun Tile.subtitleCompat(text: CharSequence) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ subtitle = text
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt
index d0b82a09e6..7f10209e3a 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NotificationSettingsScreen.kt
@@ -34,10 +34,12 @@ 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.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -50,8 +52,11 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.vitorpamplona.amethyst.AccountInfo
+import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper
import com.vitorpamplona.amethyst.service.notifications.NotificationChannels
import com.vitorpamplona.amethyst.ui.components.PushNotificationProviderTile
@@ -59,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.components.hasPushNotificationProvider
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
+import com.vitorpamplona.amethyst.ui.note.toShortDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -89,8 +95,13 @@ fun NotificationSettingsScreen(
@Composable
private fun DeliverySection(accountViewModel: AccountViewModel) {
- val alwaysOn by accountViewModel.account.settings.alwaysOnNotificationService
- .collectAsStateWithLifecycle()
+ // Global master switch (persisted, all accounts). produceState + runCatching keeps
+ // the @Preview safe when Amethyst.instance / LocalPreferences aren't available.
+ val master by produceState(initialValue = true) {
+ runCatching {
+ LocalPreferences.notificationServiceEnabledFlow().collect { value = it }
+ }
+ }
SettingsSection(R.string.notification_settings_section_delivery) {
if (hasPushNotificationProvider()) {
@@ -99,18 +110,69 @@ private fun DeliverySection(accountViewModel: AccountViewModel) {
}
SettingsSwitchTile(
icon = MaterialSymbols.Notifications,
- title = R.string.always_on_notif_setting_title,
- description = R.string.always_on_notif_setting_description,
- checked = alwaysOn,
- onCheckedChange = { accountViewModel.account.settings.toggleAlwaysOnNotificationService() },
+ title = R.string.notification_service_master_title,
+ description = R.string.notification_service_master_description,
+ checked = master,
+ onCheckedChange = { LocalPreferences.setNotificationServiceEnabled(it) },
)
}
- if (alwaysOn) {
+ if (master) {
+ BackgroundAccountsSection()
BatteryOptimizationBanner()
}
}
+/**
+ * Per-account participation list, shown under the master switch: one "keep active in the
+ * background" toggle per write-enabled account. Each row toggles that account's own
+ * [AccountSettings.alwaysOnNotificationService]; because LocalPreferences caches one
+ * AccountSettings per npub, the toggle reaches the same instance the always-on manager
+ * observes, so participation changes take effect live.
+ */
+@Composable
+private fun BackgroundAccountsSection() {
+ val accounts by produceState>>(emptyList()) {
+ value =
+ runCatching {
+ LocalPreferences
+ .allSavedAccounts()
+ .filter { it.hasPrivKey || it.loggedInWithExternalSigner }
+ .mapNotNull { info ->
+ LocalPreferences.loadAccountConfigFromEncryptedStorage(info.npub)?.let { info to it }
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ if (accounts.isEmpty()) return
+
+ SettingsSection(R.string.notification_service_accounts_title) {
+ accounts.forEachIndexed { index, (info, settings) ->
+ if (index > 0) SettingsDivider()
+ AccountParticipationRow(info, settings)
+ }
+ }
+}
+
+@Composable
+private fun AccountParticipationRow(
+ info: AccountInfo,
+ settings: AccountSettings,
+) {
+ val participates by settings.alwaysOnNotificationService.collectAsStateWithLifecycle()
+ SettingsControlRow(
+ icon = MaterialSymbols.AccountCircle,
+ title = info.npub.toShortDisplay(),
+ description = stringRes(R.string.notification_service_participation_title),
+ onClick = { settings.toggleAlwaysOnNotificationService() },
+ ) {
+ Switch(
+ checked = participates,
+ onCheckedChange = { settings.toggleAlwaysOnNotificationService() },
+ )
+ }
+}
+
@Composable
private fun DisplaySection(accountViewModel: AccountViewModel) {
val splitByFollows by accountViewModel.account.settings.splitNotificationsEnabled
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index 97004239da..513e12f364 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -1702,6 +1702,15 @@
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.
+ Notification service
+ On
+ Off
+
+ Background notification service
+ Master switch for real-time background notifications. Turn off to save battery — like an airplane mode. When off, no account stays connected in the background, and it stays off (across restarts) until you turn it back on.
+ Accounts active in the background
+ Keep this account active in the background
+
Split notifications by Follows
Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow.
Show Messages