diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 86b800d66..9f41d69f2 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -56,6 +56,9 @@ + + + @@ -290,6 +293,10 @@ android:name=".service.notifications.ServiceWatchdogManager$WatchdogReceiver" android:exported="false" /> + + () + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.KEEP, + request, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt new file mode 100644 index 000000000..220c2eec5 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BatteryOptimizationHelper.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.notifications + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import android.provider.Settings +import com.vitorpamplona.quartz.utils.Log + +/** + * Helper for checking and requesting battery optimization exemption. + * + * When the always-on notification service is enabled, the app needs to be + * exempted from battery optimizations (Doze) to maintain reliable relay + * connections. Without the exemption, Android may restrict network access + * and defer alarms even for foreground services. + * + * Messaging apps are explicitly listed as a valid use case for this exemption + * in Google Play policy. + */ +object BatteryOptimizationHelper { + private const val TAG = "BatteryOptimization" + + fun isIgnoringBatteryOptimizations(context: Context): Boolean { + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + /** + * Launches the system dialog to request battery optimization exemption. + * Falls back to the general battery settings page if the direct intent fails. + */ + fun requestBatteryOptimizationExemption(context: Context) { + try { + val intent = + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (e: Exception) { + Log.w(TAG, "Direct battery exemption request failed, opening settings", e) + try { + val fallback = + Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(fallback) + } catch (e2: Exception) { + Log.e(TAG, "Failed to open battery settings", e2) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 5c7ac4748..0268dedd0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.notifications import android.app.NotificationManager import android.content.Context import android.graphics.drawable.BitmapDrawable +import android.os.PowerManager import androidx.compose.runtime.getValue import androidx.core.content.ContextCompat import coil3.ImageLoader @@ -88,28 +89,55 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo=" class EventNotificationConsumer( private val applicationContext: Context, ) { - suspend fun consume(event: GiftWrapEvent) { - Log.d(TAG, "New Notification Arrived") + companion object { + private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes + } - // PushNotification Wraps don't include a receiver. - // Test with all logged in accounts - var matchAccount = false - LocalPreferences.allSavedAccounts().forEach { - if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) { - LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc -> - Log.d(TAG) { "New Notification Testing if for ${it.npub}" } - try { - val account = Amethyst.instance.accountsCache.loadAccount(acc) - consumeIfMatchesAccount(event, account) - matchAccount = true - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + /** + * Acquires a partial WakeLock during notification processing to ensure + * the CPU stays awake long enough to decrypt, process, and dispatch + * the notification, even in Doze mode. + */ + private inline fun withWakeLock(block: () -> T): T { + val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = + powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "amethyst:notification_processing", + ) + wakeLock.acquire(WAKELOCK_TIMEOUT_MS) + try { + return block() + } finally { + if (wakeLock.isHeld) { + wakeLock.release() + } + } + } + + suspend fun consume(event: GiftWrapEvent) = + withWakeLock { + Log.d(TAG, "New Notification Arrived") + + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + var matchAccount = false + LocalPreferences.allSavedAccounts().forEach { + if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) { + LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc -> + Log.d(TAG) { "New Notification Testing if for ${it.npub}" } + try { + val account = Amethyst.instance.accountsCache.loadAccount(acc) + consumeIfMatchesAccount(event, account) + matchAccount = true + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + } } } } } - } private suspend fun consumeIfMatchesAccount( pushWrappedEvent: GiftWrapEvent, @@ -221,30 +249,31 @@ class EventNotificationConsumer( } } - suspend fun findAccountAndConsume(event: Event) { - Log.d(TAG, "New Notification Arrived") - val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) } - val npubs = users.map { it.pubkeyNpub() }.toSet() + suspend fun findAccountAndConsume(event: Event) = + withWakeLock { + Log.d(TAG, "New Notification Arrived") + val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) } + val npubs = users.map { it.pubkeyNpub() }.toSet() - // PushNotification Wraps don't include a receiver. - // Test with all logged in accounts - var matchAccount = false - LocalPreferences.allSavedAccounts().forEach { - if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) { - LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings -> - Log.d(TAG) { "New Notification Testing if for ${it.npub}" } - try { - val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) - consumeNotificationEvent(event, account) - matchAccount = true - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + // PushNotification Wraps don't include a receiver. + // Test with all logged in accounts + var matchAccount = false + LocalPreferences.allSavedAccounts().forEach { + if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) { + LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings -> + Log.d(TAG) { "New Notification Testing if for ${it.npub}" } + try { + val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + consumeNotificationEvent(event, account) + matchAccount = true + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" } + } } } } } - } private suspend fun unwrapAndConsume( event: Event, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt index 38002653e..610e57a85 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt @@ -24,7 +24,9 @@ import android.content.Context import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -83,14 +85,48 @@ class NotificationCatchUpWorker( fun cancel(context: Context) { WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_ON_NETWORK) Log.d(TAG, "Cancelled periodic catch-up work") } + + private const val WORK_NAME_ON_NETWORK = "notification_catch_up_on_network" + + /** + * Enqueues a one-time catch-up task that runs as soon as network + * connectivity is available. Call this when the service detects + * network loss to ensure it restarts immediately when network returns, + * rather than waiting for the next periodic worker. + */ + fun scheduleOnNetworkAvailable(context: Context) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME_ON_NETWORK, + ExistingWorkPolicy.KEEP, + request, + ) + Log.d(TAG, "Scheduled one-time catch-up on network available") + } } override suspend fun doWork(): Result { Log.d(TAG, "Starting notification catch-up") return try { + // If the foreground service should be running but isn't, restart it + if (NotificationRelayService.isEnabled(applicationContext)) { + NotificationRelayService.start(applicationContext) + } + val appModules = Amethyst.instance // Collecting relayServices ensures connections are active. 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 c71bef3cd..7d3fdb6f4 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 @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.notifications +import android.app.AlarmManager +import android.app.ForegroundServiceStartNotAllowedException import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -30,6 +32,7 @@ import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder +import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat @@ -51,7 +54,6 @@ import kotlinx.coroutines.launch * * This service: * - Keeps the shared NostrClient alive by collecting the relayServices flow - * - Adds notification-specific subscriptions on inbox relays * - Routes incoming events through EventNotificationConsumer * - Survives app backgrounding (connections stay open) * - Uses specialUse foreground service type (no Android 15 time limit) @@ -60,6 +62,14 @@ import kotlinx.coroutines.launch * When the app is in the foreground, both the UI and this service are subscribers. * When the app backgrounds, UI subscriptions drop but this service's subscriptions * remain, keeping inbox relay connections alive. No reconnection needed. + * + * Auto-restart mechanisms (inspired by ntfy): + * - START_STICKY: Android restarts killed services + * - onTaskRemoved(): 1-second alarm restart when swiped from recents + * - onDestroy(): broadcast to AutoRestartReceiver for WorkManager restart + * - AlarmManager watchdog (external, ServiceWatchdogManager) + * - WorkManager catch-up (external, NotificationCatchUpWorker) + * - BOOT_COMPLETED + MY_PACKAGE_REPLACED receivers (external, BootCompletedReceiver) */ class NotificationRelayService : Service() { companion object { @@ -70,12 +80,18 @@ class NotificationRelayService : Service() { private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" private const val ACTION_STOP = "com.vitorpamplona.amethyst.STOP_NOTIFICATION_SERVICE" + const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE" + fun start(context: Context) { val intent = Intent(context, NotificationRelayService::class.java).apply { action = ACTION_START } - ContextCompat.startForegroundService(context, intent) + try { + ContextCompat.startForegroundService(context, intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to start foreground service", e) + } } fun stop(context: Context) { @@ -86,9 +102,8 @@ class NotificationRelayService : Service() { context.startService(intent) } - fun isEnabled(context: Context): Boolean { - // Check if service should be enabled based on account settings - return try { + fun isEnabled(context: Context): Boolean = + try { Amethyst.instance.sessionManager .loggedInAccount() ?.settings @@ -97,12 +112,12 @@ class NotificationRelayService : Service() { } catch (e: Exception) { false } - } } private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var relayServiceCollectorJob: Job? = null private var connectedRelayCount = 0 + private var foregroundStarted = false override fun onBind(intent: Intent?): IBinder? = null @@ -110,6 +125,7 @@ class NotificationRelayService : Service() { super.onCreate() Log.d(TAG, "Service created") createNotificationChannel() + initializeForeground() } override fun onStartCommand( @@ -126,27 +142,92 @@ class NotificationRelayService : Service() { else -> { Log.d(TAG, "Starting service") - startForegroundWithNotification() + // Safety: also call startForeground from onStartCommand in case + // onCreate didn't complete before onStartCommand fired (ntfy #1520) + initializeForeground() startRelayConnection() } } return START_STICKY } - private fun startForegroundWithNotification() { - val notification = buildNotification(0) - ServiceCompat.startForeground( - this, - NOTIFICATION_ID, - notification, - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE - } else { - 0 - }, + /** + * Called when the user swipes the app from recents. Some OEMs kill the + * foreground service at this point. Schedule an alarm to restart in 1 second. + */ + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + if (!isEnabled(this)) return + + Log.d(TAG, "Task removed, scheduling restart alarm") + val restartIntent = + Intent(this, NotificationRelayService::class.java).apply { + action = ACTION_START + } + val pendingIntent = + PendingIntent.getService( + this, + 2, + restartIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT, + ) + val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + SystemClock.elapsedRealtime() + 1000, + pendingIntent, ) } + /** + * Called when the service is being destroyed. Send a broadcast to + * AutoRestartReceiver which will enqueue a WorkManager task to restart. + * This catches kills that START_STICKY might miss. + */ + override fun onDestroy() { + Log.d(TAG, "Service destroyed") + relayServiceCollectorJob?.cancel() + scope.cancel() + + if (isEnabled(this)) { + Log.d(TAG, "Broadcasting auto-restart request") + val intent = + Intent(this, AutoRestartReceiver::class.java).apply { + action = ACTION_AUTO_RESTART + } + sendBroadcast(intent) + } + + super.onDestroy() + } + + private fun initializeForeground() { + if (foregroundStarted) return + try { + val notification = buildNotification(0) + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + } else { + 0 + }, + ) + foregroundStarted = true + } catch (e: Exception) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + e is ForegroundServiceStartNotAllowedException + ) { + Log.w(TAG, "Foreground service start not allowed, stopping self") + stopSelf() + } else { + Log.e(TAG, "Failed to start foreground", e) + } + } + } + private fun startRelayConnection() { relayServiceCollectorJob?.cancel() relayServiceCollectorJob = @@ -156,7 +237,7 @@ class NotificationRelayService : Service() { // By collecting it here, relay connections survive app backgrounding. launch { Amethyst.instance.relayProxyClientConnector.relayServices.collectLatest { - Log.d(TAG, "Relay services state updated: $it") + Log.d(TAG) { "Relay services state updated: $it" } } } @@ -238,11 +319,4 @@ class NotificationRelayService : Service() { val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } - - override fun onDestroy() { - Log.d(TAG, "Service destroyed") - relayServiceCollectorJob?.cancel() - scope.cancel() - super.onDestroy() - } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index cc31227e9..9a18876d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -31,6 +31,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch @@ -43,6 +46,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -60,6 +64,7 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseThemeType +import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer @@ -520,4 +525,51 @@ fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) { }, ) } + + if (enabled) { + BatteryOptimizationBanner() + } +} + +@Composable +fun BatteryOptimizationBanner() { + val context = LocalContext.current + val isExempt = + remember { + BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context) + } + + if (!isExempt) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringRes(R.string.battery_optimization_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Text( + text = stringRes(R.string.battery_optimization_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Button( + onClick = { + BatteryOptimizationHelper.requestBatteryOptimizationExemption(context) + }, + ) { + Text(stringRes(R.string.battery_optimization_fix_now)) + } + } + } + } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 79e216db4..2d8110932 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -929,6 +929,10 @@ 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. + Battery optimization active + Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications. + Fix now + Notify: Join Conversation