Merge pull request #1983 from vitorpamplona/claude/always-on-notification-service-NuW9I
Add always-on notification service for real-time Nostr relay connections
This commit is contained in:
@@ -0,0 +1,299 @@
|
|||||||
|
# Always-On Notification Service
|
||||||
|
|
||||||
|
Amethyst's always-on notification service maintains persistent WebSocket connections to the
|
||||||
|
user's inbox relays and DM relays, ensuring real-time delivery of DMs, zaps, mentions, and
|
||||||
|
other notifications without depending on an external push server.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
The existing push notification system (`push.amethyst.social`) can only monitor relays it
|
||||||
|
knows about. Private, paid, or obscure inbox relays get missed entirely. The only way to
|
||||||
|
guarantee 100% notification coverage is for the device itself to maintain connections to
|
||||||
|
the user's NIP-65 inbox relays and NIP-17 DM relays.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
NIP-65 notification inbox relays ──WebSocket──┐
|
||||||
|
├──> [NotificationRelayService]
|
||||||
|
NIP-17 DM inbox relays ───────────WebSocket──┘ |
|
||||||
|
v
|
||||||
|
EventNotificationConsumer
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Android Notification
|
||||||
|
```
|
||||||
|
|
||||||
|
The service shares the **same `NostrClient` instance** as the UI. This is the key design
|
||||||
|
decision. When the app is in the foreground, both the UI and the service are collecting
|
||||||
|
the `relayServices` flow. The `AccountFilterAssembler` subscription from the Compose UI
|
||||||
|
tree stays active as long as the Activity exists (even when stopped/backgrounded),
|
||||||
|
keeping notification and DM relay connections alive. When the app returns to the
|
||||||
|
foreground, the UI piggybacks on the already-open connections. **Zero reconnection, zero
|
||||||
|
dropped messages.**
|
||||||
|
|
||||||
|
```
|
||||||
|
BACKGROUND MODE (service running):
|
||||||
|
inbox-relay-1 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows]
|
||||||
|
inbox-relay-2 ──WebSocket──> [AccountFilterAssembler: notifications, metadata, follows]
|
||||||
|
dm-relay-1 ────WebSocket──> [AccountFilterAssembler: gift wraps]
|
||||||
|
(outbox relays disconnected — no Home/Video/Discovery subscriptions)
|
||||||
|
|
||||||
|
APP FOREGROUNDS:
|
||||||
|
inbox-relay-1 ──WebSocket──> [Same connection] <── Home/Discovery subs resume
|
||||||
|
inbox-relay-2 ──WebSocket──> [Same connection] <── Home/Discovery subs resume
|
||||||
|
dm-relay-1 ────WebSocket──> [Same connection] <── ChatroomList subs resume
|
||||||
|
outbox-relay-3 ──WebSocket──> [New connection] <── Home/Video feed relay
|
||||||
|
```
|
||||||
|
|
||||||
|
## Subscription Architecture
|
||||||
|
|
||||||
|
### What the service does
|
||||||
|
|
||||||
|
The service does NOT create its own relay subscriptions. It only keeps the
|
||||||
|
`RelayProxyClientConnector` alive by collecting `relayServices`. The actual subscriptions
|
||||||
|
come from the `AccountFilterAssembler` in the Compose tree (`LoggedInPage`), which
|
||||||
|
stays active as long as the Activity exists and covers:
|
||||||
|
|
||||||
|
- **Metadata** — user profile, relay lists, mute lists, follows (via `AccountMetadataEoseManager`)
|
||||||
|
- **Follows** — follow list changes that affect notification filtering (via `AccountFollowsLoaderSubAssembler`)
|
||||||
|
- **Notifications** — mentions, zaps, reactions on NIP-65 inbox relays (via `AccountNotificationsEoseFromInboxRelaysManager`)
|
||||||
|
- **Gift wraps** — NIP-59 encrypted DMs on NIP-17 DM relays (via `AccountGiftWrapsEoseManager`)
|
||||||
|
- **Drafts** — draft events (via `AccountDraftsEoseManager`)
|
||||||
|
|
||||||
|
This is critical because notification filtering depends on follow lists, mute lists,
|
||||||
|
and relay configurations. If the service maintained its own isolated subscriptions,
|
||||||
|
it would miss follow list changes and display notifications from muted users.
|
||||||
|
|
||||||
|
### What pauses on background
|
||||||
|
|
||||||
|
Heavy feed subscriptions use `LifecycleAwareKeyDataSourceSubscription` which subscribes
|
||||||
|
on `ON_START` and unsubscribes on `ON_STOP`. When the app backgrounds:
|
||||||
|
|
||||||
|
| Subscription | Behavior | Why |
|
||||||
|
|-------------|----------|-----|
|
||||||
|
| `AccountFilterAssembler` | **Stays active** | Needed for notifications, DMs, follow/mute list changes |
|
||||||
|
| `HomeFilterAssembler` | **Pauses** | Home feed data with nobody viewing wastes bandwidth |
|
||||||
|
| `VideoFilterAssembler` | **Pauses** | Video feed data with nobody viewing wastes bandwidth |
|
||||||
|
| `DiscoveryFilterAssembler` | **Pauses** | Discovery feed data with nobody viewing wastes bandwidth |
|
||||||
|
| `ChatroomListFilterAssembler` | **Pauses** | Chatroom list updates with nobody viewing waste bandwidth |
|
||||||
|
|
||||||
|
When the feed subscriptions pause, the relay pool automatically disconnects outbox relays
|
||||||
|
that no longer have any active subscriptions. Only inbox and DM relays stay connected
|
||||||
|
(because `AccountFilterAssembler` still has active subscriptions on them).
|
||||||
|
|
||||||
|
## Foreground Service
|
||||||
|
|
||||||
|
`NotificationRelayService` is a foreground service with `specialUse` type:
|
||||||
|
|
||||||
|
- **No time limit**: Unlike `dataSync` (6-hour limit on Android 15), `specialUse` has no
|
||||||
|
timeout restriction. **Why it matters:** A notification service must run indefinitely.
|
||||||
|
The `dataSync` type would force the service to stop after 6 cumulative hours per 24-hour
|
||||||
|
period, making it useless for always-on notifications.
|
||||||
|
- **BOOT_COMPLETED safe**: Can be started from boot receivers on Android 15+, unlike
|
||||||
|
`dataSync` which is restricted. **Why it matters:** Without this, the service couldn't
|
||||||
|
auto-restart after a reboot on modern Android.
|
||||||
|
- **START_STICKY**: Android will restart the service if it's killed by the system.
|
||||||
|
- **Persistent notification**: Shows "Connected to N inbox relays" with a Pause action.
|
||||||
|
Uses `IMPORTANCE_LOW` so it's silent and unobtrusive.
|
||||||
|
|
||||||
|
### ForegroundServiceStartNotAllowedException
|
||||||
|
|
||||||
|
On Android 12+, starting a foreground service from the background can throw
|
||||||
|
`ForegroundServiceStartNotAllowedException`. The service catches this gracefully and stops
|
||||||
|
itself rather than crashing the app.
|
||||||
|
|
||||||
|
**Why it matters:** Without this catch, if the watchdog alarm or WorkManager tries to
|
||||||
|
restart the service while the app lacks the background-start exemption (e.g., battery
|
||||||
|
optimization is active), the app would crash with an unhandled exception.
|
||||||
|
|
||||||
|
### Redundant startForeground()
|
||||||
|
|
||||||
|
`startForeground()` is called from both `onCreate()` and `onStartCommand()` as a safety
|
||||||
|
net. In rare edge cases, `onStartCommand()` can fire before `onCreate()` completes
|
||||||
|
(observed in ntfy issue #1520). The `foregroundStarted` flag prevents double invocation.
|
||||||
|
|
||||||
|
**Why it matters:** If `startForeground()` isn't called within 5 seconds of
|
||||||
|
`startForegroundService()`, the app crashes with an ANR. The redundant call ensures the
|
||||||
|
foreground notification is posted regardless of which lifecycle method runs first.
|
||||||
|
|
||||||
|
## 8-Layer Auto-Restart Defense
|
||||||
|
|
||||||
|
Android (and OEM battery optimizers) will aggressively try to kill background services.
|
||||||
|
The notification service uses 8 independent mechanisms to stay alive. Each addresses a
|
||||||
|
specific kill vector that the others don't cover:
|
||||||
|
|
||||||
|
### Layer 1: START_STICKY
|
||||||
|
|
||||||
|
**What:** When Android kills the service due to memory pressure, `START_STICKY` tells the
|
||||||
|
system to recreate it with a null intent.
|
||||||
|
|
||||||
|
**Why needed:** This is the baseline restart mechanism provided by Android. However, it's
|
||||||
|
unreliable in practice — many OEMs (Xiaomi MIUI, Huawei EMUI, Samsung One UI, Oppo
|
||||||
|
ColorOS) override this behavior and prevent sticky service restarts. That's why we need
|
||||||
|
the remaining 7 layers.
|
||||||
|
|
||||||
|
### Layer 2: onTaskRemoved() Alarm
|
||||||
|
|
||||||
|
**What:** When the user swipes the app from recents, schedules a 1-second alarm to restart
|
||||||
|
the service.
|
||||||
|
|
||||||
|
**Why needed:** On stock Android, swiping from recents only removes the task but leaves
|
||||||
|
the foreground service running. However, many OEMs treat swipe-from-recents as a force
|
||||||
|
stop, killing the foreground service. `START_STICKY` won't help because some OEMs block
|
||||||
|
sticky restarts after a task removal. The alarm bypasses this by scheduling the restart
|
||||||
|
through `AlarmManager`, which is a separate system that OEM modifications rarely touch.
|
||||||
|
|
||||||
|
### Layer 3: onDestroy() Broadcast
|
||||||
|
|
||||||
|
**What:** When the service is destroyed for any reason, it broadcasts to
|
||||||
|
`AutoRestartReceiver`, which enqueues a one-time WorkManager task with a network
|
||||||
|
connectivity constraint.
|
||||||
|
|
||||||
|
**Why needed:** This catches the gap between `START_STICKY` and `onTaskRemoved()`.
|
||||||
|
If the system kills the service during normal operation (not from recents), `START_STICKY`
|
||||||
|
should restart it — but if the OEM blocks that restart, the broadcast fires a WorkManager
|
||||||
|
task as a backup. WorkManager is harder for OEMs to suppress because it's part of
|
||||||
|
Google Play Services infrastructure.
|
||||||
|
|
||||||
|
### Layer 4: AlarmManager Watchdog (5 minutes)
|
||||||
|
|
||||||
|
**What:** `ServiceWatchdogManager` fires an `ELAPSED_REALTIME_WAKEUP` alarm every 5
|
||||||
|
minutes. The receiver checks if the service should be running and restarts it.
|
||||||
|
|
||||||
|
**Why needed:** This is the "belt and suspenders" layer. If all of the above layers fail
|
||||||
|
(sticky restart blocked, alarm from `onTaskRemoved` didn't fire, broadcast wasn't
|
||||||
|
delivered), the watchdog will catch it within 5 minutes. Uses `ELAPSED_REALTIME_WAKEUP` to
|
||||||
|
wake the device from sleep, ensuring the check happens even in Doze.
|
||||||
|
|
||||||
|
### Layer 5: WorkManager Periodic Catch-Up (15 minutes)
|
||||||
|
|
||||||
|
**What:** Runs every 15 minutes with a network connectivity constraint. Ensures relay
|
||||||
|
connections are active and restarts the foreground service if needed.
|
||||||
|
|
||||||
|
**Why needed:** WorkManager survives process death and device reboots — it's the most
|
||||||
|
persistent scheduling mechanism on Android. Even if the app process is completely dead,
|
||||||
|
WorkManager (backed by JobScheduler) will eventually wake it. The 15-minute interval is
|
||||||
|
WorkManager's minimum, ensuring regular catch-up even if the foreground service has been
|
||||||
|
dead for a while.
|
||||||
|
|
||||||
|
### Layer 6: Network-Available One-Time Worker
|
||||||
|
|
||||||
|
**What:** When `AutoRestartReceiver` fires, it enqueues a one-time WorkManager task that
|
||||||
|
runs as soon as network connectivity is available.
|
||||||
|
|
||||||
|
**Why needed:** If the service dies during a network outage, there's no point restarting it
|
||||||
|
immediately (the relays won't connect). This worker waits for connectivity and restarts
|
||||||
|
then, rather than waiting up to 15 minutes for the next periodic worker. This is especially
|
||||||
|
important after airplane mode, tunnel/elevator scenarios, or switching between WiFi and
|
||||||
|
cellular.
|
||||||
|
|
||||||
|
### Layer 7: Boot and Package Receivers
|
||||||
|
|
||||||
|
**What:** `BootCompletedReceiver` restarts the service after device reboot
|
||||||
|
(`BOOT_COMPLETED`, `QUICKBOOT_POWERON`) and app update (`MY_PACKAGE_REPLACED`).
|
||||||
|
|
||||||
|
**Why needed:** After a reboot, no services are running — `START_STICKY` doesn't apply
|
||||||
|
across reboots. The boot receiver is the only way to restart. After an app update, the
|
||||||
|
old process is killed and the new version's services don't auto-start. Without
|
||||||
|
`MY_PACKAGE_REPLACED`, users would need to manually open the app after every Play Store
|
||||||
|
update to restore notifications.
|
||||||
|
|
||||||
|
### Layer 8: FCM / UnifiedPush (existing)
|
||||||
|
|
||||||
|
**What:** The existing push notification system continues to work alongside the always-on
|
||||||
|
service.
|
||||||
|
|
||||||
|
**Why needed:** FCM is the only mechanism that survives a force stop (because Google Play
|
||||||
|
Services handles delivery outside the app's process). If the user explicitly force-stops
|
||||||
|
Amethyst from Settings, all 7 layers above are disabled. Only FCM can still deliver
|
||||||
|
notifications until the user opens the app again.
|
||||||
|
|
||||||
|
## WakeLock During Notification Processing
|
||||||
|
|
||||||
|
`EventNotificationConsumer` acquires a `PARTIAL_WAKE_LOCK` with a 10-minute timeout when
|
||||||
|
processing incoming notifications.
|
||||||
|
|
||||||
|
**Why needed:** When a notification event arrives from a relay, the app needs to decrypt
|
||||||
|
NIP-59 gift wraps, verify signatures, look up accounts, resolve display names, load
|
||||||
|
profile pictures, and construct the Android notification. In Doze mode, the CPU can sleep
|
||||||
|
between alarm windows. Without a WakeLock, the CPU could sleep mid-processing, causing the
|
||||||
|
notification to be delayed or lost. The 10-minute timeout is generous to handle slow
|
||||||
|
decryption (especially with external signers) while preventing indefinite wake locks from
|
||||||
|
battery drain.
|
||||||
|
|
||||||
|
## Battery Optimization Exemption
|
||||||
|
|
||||||
|
The service works best when the app is exempted from Android's battery optimizations (Doze).
|
||||||
|
|
||||||
|
**Why needed:** Even with a foreground service, Android can restrict network access during
|
||||||
|
Doze maintenance windows. The battery optimization exemption tells Android that this app's
|
||||||
|
network activity is user-expected and should not be deferred. Without it, relay connections
|
||||||
|
may be broken during Doze, causing missed notifications that only arrive when the device
|
||||||
|
exits Doze (which can be hours for a stationary, charging device).
|
||||||
|
|
||||||
|
`BatteryOptimizationHelper` checks the exemption status and provides a method to launch
|
||||||
|
the system settings dialog. When the always-on service is enabled but the app isn't
|
||||||
|
whitelisted, the settings screen shows a warning banner with a "Fix now" button.
|
||||||
|
|
||||||
|
Messaging apps are explicitly listed as a valid use case for this exemption in Google Play
|
||||||
|
policy.
|
||||||
|
|
||||||
|
## Coordinator
|
||||||
|
|
||||||
|
`AlwaysOnNotificationServiceManager` watches the account's `alwaysOnNotificationService`
|
||||||
|
setting (a `MutableStateFlow<Boolean>`) and activates or deactivates all layers in
|
||||||
|
response:
|
||||||
|
|
||||||
|
```
|
||||||
|
Setting ON → Start foreground service + Schedule WorkManager + Schedule watchdog alarm
|
||||||
|
Setting OFF → Stop foreground service + Cancel WorkManager + Cancel watchdog alarm
|
||||||
|
```
|
||||||
|
|
||||||
|
The manager is initialized in `AppModules` and watches the account state. When a user
|
||||||
|
logs in, it starts watching their setting. When they log out, it stops.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
The always-on notification service is **opt-in** (off by default). Users enable it from
|
||||||
|
**App Settings** with a toggle switch. The setting is persisted per-account in
|
||||||
|
`AccountSettings.alwaysOnNotificationService` and saved to `EncryptedSharedPreferences`.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `NotificationRelayService.kt` | Foreground service, keeps relay connections alive, auto-restart |
|
||||||
|
| `LifecycleAwareKeyDataSourceSubscription.kt` | Pauses heavy feed subs when app backgrounds |
|
||||||
|
| `BootCompletedReceiver.kt` | Restart on boot and app update |
|
||||||
|
| `AutoRestartReceiver.kt` | Restart via WorkManager when service is destroyed |
|
||||||
|
| `NotificationCatchUpWorker.kt` | Periodic and on-demand catch-up worker |
|
||||||
|
| `ServiceWatchdogManager.kt` | AlarmManager-based health monitor |
|
||||||
|
| `AlwaysOnNotificationServiceManager.kt` | Coordinates all layers based on setting |
|
||||||
|
| `BatteryOptimizationHelper.kt` | Battery optimization check and exemption request |
|
||||||
|
| `EventNotificationConsumer.kt` | WakeLock wrapper for notification processing |
|
||||||
|
| `AccountSettings.kt` | `alwaysOnNotificationService` setting |
|
||||||
|
| `LocalPreferences.kt` | Setting persistence |
|
||||||
|
| `AppSettingsScreen.kt` | Toggle UI and battery optimization banner |
|
||||||
|
| `AndroidManifest.xml` | Permissions, service, and receiver declarations |
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
| Permission | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| `FOREGROUND_SERVICE` | Run the foreground service |
|
||||||
|
| `FOREGROUND_SERVICE_SPECIAL_USE` | Declare `specialUse` service type |
|
||||||
|
| `RECEIVE_BOOT_COMPLETED` | Restart on boot |
|
||||||
|
| `WAKE_LOCK` | Keep CPU awake during notification processing |
|
||||||
|
| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | Request Doze exemption |
|
||||||
|
|
||||||
|
## Inspiration
|
||||||
|
|
||||||
|
This implementation draws from battle-tested patterns in:
|
||||||
|
|
||||||
|
- **ntfy** (millions of users) — `onTaskRemoved()` alarm, `onDestroy()` broadcast restart,
|
||||||
|
`ForegroundServiceStartNotAllowedException` handling, redundant `startForeground()`,
|
||||||
|
battery optimization guidance, WakeLock during processing
|
||||||
|
- **Pokey** (Nostr notification app) — `specialUse` foreground service type,
|
||||||
|
`MY_PACKAGE_REPLACED` restart
|
||||||
|
- **Signal** — Hybrid FCM + persistent WebSocket architecture
|
||||||
@@ -280,6 +280,9 @@ dependencies {
|
|||||||
// Biometrics
|
// Biometrics
|
||||||
implementation libs.androidx.biometric.ktx
|
implementation libs.androidx.biometric.ktx
|
||||||
|
|
||||||
|
// Background Work
|
||||||
|
implementation libs.androidx.work.runtime.ktx
|
||||||
|
|
||||||
// Websockets API
|
// Websockets API
|
||||||
implementation libs.okhttp
|
implementation libs.okhttp
|
||||||
implementation libs.okhttpCoroutines
|
implementation libs.okhttpCoroutines
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
<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_CAMERA" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
<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" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
|
||||||
<!-- Phone calls -->
|
<!-- Phone calls -->
|
||||||
@@ -52,6 +53,12 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
|
<!-- Always-on notification service: restart after reboot -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<!-- Always-on notification service: battery optimization exemption -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<!-- Keeps screen on while playing videos -->
|
<!-- Keeps screen on while playing videos -->
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
@@ -263,6 +270,33 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</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" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".service.notifications.ServiceWatchdogManager$WatchdogReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".service.notifications.AutoRestartReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".service.notifications.PokeyReceiver"
|
android:name=".service.notifications.PokeyReceiver"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
|
|||||||
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
|
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
|
||||||
import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
|
import com.vitorpamplona.amethyst.service.images.ThumbnailDiskCache
|
||||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
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.notifications.PokeyReceiver
|
||||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
|
||||||
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
|
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.service.uploads.nip95.Nip95CacheFactory
|
||||||
import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
import com.vitorpamplona.amethyst.ui.resourceCacheInit
|
||||||
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
|
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.screen.UiSettingsState
|
||||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||||
@@ -98,6 +100,7 @@ import kotlinx.coroutines.async
|
|||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.onCompletion
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.transform
|
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
|
// Organizes cache clearing
|
||||||
val trimmingService by
|
val trimmingService by
|
||||||
lazy {
|
lazy {
|
||||||
@@ -488,6 +494,17 @@ class AppModules(
|
|||||||
// registers to receive events
|
// registers to receive events
|
||||||
pokeyReceiver.register(appContext)
|
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.
|
// initializes diskcache on an IO thread.
|
||||||
applicationIOScope.launch {
|
applicationIOScope.launch {
|
||||||
// Prepares video cache later
|
// Prepares video cache later
|
||||||
@@ -500,6 +517,7 @@ class AppModules(
|
|||||||
pokeyReceiver.unregister(appContext)
|
pokeyReceiver.unregister(appContext)
|
||||||
BackgroundMedia.removeBackgroundControllerAndReleaseIt()
|
BackgroundMedia.removeBackgroundControllerAndReleaseIt()
|
||||||
PlaybackServiceClient.shutdown()
|
PlaybackServiceClient.shutdown()
|
||||||
|
alwaysOnNotificationServiceManager.stop()
|
||||||
applicationIOScope.cancel("Application onTerminate $appContext")
|
applicationIOScope.cancel("Application onTerminate $appContext")
|
||||||
accountsCache.clear()
|
accountsCache.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ private object PrefKeys {
|
|||||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_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 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 TOR_SETTINGS = "tor_settings"
|
||||||
const val USE_PROXY = "use_proxy"
|
const val USE_PROXY = "use_proxy"
|
||||||
const val PROXY_PORT = "proxy_port"
|
const val PROXY_PORT = "proxy_port"
|
||||||
@@ -409,6 +410,7 @@ object LocalPreferences {
|
|||||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
||||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
||||||
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
|
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
|
||||||
|
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
|
||||||
|
|
||||||
// migrating from previous design
|
// migrating from previous design
|
||||||
remove(PrefKeys.USE_PROXY)
|
remove(PrefKeys.USE_PROXY)
|
||||||
@@ -513,6 +515,7 @@ object LocalPreferences {
|
|||||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||||
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
|
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 hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
|
||||||
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
|
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
|
||||||
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
|
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
|
||||||
@@ -636,6 +639,7 @@ object LocalPreferences {
|
|||||||
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
hideDeleteRequestDialog = hideDeleteRequestDialog,
|
||||||
hideBlockAlertDialog = hideBlockAlertDialog,
|
hideBlockAlertDialog = hideBlockAlertDialog,
|
||||||
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
hideNIP17WarningDialog = hideNIP17WarningDialog,
|
||||||
|
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
|
||||||
backupUserMetadata = latestUserMetadata.await(),
|
backupUserMetadata = latestUserMetadata.await(),
|
||||||
backupContactList = latestContactList.await(),
|
backupContactList = latestContactList.await(),
|
||||||
backupNIP65RelayList = latestNip65RelayList.await(),
|
backupNIP65RelayList = latestNip65RelayList.await(),
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class AccountSettings(
|
|||||||
var hideDeleteRequestDialog: Boolean = false,
|
var hideDeleteRequestDialog: Boolean = false,
|
||||||
var hideBlockAlertDialog: Boolean = false,
|
var hideBlockAlertDialog: Boolean = false,
|
||||||
var hideNIP17WarningDialog: Boolean = false,
|
var hideNIP17WarningDialog: Boolean = false,
|
||||||
|
val alwaysOnNotificationService: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||||
var backupUserMetadata: MetadataEvent? = null,
|
var backupUserMetadata: MetadataEvent? = null,
|
||||||
var backupContactList: ContactListEvent? = null,
|
var backupContactList: ContactListEvent? = null,
|
||||||
var backupDMRelayList: ChatMessageRelayListEvent? = null,
|
var backupDMRelayList: ChatMessageRelayListEvent? = null,
|
||||||
@@ -215,6 +216,17 @@ class AccountSettings(
|
|||||||
|
|
||||||
fun isWriteable(): Boolean = keyPair.privKey != null || externalSignerPackageName != null
|
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
|
// Zaps and Reactions
|
||||||
// ---
|
// ---
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
private var wasEnabled = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts watching the given account's always-on setting.
|
||||||
|
* When the setting changes, all layers are started or stopped accordingly.
|
||||||
|
* On initial load with false, nothing happens (no-op for users who never enabled it).
|
||||||
|
*/
|
||||||
|
fun watchAccount(account: Account) {
|
||||||
|
watchJob?.cancel()
|
||||||
|
wasEnabled = false
|
||||||
|
watchJob =
|
||||||
|
scope.launch {
|
||||||
|
account.settings.alwaysOnNotificationService.collectLatest { enabled ->
|
||||||
|
if (enabled) {
|
||||||
|
wasEnabled = true
|
||||||
|
enableAllLayers()
|
||||||
|
} else if (wasEnabled) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.notifications
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives a broadcast from NotificationRelayService.onDestroy() and
|
||||||
|
* enqueues a one-time WorkManager task to restart the service.
|
||||||
|
*
|
||||||
|
* This catches kills that START_STICKY might miss (e.g., OEM battery
|
||||||
|
* optimizers, aggressive memory reclaim). The WorkManager task requires
|
||||||
|
* network connectivity, so the service won't restart until the device
|
||||||
|
* has an active connection.
|
||||||
|
*
|
||||||
|
* Pattern inspired by ntfy's AutoRestartReceiver.
|
||||||
|
*/
|
||||||
|
class AutoRestartReceiver : BroadcastReceiver() {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AutoRestartReceiver"
|
||||||
|
private const val WORK_NAME = "notification_service_restart"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(
|
||||||
|
context: Context,
|
||||||
|
intent: Intent?,
|
||||||
|
) {
|
||||||
|
if (intent?.action != NotificationRelayService.ACTION_AUTO_RESTART) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Received auto-restart broadcast, enqueuing restart work")
|
||||||
|
|
||||||
|
val constraints =
|
||||||
|
Constraints
|
||||||
|
.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request =
|
||||||
|
OneTimeWorkRequestBuilder<NotificationCatchUpWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.service.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper for checking and requesting battery optimization exemption.
|
||||||
|
*
|
||||||
|
* When the always-on notification service is enabled, the app needs to be
|
||||||
|
* exempted from battery optimizations (Doze) to maintain reliable relay
|
||||||
|
* connections. Without the exemption, Android may restrict network access
|
||||||
|
* and defer alarms even for foreground services.
|
||||||
|
*
|
||||||
|
* Messaging apps are explicitly listed as a valid use case for this exemption
|
||||||
|
* in Google Play policy.
|
||||||
|
*/
|
||||||
|
object BatteryOptimizationHelper {
|
||||||
|
private const val TAG = "BatteryOptimization"
|
||||||
|
|
||||||
|
fun isIgnoringBatteryOptimizations(context: Context): Boolean {
|
||||||
|
val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
return powerManager.isIgnoringBatteryOptimizations(context.packageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launches the system dialog to request battery optimization exemption.
|
||||||
|
* Falls back to the general battery settings page if the direct intent fails.
|
||||||
|
*/
|
||||||
|
fun requestBatteryOptimizationExemption(context: Context) {
|
||||||
|
try {
|
||||||
|
val intent =
|
||||||
|
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:${context.packageName}")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Direct battery exemption request failed, opening settings", e)
|
||||||
|
try {
|
||||||
|
val fallback =
|
||||||
|
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(fallback)
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Log.e(TAG, "Failed to open battery settings", e2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* 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 or app update.
|
||||||
|
*
|
||||||
|
* Handles:
|
||||||
|
* - BOOT_COMPLETED / QUICKBOOT_POWERON: restart after device reboot
|
||||||
|
* - MY_PACKAGE_REPLACED: restart after app update (without this, the service
|
||||||
|
* stays dead until the user manually opens the app or reboots)
|
||||||
|
*
|
||||||
|
* 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,
|
||||||
|
) {
|
||||||
|
when (intent.action) {
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
"android.intent.action.QUICKBOOT_POWERON",
|
||||||
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
||||||
|
-> {
|
||||||
|
Log.d(TAG) { "Received ${intent.action}, checking if notification service should start" }
|
||||||
|
if (NotificationRelayService.isEnabled(context)) {
|
||||||
|
Log.d(TAG, "Starting notification relay service")
|
||||||
|
NotificationRelayService.start(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-36
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.notifications
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.drawable.BitmapDrawable
|
import android.graphics.drawable.BitmapDrawable
|
||||||
|
import android.os.PowerManager
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
@@ -88,28 +89,55 @@ private const val SCROLL_TO_QUERY_PARAM = "&scrollTo="
|
|||||||
class EventNotificationConsumer(
|
class EventNotificationConsumer(
|
||||||
private val applicationContext: Context,
|
private val applicationContext: Context,
|
||||||
) {
|
) {
|
||||||
suspend fun consume(event: GiftWrapEvent) {
|
companion object {
|
||||||
Log.d(TAG, "New Notification Arrived")
|
private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes
|
||||||
|
}
|
||||||
|
|
||||||
// PushNotification Wraps don't include a receiver.
|
/**
|
||||||
// Test with all logged in accounts
|
* Acquires a partial WakeLock during notification processing to ensure
|
||||||
var matchAccount = false
|
* the CPU stays awake long enough to decrypt, process, and dispatch
|
||||||
LocalPreferences.allSavedAccounts().forEach {
|
* the notification, even in Doze mode.
|
||||||
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
|
*/
|
||||||
LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc ->
|
private inline fun <T> withWakeLock(block: () -> T): T {
|
||||||
Log.d(TAG) { "New Notification Testing if for ${it.npub}" }
|
val powerManager = applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
try {
|
val wakeLock =
|
||||||
val account = Amethyst.instance.accountsCache.loadAccount(acc)
|
powerManager.newWakeLock(
|
||||||
consumeIfMatchesAccount(event, account)
|
PowerManager.PARTIAL_WAKE_LOCK,
|
||||||
matchAccount = true
|
"amethyst:notification_processing",
|
||||||
} catch (e: Exception) {
|
)
|
||||||
if (e is CancellationException) throw e
|
wakeLock.acquire(WAKELOCK_TIMEOUT_MS)
|
||||||
Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" }
|
try {
|
||||||
|
return block()
|
||||||
|
} finally {
|
||||||
|
if (wakeLock.isHeld) {
|
||||||
|
wakeLock.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun consume(event: GiftWrapEvent) =
|
||||||
|
withWakeLock {
|
||||||
|
Log.d(TAG, "New Notification Arrived")
|
||||||
|
|
||||||
|
// PushNotification Wraps don't include a receiver.
|
||||||
|
// Test with all logged in accounts
|
||||||
|
var matchAccount = false
|
||||||
|
LocalPreferences.allSavedAccounts().forEach {
|
||||||
|
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner)) {
|
||||||
|
LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { acc ->
|
||||||
|
Log.d(TAG) { "New Notification Testing if for ${it.npub}" }
|
||||||
|
try {
|
||||||
|
val account = Amethyst.instance.accountsCache.loadAccount(acc)
|
||||||
|
consumeIfMatchesAccount(event, account)
|
||||||
|
matchAccount = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun consumeIfMatchesAccount(
|
private suspend fun consumeIfMatchesAccount(
|
||||||
pushWrappedEvent: GiftWrapEvent,
|
pushWrappedEvent: GiftWrapEvent,
|
||||||
@@ -221,30 +249,31 @@ class EventNotificationConsumer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun findAccountAndConsume(event: Event) {
|
suspend fun findAccountAndConsume(event: Event) =
|
||||||
Log.d(TAG, "New Notification Arrived")
|
withWakeLock {
|
||||||
val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) }
|
Log.d(TAG, "New Notification Arrived")
|
||||||
val npubs = users.map { it.pubkeyNpub() }.toSet()
|
val users = event.taggedUserIds().map { LocalCache.getOrCreateUser(it) }
|
||||||
|
val npubs = users.map { it.pubkeyNpub() }.toSet()
|
||||||
|
|
||||||
// PushNotification Wraps don't include a receiver.
|
// PushNotification Wraps don't include a receiver.
|
||||||
// Test with all logged in accounts
|
// Test with all logged in accounts
|
||||||
var matchAccount = false
|
var matchAccount = false
|
||||||
LocalPreferences.allSavedAccounts().forEach {
|
LocalPreferences.allSavedAccounts().forEach {
|
||||||
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) {
|
if (!matchAccount && (it.hasPrivKey || it.loggedInWithExternalSigner) && it.npub in npubs) {
|
||||||
LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings ->
|
LocalPreferences.loadAccountConfigFromEncryptedStorage(it.npub)?.let { accountSettings ->
|
||||||
Log.d(TAG) { "New Notification Testing if for ${it.npub}" }
|
Log.d(TAG) { "New Notification Testing if for ${it.npub}" }
|
||||||
try {
|
try {
|
||||||
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
|
val account = Amethyst.instance.accountsCache.loadAccount(accountSettings)
|
||||||
consumeNotificationEvent(event, account)
|
consumeNotificationEvent(event, account)
|
||||||
matchAccount = true
|
matchAccount = true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" }
|
Log.d(TAG) { "Message was not for user ${it.npub}: ${e.message}" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun unwrapAndConsume(
|
private suspend fun unwrapAndConsume(
|
||||||
event: Event,
|
event: Event,
|
||||||
|
|||||||
+151
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* 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.ExistingWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
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)
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME_ON_NETWORK)
|
||||||
|
Log.d(TAG, "Cancelled periodic catch-up work")
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val WORK_NAME_ON_NETWORK = "notification_catch_up_on_network"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueues a one-time catch-up task that runs as soon as network
|
||||||
|
* connectivity is available. Call this when the service detects
|
||||||
|
* network loss to ensure it restarts immediately when network returns,
|
||||||
|
* rather than waiting for the next periodic worker.
|
||||||
|
*/
|
||||||
|
fun scheduleOnNetworkAvailable(context: Context) {
|
||||||
|
val constraints =
|
||||||
|
Constraints
|
||||||
|
.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request =
|
||||||
|
OneTimeWorkRequestBuilder<NotificationCatchUpWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_NAME_ON_NETWORK,
|
||||||
|
ExistingWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
Log.d(TAG, "Scheduled one-time catch-up on network available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
Log.d(TAG, "Starting notification catch-up")
|
||||||
|
|
||||||
|
return try {
|
||||||
|
// If the foreground service should be running but isn't, restart it
|
||||||
|
if (NotificationRelayService.isEnabled(applicationContext)) {
|
||||||
|
NotificationRelayService.start(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
val appModules = Amethyst.instance
|
||||||
|
|
||||||
|
// Collecting relayServices ensures connections are active.
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+330
@@ -0,0 +1,330 @@
|
|||||||
|
/*
|
||||||
|
* 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.ForegroundServiceStartNotAllowedException
|
||||||
|
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 android.os.SystemClock
|
||||||
|
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.
|
||||||
|
* It does NOT create its own relay subscriptions — instead, it relies on the
|
||||||
|
* AccountFilterAssembler subscription from the Compose UI tree, which stays active
|
||||||
|
* as long as the Activity is alive (even when stopped/backgrounded). That subscription
|
||||||
|
* covers notifications, gift wraps, metadata, follows, relay lists, and drafts.
|
||||||
|
*
|
||||||
|
* Heavy feed subscriptions (Home, Video, Discovery, ChatroomList) are managed
|
||||||
|
* separately with lifecycle awareness — they pause when the app backgrounds
|
||||||
|
* and resume when it foregrounds. This prevents bandwidth waste on feeds
|
||||||
|
* nobody is viewing.
|
||||||
|
*
|
||||||
|
* Auto-restart mechanisms (inspired by ntfy):
|
||||||
|
* - START_STICKY: Android restarts killed services
|
||||||
|
* - onTaskRemoved(): 1-second alarm restart when swiped from recents
|
||||||
|
* - onDestroy(): broadcast to AutoRestartReceiver for WorkManager restart
|
||||||
|
* - AlarmManager watchdog (external, ServiceWatchdogManager)
|
||||||
|
* - WorkManager catch-up (external, NotificationCatchUpWorker)
|
||||||
|
* - BOOT_COMPLETED + MY_PACKAGE_REPLACED receivers (external, BootCompletedReceiver)
|
||||||
|
*/
|
||||||
|
class NotificationRelayService : Service() {
|
||||||
|
companion object {
|
||||||
|
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"
|
||||||
|
|
||||||
|
const val ACTION_AUTO_RESTART = "com.vitorpamplona.amethyst.AUTO_RESTART_NOTIFICATION_SERVICE"
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent =
|
||||||
|
Intent(context, NotificationRelayService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start foreground service", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, NotificationRelayService::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEnabled(context: Context): Boolean =
|
||||||
|
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
|
||||||
|
private var foregroundStarted = false
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.d(TAG, "Service created")
|
||||||
|
createNotificationChannel()
|
||||||
|
initializeForeground()
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
// Safety: also call startForeground from onStartCommand in case
|
||||||
|
// onCreate didn't complete before onStartCommand fired (ntfy #1520)
|
||||||
|
initializeForeground()
|
||||||
|
startRelayConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user swipes the app from recents. Some OEMs kill the
|
||||||
|
* foreground service at this point. Schedule an alarm to restart in 1 second.
|
||||||
|
*/
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
if (!isEnabled(this)) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Task removed, scheduling restart alarm")
|
||||||
|
val restartIntent =
|
||||||
|
Intent(this, NotificationRelayService::class.java).apply {
|
||||||
|
action = ACTION_START
|
||||||
|
}
|
||||||
|
val pendingIntent =
|
||||||
|
PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
2,
|
||||||
|
restartIntent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_ONE_SHOT,
|
||||||
|
)
|
||||||
|
val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager
|
||||||
|
alarmManager.set(
|
||||||
|
AlarmManager.ELAPSED_REALTIME,
|
||||||
|
SystemClock.elapsedRealtime() + 1000,
|
||||||
|
pendingIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the service is being destroyed. Send a broadcast to
|
||||||
|
* AutoRestartReceiver which will enqueue a WorkManager task to restart.
|
||||||
|
* This catches kills that START_STICKY might miss.
|
||||||
|
*/
|
||||||
|
override fun onDestroy() {
|
||||||
|
Log.d(TAG, "Service destroyed")
|
||||||
|
relayServiceCollectorJob?.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
|
||||||
|
if (isEnabled(this)) {
|
||||||
|
Log.d(TAG, "Broadcasting auto-restart request")
|
||||||
|
val intent =
|
||||||
|
Intent(this, AutoRestartReceiver::class.java).apply {
|
||||||
|
action = ACTION_AUTO_RESTART
|
||||||
|
}
|
||||||
|
sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeForeground() {
|
||||||
|
if (foregroundStarted) return
|
||||||
|
try {
|
||||||
|
val notification = buildNotification(0)
|
||||||
|
ServiceCompat.startForeground(
|
||||||
|
this,
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
foregroundStarted = true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
|
||||||
|
e is ForegroundServiceStartNotAllowedException
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Foreground service start not allowed, stopping self")
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Failed to start foreground", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the relay infrastructure alive. The service collects two flows:
|
||||||
|
*
|
||||||
|
* 1. relayServices: Keeps the RelayProxyClientConnector active (connectivity,
|
||||||
|
* Tor, network changes). Without this, the client disconnects 30s after
|
||||||
|
* the UI stops collecting.
|
||||||
|
*
|
||||||
|
* 2. connectedRelaysFlow: Updates the persistent notification with relay count.
|
||||||
|
*
|
||||||
|
* The service does NOT create its own relay subscriptions. Instead, it relies on
|
||||||
|
* the AccountFilterAssembler subscription that lives in the Compose tree (LoggedInPage).
|
||||||
|
* That subscription stays active as long as the Activity exists (even when stopped)
|
||||||
|
* and covers: metadata, follows, notifications (inbox relays), gift wraps (DM relays),
|
||||||
|
* drafts, and relay list changes. Since the service keeps the client connected,
|
||||||
|
* those subscriptions remain active on the relays.
|
||||||
|
*/
|
||||||
|
private fun startRelayConnection() {
|
||||||
|
relayServiceCollectorJob?.cancel()
|
||||||
|
relayServiceCollectorJob =
|
||||||
|
scope.launch {
|
||||||
|
launch {
|
||||||
|
Amethyst.instance.relayProxyClientConnector.relayServices.collectLatest {
|
||||||
|
Log.d(TAG) { "Relay services state updated: $it" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -45,5 +45,5 @@ fun ChatroomListFilterAssemblerSubscription(
|
|||||||
ChatroomListState(accountViewModel.account)
|
ChatroomListState(accountViewModel.account)
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyDataSourceSubscription(state, dataSource)
|
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,5 +46,5 @@ fun DiscoveryFilterAssemblerSubscription(
|
|||||||
DiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
DiscoveryQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyDataSourceSubscription(state, dataSource)
|
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -50,5 +50,5 @@ fun HomeFilterAssemblerSubscription(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyDataSourceSubscription(state, dataSource)
|
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-2
@@ -31,8 +31,12 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -42,11 +46,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.vitorpamplona.amethyst.R
|
import com.vitorpamplona.amethyst.R
|
||||||
import com.vitorpamplona.amethyst.model.ConnectivityType
|
import com.vitorpamplona.amethyst.model.ConnectivityType
|
||||||
import com.vitorpamplona.amethyst.model.FeatureSetType
|
import com.vitorpamplona.amethyst.model.FeatureSetType
|
||||||
@@ -58,6 +64,7 @@ import com.vitorpamplona.amethyst.model.parseConnectivityType
|
|||||||
import com.vitorpamplona.amethyst.model.parseFeatureSetType
|
import com.vitorpamplona.amethyst.model.parseFeatureSetType
|
||||||
import com.vitorpamplona.amethyst.model.parseGalleryType
|
import com.vitorpamplona.amethyst.model.parseGalleryType
|
||||||
import com.vitorpamplona.amethyst.model.parseThemeType
|
import com.vitorpamplona.amethyst.model.parseThemeType
|
||||||
|
import com.vitorpamplona.amethyst.service.notifications.BatteryOptimizationHelper
|
||||||
import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow
|
import com.vitorpamplona.amethyst.ui.components.PushNotificationSettingsRow
|
||||||
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
import com.vitorpamplona.amethyst.ui.components.TextSpinner
|
||||||
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
|
||||||
@@ -89,7 +96,7 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(it)) {
|
Column(Modifier.padding(it)) {
|
||||||
SettingsScreen(accountViewModel.settings.uiSettingsFlow)
|
SettingsScreen(accountViewModel.settings.uiSettingsFlow, accountViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,7 +110,10 @@ fun SettingsScreenPreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
|
fun SettingsScreen(
|
||||||
|
sharedPrefs: UiSettingsFlow,
|
||||||
|
accountViewModel: AccountViewModel? = null,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -124,6 +134,9 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
|
|||||||
GalleryChoice(sharedPrefs)
|
GalleryChoice(sharedPrefs)
|
||||||
AiWritingHelpChoice(sharedPrefs)
|
AiWritingHelpChoice(sharedPrefs)
|
||||||
PushNotificationSettingsRow(sharedPrefs)
|
PushNotificationSettingsRow(sharedPrefs)
|
||||||
|
if (accountViewModel != null) {
|
||||||
|
AlwaysOnNotificationServiceChoice(accountViewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,3 +508,68 @@ 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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
BatteryOptimizationBanner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryOptimizationBanner() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val isExempt =
|
||||||
|
remember {
|
||||||
|
BatteryOptimizationHelper.isIgnoringBatteryOptimizations(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExempt) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.battery_optimization_title),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.battery_optimization_description),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
BatteryOptimizationHelper.requestBatteryOptimizationExemption(context)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(stringRes(R.string.battery_optimization_fix_now))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
|
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -46,5 +46,5 @@ fun VideoFilterAssemblerSubscription(
|
|||||||
VideoQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
VideoQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyDataSourceSubscription(state, filterAssembler)
|
LifecycleAwareKeyDataSourceSubscription(state, filterAssembler)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -925,6 +925,19 @@
|
|||||||
<string name="call_settings_turn_username">Username</string>
|
<string name="call_settings_turn_username">Username</string>
|
||||||
<string name="call_settings_turn_credential">Credential</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="battery_optimization_title">Battery optimization active</string>
|
||||||
|
<string name="battery_optimization_description">Android may restrict relay connections in the background. Disable battery optimization for Amethyst to ensure reliable notifications.</string>
|
||||||
|
<string name="battery_optimization_fix_now">Fix now</string>
|
||||||
|
|
||||||
<string name="reply_notify">Notify: </string>
|
<string name="reply_notify">Notify: </string>
|
||||||
|
|
||||||
<string name="channel_list_join_conversation">Join Conversation</string>
|
<string name="channel_list_join_conversation">Join Conversation</string>
|
||||||
|
|||||||
+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.commons.relayClient.subscriptions
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lifecycle-aware version of [KeyDataSourceSubscription] that subscribes
|
||||||
|
* when the lifecycle reaches STARTED and unsubscribes when it reaches STOPPED.
|
||||||
|
*
|
||||||
|
* Use this for heavy feed subscriptions (home, video, discovery, chatroom list)
|
||||||
|
* that should NOT run when the app is in the background. When an always-on
|
||||||
|
* notification service keeps the relay client connected, these subscriptions
|
||||||
|
* would otherwise leak bandwidth on feeds nobody is viewing.
|
||||||
|
*
|
||||||
|
* Lightweight subscriptions that should always run (account metadata, notifications,
|
||||||
|
* gift wraps) should continue using the regular [KeyDataSourceSubscription].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> LifecycleAwareKeyDataSourceSubscription(
|
||||||
|
state: T,
|
||||||
|
dataSource: ComposeSubscriptionManager<T>,
|
||||||
|
) {
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
var isStarted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
DisposableEffect(state, lifecycleOwner) {
|
||||||
|
val observer =
|
||||||
|
LifecycleEventObserver { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_START -> {
|
||||||
|
if (!isStarted) {
|
||||||
|
dataSource.subscribe(state)
|
||||||
|
isStarted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Lifecycle.Event.ON_STOP -> {
|
||||||
|
if (isStarted) {
|
||||||
|
dataSource.unsubscribe(state)
|
||||||
|
isStarted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
|
|
||||||
|
// If already started (e.g., recomposition while visible), subscribe immediately
|
||||||
|
if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
|
||||||
|
dataSource.subscribe(state)
|
||||||
|
isStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onDispose {
|
||||||
|
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||||
|
if (isStarted) {
|
||||||
|
dataSource.unsubscribe(state)
|
||||||
|
isStarted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ commonsImaging = "1.0.0-alpha6"
|
|||||||
zxing = "3.5.4"
|
zxing = "3.5.4"
|
||||||
zxingAndroidEmbedded = "4.3.0"
|
zxingAndroidEmbedded = "4.3.0"
|
||||||
windowCoreAndroid = "1.5.1"
|
windowCoreAndroid = "1.5.1"
|
||||||
|
workRuntime = "2.10.1"
|
||||||
androidxCamera = "1.6.0"
|
androidxCamera = "1.6.0"
|
||||||
androidxCollection = "1.6.0"
|
androidxCollection = "1.6.0"
|
||||||
androidxExifinterface = "1.4.2"
|
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 = { group = "androidx.sqlite", name = "sqlite", version.ref = "sqlite" }
|
||||||
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", 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-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]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
Reference in New Issue
Block a user