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 -->
<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 -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -290,6 +293,10 @@
android:name=".service.notifications.ServiceWatchdogManager$WatchdogReceiver"
android:exported="false" />
<receiver
android:name=".service.notifications.AutoRestartReceiver"
android:exported="false" />
<receiver
android:name=".service.notifications.PokeyReceiver"
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.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 <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")
// 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,
@@ -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<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 {
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.
@@ -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()
}
}
@@ -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))
}
}
}
}
}
+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_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="channel_list_join_conversation">Join Conversation</string>