diff --git a/amethyst/build.gradle b/amethyst/build.gradle index c83ae17f8..802488116 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -279,6 +279,9 @@ dependencies { // Biometrics implementation libs.androidx.biometric.ktx + // Background Work + implementation libs.androidx.work.runtime.ktx + // Websockets API implementation libs.okhttp implementation libs.okhttpCoroutines diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 986175094..7d7596a01 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -43,6 +43,7 @@ + @@ -52,6 +53,9 @@ + + + @@ -263,6 +267,28 @@ android:resource="@xml/file_paths" /> + + + + + + + + + + + + + + if (state is AccountState.LoggedIn) { + alwaysOnNotificationServiceManager.watchAccount(state.account) + } else { + alwaysOnNotificationServiceManager.stop() + } + } + } + // initializes diskcache on an IO thread. applicationIOScope.launch { // Prepares video cache later @@ -500,6 +517,7 @@ class AppModules( pokeyReceiver.unregister(appContext) BackgroundMedia.removeBackgroundControllerAndReleaseIt() PlaybackServiceClient.shutdown() + alwaysOnNotificationServiceManager.stop() applicationIOScope.cancel("Application onTerminate $appContext") accountsCache.clear() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index d0c49b844..efcfad223 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -136,6 +136,7 @@ private object PrefKeys { const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later + const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service" const val TOR_SETTINGS = "tor_settings" const val USE_PROXY = "use_proxy" const val PROXY_PORT = "proxy_port" @@ -409,6 +410,7 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) + putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value) // migrating from previous design remove(PrefKeys.USE_PROXY) @@ -513,6 +515,7 @@ object LocalPreferences { val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) + val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) @@ -636,6 +639,7 @@ object LocalPreferences { hideDeleteRequestDialog = hideDeleteRequestDialog, hideBlockAlertDialog = hideBlockAlertDialog, hideNIP17WarningDialog = hideNIP17WarningDialog, + alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService), backupUserMetadata = latestUserMetadata.await(), backupContactList = latestContactList.await(), backupNIP65RelayList = latestNip65RelayList.await(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 9022e400d..10521231f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -170,6 +170,7 @@ class AccountSettings( var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, var hideNIP17WarningDialog: Boolean = false, + val alwaysOnNotificationService: MutableStateFlow = MutableStateFlow(false), var backupUserMetadata: MetadataEvent? = null, var backupContactList: ContactListEvent? = null, var backupDMRelayList: ChatMessageRelayListEvent? = null, @@ -215,6 +216,17 @@ class AccountSettings( fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null + // --- + // Always-on Notification Service + // --- + + fun toggleAlwaysOnNotificationService(): Boolean { + val newValue = !alwaysOnNotificationService.value + alwaysOnNotificationService.tryEmit(newValue) + saveAccountSettings() + return newValue + } + // --- // Zaps and Reactions // --- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt new file mode 100644 index 000000000..bfbf4139b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -0,0 +1,104 @@ +/* + * 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 com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * Coordinates all 5 layers of the always-on notification system: + * + * L1 - NotificationRelayService (foreground service with persistent WebSocket) + * L2 - FCM/UnifiedPush (existing push system, wakeup trigger) + * L3 - NotificationCatchUpWorker (WorkManager, 15-min periodic catch-up) + * L4 - BootCompletedReceiver (restart on boot) + * L5 - ServiceWatchdogManager (AlarmManager, 5-min health check) + * + * When enabled, all layers activate. When disabled, all layers deactivate. + * The manager watches the account's alwaysOnNotificationService setting + * and reacts to changes in real time. + */ +class AlwaysOnNotificationServiceManager( + private val context: Context, + private val scope: CoroutineScope, +) { + companion object { + private const val TAG = "AlwaysOnNotifManager" + } + + private var watchJob: Job? = null + + /** + * Starts watching the given account's always-on setting. + * When the setting changes, all layers are started or stopped accordingly. + */ + fun watchAccount(account: Account) { + watchJob?.cancel() + watchJob = + scope.launch { + account.settings.alwaysOnNotificationService.collectLatest { enabled -> + if (enabled) { + enableAllLayers() + } else { + disableAllLayers() + } + } + } + } + + fun stop() { + watchJob?.cancel() + watchJob = null + } + + private fun enableAllLayers() { + Log.d(TAG, "Enabling all notification service layers") + + // L1: Start foreground service + NotificationRelayService.start(context) + + // L3: Schedule periodic catch-up worker + NotificationCatchUpWorker.schedule(context) + + // L5: Start watchdog alarm + ServiceWatchdogManager.schedule(context) + + // L2 (FCM) and L4 (BOOT_COMPLETED) are always active via manifest + } + + private fun disableAllLayers() { + Log.d(TAG, "Disabling all notification service layers") + + // L1: Stop foreground service + NotificationRelayService.stop(context) + + // L3: Cancel periodic catch-up worker + NotificationCatchUpWorker.cancel(context) + + // L5: Cancel watchdog alarm + ServiceWatchdogManager.cancel(context) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BootCompletedReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BootCompletedReceiver.kt new file mode 100644 index 000000000..baa0f3584 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/BootCompletedReceiver.kt @@ -0,0 +1,53 @@ +/* + * 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 com.vitorpamplona.quartz.utils.Log + +/** + * Restarts the NotificationRelayService after device reboot. + * + * The specialUse foreground service type is allowed to start from BOOT_COMPLETED + * on Android 15+, unlike dataSync which is restricted. + */ +class BootCompletedReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "BootCompletedReceiver" + } + + override fun onReceive( + context: Context, + intent: Intent, + ) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED || + intent.action == "android.intent.action.QUICKBOOT_POWERON" + ) { + Log.d(TAG, "Boot completed, checking if notification service should start") + if (NotificationRelayService.isEnabled(context)) { + Log.d(TAG, "Starting notification relay service after boot") + NotificationRelayService.start(context) + } + } + } +} 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 new file mode 100644 index 000000000..38002653e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationCatchUpWorker.kt @@ -0,0 +1,115 @@ +/* + * 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 androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.TimeUnit + +/** + * WorkManager periodic worker that catches up on missed notifications. + * + * This runs every 15 minutes (WorkManager's minimum interval) and ensures + * relay connections are active. It acts as a safety net: + * + * - If the foreground service was killed by an OEM battery optimizer, this + * worker will briefly restore connections and pull missed events. + * - If the foreground service is running fine, this worker is essentially a no-op + * since the connections are already live and filters are active. + * + * The worker collects relayServices to ensure connections are active, + * waits briefly for data to flow, then exits. The foreground service + * (if alive) handles the persistent connection. + */ +class NotificationCatchUpWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + companion object { + private const val TAG = "NotificationCatchUpWorker" + private const val WORK_NAME = "notification_catch_up" + private const val CATCH_UP_DURATION_MS = 30_000L + + fun schedule(context: Context) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = + PeriodicWorkRequestBuilder( + 15, + TimeUnit.MINUTES, + ).setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + Log.d(TAG, "Scheduled periodic catch-up work") + } + + fun cancel(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + Log.d(TAG, "Cancelled periodic catch-up work") + } + } + + override suspend fun doWork(): Result { + Log.d(TAG, "Starting notification catch-up") + + return try { + val appModules = Amethyst.instance + + // Collecting relayServices ensures connections are active. + // If the foreground service is alive, connections are already up + // and this is essentially free. If it was killed, this briefly + // restores them. + withTimeoutOrNull(CATCH_UP_DURATION_MS) { + // Trigger connection by collecting the first emission + appModules.relayProxyClientConnector.relayServices.first() + + // Give the relay subscriptions time to receive pending events + delay(CATCH_UP_DURATION_MS - 5_000) + } + + Log.d(TAG, "Notification catch-up completed") + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Notification catch-up failed", e) + Result.retry() + } + } +} 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 new file mode 100644 index 000000000..c71bef3cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -0,0 +1,248 @@ +/* + * 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.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * A foreground service that maintains persistent WebSocket connections to the user's + * inbox relays for real-time notification delivery. + * + * 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) + * + * The key insight is that this service shares the same NostrClient as the UI. + * 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. + */ +class NotificationRelayService : Service() { + companion object { + private const val TAG = "NotificationRelayService" + private const val CHANNEL_ID = "notification_relay_service" + private const val NOTIFICATION_ID = 9832 + + private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE" + private const val ACTION_STOP = "com.vitorpamplona.amethyst.STOP_NOTIFICATION_SERVICE" + + fun start(context: Context) { + val intent = + Intent(context, NotificationRelayService::class.java).apply { + action = ACTION_START + } + ContextCompat.startForegroundService(context, intent) + } + + fun stop(context: Context) { + val intent = + Intent(context, NotificationRelayService::class.java).apply { + action = ACTION_STOP + } + context.startService(intent) + } + + fun isEnabled(context: Context): Boolean { + // Check if service should be enabled based on account settings + return try { + Amethyst.instance.sessionManager + .loggedInAccount() + ?.settings + ?.alwaysOnNotificationService + ?.value == true + } catch (e: Exception) { + false + } + } + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var relayServiceCollectorJob: Job? = null + private var connectedRelayCount = 0 + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + Log.d(TAG, "Service created") + createNotificationChannel() + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + when (intent?.action) { + ACTION_STOP -> { + Log.d(TAG, "Stopping service") + stopSelf() + return START_NOT_STICKY + } + + else -> { + Log.d(TAG, "Starting service") + startForegroundWithNotification() + 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 + }, + ) + } + + private fun startRelayConnection() { + relayServiceCollectorJob?.cancel() + relayServiceCollectorJob = + scope.launch { + // Collecting the relayServices flow keeps the RelayProxyClientConnector alive. + // This is the same flow that ManageRelayServices() composable collects in the UI. + // By collecting it here, relay connections survive app backgrounding. + launch { + Amethyst.instance.relayProxyClientConnector.relayServices.collectLatest { + Log.d(TAG, "Relay services state updated: $it") + } + } + + // Monitor connected relay count to update the notification + launch { + Amethyst.instance.client.connectedRelaysFlow().collectLatest { relays -> + val count = relays.size + if (count != connectedRelayCount) { + connectedRelayCount = count + updateNotification(count) + } + } + } + } + } + + private fun updateNotification(connectedRelays: Int) { + val notification = buildNotification(connectedRelays) + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify(NOTIFICATION_ID, notification) + } + + private fun buildNotification(connectedRelays: Int): Notification { + val contentText = + if (connectedRelays > 0) { + getString(R.string.always_on_notif_connected, connectedRelays) + } else { + getString(R.string.always_on_notif_connecting) + } + + val openAppIntent = + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + val pendingIntent = + PendingIntent.getActivity( + this, + 0, + openAppIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val stopIntent = + Intent(this, NotificationRelayService::class.java).apply { + action = ACTION_STOP + } + val stopPendingIntent = + PendingIntent.getService( + this, + 1, + stopIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.always_on_notif_title)) + .setContentText(contentText) + .setSmallIcon(R.drawable.amethyst) + .setContentIntent(pendingIntent) + .addAction(0, getString(R.string.always_on_notif_stop), stopPendingIntent) + .setOngoing(true) + .setSilent(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build() + } + + private fun createNotificationChannel() { + val channel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.always_on_notif_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.always_on_notif_channel_description) + setShowBadge(false) + } + 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/service/notifications/ServiceWatchdogManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt new file mode 100644 index 000000000..dc9fd2ccd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/ServiceWatchdogManager.kt @@ -0,0 +1,92 @@ +/* + * 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.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.SystemClock +import com.vitorpamplona.quartz.utils.Log + +/** + * Uses AlarmManager to periodically check if the NotificationRelayService is still alive + * and restart it if needed. + * + * This serves as a watchdog: OEM battery optimizations (Xiaomi MIUI, Huawei EMUI, + * Samsung One UI) may kill the foreground service despite it being "foreground". + * The alarm fires every 5 minutes and checks if the service should be running. + */ +class ServiceWatchdogManager { + companion object { + private const val TAG = "ServiceWatchdogManager" + private const val WATCHDOG_INTERVAL_MS = 5 * 60 * 1000L // 5 minutes + + fun schedule(context: Context) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, WatchdogReceiver::class.java) + val pendingIntent = + PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + alarmManager.setInexactRepeating( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + WATCHDOG_INTERVAL_MS, + WATCHDOG_INTERVAL_MS, + pendingIntent, + ) + Log.d(TAG, "Watchdog alarm scheduled") + } + + fun cancel(context: Context) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, WatchdogReceiver::class.java) + val pendingIntent = + PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_NO_CREATE, + ) + if (pendingIntent != null) { + alarmManager.cancel(pendingIntent) + Log.d(TAG, "Watchdog alarm cancelled") + } + } + } + + class WatchdogReceiver : BroadcastReceiver() { + override fun onReceive( + context: Context, + intent: Intent?, + ) { + Log.d(TAG, "Watchdog fired, checking service state") + if (NotificationRelayService.isEnabled(context)) { + NotificationRelayService.start(context) + } + } + } +} 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 5e7c8caca..cc31227e9 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 @@ -33,6 +33,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -47,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType @@ -89,7 +91,7 @@ fun SettingsScreen( }, ) { Column(Modifier.padding(it)) { - SettingsScreen(accountViewModel.settings.uiSettingsFlow) + SettingsScreen(accountViewModel.settings.uiSettingsFlow, accountViewModel) } } } @@ -103,7 +105,10 @@ fun SettingsScreenPreview() { } @Composable -fun SettingsScreen(sharedPrefs: UiSettingsFlow) { +fun SettingsScreen( + sharedPrefs: UiSettingsFlow, + accountViewModel: AccountViewModel? = null, +) { Column( Modifier .fillMaxSize() @@ -124,6 +129,9 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { GalleryChoice(sharedPrefs) AiWritingHelpChoice(sharedPrefs) PushNotificationSettingsRow(sharedPrefs) + if (accountViewModel != null) { + AlwaysOnNotificationServiceChoice(accountViewModel) + } } } @@ -495,3 +503,21 @@ fun SettingsRow( } } } + +@Composable +fun AlwaysOnNotificationServiceChoice(accountViewModel: AccountViewModel) { + val enabled by accountViewModel.account.settings.alwaysOnNotificationService + .collectAsStateWithLifecycle() + + SettingsRow( + R.string.always_on_notif_setting_title, + R.string.always_on_notif_setting_description, + ) { + Switch( + checked = enabled, + onCheckedChange = { + accountViewModel.account.settings.toggleAlwaysOnNotificationService() + }, + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 19796e0d8..79e216db4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -920,6 +920,15 @@ Username Credential + Relay Connection Service + Keeps connections to your inbox relays active for real-time notifications + Amethyst Notifications Active + Connected to %1$d inbox relays + Connecting to inbox relays\u2026 + Pause + 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. + Notify: Join Conversation diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31c83765f..bba9c6fae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,7 @@ commonsImaging = "1.0.0-alpha6" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" +workRuntime = "2.10.1" androidxCamera = "1.6.0" androidxCollection = "1.6.0" androidxExifinterface = "1.4.2" @@ -190,6 +191,7 @@ androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "sqlite" } androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" } androidx-sqlite-bundled-jvm = { module = "androidx.sqlite:sqlite-bundled-jvm", version.ref = "sqlite" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntime" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }