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" }