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