Merge remote-tracking branch 'origin/main' into claude/limits-message-support-g5plrd

This commit is contained in:
Claude
2026-07-16 18:48:35 +00:00
8 changed files with 353 additions and 43 deletions
+11
View File
@@ -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 =
@@ -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() {
@@ -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
}
@@ -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
}
}
}
@@ -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
+9
View File
@@ -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>