mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
Merge remote-tracking branch 'origin/main' into claude/limits-message-support-g5plrd
This commit is contained in:
@@ -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." />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".service.notifications.NotificationServiceTileService"
|
||||
android:icon="@drawable/amethyst_service"
|
||||
android:label="@string/always_on_notif_tile_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".service.notifications.BootCompletedReceiver"
|
||||
android:exported="false">
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<String, AccountSettings?> = 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<Boolean> by lazy {
|
||||
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
|
||||
}
|
||||
|
||||
fun notificationServiceEnabledFlow(): StateFlow<Boolean> = 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 =
|
||||
|
||||
+71
-29
@@ -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() {
|
||||
|
||||
+11
-5
@@ -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
|
||||
}
|
||||
|
||||
+141
@@ -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 (<service android:label/android:icon>),
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-7
@@ -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<List<Pair<AccountInfo, AccountSettings>>>(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
|
||||
|
||||
@@ -1702,6 +1702,15 @@
|
||||
<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="always_on_notif_tile_label">Notification service</string>
|
||||
<string name="always_on_notif_tile_subtitle_on">On</string>
|
||||
<string name="always_on_notif_tile_subtitle_off">Off</string>
|
||||
|
||||
<string name="notification_service_master_title">Background notification service</string>
|
||||
<string name="notification_service_master_description">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.</string>
|
||||
<string name="notification_service_accounts_title">Accounts active in the background</string>
|
||||
<string name="notification_service_participation_title">Keep this account active in the background</string>
|
||||
|
||||
<string name="split_notifications_setting_title">Split notifications by Follows</string>
|
||||
<string name="split_notifications_setting_description">Show two notification tabs — Following (people you follow) and Everyone. The unread indicator glows only for activity from people you follow.</string>
|
||||
<string name="show_messages_in_notifications_setting_title">Show Messages</string>
|
||||
|
||||
Reference in New Issue
Block a user