feat: harden notification service with ntfy-inspired resilience

Adds 7 improvements inspired by ntfy's battle-tested keepalive architecture:

1. onTaskRemoved() restart: When users swipe the app from recents,
   some OEMs kill the foreground service. Now schedules a 1-second
   alarm to restart immediately.

2. onDestroy() → AutoRestartReceiver: When the service is destroyed
   (by system or OEM), broadcasts to AutoRestartReceiver which
   enqueues a one-time WorkManager task to restart. Catches kills
   that START_STICKY might miss.

3. ForegroundServiceStartNotAllowedException handling: On Android 12+,
   starting a foreground service from background can throw. Now caught
   gracefully instead of crashing.

4. Redundant startForeground() from onStartCommand(): Safety fallback
   in case onCreate() didn't complete before onStartCommand() fired
   (ntfy issue #1520 edge case).

5. WakeLock during notification processing: EventNotificationConsumer
   now acquires a PARTIAL_WAKE_LOCK with 10-minute timeout to ensure
   CPU stays awake long enough to decrypt and dispatch notifications
   even in Doze mode.

6. Battery optimization exemption: Added REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
   permission and BatteryOptimizationHelper. Settings screen shows an
   error-colored banner with "Fix now" button when always-on is enabled
   but the app isn't whitelisted.

7. Network-available WorkManager pattern: Added scheduleOnNetworkAvailable()
   to NotificationCatchUpWorker. Enqueues a one-time task with
   NetworkType.CONNECTED constraint so the service restarts immediately
   when connectivity returns, rather than waiting for the next periodic
   worker.

https://claude.ai/code/session_01LEPfmgGnwjB9a5SDFw5U8t
This commit is contained in:
Claude
2026-04-08 18:35:46 +00:00
parent 6d42ab8341
commit b451cb1f19
8 changed files with 413 additions and 62 deletions
+7
View File
@@ -56,6 +56,9 @@
<!-- Always-on notification service: restart after reboot --> <!-- Always-on notification service: restart after reboot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Always-on notification service: battery optimization exemption -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Keeps screen on while playing videos --> <!-- Keeps screen on while playing videos -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -290,6 +293,10 @@
android:name=".service.notifications.ServiceWatchdogManager$WatchdogReceiver" android:name=".service.notifications.ServiceWatchdogManager$WatchdogReceiver"
android:exported="false" /> android:exported="false" />
<receiver
android:name=".service.notifications.AutoRestartReceiver"
android:exported="false" />
<receiver <receiver
android:name=".service.notifications.PokeyReceiver" android:name=".service.notifications.PokeyReceiver"
android:exported="true" android:exported="true"
@@ -0,0 +1,75 @@
/*
* 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.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.vitorpamplona.quartz.utils.Log
/**
* Receives a broadcast from NotificationRelayService.onDestroy() and
* enqueues a one-time WorkManager task to restart the service.
*
* This catches kills that START_STICKY might miss (e.g., OEM battery
* optimizers, aggressive memory reclaim). The WorkManager task requires
* network connectivity, so the service won't restart until the device
* has an active connection.
*
* Pattern inspired by ntfy's AutoRestartReceiver.
*/
class AutoRestartReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "AutoRestartReceiver"
private const val WORK_NAME = "notification_service_restart"
}
override fun onReceive(
context: Context,
intent: Intent?,
) {
if (intent?.action != NotificationRelayService.ACTION_AUTO_RESTART) return
Log.d(TAG, "Received auto-restart broadcast, enqueuing restart work")
val constraints =
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request =
OneTimeWorkRequestBuilder<NotificationCatchUpWorker>()
.setConstraints(constraints)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
WORK_NAME,
ExistingWorkPolicy.KEEP,
request,
)
}
}
@@ -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)
}
}
}
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.os.PowerManager
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil3.ImageLoader import coil3.ImageLoader
@@ -88,7 +89,34 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
class EventNotificationConsumer( class EventNotificationConsumer(
private val applicationContext: Context, private val applicationContext: Context,
) { ) {
suspend fun consume(event: GiftWrapEvent) { companion object {
private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes
}
/**
* 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 <T> 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") Log.d(TAG, "New Notification Arrived")
// PushNotification Wraps don't include a receiver. // PushNotification Wraps don't include a receiver.
@@ -221,7 +249,8 @@ class EventNotificationConsumer(
} }
} }
suspend fun findAccountAndConsume(event: Event) { suspend fun findAccountAndConsume(event: Event) =
withWakeLock {
Log.d(TAG, "New Notification Arrived") Log.d(TAG, "New Notification Arrived")
val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) } val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) }
val npubs = users.map { it.pubkeyNpub() }.toSet() val npubs = users.map { it.pubkeyNpub() }.toSet()
@@ -24,7 +24,9 @@ import android.content.Context
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
@@ -83,14 +85,48 @@ class NotificationCatchUpWorker(
fun cancel(context: Context) { fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_ON_NETWORK)
Log.d(TAG, "Cancelled periodic catch-up work") 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<NotificationCatchUpWorker>()
.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 { override suspend fun doWork(): Result {
Log.d(TAG, "Starting notification catch-up") Log.d(TAG, "Starting notification catch-up")
return try { 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 val appModules = Amethyst.instance
// Collecting relayServices ensures connections are active. // Collecting relayServices ensures connections are active.
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.service.notifications package com.vitorpamplona.amethyst.service.notifications
import android.app.AlarmManager
import android.app.ForegroundServiceStartNotAllowedException
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -30,6 +32,7 @@ import android.content.Intent
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.SystemClock
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -51,7 +54,6 @@ import kotlinx.coroutines.launch
* *
* This service: * This service:
* - Keeps the shared NostrClient alive by collecting the relayServices flow * - Keeps the shared NostrClient alive by collecting the relayServices flow
* - Adds notification-specific subscriptions on inbox relays
* - Routes incoming events through EventNotificationConsumer * - Routes incoming events through EventNotificationConsumer
* - Survives app backgrounding (connections stay open) * - Survives app backgrounding (connections stay open)
* - Uses specialUse foreground service type (no Android 15 time limit) * - 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 is in the foreground, both the UI and this service are subscribers.
* When the app backgrounds, UI subscriptions drop but this service's subscriptions * When the app backgrounds, UI subscriptions drop but this service's subscriptions
* remain, keeping inbox relay connections alive. No reconnection needed. * 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() { class NotificationRelayService : Service() {
companion object { companion object {
@@ -70,12 +80,18 @@ class NotificationRelayService : Service() {
private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE"
private const val ACTION_STOP = "com.vitorpamplona.amethyst.STOP_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) { fun start(context: Context) {
val intent = val intent =
Intent(context, NotificationRelayService::class.java).apply { Intent(context, NotificationRelayService::class.java).apply {
action = ACTION_START action = ACTION_START
} }
try {
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to start foreground service", e)
}
} }
fun stop(context: Context) { fun stop(context: Context) {
@@ -86,9 +102,8 @@ class NotificationRelayService : Service() {
context.startService(intent) context.startService(intent)
} }
fun isEnabled(context: Context): Boolean { fun isEnabled(context: Context): Boolean =
// Check if service should be enabled based on account settings try {
return try {
Amethyst.instance.sessionManager Amethyst.instance.sessionManager
.loggedInAccount() .loggedInAccount()
?.settings ?.settings
@@ -98,11 +113,11 @@ class NotificationRelayService : Service() {
false false
} }
} }
}
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var relayServiceCollectorJob: Job? = null private var relayServiceCollectorJob: Job? = null
private var connectedRelayCount = 0 private var connectedRelayCount = 0
private var foregroundStarted = false
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -110,6 +125,7 @@ class NotificationRelayService : Service() {
super.onCreate() super.onCreate()
Log.d(TAG, "Service created") Log.d(TAG, "Service created")
createNotificationChannel() createNotificationChannel()
initializeForeground()
} }
override fun onStartCommand( override fun onStartCommand(
@@ -126,14 +142,68 @@ class NotificationRelayService : Service() {
else -> { else -> {
Log.d(TAG, "Starting service") 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() startRelayConnection()
} }
} }
return START_STICKY return START_STICKY
} }
private fun startForegroundWithNotification() { /**
* 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) val notification = buildNotification(0)
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
@@ -145,6 +215,17 @@ class NotificationRelayService : Service() {
0 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() { private fun startRelayConnection() {
@@ -156,7 +237,7 @@ class NotificationRelayService : Service() {
// By collecting it here, relay connections survive app backgrounding. // By collecting it here, relay connections survive app backgrounding.
launch { launch {
Amethyst.instance.relayProxyClientConnector.relayServices.collectLatest { 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 val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
override fun onDestroy() {
Log.d(TAG, "Service destroyed")
relayServiceCollectorJob?.cancel()
scope.cancel()
super.onDestroy()
}
} }
@@ -31,6 +31,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -43,6 +46,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext 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.intl.Locale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview 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.parseFeatureSetType
import com.vitorpamplona.amethyst.model.parseGalleryType import com.vitorpamplona.amethyst.model.parseGalleryType
import com.vitorpamplona.amethyst.model.parseThemeType 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.PushNotificationSettingsRow
import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TextSpinner
import com.vitorpamplona.amethyst.ui.components.TitleExplainer 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))
}
}
}
}
} }
+4
View File
@@ -929,6 +929,10 @@
<string name="always_on_notif_setting_title">Always-on notification service</string> <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_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="battery_optimization_title">Battery optimization active</string>
<string name="battery_optimization_description">Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications.</string>
<string name="battery_optimization_fix_now">Fix now</string>
<string name="reply_notify">Notify: </string> <string name="reply_notify">Notify: </string>
<string name="channel_list_join_conversation">Join Conversation</string> <string name="channel_list_join_conversation">Join Conversation</string>