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