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:
@@ -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"
|
||||||
|
|||||||
+75
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-2
@@ -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()
|
||||||
|
|||||||
+36
@@ -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.
|
||||||
|
|||||||
+89
-15
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user