Skip to content

feat(android): foreground service and versioned push messages for ongoing notifications - #359

Open
hristototov wants to merge 1 commit into
callstackincubator:mainfrom
hristototov:feat/android-ongoing-notification-fgs
Open

hristototov wants to merge 1 commit into
callstackincubator:mainfrom
hristototov:feat/android-ongoing-notification-fgs

Conversation

@hristototov

Copy link
Copy Markdown
Contributor

What is this?

Android ongoing notifications stop at NotificationManager.notify(). Nothing keeps the app process alive, so a workout or delivery notification freezes at its last value once Android kills the app in the background. On Android 14 and later the user can swipe any ongoing notification away, and neither the capabilities API nor the docs said so. Every app also had to hand-write the same push task body, and two pushes delivered out of order left the older content on screen.

This adds an opt-in foreground service for one ongoing notification at a time, reports dismissibility in the capabilities API, and adds a versioned push message with a ready-made handler. The handler drops updates that arrive out of order. The decisions are recorded in ADR 0013. Closes #323.

How does it work?

  • Declaring the service. A new config key, ongoingNotificationForegroundService: { types }, declares voltra.ongoingnotification.VoltraOngoingNotificationService with the chosen types, plus FOREGROUND_SERVICE and one FOREGROUND_SERVICE_<TYPE> permission per type. It works in the Expo plugin and in voltra apply, which share one module.
    • Unsupported types are rejected, and so is specialUse without a subtype.
    • Removing the key, or dropping a type, removes or narrows the service but leaves the permissions in place, because a library merged in at build time may declare a service that needs them.
  • Attaching per notification. startAndroidOngoingNotification() and upsertAndroidOngoingNotification() accept foregroundService: { type, onDismiss?, fallbackBehavior? }.
    • The notification is posted first, then the service goes foreground with that same notification.
    • The result reports foregroundService: { attached: true, type }, or { attached: false, reason } with one of eight reasons, such as not_declared, missing_permission, background_start_not_allowed or busy.
    • Problems Voltra can see beforehand are checked before the service is started, including the runtime permissions Android 14 requires for location, health and connectedDevice. A service that stops without going foreground would crash the app.
    • With fallbackBehavior: 'error', such a problem rejects with VOLTRA_FOREGROUND_SERVICE_UNAVAILABLE before anything is posted.
  • Lifecycle.
    • Updates keep the service.
    • An upsert on a running notification attaches the service if an earlier attempt was refused, which a push flow needs.
    • stop and endAll release the service before cancelling, because Android ignores cancel() for a foreground service notification.
    • A swipe on Android 14+ stops the service by default (onDismiss: 'stop') and never reposts.
    • dataSync and shortService timeouts detach the service and leave the notification visible.
    • The service is START_NOT_STICKY, and any record still marked attached after process death is corrected to detached.
  • Capabilities and status.
    • getAndroidOngoingNotificationCapabilities() adds isUserDismissible, supportsForegroundService and foregroundServiceTypes.
    • getAndroidOngoingNotificationStatus() adds foregroundService: { type, state }.
  • Using your own service. getAndroidOngoingNotificationHandle() and the Kotlin VoltraOngoingNotifications.buildNotification() let an app's own foreground service carry Voltra's notification instead of posting a second one.
  • Push messages.
    • createAndroidOngoingNotificationPushEnvelope() in @use-voltra/android-server builds a version 1 message (v, notificationId, operation, revision, options, payload).
    • handleAndroidOngoingNotificationPush() is the whole background task body. It accepts the message in every shape push providers deliver it, creates the channel through a callback, applies the upsert or stop, and never throws. parseAndroidOngoingNotificationPush() exposes the parsing step on its own.
    • Messages without v are read as version 1, so existing pushes keep working.
  • Out-of-order protection.
    • Each notification remembers the highest revision applied, natively, so the check survives process restarts.
    • Start, update, upsert and stop with a revision that is not higher return reason: 'stale_revision'.
    • A stopped notification's last revision is kept as well, so a late, older upsert cannot bring it back.
    • stopAndroidOngoingNotification() takes an optional { revision }.
    • The hook's options take no revision, because they are replayed on every update.

The docs cover the service, the reason table, dismissibility by Android version, the push message and the new task body. The example app's background task now uses the handler, and its Ongoing Notifications screen has a foreground service toggle.

Why is this useful?

  • Notifications that stay live. Apps that track live work (workouts, deliveries, navigation) can keep updating the notification after the user leaves the app, without writing an Android service.
  • Clear failures. When Android refuses the service, apps get a specific reason, and the default fallback still posts the notification.
  • Honest dismissal behaviour. Apps can adapt their copy to the Android 14+ swipe behaviour.
  • Simpler, reliable push handling. Push-driven apps replace a hand-written task body with one call and stop showing stale content when pushes arrive out of order.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android ongoing notifications: foreground-service option, Android 14+ dismissibility, and a first-class push update handler

1 participant