feat: add always-on notification relay service
Implements a 5-layer always-on notification system that maintains persistent WebSocket connections to the user's inbox relays for real-time notification delivery, eliminating the dependency on the external push server seeing all relays. Layer architecture: - L1: Foreground service (specialUse type, no Android 15 time limit) keeps the shared NostrClient alive by collecting relayServices flow - L2: FCM/UnifiedPush (existing, unchanged) as wakeup trigger - L3: WorkManager periodic worker (15-min catch-up safety net) - L4: BOOT_COMPLETED receiver (restart service after reboot) - L5: AlarmManager watchdog (5-min health check for OEM killers) Key design: the foreground service shares the same NostrClient as the UI. When the app foregrounds, both the UI and service are subscribers to the relay pool. When the app backgrounds, UI subscriptions drop but service subscriptions remain — zero reconnection needed. New files: - NotificationRelayService: foreground service with persistent notification - BootCompletedReceiver: restarts service on device boot - NotificationCatchUpWorker: WorkManager fallback for missed events - ServiceWatchdogManager: AlarmManager-based health monitor - AlwaysOnNotificationServiceManager: coordinates all 5 layers Settings: opt-in toggle in App Settings, persisted per-account. https://claude.ai/code/session_01LEPfmgGnwjB9a5SDFw5U8t
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
|
||||
<!-- Phone calls -->
|
||||
@@ -52,6 +53,9 @@
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- Always-on notification service: restart after reboot -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Keeps screen on while playing videos -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
@@ -263,6 +267,28 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".service.notifications.NotificationRelayService"
|
||||
android:foregroundServiceType="specialUse"
|
||||
android:exported="false">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Persistent real-time messaging relay connection for Nostr protocol. Maintains WebSocket connections to user-configured inbox relays for immediate notification delivery of direct messages, zaps, and mentions." />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".service.notifications.BootCompletedReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".service.notifications.ServiceWatchdogManager$WatchdogReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name=".service.notifications.PokeyReceiver"
|
||||
android:exported="true"
|
||||
|
||||
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
|
||||
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
import com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager
|
||||
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
||||
@@ -71,6 +72,7 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerRes
|
||||
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
|
||||
import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountState
|
||||
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -98,6 +100,7 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.transform
|
||||
@@ -391,6 +394,9 @@ class AppModules(
|
||||
)
|
||||
}
|
||||
|
||||
// Manages always-on notification service lifecycle
|
||||
val alwaysOnNotificationServiceManager = AlwaysOnNotificationServiceManager(appContext, applicationIOScope)
|
||||
|
||||
// Organizes cache clearing
|
||||
val trimmingService by
|
||||
lazy {
|
||||
@@ -488,6 +494,17 @@ class AppModules(
|
||||
// registers to receive events
|
||||
pokeyReceiver.register(appContext)
|
||||
|
||||
// Watch for account login and start/stop always-on notification service
|
||||
applicationIOScope.launch {
|
||||
sessionManager.accountContent.collectLatest { state ->
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -170,6 +170,7 @@ class AccountSettings(
|
||||
var hideDeleteRequestDialog: Boolean = false,
|
||||
var hideBlockAlertDialog: Boolean = false,
|
||||
var hideNIP17WarningDialog: Boolean = false,
|
||||
val alwaysOnNotificationService: MutableStateFlow<Boolean> = 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
|
||||
// ---
|
||||
|
||||
+104
@@ -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)
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -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<NotificationCatchUpWorker>(
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+248
@@ -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()
|
||||
}
|
||||
}
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-2
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,6 +920,15 @@
|
||||
<string name="call_settings_turn_username">Username</string>
|
||||
<string name="call_settings_turn_credential">Credential</string>
|
||||
|
||||
<string name="always_on_notif_channel_name" translatable="false">Relay Connection Service</string>
|
||||
<string name="always_on_notif_channel_description">Keeps connections to your inbox relays active for real-time notifications</string>
|
||||
<string name="always_on_notif_title">Amethyst Notifications Active</string>
|
||||
<string name="always_on_notif_connected">Connected to %1$d inbox relays</string>
|
||||
<string name="always_on_notif_connecting">Connecting to inbox relays\u2026</string>
|
||||
<string name="always_on_notif_stop">Pause</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="reply_notify">Notify: </string>
|
||||
|
||||
<string name="channel_list_join_conversation">Join Conversation</string>
|
||||
|
||||
Reference in New Issue
Block a user