From 0c8693329e374d91ec3e2d0f5b988e99959dd37a Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 6 Oct 2026 17:13:43 +0530 Subject: [PATCH 1/4] fix: cap notification images at 5 MB and decode them downsampled to 1024 px --- .../io/appwrite/services/PushBackground.kt | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/library/src/main/java/io/appwrite/services/PushBackground.kt b/library/src/main/java/io/appwrite/services/PushBackground.kt index a550a55..e6ec5d4 100644 --- a/library/src/main/java/io/appwrite/services/PushBackground.kt +++ b/library/src/main/java/io/appwrite/services/PushBackground.kt @@ -30,8 +30,10 @@ import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish import io.appwrite.exceptions.AppwriteException import org.json.JSONArray import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.util.UUID @@ -116,6 +118,8 @@ internal object PushBackground { private const val HEARTBEAT_RESET_MS = 3 * 24 * 60 * 60 * 1_000L private const val REQUEST_TIMEOUT_SECONDS = 10L private const val IMAGE_TIMEOUT_MS = 5_000 + private const val IMAGE_MAX_BYTES = 5 * 1024 * 1024 + private const val IMAGE_MAX_PX = 1_024 private const val CONNECT_TIMEOUT_SECONDS = 20L // How long a message waits for a listener that acknowledges it itself (the React Native and @@ -746,7 +750,7 @@ internal object PushBackground { val connection = runCatching { URL(url).openConnection() as HttpURLConnection }.getOrNull() ?: return null connection.connectTimeout = IMAGE_TIMEOUT_MS connection.readTimeout = IMAGE_TIMEOUT_MS - val download = imageLoader.submit { connection.inputStream.use { BitmapFactory.decodeStream(it) } } + val download = imageLoader.submit { connection.inputStream.use { readLimited(it, IMAGE_MAX_BYTES) }?.let { decodeImage(it) } } return try { download.get(IMAGE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) } catch (e: Exception) { @@ -757,6 +761,37 @@ internal object PushBackground { } } + // At most [limit] bytes of [input], or null when it holds more. + private fun readLimited(input: InputStream, limit: Int): ByteArray? { + val out = ByteArrayOutputStream() + val buffer = ByteArray(8_192) + while (true) { + val read = input.read(buffer) + if (read < 0) { + return out.toByteArray() + } + if (out.size() + read > limit) { + return null + } + out.write(buffer, 0, read) + } + } + + // Decode [bytes] downsampled so neither side exceeds IMAGE_MAX_PX, so a large photo cannot + // exhaust memory while a notification is posted. + private fun decodeImage(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null + } + var sample = 1 + while (bounds.outWidth / sample > IMAGE_MAX_PX || bounds.outHeight / sample > IMAGE_MAX_PX) { + sample *= 2 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } + /** The ongoing notification the foreground service shows, on its own quiet channel. */ fun ongoingNotification(context: Context) = NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) .also { createChannels(context) } From 95b04cdaa048ee35db0f0267a150199eea93c281 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 06:23:10 +0000 Subject: [PATCH 2/4] chore: update Android SDK to 29.1.0-rc.1 --- CHANGELOG.md | 4 + README.md | 49 +- library/src/main/java/io/appwrite/Client.kt | 2 +- .../main/java/io/appwrite/services/Push.kt | 106 +++-- .../io/appwrite/services/PushBackground.kt | 433 ++++++++++++++++-- .../java/io/appwrite/services/PushCore.kt | 114 ++++- .../java/io/appwrite/services/PushWakeups.kt | 15 +- 7 files changed, 633 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccff23c..3339ae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## 29.1.0-rc.1 + +* Updated: regenerated with sdk-generator 5.5.0; no SDK code changes + ## 29.1.0-rc.0 * Added: optional `method` param on `Client.chunkedUpload()` to set the upload HTTP method diff --git a/README.md b/README.md index bb53402..7e69c62 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ repositories { Next, add the dependency to your project's `build.gradle(.kts)` file: ```groovy -implementation("io.appwrite:sdk-for-android:29.1.0-rc.0") +implementation("io.appwrite:sdk-for-android:29.1.0-rc.1") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 29.1.0-rc.0 + 29.1.0-rc.1 ``` @@ -77,19 +77,52 @@ app every 15 to 60 seconds to reconnect, and the broker replays what was sent in (`retry = true`). It reconnects with the credential saved at subscribe time, so use a session (`client.setSession(...)`), not a short-lived JWT. -When no in-app callback is listening, each message is posted as a notification that opens +While the app is not on screen, each message is also posted as a notification that opens your launch activity with `Push.EXTRA_TOPIC` and `Push.EXTRA_PAYLOAD` in its extras. It shows the title, body and image sent with `createPush`, falling back to the subscription's `title` and the raw payload for other messages. On Android 13 and later it needs the `POST_NOTIFICATIONS` permission: call `Push.requestNotificationPermission(activity)` before subscribing. Set its icon with `` -in your ``. To handle these messages in code, subclass `PushReceiver` and declare +in your ``. While the app is on screen your callback shows the message, so no +notification is posted unless the subscription passes `notifyInForeground = true`. To handle +messages that arrive while no callback is listening in code, subclass `PushReceiver` and declare it with the `io.appwrite.push.MESSAGE` action (see its docs). -Doze delays the scheduled runs. They are more punctual when your app may schedule exact -alarms (`SCHEDULE_EXACT_ALARM`) or is exempt from battery optimisation; both are your app's -choice and subject to Google Play policy, and the SDK uses them when granted. For immediate delivery even after a kill and -during Doze, call `push.setForeground(true)` from the foreground: it adds a foreground service +#### Delivery while the app is closed + +Messages sent while the app is closed arrive at the next scheduled wake-up. While the device is +awake that is about every 15 seconds, or about every 60 seconds once exact alarms are allowed; +without exact alarms the wake-ups are inexact, so battery saver can defer them further. In Doze +(screen off and idle for a while) Android limits background alarms, exact ones included, to about +one every nine minutes, so a closed app can take several minutes to receive a message: allowing +exact alarms makes wake-ups punctual, it does not lift Doze. For immediate delivery, also in +Doze, use `push.setForeground(true)` (see below). + +The SDK uses exact alarms on its own whenever the app may schedule them. To allow it: + +1. Declare the permissions in your app's `AndroidManifest.xml`. Both are optional and subject to + Google Play policy: `SCHEDULE_EXACT_ALARM` needs a declaration in the Play Console, and + `USE_EXACT_ALARM` is reserved for alarm, clock and calendar apps (the SDK does not use it). + + ```xml + + + ``` + +2. On Android 13 and later the user has to allow exact alarms, under Settings > Apps > Special app + access > Alarms & reminders. Android 12 grants a declared `SCHEDULE_EXACT_ALARM` + automatically, and older versions need nothing. Check with `Push.backgroundStatus(context)`: when `bestEffort` is + true, explain why to the user, then from a user action open that screen with `Push.requestExactAlarms(context)`, or + ask for the battery-optimisation exemption with `Push.requestIgnoreBatteryOptimizations(context)`. Both return false when there is + nothing to ask, including when the permission is not declared. The SDK never opens these + screens on its own. + +If the user force-stops the app (Settings > Force stop, and on some devices swiping it away from +recents), Android cancels its alarms and jobs: nothing is delivered until the app is opened +again, and the broker then replays what was sent meanwhile. + +For immediate delivery even after a kill and during Doze, call `push.setForeground(true)` from +the foreground: it adds a foreground service with a quiet ongoing notification. If you never enable it, you can remove the service from your merged manifest, so the Play Console does not ask you to declare it: diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index 1715950..a9d4219 100644 --- a/library/src/main/java/io/appwrite/Client.kt +++ b/library/src/main/java/io/appwrite/Client.kt @@ -101,7 +101,7 @@ class Client @JvmOverloads constructor( "x-sdk-name" to "Android", "x-sdk-platform" to "client", "x-sdk-language" to "android", - "x-sdk-version" to "29.1.0-rc.0", + "x-sdk-version" to "29.1.0-rc.1", "x-appwrite-response-format" to "2.3.0" ) config = mutableMapOf() diff --git a/library/src/main/java/io/appwrite/services/Push.kt b/library/src/main/java/io/appwrite/services/Push.kt index 6fab40a..a9bc641 100644 --- a/library/src/main/java/io/appwrite/services/Push.kt +++ b/library/src/main/java/io/appwrite/services/Push.kt @@ -19,6 +19,7 @@ import java.net.URI import java.util.UUID import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean private const val LOG_TAG = "AppwritePush" @@ -32,19 +33,19 @@ private const val LOG_TAG = "AppwritePush" */ class PushSubscription internal constructor( private val unsubscribeAction: () -> Unit, - private val updateAction: (Boolean?, String?, Boolean?) -> Unit, + private val updateAction: (Boolean?, String?, Boolean?, Boolean?) -> Unit, ) { /** Drop this subscription. Closes the connection once the last one is gone. */ fun unsubscribe() = unsubscribeAction() /** - * Live-update this subscription's background delivery, notification title and/or - * `retry` (QoS). Only the arguments you pass change; the rest stay as they + * Live-update this subscription's background delivery, notification title, `retry` (QoS) + * and/or `notifyInForeground`. Only the arguments you pass change; the rest stay as they * were. Changing `retry` re-subscribes this subscription's topics at the new QoS. */ @JvmOverloads - fun update(background: Boolean? = null, title: String? = null, retry: Boolean? = null) = - updateAction(background, title, retry) + fun update(background: Boolean? = null, title: String? = null, retry: Boolean? = null, notifyInForeground: Boolean? = null) = + updateAction(background, title, retry, notifyInForeground) } /** @@ -132,6 +133,9 @@ class Push( @Volatile private var mqtt: Mqtt5AsyncClient? = null + // Set when [mqtt] is replaced or closed, so that client never reconnects in the background. + private var mqttRetired: AtomicBoolean? = null + private var connectedConfig: PushConfig? = null private val connectLock = Any() @@ -153,8 +157,10 @@ class Push( private val appContext: Context = context.applicationContext init { - // Resume saved background delivery now, instead of at the next scheduled run. - PushBackground.resume(appContext) + // Resume saved background delivery now, instead of at the next scheduled run, with the + // credential set on the client: a rotated session of the same user replaces the saved one. + val auth = runCatching { resolvePushAuth(client) }.getOrNull() + PushBackground.resume(appContext, auth?.first, auth?.second) } private var onOpen: (() -> Unit)? = null @@ -167,6 +173,7 @@ class Push( var background: Boolean, var title: String?, var retry: Boolean, + var notifyInForeground: Boolean, ) { @Volatile var client: Mqtt5AsyncClient? = null @@ -244,10 +251,11 @@ class Push( background: Boolean = true, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription { val userId = reporting { resolvePushUserId(client) } - return subscribe(Topic.path(listOf("users", userId)), background, title, retry, callback) + return subscribe(Topic.path(listOf("users", userId)), background, title, retry, notifyInForeground, callback) } /** @@ -262,9 +270,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic), background, title, retry, callback) + subscribe(listOf(topic), background, title, retry, notifyInForeground, callback) /** * Subscribe to a single topic built with [io.appwrite.Topic], e.g. @@ -277,9 +286,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic.toString()), background, title, retry, callback) + subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) /** * Subscribe to a single topic ended with `all()`, e.g. @@ -292,9 +302,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic.toString()), background, title, retry, callback) + subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) /** * Subscribe to a list mixing raw strings and topics built with [io.appwrite.Topic], e.g. @@ -310,6 +321,7 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription { val filters = topics.mapIndexed { index, topic -> @@ -318,7 +330,7 @@ class Push( } topic.toString() } - return subscribe(filters, background, title, retry, callback) + return subscribe(filters, background, title, retry, notifyInForeground, callback) } /** @@ -334,7 +346,8 @@ class Push( * * Set [background] to keep receiving while the app is backgrounded or closed, also after the * process is killed or the device restarts, and post a notification per matching message - * with [title] (defaulting to the message topic); see the class docs. [retry] chooses this + * with [title] (defaulting to the message topic) while the app is not on screen; set + * [notifyInForeground] to also post one while it is. See the class docs. [retry] chooses this * subscription's QoS * (`true` -> QoS 1 default, `false` -> QoS 0). Toggle any of these later with * [PushSubscription.update]. @@ -351,13 +364,14 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = reporting { val ids = mutableListOf() for (topic in topics) { val id = UUID.randomUUID().toString() ids.add(id) - subscriptions[id] = Sub(topic, callback, background, title, retry) + subscriptions[id] = Sub(topic, callback, background, title, retry, notifyInForeground) } // If any SUBSCRIBE fails, roll this call's registrations back and return to a @@ -382,7 +396,7 @@ class Push( PushSubscription( { reporting { unsubscribeIds(ids) } }, - { bg, t, rd -> reporting { updateIds(ids, bg, t, rd) } }, + { bg, t, rd, nf -> reporting { updateIds(ids, bg, t, rd, nf) } }, ) } @@ -463,13 +477,19 @@ class Push( } } + // Close the in-process client for good: retired first, so one still reconnecting in the + // background does not come back as a second connection for the same client id. + private fun dropClient() { + mqttRetired?.set(true) + mqttRetired = null + mqtt?.disconnect() + mqtt = null + } + // Relocate the connection into the background host and sync it with the current // subscription set (filters, per-sub callbacks/titles), saving the background ones. private fun hostInBackground() { - if (mqtt != null) { - mqtt?.disconnect() - mqtt = null - } + dropClient() subscriptions.values.forEach { it.client = null } PushBackground.errorHandler = { deliverBackgroundError(it) } PushBackground.host(appContext, buildConfig(), currentListeners()) @@ -563,7 +583,7 @@ class Push( } } - private fun updateIds(ids: List, background: Boolean?, title: String?, retry: Boolean?) { + private fun updateIds(ids: List, background: Boolean?, title: String?, retry: Boolean?, notifyInForeground: Boolean?) { val changedFilters = mutableSetOf() for (id in ids) { val sub = subscriptions[id] ?: continue @@ -573,6 +593,9 @@ class Push( if (title != null) { sub.title = title } + if (notifyInForeground != null) { + sub.notifyInForeground = notifyInForeground + } if (retry != null && retry != sub.retry) { sub.retry = retry changedFilters.add(sub.filter) @@ -597,15 +620,16 @@ class Push( subscriptions.values.any { it.background } || PushBackground.hasSaved(appContext) private fun currentListeners(): List = - subscriptions.values.map { PushListener(setOf(it.filter), it.callback, it.background, it.title, it.retry) } + subscriptions.values.map { + PushListener(setOf(it.filter), it.callback, it.background, it.title, it.retry, notifyInForeground = it.notifyInForeground) + } /** * Tear down the connection and drop all subscriptions, including the background ones saved * by an earlier run, and stop background delivery. Call it on sign-out. */ fun close() { - mqtt?.disconnect() - mqtt = null + dropClient() subscriptions.clear() backgroundActive = false PushBackground.stop(appContext) @@ -614,8 +638,7 @@ class Push( // The last live subscription is gone: close the connection, keeping any background // subscriptions saved by an earlier run. private fun releaseAll() { - mqtt?.disconnect() - mqtt = null + dropClient() subscriptions.clear() if (backgroundActive) { backgroundActive = false @@ -633,25 +656,28 @@ class Push( return existing } connectionEpoch++ - existing.disconnect() - mqtt = null + dropClient() } // The listeners surface the lifecycle to onOpen/onClose and every connection error to // onError, except a refused initial CONNECT: pushConnect throws that one, and the // failing call reports it. + val retired = AtomicBoolean(false) val client = buildPushClient( config, onConnected = { onOpen?.invoke() }, onDisconnected = { onClose?.invoke() }, onError = { reportAsync(it) }, + retired = retired, ) mqtt = client + mqttRetired = retired connectedConfig = config try { client.pushConnect(config) } catch (e: Exception) { + retired.set(true) client.disconnect() if (mqtt === client) { mqtt = null @@ -707,6 +733,32 @@ class Push( /** The notification tap intent's extra holding the message payload as a string. */ const val EXTRA_PAYLOAD = PushBackground.EXTRA_PAYLOAD + /** + * What background delivery can rely on: exact alarms, battery-optimisation exemption and + * foreground mode. When [PushBackgroundStatus.bestEffort] is true, wake-ups are inexact and + * messages can arrive late while the app is closed; explain why before asking with + * [requestExactAlarms] or [requestIgnoreBatteryOptimizations]. + */ + @JvmStatic + fun backgroundStatus(context: Context): PushBackgroundStatus = PushBackground.backgroundStatus(context) + + /** + * Open the system screen where the user allows exact alarms (Android 12+), for punctual + * background wake-ups. Call it from a user action. Returns false when there is nothing to + * ask: already allowed, older Android, or the app does not declare `SCHEDULE_EXACT_ALARM`. + */ + @JvmStatic + fun requestExactAlarms(context: Context): Boolean = PushBackground.requestExactAlarms(context) + + /** + * Ask the user to exempt the app from battery optimisation. Call it from a user action. + * Returns false when there is nothing to ask: already exempt, or the app does not declare + * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. + */ + @JvmStatic + fun requestIgnoreBatteryOptimizations(context: Context): Boolean = + PushBackground.requestIgnoreBatteryOptimizations(context) + /** * Request the `POST_NOTIFICATIONS` runtime permission (Android 13+). Call from an * Activity before subscribing with `background = true`; on older versions it is a no-op. diff --git a/library/src/main/java/io/appwrite/services/PushBackground.kt b/library/src/main/java/io/appwrite/services/PushBackground.kt index a550a55..1b8b5f5 100644 --- a/library/src/main/java/io/appwrite/services/PushBackground.kt +++ b/library/src/main/java/io/appwrite/services/PushBackground.kt @@ -1,5 +1,7 @@ package io.appwrite.services +import android.Manifest +import android.app.ActivityManager import android.app.AlarmManager import android.app.NotificationChannel import android.app.NotificationManager @@ -14,11 +16,14 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.ConnectivityManager import android.net.Network +import android.net.Uri import android.os.Build import android.os.PowerManager import android.os.SystemClock +import android.provider.Settings import android.util.AtomicFile import android.util.Log +import android.webkit.CookieManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -30,8 +35,10 @@ import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish import io.appwrite.exceptions.AppwriteException import org.json.JSONArray import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.util.UUID @@ -53,6 +60,7 @@ internal data class PushEntry( val filter: String, val retry: Boolean, val title: String?, + val notifyInForeground: Boolean = false, ) /** @@ -96,7 +104,11 @@ internal object PushBackground { const val EXTRA_PAYLOAD = "io.appwrite.push.PAYLOAD" private const val SERVICE_CHANNEL_ID = "appwrite-push-service" - private const val JOB_ID = 0x50555348 + const val JOB_ID = 0x50555348 + const val NEXT_JOB_ID = 0x5055534A + const val EXPEDITED_JOB_ID = 0x5055534B + const val WATCHDOG_JOB_ID = 0x50555349 + private const val WATCHDOG_INTERVAL_MS = 15 * 60 * 1_000L private const val INTERVAL_MS = 15_000L private const val PRIVILEGED_INTERVAL_MS = 60_000L private const val RESTART_DELAY_MS = 3_000L @@ -116,6 +128,15 @@ internal object PushBackground { private const val HEARTBEAT_RESET_MS = 3 * 24 * 60 * 60 * 1_000L private const val REQUEST_TIMEOUT_SECONDS = 10L private const val IMAGE_TIMEOUT_MS = 5_000 + + // How long a scheduled run stays up after connecting so the broker can replay what was missed: + // until nothing arrived for DRAIN_QUIET_MS and nothing is being delivered, at most the run's cap. + const val JOB_DRAIN_MS = 15_000L + const val RECEIVER_DRAIN_MS = 5_000L + private const val DRAIN_QUIET_MS = 2_000L + private const val DRAIN_POLL_MS = 200L + private const val IMAGE_MAX_BYTES = 5 * 1024 * 1024 + private const val IMAGE_MAX_PX = 1_024 private const val CONNECT_TIMEOUT_SECONDS = 20L // How long a message waits for a listener that acknowledges it itself (the React Native and @@ -164,6 +185,15 @@ internal object PushBackground { Thread(runnable, "AppwritePushBackground").apply { isDaemon = true } } private var mqtt: Mqtt5AsyncClient? = null + + // Set when [mqtt] is replaced or closed, so that client stops reconnecting and delivering. + private var mqttRetired: AtomicBoolean? = null + + // Messages being delivered, and when one last arrived or finished, for a run's drain window. + private val delivering = AtomicInteger(0) + + @Volatile + private var lastDelivery = 0L private var connectedConfig: PushConfig? = null private var connectedNetwork: Network? = null private var lastHeartbeat = 0L @@ -264,23 +294,116 @@ internal object PushBackground { } } - /** Resume saved background delivery when the app starts, without waiting for the next run. */ - fun resume(context: Context) { + /** + * Resume saved background delivery when the app starts, without waiting for the next run, with + * the app's current credential. A rotated session of the same user replaces the saved one; a + * different user drops the saved subscriptions, as does no credential when + * [signedOutWhenMissing] (the caller read the session store, so none means signed out). + */ + fun resume(context: Context, authMethod: String? = null, credential: String? = null, signedOutWhenMissing: Boolean = false) { val app = context.applicationContext - val active = synchronized(lock) { + val resume = synchronized(lock) { load(app) - isActive() + val current = config + when { + !isActive() || current == null -> null + // No credential: signed out when the caller can tell, else possibly not set yet. + authMethod == null || credential == null -> if (signedOutWhenMissing) false else null + current.authMethod == authMethod && current.credential == credential -> true + // A new credential for the same user (a rotated session) replaces the saved one, + // so the stale one is never sent and refused. + sameUser(current.authMethod, current.credential, authMethod, credential) -> { + config = current.copy(authMethod = authMethod, credential = credential) + persist(app) + true + } + // Another user signed in: the saved subscriptions were not theirs. + else -> false + } } - if (active) { - tick(app) {} + when (resume) { + true -> tick(app) {} + false -> stop(app) + null -> Unit + } + } + + private enum class CredentialRefresh { UNCHANGED, CHANGED, GONE } + + // Bring the saved credential up to date with the session cookie the app + // signs in with, when it comes from one (see [PushConfig.sessionCookieUrl]): a rotated session of + // the same user replaces it, and no session or another user's is GONE. UNCHANGED when there is + // no cookie source or it cannot be read. + private fun refreshCredential(context: Context): CredentialRefresh { + val current = synchronized(lock) { config } ?: return CredentialRefresh.UNCHANGED + val url = current.sessionCookieUrl ?: return CredentialRefresh.UNCHANGED + val session = readSessionCookie(url, current.project) ?: return CredentialRefresh.UNCHANGED + return when { + session.isEmpty() -> CredentialRefresh.GONE + current.authMethod == "appwrite-session" && session == current.credential -> CredentialRefresh.UNCHANGED + sameUser(current.authMethod, current.credential, "appwrite-session", session) -> { + synchronized(lock) { + config = current.copy(authMethod = "appwrite-session", credential = session) + persist(context.applicationContext) + } + CredentialRefresh.CHANGED + } + else -> CredentialRefresh.GONE } } + // The credential to send for a connection made with [connected]: the session re-read from the + // cookie store when one backs it (so an automatic reconnect uses a rotated session), else the + // one it connected with. Only a newer credential of the same user, for the same connection + // (broker, client id, project), is borrowed: a connection being replaced by [host] keeps its + // own. Null when the app signed out or another user signed in: authentication is aborted and + // delivery stops. + private fun currentCredential(context: Context, connected: PushConfig): String? { + if (refreshCredential(context) == CredentialRefresh.GONE) { + worker.execute { stop(context) } + return null + } + val current = synchronized(lock) { config } ?: return connected.credential + val sameConnection = current.host == connected.host && current.port == connected.port && + current.clientId == connected.clientId && current.project == connected.project && + current.authMethod == connected.authMethod + return if (sameConnection && sameUser(connected.authMethod, connected.credential, current.authMethod, current.credential)) { + current.credential + } else { + connected.credential + } + } + + // The `a_session_` cookie for [url] in the WebView cookie store, decoded; "" when there + // is none, and null when the store cannot be read. + private fun readSessionCookie(url: String, project: String): String? = runCatching { + val name = "a_session_$project" + CookieManager.getInstance().getCookie(url) + ?.split(";") + ?.map { it.trim() } + ?.firstOrNull { it.substringBefore("=") == name } + ?.substringAfter("=") + ?.let { Uri.decode(it) } + ?: "" + }.getOrNull() + + private fun sameUser(savedMethod: String, saved: String, method: String, credential: String): Boolean { + fun userId(method: String, credential: String) = + if (method == "appwrite-session") userIdFromSession(credential) else userIdFromJwt(credential) + val user = userId(method, credential) + return user.isNotEmpty() && user == userId(savedMethod, saved) + } + /** * One scheduled run: under a wakelock, reconnect if needed, send a heartbeat when one is due, * re-arm the next run, and call [done] once finished. Shuts down when nothing is saved. + * + * A run of a chained job ([jobId]) arms the next run under the other chained id: scheduling the + * id that is running would stop the run in progress. With [drainMs], the run stays up after + * connecting while the broker replays what was missed (at most that long), so a process woken + * from the background is not cached and frozen before the messages are delivered. */ - fun tick(context: Context, done: () -> Unit) { + fun tick(context: Context, jobId: Int? = null, drainMs: Long = 0, done: () -> Unit) { val app = context.applicationContext val wakeLock = acquireWakeLock(app) worker.execute { @@ -291,12 +414,17 @@ internal object PushBackground { } if (active) { // Arm the next run first, so a run cut short (killed, or a slow broker outlasting - // the wakelock) still leaves the chain armed. A refused credential in converge() - // shuts down, cancelling it again. - schedule(app, INTERVAL_MS) + // the wakelock) still leaves the chain armed; the watchdog job restarts a chain + // that was dropped anyway. A refused credential in converge() shuts down, + // cancelling them again. + schedule(app, INTERVAL_MS, runningJobId = jobId) + ensureWatchdog(app) ensureService(app) registerNetworkCallback(app) converge(app, heartbeat = true) + if (drainMs > 0 && mqtt != null) { + awaitDeliveries(drainMs) + } } else { shutdown(app) } @@ -309,9 +437,49 @@ internal object PushBackground { } } + // Worker thread only. Wait until no message arrived for DRAIN_QUIET_MS and none is being + // delivered, at most [maxMs]. Timed with the real monotonic clock ([nowMs]), which keeps moving + // while this thread sleeps. + private fun awaitDeliveries(maxMs: Long) { + val start = nowMs() + if (lastDelivery < start) { + lastDelivery = start + } + while (nowMs() - start < maxMs) { + if (delivering.get() == 0 && nowMs() - lastDelivery >= DRAIN_QUIET_MS) { + return + } + Thread.sleep(DRAIN_POLL_MS) + } + } + + private fun nowMs(): Long = System.nanoTime() / 1_000_000 + + /** + * Android 12+: hand a run to an expedited job, which starts at once with network access and + * keeps the process runnable until it finishes. A broadcast receiver woken in a cached process + * runs at background priority and can be frozen before it connects. Returns false when the + * job cannot be scheduled (older Android, or the expedited quota is used up), so the caller + * runs the tick itself. + */ + fun runExpedited(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return false + } + return try { + val job = JobInfo.Builder(EXPEDITED_JOB_ID, ComponentName(context, PushJobService::class.java)) + .setExpedited(true) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .build() + context.getSystemService(JobScheduler::class.java)?.schedule(job) == JobScheduler.RESULT_SUCCESS + } catch (e: Exception) { + false + } + } + /** Run once more shortly after the task was removed, so an OEM kill that follows is undone. */ fun scheduleRestart(context: Context) { - schedule(context.applicationContext, RESTART_DELAY_MS, jobToo = false) + schedule(context.applicationContext, RESTART_DELAY_MS, alarmOnly = true) } /** @@ -361,9 +529,14 @@ internal object PushBackground { // Must hold [lock]. Every background subscription, live or saved, one per filter and title. // Subscriptions sharing a filter and title are saved once, with QoS 1 when any of them wants it. private fun entries(): List = - (saved + listeners.filter { it.background }.flatMap { listener -> listener.topics.map { PushEntry(it, listener.retry, listener.title) } }) + (saved + liveEntries()) .groupBy { it.filter to it.title } - .map { (key, group) -> PushEntry(key.first, group.any { it.retry }, key.second) } + .map { (key, group) -> PushEntry(key.first, group.any { it.retry }, key.second, group.any { it.notifyInForeground }) } + + // Must hold [lock]. + private fun liveEntries(): List = listeners.filter { it.background }.flatMap { listener -> + listener.topics.map { PushEntry(it, listener.retry, listener.title, listener.notifyInForeground) } + } // Must hold [lock]. private fun persist(context: Context) { @@ -397,6 +570,7 @@ internal object PushBackground { private fun activate(context: Context, done: ((Throwable?) -> Unit)? = null) { registerNetworkCallback(context) schedule(context, INTERVAL_MS) + ensureWatchdog(context) ensureService(context) worker.execute { converge(context, heartbeat = false, done) } } @@ -437,6 +611,11 @@ internal object PushBackground { } private fun convergeOnce(context: Context, heartbeat: Boolean) { + // Signed out, or signed in as someone else, since the subscriptions were saved. + if (refreshCredential(context) == CredentialRefresh.GONE) { + stop(context) + return + } val (current, wanted) = synchronized(lock) { config to wanted() } if (current == null || wanted.isEmpty()) { disconnect() @@ -477,6 +656,7 @@ internal object PushBackground { // A failed first connect of a waited-for converge goes to the waiting caller only. val quiet = waiting val connected = AtomicBoolean(false) + val retired = AtomicBoolean(false) val client = buildPushClient( config.copy(keepAlive = KEEP_ALIVE_SECONDS), onError = { error -> @@ -485,16 +665,29 @@ internal object PushBackground { } }, onAuthRefused = { error -> worker.execute { refused(context, error) } }, + credential = { currentCredential(context, config) }, + retired = retired, ) // Registered before connecting, so the backlog the broker replays right after each // SUBSCRIBE reaches the app however the subscription was made (including the ones the // client restores itself after an automatic reconnect). Acknowledged by hand, once the // message was delivered (see [deliver]). - client.publishes(MqttGlobalPublishFilter.ALL, { publish -> deliver(context, publish) }, true) + client.publishes( + MqttGlobalPublishFilter.ALL, + { publish -> + if (!retired.get()) { + deliver(context, publish) + } + }, + true, + ) try { - client.sendPushConnect(config.copy(keepAlive = KEEP_ALIVE_SECONDS), CONNECT_TIMEOUT_SECONDS) + client.sendPushConnect(config.copy(keepAlive = KEEP_ALIVE_SECONDS), CONNECT_TIMEOUT_SECONDS) { + currentCredential(context, config) + } } catch (e: Exception) { val cause = (e as? ExecutionException)?.cause ?: e + retired.set(true) client.disconnect() val error = connectError(cause) when { @@ -508,6 +701,7 @@ internal object PushBackground { } connected.set(true) mqtt = client + mqttRetired = retired connectedConfig = config connectedNetwork = activeNetwork(context) lastHeartbeat = SystemClock.elapsedRealtime() @@ -575,6 +769,8 @@ internal object PushBackground { // Worker thread only. private fun disconnect() { + mqttRetired?.set(true) + mqttRetired = null mqtt?.disconnect() mqtt = null connectedConfig = null @@ -586,6 +782,21 @@ internal object PushBackground { // registered the message is also saved, and the next onError registration receives it. A // waiting caller receives it instead. private fun refused(context: Context, error: Throwable) { + // The saved credential may be a session the app has since rotated: retry once with the + // current one. Refused again, it is unchanged and delivery stops below. + if (!waiting) { + when (refreshCredential(context)) { + CredentialRefresh.CHANGED -> { + converge(context, heartbeat = false) + return + } + CredentialRefresh.GONE -> { + stop(context) + return + } + CredentialRefresh.UNCHANGED -> Unit + } + } if (waiting) { fail(error) } else if (!report(error)) { @@ -596,11 +807,24 @@ internal object PushBackground { // Deliver a message, then acknowledge it: right away, or once every listener that acknowledges // messages itself has done so (at most ACK_TIMEOUT_SECONDS later). + // A message counts as being delivered until it is acknowledged: for the React Native and Flutter + // bridges, once their callback has run (or the acknowledgement times out), not when the event is + // handed over, so a run's drain window waits for it. private fun deliver(context: Context, publish: Mqtt5Publish) { + delivering.incrementAndGet() + lastDelivery = nowMs() + deliverAcknowledged(context, publish) { + lastDelivery = nowMs() + delivering.decrementAndGet() + } + } + + private fun deliverAcknowledged(context: Context, publish: Mqtt5Publish, settled: () -> Unit) { val acknowledged = AtomicBoolean(false) val acknowledge = { if (acknowledged.compareAndSet(false, true)) { runCatching { publish.acknowledge() } + settled() } } val pending = AtomicInteger(1) @@ -624,7 +848,7 @@ internal object PushBackground { settle() } if (!acknowledged.get()) { - worker.schedule({ acknowledge() }, ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ackTimer.schedule({ acknowledge() }, ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) } } @@ -656,8 +880,11 @@ internal object PushBackground { // The app's PushReceiver gets only messages no live callback received. val handled = matching.isEmpty() && deliverToReceivers(context, message) val content = notificationContent(message) - val titles = matching.filter { it.background }.map { it.title ?: message.topic } + - if (handled) emptyList() else entries.map { it.title ?: message.topic } + // Background subscriptions notify while the app is not visible; on screen, only those + // that opted in with notifyInForeground do, since the app shows the message itself. + val foreground = appInForeground() + val titles = matching.filter { it.background && (!foreground || it.notifyInForeground) }.map { it.title ?: message.topic } + + if (handled) emptyList() else entries.filter { !foreground || it.notifyInForeground }.map { it.title ?: message.topic } // A title the server sent replaces every subscription's, so one notification is posted. titles.map { content.title ?: it }.distinct().forEach { notify(context, message, it, content) } } finally { @@ -727,6 +954,11 @@ internal object PushBackground { } } + // Whether one of the app's activities is visible: a foreground service alone does not count. + private fun appInForeground(): Boolean = ActivityManager.RunningAppProcessInfo() + .also { ActivityManager.getMyMemoryState(it) } + .importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + /** The server's `notification` block in [message]: nulls when the payload has none or is not JSON. */ fun notificationContent(message: PushMessage): PushNotificationContent { val notification = runCatching { JSONObject(message.data) }.getOrNull()?.optJSONObject("notification") @@ -736,6 +968,11 @@ internal object PushBackground { } // Downloads notification images, so a slow one is abandoned without holding up delivery. + // Acknowledgement timeouts, apart from [worker] so they fire while a run's drain window holds it. + private val ackTimer = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "AppwritePushAck").apply { isDaemon = true } + } + private val imageLoader = Executors.newCachedThreadPool { runnable -> Thread(runnable, "AppwritePushImage").apply { isDaemon = true } } @@ -746,7 +983,7 @@ internal object PushBackground { val connection = runCatching { URL(url).openConnection() as HttpURLConnection }.getOrNull() ?: return null connection.connectTimeout = IMAGE_TIMEOUT_MS connection.readTimeout = IMAGE_TIMEOUT_MS - val download = imageLoader.submit { connection.inputStream.use { BitmapFactory.decodeStream(it) } } + val download = imageLoader.submit { connection.inputStream.use { readLimited(it, IMAGE_MAX_BYTES) }?.let { decodeImage(it) } } return try { download.get(IMAGE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) } catch (e: Exception) { @@ -757,6 +994,37 @@ internal object PushBackground { } } + // At most [limit] bytes of [input], or null when it holds more. + private fun readLimited(input: InputStream, limit: Int): ByteArray? { + val out = ByteArrayOutputStream() + val buffer = ByteArray(8_192) + while (true) { + val read = input.read(buffer) + if (read < 0) { + return out.toByteArray() + } + if (out.size() + read > limit) { + return null + } + out.write(buffer, 0, read) + } + } + + // Decode [bytes] downsampled so neither side exceeds IMAGE_MAX_PX, so a large photo cannot + // exhaust memory while a notification is posted. + private fun decodeImage(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null + } + var sample = 1 + while (bounds.outWidth / sample > IMAGE_MAX_PX || bounds.outHeight / sample > IMAGE_MAX_PX) { + sample *= 2 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } + /** The ongoing notification the foreground service shows, on its own quiet channel. */ fun ongoingNotification(context: Context) = NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) .also { createChannels(context) } @@ -815,19 +1083,16 @@ internal object PushBackground { } // Arm the next run in [delayMs]: the job, and an alarm just after it in case the job is late. - private fun schedule(context: Context, delayMs: Long, jobToo: Boolean = true) { - val interval = if (delayMs == INTERVAL_MS && privileged(context)) PRIVILEGED_INTERVAL_MS else delayMs - if (jobToo) { - try { - val job = JobInfo.Builder(JOB_ID, ComponentName(context, PushJobService::class.java)) - .setMinimumLatency(interval) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) - .setPersisted(true) - .build() - context.getSystemService(JobScheduler::class.java)?.schedule(job) - } catch (e: Exception) { - report(e) + // The chained job alternates between two ids, so a run ([runningJobId]) never schedules its + // own id, which would stop it; the other id is cancelled only when it is not the one running. + private fun schedule(context: Context, delayMs: Long, alarmOnly: Boolean = false, runningJobId: Int? = null) { + val interval = interval(context, delayMs) + if (!alarmOnly) { + val next = if (runningJobId == JOB_ID) NEXT_JOB_ID else JOB_ID + if (runningJobId == null) { + context.getSystemService(JobScheduler::class.java)?.cancel(if (next == JOB_ID) NEXT_JOB_ID else JOB_ID) } + scheduleJob(context, delayMs, next) } val alarms = context.getSystemService(AlarmManager::class.java) ?: return val at = SystemClock.elapsedRealtime() + interval + 1_000L @@ -843,8 +1108,48 @@ internal object PushBackground { } } + private fun interval(context: Context, delayMs: Long): Long = + if (delayMs == INTERVAL_MS && privileged(context)) PRIVILEGED_INTERVAL_MS else delayMs + + private fun scheduleJob(context: Context, delayMs: Long, id: Int) { + try { + val job = JobInfo.Builder(id, ComponentName(context, PushJobService::class.java)) + .setMinimumLatency(interval(context, delayMs)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build() + context.getSystemService(JobScheduler::class.java)?.schedule(job) + } catch (e: Exception) { + report(e) + } + } + + // A periodic job, independent of the chain of runs, that restarts the chain when a run was + // dropped (the process killed between runs, a deferred alarm, a throttled job). Scheduled once, + // so its period is not reset by every run. + private fun ensureWatchdog(context: Context) { + val jobs = context.getSystemService(JobScheduler::class.java) ?: return + try { + if (jobs.allPendingJobs.any { it.id == WATCHDOG_JOB_ID }) { + return + } + jobs.schedule( + JobInfo.Builder(WATCHDOG_JOB_ID, ComponentName(context, PushJobService::class.java)) + .setPeriodic(WATCHDOG_INTERVAL_MS) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build(), + ) + } catch (e: Exception) { + report(e) + } + } + private fun cancelSchedule(context: Context) { context.getSystemService(JobScheduler::class.java)?.cancel(JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(NEXT_JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(EXPEDITED_JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(WATCHDOG_JOB_ID) context.getSystemService(AlarmManager::class.java)?.cancel(tickIntent(context)) } @@ -855,6 +1160,63 @@ internal object PushBackground { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + /** What background delivery can rely on now; see [PushBackgroundStatus]. */ + fun backgroundStatus(context: Context): PushBackgroundStatus { + val app = context.applicationContext + return PushBackgroundStatus( + exactAlarms = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + app.getSystemService(AlarmManager::class.java)?.canScheduleExactAlarms() == true, + ignoringBatteryOptimizations = app.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(app.packageName) == true, + foregroundService = PushStore.foreground(app), + ) + } + + /** + * Open the system screen where the user allows exact alarms (Android 12+). Returns false when + * there is nothing to ask: already allowed, older Android, or the app does not declare + * `SCHEDULE_EXACT_ALARM`. + */ + fun requestExactAlarms(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || backgroundStatus(context).exactAlarms) { + return false + } + return openSettings(context, Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Manifest.permission.SCHEDULE_EXACT_ALARM) + } + + /** + * Ask the user to exempt the app from battery optimisation. Returns false when there is + * nothing to ask: already exempt, or the app does not declare + * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. + */ + fun requestIgnoreBatteryOptimizations(context: Context): Boolean { + if (backgroundStatus(context).ignoringBatteryOptimizations) { + return false + } + return openSettings( + context, + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + ) + } + + // Open a settings screen for this package when the app declares [permission]. + private fun openSettings(context: Context, action: String, permission: String): Boolean { + val app = context.applicationContext + val declared = runCatching { + @Suppress("DEPRECATION") + app.packageManager.getPackageInfo(app.packageName, PackageManager.GET_PERMISSIONS).requestedPermissions + }.getOrNull()?.contains(permission) == true + if (!declared) { + return false + } + return try { + app.startActivity(Intent(action, Uri.parse("package:${app.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + } + } + // Allowed exact alarms or exempt from battery optimisation: the alarm is reliable, so the // runs can be spaced further apart. private fun privileged(context: Context): Boolean = @@ -945,6 +1307,7 @@ internal object PushStore { authMethod = it.getString("authMethod"), credential = it.getString("credential"), project = it.getString("project"), + sessionCookieUrl = if (it.isNull("sessionCookieUrl")) null else it.optString("sessionCookieUrl").ifEmpty { null }, ) } val list = json.getJSONArray("entries") @@ -954,6 +1317,7 @@ internal object PushStore { filter = entry.getString("filter"), retry = entry.getBoolean("retry"), title = if (entry.isNull("title")) null else entry.getString("title"), + notifyInForeground = entry.optBoolean("notifyInForeground", false), ) } config to entries @@ -973,11 +1337,12 @@ internal object PushStore { .put("keepAlive", config.keepAlive) .put("authMethod", config.authMethod) .put("credential", config.credential) - .put("project", config.project), + .put("project", config.project) + .put("sessionCookieUrl", config.sessionCookieUrl ?: JSONObject.NULL), ) .put( "entries", - JSONArray(entries.map { JSONObject().put("filter", it.filter).put("retry", it.retry).put("title", it.title ?: JSONObject.NULL) }), + JSONArray(entries.map { JSONObject().put("filter", it.filter).put("retry", it.retry).put("title", it.title ?: JSONObject.NULL).put("notifyInForeground", it.notifyInForeground) }), ) write(context, STATE_FILE, json) } diff --git a/library/src/main/java/io/appwrite/services/PushCore.kt b/library/src/main/java/io/appwrite/services/PushCore.kt index 20c5624..15f86b6 100644 --- a/library/src/main/java/io/appwrite/services/PushCore.kt +++ b/library/src/main/java/io/appwrite/services/PushCore.kt @@ -12,6 +12,7 @@ import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientConfig import com.hivemq.client.mqtt.mqtt5.auth.Mqtt5EnhancedAuthMechanism import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5DisconnectException +import com.hivemq.client.mqtt.mqtt5.lifecycle.Mqtt5ClientReconnector import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5Auth import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5AuthBuilder import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5EnhancedAuthBuilder @@ -30,6 +31,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import javax.net.ssl.ManagerFactoryParameters import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory @@ -49,6 +51,24 @@ data class PushMessage( get() = String(payload, Charsets.UTF_8) } +/** + * What background delivery can rely on. Without exact alarms, the scheduled wake-ups are inexact + * and Doze can defer them, so delivery is [bestEffort] unless foreground mode keeps the + * connection open. + */ +data class PushBackgroundStatus( + /** The app may schedule exact alarms (`SCHEDULE_EXACT_ALARM`, granted). */ + val exactAlarms: Boolean, + /** The app is exempt from battery optimisation. */ + val ignoringBatteryOptimizations: Boolean, + /** Foreground mode (`setForeground(true)`) keeps the connection open in a service. */ + val foregroundService: Boolean, +) { + /** Wake-ups may be deferred by Doze, so messages can arrive late while the app is closed. */ + val bestEffort: Boolean + get() = !exactAlarms && !foregroundService +} + /** Connection parameters shared between the in-process client and the foreground service. */ internal data class PushConfig( val host: String, @@ -60,6 +80,10 @@ internal data class PushConfig( val authMethod: String, val credential: String, val project: String, + // The endpoint whose `a_session_` cookie in the WebView cookie store holds the app's + // session, when its credential comes from there (React Native). Background runs read it, so + // they keep up with a rotated session while no app code runs. + val sessionCookieUrl: String? = null, ) /** @@ -110,6 +134,8 @@ internal fun buildPushClient( onDisconnected: (() -> Unit)? = null, onError: ((Throwable) -> Unit)? = null, onAuthRefused: ((Throwable) -> Unit)? = null, + credential: () -> String? = { config.credential }, + retired: AtomicBoolean? = null, ): Mqtt5AsyncClient { // config.clientId is stable per user (see buildConfig), so a reconnect rebuild reuses it // and resumes the same session. @@ -130,11 +156,25 @@ internal fun buildPushClient( // Whether this client has connected yet: until it has, a refused CONNECT is final. val connected = AtomicBoolean(false) + val client = AtomicReference() builder = builder.addConnectedListener { + // A reconnect that was already under way when the client was retired disconnects at once, + // so it never competes with its replacement for the broker session. + if (retired?.get() == true) { + client.get()?.disconnect() + return@addConnectedListener + } connected.set(true) onConnected?.invoke() } builder = builder.addDisconnectedListener { context -> + // A client its owner replaced or closed ([retired]) never reconnects: one still retrying + // in the background would otherwise come back as a second connection for the same id. + if (retired?.get() == true) { + context.reconnector.reconnect(false) + onDisconnected?.invoke() + return@addDisconnectedListener + } // A refused first CONNECT (bad credential, rate limit) is final: stop the automatic // reconnect so pushConnect fails with the broker's reason instead of retrying forever. val refused = !connected.get() && context.cause is Mqtt5ConnAckException @@ -142,8 +182,16 @@ internal fun buildPushClient( // re-auth) is final too, instead of being retried with the same credential forever. val authRefused = onAuthRefused != null && connected.get() && context.source != MqttDisconnectSource.USER && isAuthRefusal(context.cause) - if (refused || authRefused) { + // Authentication was aborted because the app has no current credential (signed out): there + // is nothing to reconnect with, so the attempt fails now instead of retrying until timeout. + val noCredential = generateSequence(context.cause) { it.cause }.any { it is PushNoCredentialException } + if (refused || authRefused || noCredential) { context.reconnector.reconnect(false) + } else if (context.reconnector.isReconnect) { + // HiveMQ's automatic reconnect sends a CONNECT it builds itself, without user + // properties: the broker then misses the project and refuses the credential. Reconnect + // with the same CONNECT this client connects with instead. + (context.reconnector as? Mqtt5ClientReconnector)?.connect(pushConnectMessage(config, credential)) } onDisconnected?.invoke() if (!refused) { @@ -168,7 +216,7 @@ internal fun buildPushClient( } } - return builder.buildAsync() + return builder.buildAsync().also { client.set(it) } } /** @@ -207,10 +255,31 @@ internal fun isAuthRefusal(error: Throwable?): Boolean = when (error) { else -> false } -/** Send the CONNECT and wait for the CONNACK, at most [timeoutSeconds] when given. */ -internal fun Mqtt5AsyncClient.sendPushConnect(config: PushConfig, timeoutSeconds: Long? = null) { - val connAck = connectWith() - .enhancedAuth(PushAuthMechanism(config.authMethod, config.credential)) +/** + * Send the CONNECT and wait for the CONNACK, at most [timeoutSeconds] when given. [credential] + * supplies the credential each time one is sent, including on the client's automatic reconnects + * and re-auths, so they can use a session rotated since this CONNECT; null aborts the attempt. + */ +internal fun Mqtt5AsyncClient.sendPushConnect( + config: PushConfig, + timeoutSeconds: Long? = null, + credential: () -> String? = { config.credential }, +) { + val connAck = connect(pushConnectMessage(config, credential)) + if (timeoutSeconds == null) { + connAck.get() + } else { + connAck.get(timeoutSeconds, TimeUnit.SECONDS) + } +} + +/** + * The CONNECT for [config]: enhanced auth with the credential from [credential], and the project + * as a user property, which the broker resolves the credential against. + */ +internal fun pushConnectMessage(config: PushConfig, credential: () -> String?): Mqtt5Connect = + Mqtt5Connect.builder() + .enhancedAuth(PushAuthMechanism(config.authMethod, config.project, credential)) // Clean start is always off, so the broker keeps this client's session and can // redeliver missed messages to QoS-1 subscriptions on reconnect. .cleanStart(false) @@ -218,13 +287,7 @@ internal fun Mqtt5AsyncClient.sendPushConnect(config: PushConfig, timeoutSeconds .userProperties() .add("projectId", config.project) .applyUserProperties() - .send() - if (timeoutSeconds == null) { - connAck.get() - } else { - connAck.get(timeoutSeconds, TimeUnit.SECONDS) - } -} + .build() /** * The error a lost connection reports, or null when it is not an error (a disconnect the app @@ -266,11 +329,14 @@ internal fun Mqtt5Publish.toPushMessage(): PushMessage = /** * Single-step MQTT 5 enhanced auth: the method and credential are placed in the CONNECT - * packet and the broker accepts them in the CONNACK, with no AUTH round-trip. + * packet and the broker accepts them in the CONNACK, with no AUTH round-trip. The credential is + * read from [credential] each time, so an automatic reconnect or re-auth sends the current one; + * when it has none, the attempt fails instead of sending a credential known to be stale. */ internal class PushAuthMechanism( private val method: String, - private val credential: String, + private val project: String, + private val credential: () -> String?, ) : Mqtt5EnhancedAuthMechanism { override fun getMethod(): MqttUtf8String = MqttUtf8String.of(method) @@ -282,7 +348,8 @@ internal class PushAuthMechanism( connect: Mqtt5Connect, authBuilder: Mqtt5EnhancedAuthBuilder, ): CompletableFuture { - authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + val current = credential() ?: return failedAuth() + authBuilder.data(current.toByteArray(Charsets.UTF_8)) return CompletableFuture.completedFuture(null) } @@ -290,7 +357,12 @@ internal class PushAuthMechanism( clientConfig: Mqtt5ClientConfig, authBuilder: Mqtt5AuthBuilder, ): CompletableFuture { - authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + val current = credential() ?: return failedAuth() + // The broker resolves a re-auth against the project in its user properties too. + authBuilder.data(current.toByteArray(Charsets.UTF_8)) + .userProperties() + .add("projectId", project) + .applyUserProperties() return CompletableFuture.completedFuture(null) } @@ -317,8 +389,14 @@ internal class PushAuthMechanism( override fun onAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit override fun onReAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit + + private fun failedAuth(): CompletableFuture = + CompletableFuture().apply { completeExceptionally(PushNoCredentialException()) } } +/** Authentication aborted because there is no current credential to send (the app signed out). */ +internal class PushNoCredentialException : IllegalStateException("No current credential to authenticate with") + /** * A [TrustManagerFactory] that accepts any certificate — used when `tlsInsecure` is set * (for a broker whose certificate does not chain to a public root). @@ -353,6 +431,8 @@ internal class PushListener( // Called instead of [callback] when set, with the acknowledgement to run once the message was // handled: the React Native and Flutter bridges acknowledge after their callback has run. val acknowledgingCallback: ((PushMessage, () -> Unit) -> Unit)? = null, + // Also post a background notification while the app is visible. + val notifyInForeground: Boolean = false, ) /** MQTT topic-filter match with '+' (single level) and '#' (multi level). */ diff --git a/library/src/main/java/io/appwrite/services/PushWakeups.kt b/library/src/main/java/io/appwrite/services/PushWakeups.kt index 091fd52..c6d5616 100644 --- a/library/src/main/java/io/appwrite/services/PushWakeups.kt +++ b/library/src/main/java/io/appwrite/services/PushWakeups.kt @@ -12,11 +12,15 @@ import android.content.Intent */ class PushJobService : JobService() { override fun onStartJob(params: JobParameters): Boolean { - PushBackground.tick(this) { jobFinished(params, false) } + // A chained job arms the next under the other chained id; the watchdog and an expedited + // run (handed over by the alarm) only run once. Each stays up while missed messages arrive. + val chained = params.jobId.takeIf { it == PushBackground.JOB_ID || it == PushBackground.NEXT_JOB_ID } + PushBackground.tick(this, chained, PushBackground.JOB_DRAIN_MS) { jobFinished(params, false) } return true } - // The run re-arms the next one itself, so a stopped run needs no retry. + // The run re-arms the next one itself, and the watchdog repeats on its own, so a stopped run + // needs no retry. override fun onStopJob(params: JobParameters): Boolean = false } @@ -29,8 +33,13 @@ class PushAlarmReceiver : BroadcastReceiver() { if (intent.action != PushBackground.ACTION_TICK) { return } + // On Android 12+ the run goes to an expedited job, which keeps the process runnable while it + // connects and the broker replays; it runs here when the system refuses one. + if (PushBackground.runExpedited(context)) { + return + } val pending = goAsync() - PushBackground.tick(context) { pending.finish() } + PushBackground.tick(context, drainMs = PushBackground.RECEIVER_DRAIN_MS) { pending.finish() } } } From 40fbc3ab8ec1c9363a57fb4ae41ef3bb836509ff Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 09:52:56 +0000 Subject: [PATCH 3/4] chore: update Android SDK to 29.1.0-rc.2 --- CHANGELOG.md | 12 + README.md | 64 ++- library/src/main/AndroidManifest.xml | 7 + library/src/main/java/io/appwrite/Client.kt | 2 +- .../main/java/io/appwrite/services/Push.kt | 203 ++++++-- .../io/appwrite/services/PushBackground.kt | 466 ++++++++++++++++-- .../java/io/appwrite/services/PushCore.kt | 114 ++++- .../io/appwrite/services/PushOpenActivity.kt | 56 +++ .../java/io/appwrite/services/PushWakeups.kt | 15 +- 9 files changed, 831 insertions(+), 108 deletions(-) create mode 100644 library/src/main/java/io/appwrite/services/PushOpenActivity.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ccff23c..fe45a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## 29.1.0-rc.2 + +* Added: `getInitialNotification()`, `onNotificationOpened()`, and the `PushNotificationOpened` data class (topic + data) +* Added: `onOpen`/`onClose` callbacks now fire for background push connections +* Added: push auth falls back to the client's cookie-store session +* Fixed: a foreground message with no live callback now still posts a notification +* Updated: notification taps route through an internal `PushOpenActivity` + +## 29.1.0-rc.1 + +* Updated: regenerated with sdk-generator 5.5.0; no SDK code changes + ## 29.1.0-rc.0 * Added: optional `method` param on `Client.chunkedUpload()` to set the upload HTTP method diff --git a/README.md b/README.md index bb53402..310bed7 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ repositories { Next, add the dependency to your project's `build.gradle(.kts)` file: ```groovy -implementation("io.appwrite:sdk-for-android:29.1.0-rc.0") +implementation("io.appwrite:sdk-for-android:29.1.0-rc.2") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 29.1.0-rc.0 + 29.1.0-rc.2 ``` @@ -77,19 +77,52 @@ app every 15 to 60 seconds to reconnect, and the broker replays what was sent in (`retry = true`). It reconnects with the credential saved at subscribe time, so use a session (`client.setSession(...)`), not a short-lived JWT. -When no in-app callback is listening, each message is posted as a notification that opens +While the app is not on screen, each message is also posted as a notification that opens your launch activity with `Push.EXTRA_TOPIC` and `Push.EXTRA_PAYLOAD` in its extras. It shows the title, body and image sent with `createPush`, falling back to the subscription's `title` and the raw payload for other messages. On Android 13 and later it needs the `POST_NOTIFICATIONS` permission: call `Push.requestNotificationPermission(activity)` before subscribing. Set its icon with `` -in your ``. To handle these messages in code, subclass `PushReceiver` and declare +in your ``. While the app is on screen your callback shows the message, so no +notification is posted unless the subscription passes `notifyInForeground = true`. To handle +messages that arrive while no callback is listening in code, subclass `PushReceiver` and declare it with the `io.appwrite.push.MESSAGE` action (see its docs). -Doze delays the scheduled runs. They are more punctual when your app may schedule exact -alarms (`SCHEDULE_EXACT_ALARM`) or is exempt from battery optimisation; both are your app's -choice and subject to Google Play policy, and the SDK uses them when granted. For immediate delivery even after a kill and -during Doze, call `push.setForeground(true)` from the foreground: it adds a foreground service +#### Delivery while the app is closed + +Messages sent while the app is closed arrive at the next scheduled wake-up. While the device is +awake that is about every 15 seconds, or about every 60 seconds once exact alarms are allowed; +without exact alarms the wake-ups are inexact, so battery saver can defer them further. In Doze +(screen off and idle for a while) Android limits background alarms, exact ones included, to about +one every nine minutes, so a closed app can take several minutes to receive a message: allowing +exact alarms makes wake-ups punctual, it does not lift Doze. For immediate delivery, also in +Doze, use `push.setForeground(true)` (see below). + +The SDK uses exact alarms on its own whenever the app may schedule them. To allow it: + +1. Declare the permissions in your app's `AndroidManifest.xml`. Both are optional and subject to + Google Play policy: `SCHEDULE_EXACT_ALARM` needs a declaration in the Play Console, and + `USE_EXACT_ALARM` is reserved for alarm, clock and calendar apps (the SDK does not use it). + + ```xml + + + ``` + +2. On Android 13 and later the user has to allow exact alarms, under Settings > Apps > Special app + access > Alarms & reminders. Android 12 grants a declared `SCHEDULE_EXACT_ALARM` + automatically, and older versions need nothing. Check with `Push.backgroundStatus(context)`: when `bestEffort` is + true, explain why to the user, then from a user action open that screen with `Push.requestExactAlarms(context)`, or + ask for the battery-optimisation exemption with `Push.requestIgnoreBatteryOptimizations(context)`. Both return false when there is + nothing to ask, including when the permission is not declared. The SDK never opens these + screens on its own. + +If the user force-stops the app (Settings > Force stop, and on some devices swiping it away from +recents), Android cancels its alarms and jobs: nothing is delivered until the app is opened +again, and the broker then replays what was sent meanwhile. + +For immediate delivery even after a kill and during Doze, call `push.setForeground(true)` from +the foreground: it adds a foreground service with a quiet ongoing notification. If you never enable it, you can remove the service from your merged manifest, so the Play Console does not ask you to declare it: @@ -98,6 +131,21 @@ your merged manifest, so the Play Console does not ask you to declare it: ``` +#### Opening a tapped notification + +Read the `data` sent with `createPush` when the user taps a background notification: + +```kotlin +// The tap that launched the app (reported once, so call it at startup). +push.getInitialNotification()?.let { openSale(it.data["saleId"]) } + +// Taps while the app is running, including in the background. +val stop = push.onNotificationOpened { opened -> openSale(opened.data["saleId"]) } +``` + +A tap made while nothing listens is kept for `getInitialNotification()`. The tap intent still +carries `Push.EXTRA_TOPIC` and `Push.EXTRA_PAYLOAD` for apps that read their activity's intent. + ## Getting Started diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml index 44f894e..d2b7d50 100644 --- a/library/src/main/AndroidManifest.xml +++ b/library/src/main/AndroidManifest.xml @@ -16,6 +16,13 @@ + , +) { + internal companion object { + fun of(tap: PushTap): PushNotificationOpened { + val data = try { + JSONObject(tap.payload).optJSONObject("data")?.toMap() + } catch (e: JSONException) { + null + } + return PushNotificationOpened(tap.topic, data ?: emptyMap()) + } + + private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { jsonValue(get(it)) } + + private fun jsonValue(value: Any?): Any? = when (value) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> (0 until value.length()).map { jsonValue(value.get(it)) } + else -> value + } + } +} + /** * Handle for a live subscription created with [Push.subscribe]. * @@ -32,19 +65,19 @@ private const val LOG_TAG = "AppwritePush" */ class PushSubscription internal constructor( private val unsubscribeAction: () -> Unit, - private val updateAction: (Boolean?, String?, Boolean?) -> Unit, + private val updateAction: (Boolean?, String?, Boolean?, Boolean?) -> Unit, ) { /** Drop this subscription. Closes the connection once the last one is gone. */ fun unsubscribe() = unsubscribeAction() /** - * Live-update this subscription's background delivery, notification title and/or - * `retry` (QoS). Only the arguments you pass change; the rest stay as they + * Live-update this subscription's background delivery, notification title, `retry` (QoS) + * and/or `notifyInForeground`. Only the arguments you pass change; the rest stay as they * were. Changing `retry` re-subscribes this subscription's topics at the new QoS. */ @JvmOverloads - fun update(background: Boolean? = null, title: String? = null, retry: Boolean? = null) = - updateAction(background, title, retry) + fun update(background: Boolean? = null, title: String? = null, retry: Boolean? = null, notifyInForeground: Boolean? = null) = + updateAction(background, title, retry, notifyInForeground) } /** @@ -65,11 +98,12 @@ class PushSubscription internal constructor( * share its connection. * * Background delivery reconnects with the credential saved at subscribe time, so use a session - * (`Client.setSession`) for it: a JWT expires after minutes. A refused credential stops + * (the app's sign-in session, or `Client.setSession`) for it: a JWT expires after minutes. A refused credential stops * background delivery and reaches [onError], also when it was registered later. * - * The credential is read off the client — set a JWT or session on it - * (`Client.setJWT` / `Client.setSession`), the same way every other service reads auth. + * The credential is read off the client: a JWT or session set on it (`Client.setJWT` / + * `Client.setSession`), else the session the app signed in with through it (e.g. + * `Account.createEmailPasswordSession`). * * The connection always keeps its session (clean start off) so the broker can redeliver * missed messages, and reliability is chosen per subscription via [subscribe]'s @@ -132,6 +166,9 @@ class Push( @Volatile private var mqtt: Mqtt5AsyncClient? = null + // Set when [mqtt] is replaced or closed, so that client never reconnects in the background. + private var mqttRetired: AtomicBoolean? = null + private var connectedConfig: PushConfig? = null private val connectLock = Any() @@ -153,8 +190,10 @@ class Push( private val appContext: Context = context.applicationContext init { - // Resume saved background delivery now, instead of at the next scheduled run. - PushBackground.resume(appContext) + // Resume saved background delivery now, instead of at the next scheduled run, with the + // credential set on the client: a rotated session of the same user replaces the saved one. + val auth = runCatching { resolvePushAuth(client) }.getOrNull() + PushBackground.resume(appContext, auth?.first, auth?.second) } private var onOpen: (() -> Unit)? = null @@ -167,6 +206,7 @@ class Push( var background: Boolean, var title: String?, var retry: Boolean, + var notifyInForeground: Boolean, ) { @Volatile var client: Mqtt5AsyncClient? = null @@ -227,12 +267,36 @@ class Push( return this } + /** + * The background notification whose tap launched the app, or null. Reported once; call it + * when the app starts, e.g. to open the screen the message is about. + * + * ```kotlin + * push.getInitialNotification()?.let { openSale(it.data["saleId"]) } + * ``` + */ + fun getInitialNotification(): PushNotificationOpened? = PushTaps.take()?.let { PushNotificationOpened.of(it) } + + /** + * Call [callback] on the main thread each time the user taps a background notification while + * the app is running, including in the background. Returns a function that stops listening. + * The tap that launched the app comes from [getInitialNotification] instead, as does a tap + * made while nothing listens. + * + * ```kotlin + * val stop = push.onNotificationOpened { openSale(it.data["saleId"]) } + * ``` + */ + fun onNotificationOpened(callback: (PushNotificationOpened) -> Unit): () -> Unit = + PushTaps.listen { callback(PushNotificationOpened.of(it)) } + /** * Subscribe to the signed-in user's own topic, `users/` — "messages for me" * without knowing any topic, e.g. `push.subscribe { message -> ... }`. * - * The user id is read locally from the credential set on the client: the `userId` claim of - * the JWT (`Client.setJWT`), else the `id` in the session secret (`Client.setSession`). + * The user id is read locally from the client's credential: the `userId` claim of the JWT + * (`Client.setJWT`), else the `id` in the session (`Client.setSession`, or the session the app + * signed in with through the client). * Throws when neither yields one. Like FCM/APNs delivery, it defaults to [background] `true` * (keeps delivering while the app is backgrounded or closed) and [retry] * `true` (QoS 1); pass either to override. Otherwise the options and the returned @@ -244,10 +308,11 @@ class Push( background: Boolean = true, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription { val userId = reporting { resolvePushUserId(client) } - return subscribe(Topic.path(listOf("users", userId)), background, title, retry, callback) + return subscribe(Topic.path(listOf("users", userId)), background, title, retry, notifyInForeground, callback) } /** @@ -262,9 +327,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic), background, title, retry, callback) + subscribe(listOf(topic), background, title, retry, notifyInForeground, callback) /** * Subscribe to a single topic built with [io.appwrite.Topic], e.g. @@ -277,9 +343,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic.toString()), background, title, retry, callback) + subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) /** * Subscribe to a single topic ended with `all()`, e.g. @@ -292,9 +359,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic.toString()), background, title, retry, callback) + subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) /** * Subscribe to a list mixing raw strings and topics built with [io.appwrite.Topic], e.g. @@ -310,6 +378,7 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription { val filters = topics.mapIndexed { index, topic -> @@ -318,7 +387,7 @@ class Push( } topic.toString() } - return subscribe(filters, background, title, retry, callback) + return subscribe(filters, background, title, retry, notifyInForeground, callback) } /** @@ -334,7 +403,8 @@ class Push( * * Set [background] to keep receiving while the app is backgrounded or closed, also after the * process is killed or the device restarts, and post a notification per matching message - * with [title] (defaulting to the message topic); see the class docs. [retry] chooses this + * with [title] (defaulting to the message topic) while the app is not on screen; set + * [notifyInForeground] to also post one while it is. See the class docs. [retry] chooses this * subscription's QoS * (`true` -> QoS 1 default, `false` -> QoS 0). Toggle any of these later with * [PushSubscription.update]. @@ -351,13 +421,14 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = reporting { val ids = mutableListOf() for (topic in topics) { val id = UUID.randomUUID().toString() ids.add(id) - subscriptions[id] = Sub(topic, callback, background, title, retry) + subscriptions[id] = Sub(topic, callback, background, title, retry, notifyInForeground) } // If any SUBSCRIBE fails, roll this call's registrations back and return to a @@ -382,7 +453,7 @@ class Push( PushSubscription( { reporting { unsubscribeIds(ids) } }, - { bg, t, rd -> reporting { updateIds(ids, bg, t, rd) } }, + { bg, t, rd, nf -> reporting { updateIds(ids, bg, t, rd, nf) } }, ) } @@ -463,15 +534,22 @@ class Push( } } + // Close the in-process client for good: retired first, so one still reconnecting in the + // background does not come back as a second connection for the same client id. + private fun dropClient() { + mqttRetired?.set(true) + mqttRetired = null + mqtt?.disconnect() + mqtt = null + } + // Relocate the connection into the background host and sync it with the current // subscription set (filters, per-sub callbacks/titles), saving the background ones. private fun hostInBackground() { - if (mqtt != null) { - mqtt?.disconnect() - mqtt = null - } + dropClient() subscriptions.values.forEach { it.client = null } PushBackground.errorHandler = { deliverBackgroundError(it) } + PushBackground.connectionHandler = { open -> if (open) onOpen?.invoke() else onClose?.invoke() } PushBackground.host(appContext, buildConfig(), currentListeners()) backgroundActive = true } @@ -525,6 +603,7 @@ class Push( } else if (backgroundActive) { // Relocate back in-process. backgroundActive = false + PushBackground.connectionHandler = null PushBackground.release(appContext) ensureInProcessSubscribed() } @@ -563,7 +642,7 @@ class Push( } } - private fun updateIds(ids: List, background: Boolean?, title: String?, retry: Boolean?) { + private fun updateIds(ids: List, background: Boolean?, title: String?, retry: Boolean?, notifyInForeground: Boolean?) { val changedFilters = mutableSetOf() for (id in ids) { val sub = subscriptions[id] ?: continue @@ -573,6 +652,9 @@ class Push( if (title != null) { sub.title = title } + if (notifyInForeground != null) { + sub.notifyInForeground = notifyInForeground + } if (retry != null && retry != sub.retry) { sub.retry = retry changedFilters.add(sub.filter) @@ -597,28 +679,30 @@ class Push( subscriptions.values.any { it.background } || PushBackground.hasSaved(appContext) private fun currentListeners(): List = - subscriptions.values.map { PushListener(setOf(it.filter), it.callback, it.background, it.title, it.retry) } + subscriptions.values.map { + PushListener(setOf(it.filter), it.callback, it.background, it.title, it.retry, notifyInForeground = it.notifyInForeground) + } /** * Tear down the connection and drop all subscriptions, including the background ones saved * by an earlier run, and stop background delivery. Call it on sign-out. */ fun close() { - mqtt?.disconnect() - mqtt = null + dropClient() subscriptions.clear() backgroundActive = false + PushBackground.connectionHandler = null PushBackground.stop(appContext) } // The last live subscription is gone: close the connection, keeping any background // subscriptions saved by an earlier run. private fun releaseAll() { - mqtt?.disconnect() - mqtt = null + dropClient() subscriptions.clear() if (backgroundActive) { backgroundActive = false + PushBackground.connectionHandler = null PushBackground.release(appContext) } } @@ -633,25 +717,28 @@ class Push( return existing } connectionEpoch++ - existing.disconnect() - mqtt = null + dropClient() } // The listeners surface the lifecycle to onOpen/onClose and every connection error to // onError, except a refused initial CONNECT: pushConnect throws that one, and the // failing call reports it. + val retired = AtomicBoolean(false) val client = buildPushClient( config, onConnected = { onOpen?.invoke() }, onDisconnected = { onClose?.invoke() }, onError = { reportAsync(it) }, + retired = retired, ) mqtt = client + mqttRetired = retired connectedConfig = config try { client.pushConnect(config) } catch (e: Exception) { + retired.set(true) client.disconnect() if (mqtt === client) { mqtt = null @@ -707,6 +794,32 @@ class Push( /** The notification tap intent's extra holding the message payload as a string. */ const val EXTRA_PAYLOAD = PushBackground.EXTRA_PAYLOAD + /** + * What background delivery can rely on: exact alarms, battery-optimisation exemption and + * foreground mode. When [PushBackgroundStatus.bestEffort] is true, wake-ups are inexact and + * messages can arrive late while the app is closed; explain why before asking with + * [requestExactAlarms] or [requestIgnoreBatteryOptimizations]. + */ + @JvmStatic + fun backgroundStatus(context: Context): PushBackgroundStatus = PushBackground.backgroundStatus(context) + + /** + * Open the system screen where the user allows exact alarms (Android 12+), for punctual + * background wake-ups. Call it from a user action. Returns false when there is nothing to + * ask: already allowed, older Android, or the app does not declare `SCHEDULE_EXACT_ALARM`. + */ + @JvmStatic + fun requestExactAlarms(context: Context): Boolean = PushBackground.requestExactAlarms(context) + + /** + * Ask the user to exempt the app from battery optimisation. Call it from a user action. + * Returns false when there is nothing to ask: already exempt, or the app does not declare + * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. + */ + @JvmStatic + fun requestIgnoreBatteryOptimizations(context: Context): Boolean = + PushBackground.requestIgnoreBatteryOptimizations(context) + /** * Request the `POST_NOTIFICATIONS` runtime permission (Android 13+). Call from an * Activity before subscribing with `background = true`; on older versions it is a no-op. @@ -730,15 +843,31 @@ class Push( } } -/** Read the credential set on the client (via Client.setJWT / setSession). */ +/** + * The session set on the client (Client.setSession), else the one the app signed in with through it + * (e.g. Account.createEmailPasswordSession), read from its cookie store. + */ +internal fun pushSession(client: Client): String? { + client.config["session"]?.takeIf { it.isNotEmpty() }?.let { return it } + val name = "a_session_${client.config["project"] ?: ""}" + return try { + client.getCookies(client.endpoint) + .firstOrNull { it.name == name && it.value.isNotEmpty() } + ?.let { Uri.decode(it.value) } + } catch (e: IllegalArgumentException) { + null + } +} + +/** Read the credential set on the client (via Client.setJWT / setSession), else its sign-in session. */ internal fun resolvePushAuth(client: Client): Pair { val jwt = client.config["jwt"] ?: client.config["jWT"] - val session = client.config["session"] + val session = pushSession(client) return when { !jwt.isNullOrEmpty() -> "appwrite-jwt" to jwt !session.isNullOrEmpty() -> "appwrite-session" to session else -> throw AppwriteException( - "No credential set on the client; call Client.setJWT() or Client.setSession() first.", + "No credential: sign in with this client, or call Client.setJWT() or Client.setSession() first.", ) } } @@ -750,7 +879,7 @@ internal fun resolvePushAuth(client: Client): Pair { */ internal fun resolvePushUserId(client: Client): String { val jwt = client.config["jwt"] ?: client.config["jWT"] - val session = client.config["session"] + val session = pushSession(client) val userId = when { !jwt.isNullOrEmpty() -> userIdFromJwt(jwt) !session.isNullOrEmpty() -> userIdFromSession(session) @@ -758,7 +887,7 @@ internal fun resolvePushUserId(client: Client): String { } if (userId.isEmpty()) { throw AppwriteException( - "subscribe() without a topic needs a signed-in user: set a JWT or session on the client", + "subscribe() without a topic needs a signed-in user: sign in with this client, or set a JWT or session on it", ) } return userId diff --git a/library/src/main/java/io/appwrite/services/PushBackground.kt b/library/src/main/java/io/appwrite/services/PushBackground.kt index a550a55..1d1a804 100644 --- a/library/src/main/java/io/appwrite/services/PushBackground.kt +++ b/library/src/main/java/io/appwrite/services/PushBackground.kt @@ -1,5 +1,7 @@ package io.appwrite.services +import android.Manifest +import android.app.ActivityManager import android.app.AlarmManager import android.app.NotificationChannel import android.app.NotificationManager @@ -14,11 +16,14 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.ConnectivityManager import android.net.Network +import android.net.Uri import android.os.Build import android.os.PowerManager import android.os.SystemClock +import android.provider.Settings import android.util.AtomicFile import android.util.Log +import android.webkit.CookieManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -30,8 +35,10 @@ import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish import io.appwrite.exceptions.AppwriteException import org.json.JSONArray import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.util.UUID @@ -53,6 +60,7 @@ internal data class PushEntry( val filter: String, val retry: Boolean, val title: String?, + val notifyInForeground: Boolean = false, ) /** @@ -96,7 +104,11 @@ internal object PushBackground { const val EXTRA_PAYLOAD = "io.appwrite.push.PAYLOAD" private const val SERVICE_CHANNEL_ID = "appwrite-push-service" - private const val JOB_ID = 0x50555348 + const val JOB_ID = 0x50555348 + const val NEXT_JOB_ID = 0x5055534A + const val EXPEDITED_JOB_ID = 0x5055534B + const val WATCHDOG_JOB_ID = 0x50555349 + private const val WATCHDOG_INTERVAL_MS = 15 * 60 * 1_000L private const val INTERVAL_MS = 15_000L private const val PRIVILEGED_INTERVAL_MS = 60_000L private const val RESTART_DELAY_MS = 3_000L @@ -116,6 +128,15 @@ internal object PushBackground { private const val HEARTBEAT_RESET_MS = 3 * 24 * 60 * 60 * 1_000L private const val REQUEST_TIMEOUT_SECONDS = 10L private const val IMAGE_TIMEOUT_MS = 5_000 + + // How long a scheduled run stays up after connecting so the broker can replay what was missed: + // until nothing arrived for DRAIN_QUIET_MS and nothing is being delivered, at most the run's cap. + const val JOB_DRAIN_MS = 15_000L + const val RECEIVER_DRAIN_MS = 5_000L + private const val DRAIN_QUIET_MS = 2_000L + private const val DRAIN_POLL_MS = 200L + private const val IMAGE_MAX_BYTES = 5 * 1024 * 1024 + private const val IMAGE_MAX_PX = 1_024 private const val CONNECT_TIMEOUT_SECONDS = 20L // How long a message waits for a listener that acknowledges it itself (the React Native and @@ -139,6 +160,11 @@ internal object PushBackground { @Volatile var errorHandler: ((Throwable) -> Boolean)? = null + // Tells the Push instance that hosts its subscriptions here when the connection opens (true) + // or closes (false). + @Volatile + var connectionHandler: ((Boolean) -> Unit)? = null + @Volatile var serviceRunning = false @@ -164,6 +190,15 @@ internal object PushBackground { Thread(runnable, "AppwritePushBackground").apply { isDaemon = true } } private var mqtt: Mqtt5AsyncClient? = null + + // Set when [mqtt] is replaced or closed, so that client stops reconnecting and delivering. + private var mqttRetired: AtomicBoolean? = null + + // Messages being delivered, and when one last arrived or finished, for a run's drain window. + private val delivering = AtomicInteger(0) + + @Volatile + private var lastDelivery = 0L private var connectedConfig: PushConfig? = null private var connectedNetwork: Network? = null private var lastHeartbeat = 0L @@ -264,23 +299,120 @@ internal object PushBackground { } } - /** Resume saved background delivery when the app starts, without waiting for the next run. */ - fun resume(context: Context) { + /** + * Resume saved background delivery when the app starts, without waiting for the next run, with + * the app's current credential. A rotated session of the same user replaces the saved one; a + * different user drops the saved subscriptions, as does no credential when + * [signedOutWhenMissing] (the caller read the session store, so none means signed out). + * While a live Push hosts subscriptions here, it does nothing: that Push owns the connection. + */ + fun resume(context: Context, authMethod: String? = null, credential: String? = null, signedOutWhenMissing: Boolean = false) { val app = context.applicationContext - val active = synchronized(lock) { + val resume = synchronized(lock) { load(app) - isActive() + val current = config + when { + !isActive() || current == null -> null + // A live Push hosts here and owns the connection and its credential; resuming only + // reconciles saved delivery, so it leaves a live host alone. + listeners.isNotEmpty() -> null + // No credential: signed out when the caller can tell, else possibly not set yet. + authMethod == null || credential == null -> if (signedOutWhenMissing) false else null + current.authMethod == authMethod && current.credential == credential -> true + // A new credential for the same user (a rotated session) replaces the saved one, + // so the stale one is never sent and refused. + sameUser(current.authMethod, current.credential, authMethod, credential) -> { + config = current.copy(authMethod = authMethod, credential = credential) + persist(app) + true + } + // Another user signed in: the saved subscriptions were not theirs. + else -> false + } } - if (active) { - tick(app) {} + when (resume) { + true -> tick(app) {} + false -> stop(app) + null -> Unit + } + } + + private enum class CredentialRefresh { UNCHANGED, CHANGED, GONE } + + // Bring the saved credential up to date with the session cookie the app + // signs in with, when it comes from one (see [PushConfig.sessionCookieUrl]): a rotated session of + // the same user replaces it, and no session or another user's is GONE. UNCHANGED when there is + // no cookie source or it cannot be read. + private fun refreshCredential(context: Context): CredentialRefresh { + val current = synchronized(lock) { config } ?: return CredentialRefresh.UNCHANGED + val url = current.sessionCookieUrl ?: return CredentialRefresh.UNCHANGED + val session = readSessionCookie(url, current.project) ?: return CredentialRefresh.UNCHANGED + return when { + session.isEmpty() -> CredentialRefresh.GONE + current.authMethod == "appwrite-session" && session == current.credential -> CredentialRefresh.UNCHANGED + sameUser(current.authMethod, current.credential, "appwrite-session", session) -> { + synchronized(lock) { + config = current.copy(authMethod = "appwrite-session", credential = session) + persist(context.applicationContext) + } + CredentialRefresh.CHANGED + } + else -> CredentialRefresh.GONE + } + } + + // The credential to send for a connection made with [connected]: the session re-read from the + // cookie store when one backs it (so an automatic reconnect uses a rotated session), else the + // one it connected with. Only a newer credential of the same user, for the same connection + // (broker, client id, project), is borrowed: a connection being replaced by [host] keeps its + // own. Null when the app signed out or another user signed in: authentication is aborted and + // delivery stops. + private fun currentCredential(context: Context, connected: PushConfig): String? { + if (refreshCredential(context) == CredentialRefresh.GONE) { + worker.execute { stop(context) } + return null } + val current = synchronized(lock) { config } ?: return connected.credential + val sameConnection = current.host == connected.host && current.port == connected.port && + current.clientId == connected.clientId && current.project == connected.project && + current.authMethod == connected.authMethod + return if (sameConnection && sameUser(connected.authMethod, connected.credential, current.authMethod, current.credential)) { + current.credential + } else { + connected.credential + } + } + + // The `a_session_` cookie for [url] in the WebView cookie store, decoded; "" when there + // is none, and null when the store cannot be read. + private fun readSessionCookie(url: String, project: String): String? = runCatching { + val name = "a_session_$project" + CookieManager.getInstance().getCookie(url) + ?.split(";") + ?.map { it.trim() } + ?.firstOrNull { it.substringBefore("=") == name } + ?.substringAfter("=") + ?.let { Uri.decode(it) } + ?: "" + }.getOrNull() + + private fun sameUser(savedMethod: String, saved: String, method: String, credential: String): Boolean { + fun userId(method: String, credential: String) = + if (method == "appwrite-session") userIdFromSession(credential) else userIdFromJwt(credential) + val user = userId(method, credential) + return user.isNotEmpty() && user == userId(savedMethod, saved) } /** * One scheduled run: under a wakelock, reconnect if needed, send a heartbeat when one is due, * re-arm the next run, and call [done] once finished. Shuts down when nothing is saved. + * + * A run of a chained job ([jobId]) arms the next run under the other chained id: scheduling the + * id that is running would stop the run in progress. With [drainMs], the run stays up after + * connecting while the broker replays what was missed (at most that long), so a process woken + * from the background is not cached and frozen before the messages are delivered. */ - fun tick(context: Context, done: () -> Unit) { + fun tick(context: Context, jobId: Int? = null, drainMs: Long = 0, done: () -> Unit) { val app = context.applicationContext val wakeLock = acquireWakeLock(app) worker.execute { @@ -291,12 +423,17 @@ internal object PushBackground { } if (active) { // Arm the next run first, so a run cut short (killed, or a slow broker outlasting - // the wakelock) still leaves the chain armed. A refused credential in converge() - // shuts down, cancelling it again. - schedule(app, INTERVAL_MS) + // the wakelock) still leaves the chain armed; the watchdog job restarts a chain + // that was dropped anyway. A refused credential in converge() shuts down, + // cancelling them again. + schedule(app, INTERVAL_MS, runningJobId = jobId) + ensureWatchdog(app) ensureService(app) registerNetworkCallback(app) converge(app, heartbeat = true) + if (drainMs > 0 && mqtt != null) { + awaitDeliveries(drainMs) + } } else { shutdown(app) } @@ -309,9 +446,49 @@ internal object PushBackground { } } + // Worker thread only. Wait until no message arrived for DRAIN_QUIET_MS and none is being + // delivered, at most [maxMs]. Timed with the real monotonic clock ([nowMs]), which keeps moving + // while this thread sleeps. + private fun awaitDeliveries(maxMs: Long) { + val start = nowMs() + if (lastDelivery < start) { + lastDelivery = start + } + while (nowMs() - start < maxMs) { + if (delivering.get() == 0 && nowMs() - lastDelivery >= DRAIN_QUIET_MS) { + return + } + Thread.sleep(DRAIN_POLL_MS) + } + } + + private fun nowMs(): Long = System.nanoTime() / 1_000_000 + + /** + * Android 12+: hand a run to an expedited job, which starts at once with network access and + * keeps the process runnable until it finishes. A broadcast receiver woken in a cached process + * runs at background priority and can be frozen before it connects. Returns false when the + * job cannot be scheduled (older Android, or the expedited quota is used up), so the caller + * runs the tick itself. + */ + fun runExpedited(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return false + } + return try { + val job = JobInfo.Builder(EXPEDITED_JOB_ID, ComponentName(context, PushJobService::class.java)) + .setExpedited(true) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .build() + context.getSystemService(JobScheduler::class.java)?.schedule(job) == JobScheduler.RESULT_SUCCESS + } catch (e: Exception) { + false + } + } + /** Run once more shortly after the task was removed, so an OEM kill that follows is undone. */ fun scheduleRestart(context: Context) { - schedule(context.applicationContext, RESTART_DELAY_MS, jobToo = false) + schedule(context.applicationContext, RESTART_DELAY_MS, alarmOnly = true) } /** @@ -326,6 +503,7 @@ internal object PushBackground { loaded = false } errorHandler = null + connectionHandler = null unregisterNetworkCallback(context.applicationContext) worker.submit { disconnect() }.get() } @@ -361,9 +539,14 @@ internal object PushBackground { // Must hold [lock]. Every background subscription, live or saved, one per filter and title. // Subscriptions sharing a filter and title are saved once, with QoS 1 when any of them wants it. private fun entries(): List = - (saved + listeners.filter { it.background }.flatMap { listener -> listener.topics.map { PushEntry(it, listener.retry, listener.title) } }) + (saved + liveEntries()) .groupBy { it.filter to it.title } - .map { (key, group) -> PushEntry(key.first, group.any { it.retry }, key.second) } + .map { (key, group) -> PushEntry(key.first, group.any { it.retry }, key.second, group.any { it.notifyInForeground }) } + + // Must hold [lock]. + private fun liveEntries(): List = listeners.filter { it.background }.flatMap { listener -> + listener.topics.map { PushEntry(it, listener.retry, listener.title, listener.notifyInForeground) } + } // Must hold [lock]. private fun persist(context: Context) { @@ -397,6 +580,7 @@ internal object PushBackground { private fun activate(context: Context, done: ((Throwable?) -> Unit)? = null) { registerNetworkCallback(context) schedule(context, INTERVAL_MS) + ensureWatchdog(context) ensureService(context) worker.execute { converge(context, heartbeat = false, done) } } @@ -437,6 +621,11 @@ internal object PushBackground { } private fun convergeOnce(context: Context, heartbeat: Boolean) { + // Signed out, or signed in as someone else, since the subscriptions were saved. + if (refreshCredential(context) == CredentialRefresh.GONE) { + stop(context) + return + } val (current, wanted) = synchronized(lock) { config to wanted() } if (current == null || wanted.isEmpty()) { disconnect() @@ -477,24 +666,44 @@ internal object PushBackground { // A failed first connect of a waited-for converge goes to the waiting caller only. val quiet = waiting val connected = AtomicBoolean(false) + val retired = AtomicBoolean(false) val client = buildPushClient( config.copy(keepAlive = KEEP_ALIVE_SECONDS), + onConnected = { connectionHandler?.invoke(true) }, + onDisconnected = { + if (!retired.get()) { + connectionHandler?.invoke(false) + } + }, onError = { error -> if (!quiet || connected.get()) { report(error) } }, onAuthRefused = { error -> worker.execute { refused(context, error) } }, + credential = { currentCredential(context, config) }, + retired = retired, ) // Registered before connecting, so the backlog the broker replays right after each // SUBSCRIBE reaches the app however the subscription was made (including the ones the // client restores itself after an automatic reconnect). Acknowledged by hand, once the // message was delivered (see [deliver]). - client.publishes(MqttGlobalPublishFilter.ALL, { publish -> deliver(context, publish) }, true) + client.publishes( + MqttGlobalPublishFilter.ALL, + { publish -> + if (!retired.get()) { + deliver(context, publish) + } + }, + true, + ) try { - client.sendPushConnect(config.copy(keepAlive = KEEP_ALIVE_SECONDS), CONNECT_TIMEOUT_SECONDS) + client.sendPushConnect(config.copy(keepAlive = KEEP_ALIVE_SECONDS), CONNECT_TIMEOUT_SECONDS) { + currentCredential(context, config) + } } catch (e: Exception) { val cause = (e as? ExecutionException)?.cause ?: e + retired.set(true) client.disconnect() val error = connectError(cause) when { @@ -508,6 +717,7 @@ internal object PushBackground { } connected.set(true) mqtt = client + mqttRetired = retired connectedConfig = config connectedNetwork = activeNetwork(context) lastHeartbeat = SystemClock.elapsedRealtime() @@ -575,6 +785,8 @@ internal object PushBackground { // Worker thread only. private fun disconnect() { + mqttRetired?.set(true) + mqttRetired = null mqtt?.disconnect() mqtt = null connectedConfig = null @@ -586,6 +798,21 @@ internal object PushBackground { // registered the message is also saved, and the next onError registration receives it. A // waiting caller receives it instead. private fun refused(context: Context, error: Throwable) { + // The saved credential may be a session the app has since rotated: retry once with the + // current one. Refused again, it is unchanged and delivery stops below. + if (!waiting) { + when (refreshCredential(context)) { + CredentialRefresh.CHANGED -> { + converge(context, heartbeat = false) + return + } + CredentialRefresh.GONE -> { + stop(context) + return + } + CredentialRefresh.UNCHANGED -> Unit + } + } if (waiting) { fail(error) } else if (!report(error)) { @@ -596,11 +823,24 @@ internal object PushBackground { // Deliver a message, then acknowledge it: right away, or once every listener that acknowledges // messages itself has done so (at most ACK_TIMEOUT_SECONDS later). + // A message counts as being delivered until it is acknowledged: for the React Native and Flutter + // bridges, once their callback has run (or the acknowledgement times out), not when the event is + // handed over, so a run's drain window waits for it. private fun deliver(context: Context, publish: Mqtt5Publish) { + delivering.incrementAndGet() + lastDelivery = nowMs() + deliverAcknowledged(context, publish) { + lastDelivery = nowMs() + delivering.decrementAndGet() + } + } + + private fun deliverAcknowledged(context: Context, publish: Mqtt5Publish, settled: () -> Unit) { val acknowledged = AtomicBoolean(false) val acknowledge = { if (acknowledged.compareAndSet(false, true)) { runCatching { publish.acknowledge() } + settled() } } val pending = AtomicInteger(1) @@ -624,7 +864,7 @@ internal object PushBackground { settle() } if (!acknowledged.get()) { - worker.schedule({ acknowledge() }, ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ackTimer.schedule({ acknowledge() }, ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) } } @@ -656,8 +896,13 @@ internal object PushBackground { // The app's PushReceiver gets only messages no live callback received. val handled = matching.isEmpty() && deliverToReceivers(context, message) val content = notificationContent(message) - val titles = matching.filter { it.background }.map { it.title ?: message.topic } + - if (handled) emptyList() else entries.map { it.title ?: message.topic } + // Background subscriptions notify while the app is not visible; on screen, only those + // that opted in with notifyInForeground do, since the app shows the message itself. A + // message no live callback received still notifies, as the app has not shown it. + val foreground = appInForeground() + val shown = foreground && matching.isNotEmpty() + val titles = matching.filter { it.background && (!foreground || it.notifyInForeground) }.map { it.title ?: message.topic } + + if (handled) emptyList() else entries.filter { !shown || it.notifyInForeground }.map { it.title ?: message.topic } // A title the server sent replaces every subscription's, so one notification is posted. titles.map { content.title ?: it }.distinct().forEach { notify(context, message, it, content) } } finally { @@ -712,14 +957,13 @@ internal object PushBackground { builder.setLargeIcon(image) .setStyle(NotificationCompat.BigPictureStyle().bigPicture(image).bigLargeIcon(null as Bitmap?).setSummaryText(body)) } - context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch -> - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - .putExtra(EXTRA_TOPIC, message.topic) - .putExtra(EXTRA_PAYLOAD, message.data) - builder.setContentIntent( - PendingIntent.getActivity(context, id, launch, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE), - ) - } + val open = Intent(context, PushOpenActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION) + .putExtra(EXTRA_TOPIC, message.topic) + .putExtra(EXTRA_PAYLOAD, message.data) + builder.setContentIntent( + PendingIntent.getActivity(context, id, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE), + ) try { manager.notify(id, builder.build()) } catch (e: SecurityException) { @@ -727,6 +971,11 @@ internal object PushBackground { } } + // Whether one of the app's activities is visible: a foreground service alone does not count. + private fun appInForeground(): Boolean = ActivityManager.RunningAppProcessInfo() + .also { ActivityManager.getMyMemoryState(it) } + .importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + /** The server's `notification` block in [message]: nulls when the payload has none or is not JSON. */ fun notificationContent(message: PushMessage): PushNotificationContent { val notification = runCatching { JSONObject(message.data) }.getOrNull()?.optJSONObject("notification") @@ -736,6 +985,11 @@ internal object PushBackground { } // Downloads notification images, so a slow one is abandoned without holding up delivery. + // Acknowledgement timeouts, apart from [worker] so they fire while a run's drain window holds it. + private val ackTimer = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "AppwritePushAck").apply { isDaemon = true } + } + private val imageLoader = Executors.newCachedThreadPool { runnable -> Thread(runnable, "AppwritePushImage").apply { isDaemon = true } } @@ -746,7 +1000,7 @@ internal object PushBackground { val connection = runCatching { URL(url).openConnection() as HttpURLConnection }.getOrNull() ?: return null connection.connectTimeout = IMAGE_TIMEOUT_MS connection.readTimeout = IMAGE_TIMEOUT_MS - val download = imageLoader.submit { connection.inputStream.use { BitmapFactory.decodeStream(it) } } + val download = imageLoader.submit { connection.inputStream.use { readLimited(it, IMAGE_MAX_BYTES) }?.let { decodeImage(it) } } return try { download.get(IMAGE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) } catch (e: Exception) { @@ -757,6 +1011,37 @@ internal object PushBackground { } } + // At most [limit] bytes of [input], or null when it holds more. + private fun readLimited(input: InputStream, limit: Int): ByteArray? { + val out = ByteArrayOutputStream() + val buffer = ByteArray(8_192) + while (true) { + val read = input.read(buffer) + if (read < 0) { + return out.toByteArray() + } + if (out.size() + read > limit) { + return null + } + out.write(buffer, 0, read) + } + } + + // Decode [bytes] downsampled so neither side exceeds IMAGE_MAX_PX, so a large photo cannot + // exhaust memory while a notification is posted. + private fun decodeImage(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null + } + var sample = 1 + while (bounds.outWidth / sample > IMAGE_MAX_PX || bounds.outHeight / sample > IMAGE_MAX_PX) { + sample *= 2 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } + /** The ongoing notification the foreground service shows, on its own quiet channel. */ fun ongoingNotification(context: Context) = NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) .also { createChannels(context) } @@ -815,19 +1100,16 @@ internal object PushBackground { } // Arm the next run in [delayMs]: the job, and an alarm just after it in case the job is late. - private fun schedule(context: Context, delayMs: Long, jobToo: Boolean = true) { - val interval = if (delayMs == INTERVAL_MS && privileged(context)) PRIVILEGED_INTERVAL_MS else delayMs - if (jobToo) { - try { - val job = JobInfo.Builder(JOB_ID, ComponentName(context, PushJobService::class.java)) - .setMinimumLatency(interval) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) - .setPersisted(true) - .build() - context.getSystemService(JobScheduler::class.java)?.schedule(job) - } catch (e: Exception) { - report(e) + // The chained job alternates between two ids, so a run ([runningJobId]) never schedules its + // own id, which would stop it; the other id is cancelled only when it is not the one running. + private fun schedule(context: Context, delayMs: Long, alarmOnly: Boolean = false, runningJobId: Int? = null) { + val interval = interval(context, delayMs) + if (!alarmOnly) { + val next = if (runningJobId == JOB_ID) NEXT_JOB_ID else JOB_ID + if (runningJobId == null) { + context.getSystemService(JobScheduler::class.java)?.cancel(if (next == JOB_ID) NEXT_JOB_ID else JOB_ID) } + scheduleJob(context, delayMs, next) } val alarms = context.getSystemService(AlarmManager::class.java) ?: return val at = SystemClock.elapsedRealtime() + interval + 1_000L @@ -843,8 +1125,48 @@ internal object PushBackground { } } + private fun interval(context: Context, delayMs: Long): Long = + if (delayMs == INTERVAL_MS && privileged(context)) PRIVILEGED_INTERVAL_MS else delayMs + + private fun scheduleJob(context: Context, delayMs: Long, id: Int) { + try { + val job = JobInfo.Builder(id, ComponentName(context, PushJobService::class.java)) + .setMinimumLatency(interval(context, delayMs)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build() + context.getSystemService(JobScheduler::class.java)?.schedule(job) + } catch (e: Exception) { + report(e) + } + } + + // A periodic job, independent of the chain of runs, that restarts the chain when a run was + // dropped (the process killed between runs, a deferred alarm, a throttled job). Scheduled once, + // so its period is not reset by every run. + private fun ensureWatchdog(context: Context) { + val jobs = context.getSystemService(JobScheduler::class.java) ?: return + try { + if (jobs.allPendingJobs.any { it.id == WATCHDOG_JOB_ID }) { + return + } + jobs.schedule( + JobInfo.Builder(WATCHDOG_JOB_ID, ComponentName(context, PushJobService::class.java)) + .setPeriodic(WATCHDOG_INTERVAL_MS) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build(), + ) + } catch (e: Exception) { + report(e) + } + } + private fun cancelSchedule(context: Context) { context.getSystemService(JobScheduler::class.java)?.cancel(JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(NEXT_JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(EXPEDITED_JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(WATCHDOG_JOB_ID) context.getSystemService(AlarmManager::class.java)?.cancel(tickIntent(context)) } @@ -855,6 +1177,63 @@ internal object PushBackground { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + /** What background delivery can rely on now; see [PushBackgroundStatus]. */ + fun backgroundStatus(context: Context): PushBackgroundStatus { + val app = context.applicationContext + return PushBackgroundStatus( + exactAlarms = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + app.getSystemService(AlarmManager::class.java)?.canScheduleExactAlarms() == true, + ignoringBatteryOptimizations = app.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(app.packageName) == true, + foregroundService = PushStore.foreground(app), + ) + } + + /** + * Open the system screen where the user allows exact alarms (Android 12+). Returns false when + * there is nothing to ask: already allowed, older Android, or the app does not declare + * `SCHEDULE_EXACT_ALARM`. + */ + fun requestExactAlarms(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || backgroundStatus(context).exactAlarms) { + return false + } + return openSettings(context, Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Manifest.permission.SCHEDULE_EXACT_ALARM) + } + + /** + * Ask the user to exempt the app from battery optimisation. Returns false when there is + * nothing to ask: already exempt, or the app does not declare + * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. + */ + fun requestIgnoreBatteryOptimizations(context: Context): Boolean { + if (backgroundStatus(context).ignoringBatteryOptimizations) { + return false + } + return openSettings( + context, + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + ) + } + + // Open a settings screen for this package when the app declares [permission]. + private fun openSettings(context: Context, action: String, permission: String): Boolean { + val app = context.applicationContext + val declared = runCatching { + @Suppress("DEPRECATION") + app.packageManager.getPackageInfo(app.packageName, PackageManager.GET_PERMISSIONS).requestedPermissions + }.getOrNull()?.contains(permission) == true + if (!declared) { + return false + } + return try { + app.startActivity(Intent(action, Uri.parse("package:${app.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + } + } + // Allowed exact alarms or exempt from battery optimisation: the alarm is reliable, so the // runs can be spaced further apart. private fun privileged(context: Context): Boolean = @@ -945,6 +1324,7 @@ internal object PushStore { authMethod = it.getString("authMethod"), credential = it.getString("credential"), project = it.getString("project"), + sessionCookieUrl = if (it.isNull("sessionCookieUrl")) null else it.optString("sessionCookieUrl").ifEmpty { null }, ) } val list = json.getJSONArray("entries") @@ -954,6 +1334,7 @@ internal object PushStore { filter = entry.getString("filter"), retry = entry.getBoolean("retry"), title = if (entry.isNull("title")) null else entry.getString("title"), + notifyInForeground = entry.optBoolean("notifyInForeground", false), ) } config to entries @@ -973,11 +1354,12 @@ internal object PushStore { .put("keepAlive", config.keepAlive) .put("authMethod", config.authMethod) .put("credential", config.credential) - .put("project", config.project), + .put("project", config.project) + .put("sessionCookieUrl", config.sessionCookieUrl ?: JSONObject.NULL), ) .put( "entries", - JSONArray(entries.map { JSONObject().put("filter", it.filter).put("retry", it.retry).put("title", it.title ?: JSONObject.NULL) }), + JSONArray(entries.map { JSONObject().put("filter", it.filter).put("retry", it.retry).put("title", it.title ?: JSONObject.NULL).put("notifyInForeground", it.notifyInForeground) }), ) write(context, STATE_FILE, json) } diff --git a/library/src/main/java/io/appwrite/services/PushCore.kt b/library/src/main/java/io/appwrite/services/PushCore.kt index 20c5624..15f86b6 100644 --- a/library/src/main/java/io/appwrite/services/PushCore.kt +++ b/library/src/main/java/io/appwrite/services/PushCore.kt @@ -12,6 +12,7 @@ import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientConfig import com.hivemq.client.mqtt.mqtt5.auth.Mqtt5EnhancedAuthMechanism import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5DisconnectException +import com.hivemq.client.mqtt.mqtt5.lifecycle.Mqtt5ClientReconnector import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5Auth import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5AuthBuilder import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5EnhancedAuthBuilder @@ -30,6 +31,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import javax.net.ssl.ManagerFactoryParameters import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory @@ -49,6 +51,24 @@ data class PushMessage( get() = String(payload, Charsets.UTF_8) } +/** + * What background delivery can rely on. Without exact alarms, the scheduled wake-ups are inexact + * and Doze can defer them, so delivery is [bestEffort] unless foreground mode keeps the + * connection open. + */ +data class PushBackgroundStatus( + /** The app may schedule exact alarms (`SCHEDULE_EXACT_ALARM`, granted). */ + val exactAlarms: Boolean, + /** The app is exempt from battery optimisation. */ + val ignoringBatteryOptimizations: Boolean, + /** Foreground mode (`setForeground(true)`) keeps the connection open in a service. */ + val foregroundService: Boolean, +) { + /** Wake-ups may be deferred by Doze, so messages can arrive late while the app is closed. */ + val bestEffort: Boolean + get() = !exactAlarms && !foregroundService +} + /** Connection parameters shared between the in-process client and the foreground service. */ internal data class PushConfig( val host: String, @@ -60,6 +80,10 @@ internal data class PushConfig( val authMethod: String, val credential: String, val project: String, + // The endpoint whose `a_session_` cookie in the WebView cookie store holds the app's + // session, when its credential comes from there (React Native). Background runs read it, so + // they keep up with a rotated session while no app code runs. + val sessionCookieUrl: String? = null, ) /** @@ -110,6 +134,8 @@ internal fun buildPushClient( onDisconnected: (() -> Unit)? = null, onError: ((Throwable) -> Unit)? = null, onAuthRefused: ((Throwable) -> Unit)? = null, + credential: () -> String? = { config.credential }, + retired: AtomicBoolean? = null, ): Mqtt5AsyncClient { // config.clientId is stable per user (see buildConfig), so a reconnect rebuild reuses it // and resumes the same session. @@ -130,11 +156,25 @@ internal fun buildPushClient( // Whether this client has connected yet: until it has, a refused CONNECT is final. val connected = AtomicBoolean(false) + val client = AtomicReference() builder = builder.addConnectedListener { + // A reconnect that was already under way when the client was retired disconnects at once, + // so it never competes with its replacement for the broker session. + if (retired?.get() == true) { + client.get()?.disconnect() + return@addConnectedListener + } connected.set(true) onConnected?.invoke() } builder = builder.addDisconnectedListener { context -> + // A client its owner replaced or closed ([retired]) never reconnects: one still retrying + // in the background would otherwise come back as a second connection for the same id. + if (retired?.get() == true) { + context.reconnector.reconnect(false) + onDisconnected?.invoke() + return@addDisconnectedListener + } // A refused first CONNECT (bad credential, rate limit) is final: stop the automatic // reconnect so pushConnect fails with the broker's reason instead of retrying forever. val refused = !connected.get() && context.cause is Mqtt5ConnAckException @@ -142,8 +182,16 @@ internal fun buildPushClient( // re-auth) is final too, instead of being retried with the same credential forever. val authRefused = onAuthRefused != null && connected.get() && context.source != MqttDisconnectSource.USER && isAuthRefusal(context.cause) - if (refused || authRefused) { + // Authentication was aborted because the app has no current credential (signed out): there + // is nothing to reconnect with, so the attempt fails now instead of retrying until timeout. + val noCredential = generateSequence(context.cause) { it.cause }.any { it is PushNoCredentialException } + if (refused || authRefused || noCredential) { context.reconnector.reconnect(false) + } else if (context.reconnector.isReconnect) { + // HiveMQ's automatic reconnect sends a CONNECT it builds itself, without user + // properties: the broker then misses the project and refuses the credential. Reconnect + // with the same CONNECT this client connects with instead. + (context.reconnector as? Mqtt5ClientReconnector)?.connect(pushConnectMessage(config, credential)) } onDisconnected?.invoke() if (!refused) { @@ -168,7 +216,7 @@ internal fun buildPushClient( } } - return builder.buildAsync() + return builder.buildAsync().also { client.set(it) } } /** @@ -207,10 +255,31 @@ internal fun isAuthRefusal(error: Throwable?): Boolean = when (error) { else -> false } -/** Send the CONNECT and wait for the CONNACK, at most [timeoutSeconds] when given. */ -internal fun Mqtt5AsyncClient.sendPushConnect(config: PushConfig, timeoutSeconds: Long? = null) { - val connAck = connectWith() - .enhancedAuth(PushAuthMechanism(config.authMethod, config.credential)) +/** + * Send the CONNECT and wait for the CONNACK, at most [timeoutSeconds] when given. [credential] + * supplies the credential each time one is sent, including on the client's automatic reconnects + * and re-auths, so they can use a session rotated since this CONNECT; null aborts the attempt. + */ +internal fun Mqtt5AsyncClient.sendPushConnect( + config: PushConfig, + timeoutSeconds: Long? = null, + credential: () -> String? = { config.credential }, +) { + val connAck = connect(pushConnectMessage(config, credential)) + if (timeoutSeconds == null) { + connAck.get() + } else { + connAck.get(timeoutSeconds, TimeUnit.SECONDS) + } +} + +/** + * The CONNECT for [config]: enhanced auth with the credential from [credential], and the project + * as a user property, which the broker resolves the credential against. + */ +internal fun pushConnectMessage(config: PushConfig, credential: () -> String?): Mqtt5Connect = + Mqtt5Connect.builder() + .enhancedAuth(PushAuthMechanism(config.authMethod, config.project, credential)) // Clean start is always off, so the broker keeps this client's session and can // redeliver missed messages to QoS-1 subscriptions on reconnect. .cleanStart(false) @@ -218,13 +287,7 @@ internal fun Mqtt5AsyncClient.sendPushConnect(config: PushConfig, timeoutSeconds .userProperties() .add("projectId", config.project) .applyUserProperties() - .send() - if (timeoutSeconds == null) { - connAck.get() - } else { - connAck.get(timeoutSeconds, TimeUnit.SECONDS) - } -} + .build() /** * The error a lost connection reports, or null when it is not an error (a disconnect the app @@ -266,11 +329,14 @@ internal fun Mqtt5Publish.toPushMessage(): PushMessage = /** * Single-step MQTT 5 enhanced auth: the method and credential are placed in the CONNECT - * packet and the broker accepts them in the CONNACK, with no AUTH round-trip. + * packet and the broker accepts them in the CONNACK, with no AUTH round-trip. The credential is + * read from [credential] each time, so an automatic reconnect or re-auth sends the current one; + * when it has none, the attempt fails instead of sending a credential known to be stale. */ internal class PushAuthMechanism( private val method: String, - private val credential: String, + private val project: String, + private val credential: () -> String?, ) : Mqtt5EnhancedAuthMechanism { override fun getMethod(): MqttUtf8String = MqttUtf8String.of(method) @@ -282,7 +348,8 @@ internal class PushAuthMechanism( connect: Mqtt5Connect, authBuilder: Mqtt5EnhancedAuthBuilder, ): CompletableFuture { - authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + val current = credential() ?: return failedAuth() + authBuilder.data(current.toByteArray(Charsets.UTF_8)) return CompletableFuture.completedFuture(null) } @@ -290,7 +357,12 @@ internal class PushAuthMechanism( clientConfig: Mqtt5ClientConfig, authBuilder: Mqtt5AuthBuilder, ): CompletableFuture { - authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + val current = credential() ?: return failedAuth() + // The broker resolves a re-auth against the project in its user properties too. + authBuilder.data(current.toByteArray(Charsets.UTF_8)) + .userProperties() + .add("projectId", project) + .applyUserProperties() return CompletableFuture.completedFuture(null) } @@ -317,8 +389,14 @@ internal class PushAuthMechanism( override fun onAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit override fun onReAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit + + private fun failedAuth(): CompletableFuture = + CompletableFuture().apply { completeExceptionally(PushNoCredentialException()) } } +/** Authentication aborted because there is no current credential to send (the app signed out). */ +internal class PushNoCredentialException : IllegalStateException("No current credential to authenticate with") + /** * A [TrustManagerFactory] that accepts any certificate — used when `tlsInsecure` is set * (for a broker whose certificate does not chain to a public root). @@ -353,6 +431,8 @@ internal class PushListener( // Called instead of [callback] when set, with the acknowledgement to run once the message was // handled: the React Native and Flutter bridges acknowledge after their callback has run. val acknowledgingCallback: ((PushMessage, () -> Unit) -> Unit)? = null, + // Also post a background notification while the app is visible. + val notifyInForeground: Boolean = false, ) /** MQTT topic-filter match with '+' (single level) and '#' (multi level). */ diff --git a/library/src/main/java/io/appwrite/services/PushOpenActivity.kt b/library/src/main/java/io/appwrite/services/PushOpenActivity.kt new file mode 100644 index 0000000..1be7a7a --- /dev/null +++ b/library/src/main/java/io/appwrite/services/PushOpenActivity.kt @@ -0,0 +1,56 @@ +package io.appwrite.services + +import android.app.Activity +import android.content.Intent +import android.os.Bundle + +class PushOpenActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val topic = intent.getStringExtra(PushBackground.EXTRA_TOPIC) + val payload = intent.getStringExtra(PushBackground.EXTRA_PAYLOAD) + if (topic != null && payload != null) { + PushTaps.record(PushTap(topic, payload)) + } + packageManager.getLaunchIntentForPackage(packageName)?.let { launch -> + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + if (topic != null && payload != null) { + launch.putExtra(PushBackground.EXTRA_TOPIC, topic).putExtra(PushBackground.EXTRA_PAYLOAD, payload) + } + startActivity(launch) + } + finish() + } +} + +internal data class PushTap(val topic: String, val payload: String) + +internal object PushTaps { + private var pending: PushTap? = null + private val listeners = mutableListOf<(PushTap) -> Unit>() + + fun record(tap: PushTap) { + val deliver = synchronized(this) { + listeners.toList().also { + if (it.isEmpty()) { + pending = tap + } + } + } + deliver.forEach { it(tap) } + } + + @Synchronized + fun take(): PushTap? = pending.also { pending = null } + + fun listen(onTap: (PushTap) -> Unit): () -> Unit { + synchronized(this) { + listeners.add(onTap) + } + return { + synchronized(this) { + listeners.remove(onTap) + } + } + } +} diff --git a/library/src/main/java/io/appwrite/services/PushWakeups.kt b/library/src/main/java/io/appwrite/services/PushWakeups.kt index 091fd52..c6d5616 100644 --- a/library/src/main/java/io/appwrite/services/PushWakeups.kt +++ b/library/src/main/java/io/appwrite/services/PushWakeups.kt @@ -12,11 +12,15 @@ import android.content.Intent */ class PushJobService : JobService() { override fun onStartJob(params: JobParameters): Boolean { - PushBackground.tick(this) { jobFinished(params, false) } + // A chained job arms the next under the other chained id; the watchdog and an expedited + // run (handed over by the alarm) only run once. Each stays up while missed messages arrive. + val chained = params.jobId.takeIf { it == PushBackground.JOB_ID || it == PushBackground.NEXT_JOB_ID } + PushBackground.tick(this, chained, PushBackground.JOB_DRAIN_MS) { jobFinished(params, false) } return true } - // The run re-arms the next one itself, so a stopped run needs no retry. + // The run re-arms the next one itself, and the watchdog repeats on its own, so a stopped run + // needs no retry. override fun onStopJob(params: JobParameters): Boolean = false } @@ -29,8 +33,13 @@ class PushAlarmReceiver : BroadcastReceiver() { if (intent.action != PushBackground.ACTION_TICK) { return } + // On Android 12+ the run goes to an expedited job, which keeps the process runnable while it + // connects and the broker replays; it runs here when the system refuses one. + if (PushBackground.runExpedited(context)) { + return + } val pending = goAsync() - PushBackground.tick(context) { pending.finish() } + PushBackground.tick(context, drainMs = PushBackground.RECEIVER_DRAIN_MS) { pending.finish() } } } From ad2d756f697b54e9c26e41ee4a1ebbd750f99375 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 7 Oct 2026 10:40:51 +0000 Subject: [PATCH 4/4] chore: update Android SDK to 29.1.0-rc.3 --- CHANGELOG.md | 18 + README.md | 68 ++- library/src/main/AndroidManifest.xml | 7 + library/src/main/java/io/appwrite/Client.kt | 2 +- .../main/java/io/appwrite/services/Push.kt | 335 ++++++++++-- .../io/appwrite/services/PushBackground.kt | 482 ++++++++++++++++-- .../java/io/appwrite/services/PushCore.kt | 114 ++++- .../io/appwrite/services/PushOpenActivity.kt | 57 +++ .../java/io/appwrite/services/PushWakeups.kt | 15 +- 9 files changed, 985 insertions(+), 113 deletions(-) create mode 100644 library/src/main/java/io/appwrite/services/PushOpenActivity.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ccff23c..21f1b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Change Log +## 29.1.0-rc.3 + +* Added: first background subscription auto-requests `POST_NOTIFICATIONS` (Android 13+) from the visible activity +* Fixed: `onOpen`/`onClose` callbacks fire correctly when joining or leaving the background host +* Fixed: notification taps are recorded after launching the app, so tap-opened screens land on top + +## 29.1.0-rc.2 + +* Added: `getInitialNotification()`, `onNotificationOpened()`, and the `PushNotificationOpened` data class (topic + data) +* Added: `onOpen`/`onClose` callbacks now fire for background push connections +* Added: push auth falls back to the client's cookie-store session +* Fixed: a foreground message with no live callback now still posts a notification +* Updated: notification taps route through an internal `PushOpenActivity` + +## 29.1.0-rc.1 + +* Updated: regenerated with sdk-generator 5.5.0; no SDK code changes + ## 29.1.0-rc.0 * Added: optional `method` param on `Client.chunkedUpload()` to set the upload HTTP method diff --git a/README.md b/README.md index bb53402..afcc18e 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ repositories { Next, add the dependency to your project's `build.gradle(.kts)` file: ```groovy -implementation("io.appwrite:sdk-for-android:29.1.0-rc.0") +implementation("io.appwrite:sdk-for-android:29.1.0-rc.3") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 29.1.0-rc.0 + 29.1.0-rc.3 ``` @@ -77,19 +77,54 @@ app every 15 to 60 seconds to reconnect, and the broker replays what was sent in (`retry = true`). It reconnects with the credential saved at subscribe time, so use a session (`client.setSession(...)`), not a short-lived JWT. -When no in-app callback is listening, each message is posted as a notification that opens +While the app is not on screen, each message is also posted as a notification that opens your launch activity with `Push.EXTRA_TOPIC` and `Push.EXTRA_PAYLOAD` in its extras. It shows the title, body and image sent with `createPush`, falling back to the subscription's `title` and the raw payload for other messages. On Android 13 and later it needs the `POST_NOTIFICATIONS` -permission: call `Push.requestNotificationPermission(activity)` before subscribing. Set its +permission: the first background subscription asks for it from the visible activity (the one +`Push` is created with, else the next one to resume), or call +`Push.requestNotificationPermission(activity)` to ask yourself. Set its icon with `` -in your ``. To handle these messages in code, subclass `PushReceiver` and declare +in your ``. While the app is on screen your callback shows the message, so no +notification is posted unless the subscription passes `notifyInForeground = true`. To handle +messages that arrive while no callback is listening in code, subclass `PushReceiver` and declare it with the `io.appwrite.push.MESSAGE` action (see its docs). -Doze delays the scheduled runs. They are more punctual when your app may schedule exact -alarms (`SCHEDULE_EXACT_ALARM`) or is exempt from battery optimisation; both are your app's -choice and subject to Google Play policy, and the SDK uses them when granted. For immediate delivery even after a kill and -during Doze, call `push.setForeground(true)` from the foreground: it adds a foreground service +#### Delivery while the app is closed + +Messages sent while the app is closed arrive at the next scheduled wake-up. While the device is +awake that is about every 15 seconds, or about every 60 seconds once exact alarms are allowed; +without exact alarms the wake-ups are inexact, so battery saver can defer them further. In Doze +(screen off and idle for a while) Android limits background alarms, exact ones included, to about +one every nine minutes, so a closed app can take several minutes to receive a message: allowing +exact alarms makes wake-ups punctual, it does not lift Doze. For immediate delivery, also in +Doze, use `push.setForeground(true)` (see below). + +The SDK uses exact alarms on its own whenever the app may schedule them. To allow it: + +1. Declare the permissions in your app's `AndroidManifest.xml`. Both are optional and subject to + Google Play policy: `SCHEDULE_EXACT_ALARM` needs a declaration in the Play Console, and + `USE_EXACT_ALARM` is reserved for alarm, clock and calendar apps (the SDK does not use it). + + ```xml + + + ``` + +2. On Android 13 and later the user has to allow exact alarms, under Settings > Apps > Special app + access > Alarms & reminders. Android 12 grants a declared `SCHEDULE_EXACT_ALARM` + automatically, and older versions need nothing. Check with `Push.backgroundStatus(context)`: when `bestEffort` is + true, explain why to the user, then from a user action open that screen with `Push.requestExactAlarms(context)`, or + ask for the battery-optimisation exemption with `Push.requestIgnoreBatteryOptimizations(context)`. Both return false when there is + nothing to ask, including when the permission is not declared. The SDK never opens these + screens on its own. + +If the user force-stops the app (Settings > Force stop, and on some devices swiping it away from +recents), Android cancels its alarms and jobs: nothing is delivered until the app is opened +again, and the broker then replays what was sent meanwhile. + +For immediate delivery even after a kill and during Doze, call `push.setForeground(true)` from +the foreground: it adds a foreground service with a quiet ongoing notification. If you never enable it, you can remove the service from your merged manifest, so the Play Console does not ask you to declare it: @@ -98,6 +133,21 @@ your merged manifest, so the Play Console does not ask you to declare it: ``` +#### Opening a tapped notification + +Read the `data` sent with `createPush` when the user taps a background notification: + +```kotlin +// The tap that launched the app (reported once, so call it at startup). +push.getInitialNotification()?.let { openSale(it.data["saleId"]) } + +// Taps while the app is running, including in the background. +val stop = push.onNotificationOpened { opened -> openSale(opened.data["saleId"]) } +``` + +A tap made while nothing listens is kept for `getInitialNotification()`. The tap intent still +carries `Push.EXTRA_TOPIC` and `Push.EXTRA_PAYLOAD` for apps that read their activity's intent. + ## Getting Started diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml index 44f894e..d2b7d50 100644 --- a/library/src/main/AndroidManifest.xml +++ b/library/src/main/AndroidManifest.xml @@ -16,6 +16,13 @@ + , +) { + internal companion object { + fun of(tap: PushTap): PushNotificationOpened { + val data = try { + JSONObject(tap.payload).optJSONObject("data")?.toMap() + } catch (e: JSONException) { + null + } + return PushNotificationOpened(tap.topic, data ?: emptyMap()) + } + + private fun JSONObject.toMap(): Map = keys().asSequence().associateWith { jsonValue(get(it)) } + + private fun jsonValue(value: Any?): Any? = when (value) { + JSONObject.NULL -> null + is JSONObject -> value.toMap() + is JSONArray -> (0 until value.length()).map { jsonValue(value.get(it)) } + else -> value + } + } +} + /** * Handle for a live subscription created with [Push.subscribe]. * @@ -32,19 +68,19 @@ private const val LOG_TAG = "AppwritePush" */ class PushSubscription internal constructor( private val unsubscribeAction: () -> Unit, - private val updateAction: (Boolean?, String?, Boolean?) -> Unit, + private val updateAction: (Boolean?, String?, Boolean?, Boolean?) -> Unit, ) { /** Drop this subscription. Closes the connection once the last one is gone. */ fun unsubscribe() = unsubscribeAction() /** - * Live-update this subscription's background delivery, notification title and/or - * `retry` (QoS). Only the arguments you pass change; the rest stay as they + * Live-update this subscription's background delivery, notification title, `retry` (QoS) + * and/or `notifyInForeground`. Only the arguments you pass change; the rest stay as they * were. Changing `retry` re-subscribes this subscription's topics at the new QoS. */ @JvmOverloads - fun update(background: Boolean? = null, title: String? = null, retry: Boolean? = null) = - updateAction(background, title, retry) + fun update(background: Boolean? = null, title: String? = null, retry: Boolean? = null, notifyInForeground: Boolean? = null) = + updateAction(background, title, retry, notifyInForeground) } /** @@ -65,11 +101,12 @@ class PushSubscription internal constructor( * share its connection. * * Background delivery reconnects with the credential saved at subscribe time, so use a session - * (`Client.setSession`) for it: a JWT expires after minutes. A refused credential stops + * (the app's sign-in session, or `Client.setSession`) for it: a JWT expires after minutes. A refused credential stops * background delivery and reaches [onError], also when it was registered later. * - * The credential is read off the client — set a JWT or session on it - * (`Client.setJWT` / `Client.setSession`), the same way every other service reads auth. + * The credential is read off the client: a JWT or session set on it (`Client.setJWT` / + * `Client.setSession`), else the session the app signed in with through it (e.g. + * `Account.createEmailPasswordSession`). * * The connection always keeps its session (clean start off) so the broker can redeliver * missed messages, and reliability is chosen per subscription via [subscribe]'s @@ -132,6 +169,9 @@ class Push( @Volatile private var mqtt: Mqtt5AsyncClient? = null + // Set when [mqtt] is replaced or closed, so that client never reconnects in the background. + private var mqttRetired: AtomicBoolean? = null + private var connectedConfig: PushConfig? = null private val connectLock = Any() @@ -153,8 +193,11 @@ class Push( private val appContext: Context = context.applicationContext init { - // Resume saved background delivery now, instead of at the next scheduled run. - PushBackground.resume(appContext) + NotificationPermissionPrompt.track(context) + // Resume saved background delivery now, instead of at the next scheduled run, with the + // credential set on the client: a rotated session of the same user replaces the saved one. + val auth = runCatching { resolvePushAuth(client) }.getOrNull() + PushBackground.resume(appContext, auth?.first, auth?.second) } private var onOpen: (() -> Unit)? = null @@ -167,6 +210,7 @@ class Push( var background: Boolean, var title: String?, var retry: Boolean, + var notifyInForeground: Boolean, ) { @Volatile var client: Mqtt5AsyncClient? = null @@ -227,12 +271,36 @@ class Push( return this } + /** + * The background notification whose tap launched the app, or null. Reported once; call it + * when the app starts, e.g. to open the screen the message is about. + * + * ```kotlin + * push.getInitialNotification()?.let { openSale(it.data["saleId"]) } + * ``` + */ + fun getInitialNotification(): PushNotificationOpened? = PushTaps.take()?.let { PushNotificationOpened.of(it) } + + /** + * Call [callback] on the main thread each time the user taps a background notification while + * the app is running, including in the background. Returns a function that stops listening. + * The tap that launched the app comes from [getInitialNotification] instead, as does a tap + * made while nothing listens. + * + * ```kotlin + * val stop = push.onNotificationOpened { openSale(it.data["saleId"]) } + * ``` + */ + fun onNotificationOpened(callback: (PushNotificationOpened) -> Unit): () -> Unit = + PushTaps.listen { callback(PushNotificationOpened.of(it)) } + /** * Subscribe to the signed-in user's own topic, `users/` — "messages for me" * without knowing any topic, e.g. `push.subscribe { message -> ... }`. * - * The user id is read locally from the credential set on the client: the `userId` claim of - * the JWT (`Client.setJWT`), else the `id` in the session secret (`Client.setSession`). + * The user id is read locally from the client's credential: the `userId` claim of the JWT + * (`Client.setJWT`), else the `id` in the session (`Client.setSession`, or the session the app + * signed in with through the client). * Throws when neither yields one. Like FCM/APNs delivery, it defaults to [background] `true` * (keeps delivering while the app is backgrounded or closed) and [retry] * `true` (QoS 1); pass either to override. Otherwise the options and the returned @@ -244,17 +312,18 @@ class Push( background: Boolean = true, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription { val userId = reporting { resolvePushUserId(client) } - return subscribe(Topic.path(listOf("users", userId)), background, title, retry, callback) + return subscribe(Topic.path(listOf("users", userId)), background, title, retry, notifyInForeground, callback) } /** * Subscribe to a single topic. Returns a [PushSubscription] handle. * - * Request the `POST_NOTIFICATIONS` runtime permission with [requestNotificationPermission] - * before subscribing with `background = true` on Android 13+. + * On Android 13+ the first `background = true` subscription asks for the + * `POST_NOTIFICATIONS` runtime permission; see [requestNotificationPermission]. */ @JvmOverloads fun subscribe( @@ -262,9 +331,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic), background, title, retry, callback) + subscribe(listOf(topic), background, title, retry, notifyInForeground, callback) /** * Subscribe to a single topic built with [io.appwrite.Topic], e.g. @@ -277,9 +347,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic.toString()), background, title, retry, callback) + subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) /** * Subscribe to a single topic ended with `all()`, e.g. @@ -292,9 +363,10 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = - subscribe(listOf(topic.toString()), background, title, retry, callback) + subscribe(listOf(topic.toString()), background, title, retry, notifyInForeground, callback) /** * Subscribe to a list mixing raw strings and topics built with [io.appwrite.Topic], e.g. @@ -310,6 +382,7 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription { val filters = topics.mapIndexed { index, topic -> @@ -318,7 +391,7 @@ class Push( } topic.toString() } - return subscribe(filters, background, title, retry, callback) + return subscribe(filters, background, title, retry, notifyInForeground, callback) } /** @@ -334,7 +407,8 @@ class Push( * * Set [background] to keep receiving while the app is backgrounded or closed, also after the * process is killed or the device restarts, and post a notification per matching message - * with [title] (defaulting to the message topic); see the class docs. [retry] chooses this + * with [title] (defaulting to the message topic) while the app is not on screen; set + * [notifyInForeground] to also post one while it is. See the class docs. [retry] chooses this * subscription's QoS * (`true` -> QoS 1 default, `false` -> QoS 0). Toggle any of these later with * [PushSubscription.update]. @@ -351,13 +425,14 @@ class Push( background: Boolean = false, title: String? = null, retry: Boolean = true, + notifyInForeground: Boolean = false, callback: (PushMessage) -> Unit, ): PushSubscription = reporting { val ids = mutableListOf() for (topic in topics) { val id = UUID.randomUUID().toString() ids.add(id) - subscriptions[id] = Sub(topic, callback, background, title, retry) + subscriptions[id] = Sub(topic, callback, background, title, retry, notifyInForeground) } // If any SUBSCRIBE fails, roll this call's registrations back and return to a @@ -382,7 +457,7 @@ class Push( PushSubscription( { reporting { unsubscribeIds(ids) } }, - { bg, t, rd -> reporting { updateIds(ids, bg, t, rd) } }, + { bg, t, rd, nf -> reporting { updateIds(ids, bg, t, rd, nf) } }, ) } @@ -463,15 +538,51 @@ class Push( } } + // Close the in-process client for good: retired first, so one still reconnecting in the + // background does not come back as a second connection for the same client id. + private fun dropClient() { + mqttRetired?.set(true) + mqttRetired = null + mqtt?.disconnect() + mqtt = null + } + + // Whether this Push reported the background host's connection open. + @Volatile + private var backgroundOpen = false + + // The background host's connection opened or closed: report it once per change. + private fun backgroundConnection(open: Boolean) { + if (backgroundOpen == open) { + return + } + backgroundOpen = open + try { + if (open) onOpen?.invoke() else onClose?.invoke() + } catch (e: Exception) { + Log.e(LOG_TAG, "Push connection callback threw", e) + } + } + + // Stop listening to the background host, reporting its connection closing when this Push + // reported it open, as the in-process connection reports its own close. + private fun leaveBackgroundHost() { + PushBackground.connectionHandler = null + backgroundConnection(false) + } + // Relocate the connection into the background host and sync it with the current // subscription set (filters, per-sub callbacks/titles), saving the background ones. private fun hostInBackground() { - if (mqtt != null) { - mqtt?.disconnect() - mqtt = null - } + NotificationPermissionPrompt.ask(appContext) + dropClient() subscriptions.values.forEach { it.client = null } PushBackground.errorHandler = { deliverBackgroundError(it) } + PushBackground.connectionHandler = { open -> backgroundConnection(open) } + // Joining a connection a background run already opened: report it open now. + if (PushBackground.isConnected) { + backgroundConnection(true) + } PushBackground.host(appContext, buildConfig(), currentListeners()) backgroundActive = true } @@ -525,6 +636,7 @@ class Push( } else if (backgroundActive) { // Relocate back in-process. backgroundActive = false + leaveBackgroundHost() PushBackground.release(appContext) ensureInProcessSubscribed() } @@ -563,7 +675,7 @@ class Push( } } - private fun updateIds(ids: List, background: Boolean?, title: String?, retry: Boolean?) { + private fun updateIds(ids: List, background: Boolean?, title: String?, retry: Boolean?, notifyInForeground: Boolean?) { val changedFilters = mutableSetOf() for (id in ids) { val sub = subscriptions[id] ?: continue @@ -573,6 +685,9 @@ class Push( if (title != null) { sub.title = title } + if (notifyInForeground != null) { + sub.notifyInForeground = notifyInForeground + } if (retry != null && retry != sub.retry) { sub.retry = retry changedFilters.add(sub.filter) @@ -597,28 +712,30 @@ class Push( subscriptions.values.any { it.background } || PushBackground.hasSaved(appContext) private fun currentListeners(): List = - subscriptions.values.map { PushListener(setOf(it.filter), it.callback, it.background, it.title, it.retry) } + subscriptions.values.map { + PushListener(setOf(it.filter), it.callback, it.background, it.title, it.retry, notifyInForeground = it.notifyInForeground) + } /** * Tear down the connection and drop all subscriptions, including the background ones saved * by an earlier run, and stop background delivery. Call it on sign-out. */ fun close() { - mqtt?.disconnect() - mqtt = null + dropClient() subscriptions.clear() backgroundActive = false + leaveBackgroundHost() PushBackground.stop(appContext) } // The last live subscription is gone: close the connection, keeping any background // subscriptions saved by an earlier run. private fun releaseAll() { - mqtt?.disconnect() - mqtt = null + dropClient() subscriptions.clear() if (backgroundActive) { backgroundActive = false + leaveBackgroundHost() PushBackground.release(appContext) } } @@ -633,25 +750,28 @@ class Push( return existing } connectionEpoch++ - existing.disconnect() - mqtt = null + dropClient() } // The listeners surface the lifecycle to onOpen/onClose and every connection error to // onError, except a refused initial CONNECT: pushConnect throws that one, and the // failing call reports it. + val retired = AtomicBoolean(false) val client = buildPushClient( config, onConnected = { onOpen?.invoke() }, onDisconnected = { onClose?.invoke() }, onError = { reportAsync(it) }, + retired = retired, ) mqtt = client + mqttRetired = retired connectedConfig = config try { client.pushConnect(config) } catch (e: Exception) { + retired.set(true) client.disconnect() if (mqtt === client) { mqtt = null @@ -708,8 +828,36 @@ class Push( const val EXTRA_PAYLOAD = PushBackground.EXTRA_PAYLOAD /** - * Request the `POST_NOTIFICATIONS` runtime permission (Android 13+). Call from an - * Activity before subscribing with `background = true`; on older versions it is a no-op. + * What background delivery can rely on: exact alarms, battery-optimisation exemption and + * foreground mode. When [PushBackgroundStatus.bestEffort] is true, wake-ups are inexact and + * messages can arrive late while the app is closed; explain why before asking with + * [requestExactAlarms] or [requestIgnoreBatteryOptimizations]. + */ + @JvmStatic + fun backgroundStatus(context: Context): PushBackgroundStatus = PushBackground.backgroundStatus(context) + + /** + * Open the system screen where the user allows exact alarms (Android 12+), for punctual + * background wake-ups. Call it from a user action. Returns false when there is nothing to + * ask: already allowed, older Android, or the app does not declare `SCHEDULE_EXACT_ALARM`. + */ + @JvmStatic + fun requestExactAlarms(context: Context): Boolean = PushBackground.requestExactAlarms(context) + + /** + * Ask the user to exempt the app from battery optimisation. Call it from a user action. + * Returns false when there is nothing to ask: already exempt, or the app does not declare + * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. + */ + @JvmStatic + fun requestIgnoreBatteryOptimizations(context: Context): Boolean = + PushBackground.requestIgnoreBatteryOptimizations(context) + + /** + * Request the `POST_NOTIFICATIONS` runtime permission (Android 13+) from [activity]. The + * first `background = true` subscription asks on its own, from the visible activity (the + * one `Push` was created with, else the next one to resume); call this to ask at a moment + * of your choosing instead. On older versions it is a no-op. */ @JvmStatic @JvmOverloads @@ -730,15 +878,31 @@ class Push( } } -/** Read the credential set on the client (via Client.setJWT / setSession). */ +/** + * The session set on the client (Client.setSession), else the one the app signed in with through it + * (e.g. Account.createEmailPasswordSession), read from its cookie store. + */ +internal fun pushSession(client: Client): String? { + client.config["session"]?.takeIf { it.isNotEmpty() }?.let { return it } + val name = "a_session_${client.config["project"] ?: ""}" + return try { + client.getCookies(client.endpoint) + .firstOrNull { it.name == name && it.value.isNotEmpty() } + ?.let { Uri.decode(it.value) } + } catch (e: IllegalArgumentException) { + null + } +} + +/** Read the credential set on the client (via Client.setJWT / setSession), else its sign-in session. */ internal fun resolvePushAuth(client: Client): Pair { val jwt = client.config["jwt"] ?: client.config["jWT"] - val session = client.config["session"] + val session = pushSession(client) return when { !jwt.isNullOrEmpty() -> "appwrite-jwt" to jwt !session.isNullOrEmpty() -> "appwrite-session" to session else -> throw AppwriteException( - "No credential set on the client; call Client.setJWT() or Client.setSession() first.", + "No credential: sign in with this client, or call Client.setJWT() or Client.setSession() first.", ) } } @@ -750,7 +914,7 @@ internal fun resolvePushAuth(client: Client): Pair { */ internal fun resolvePushUserId(client: Client): String { val jwt = client.config["jwt"] ?: client.config["jWT"] - val session = client.config["session"] + val session = pushSession(client) val userId = when { !jwt.isNullOrEmpty() -> userIdFromJwt(jwt) !session.isNullOrEmpty() -> userIdFromSession(session) @@ -758,8 +922,97 @@ internal fun resolvePushUserId(client: Client): String { } if (userId.isEmpty()) { throw AppwriteException( - "subscribe() without a topic needs a signed-in user: set a JWT or session on the client", + "subscribe() without a topic needs a signed-in user: sign in with this client, or set a JWT or session on it", ) } return userId } + +// Asks once per process for POST_NOTIFICATIONS (Android 13+) when background delivery starts, from +// the app's visible activity, or from the next one to resume when none is. An activity Push is +// created with counts as visible, since it resumed before the lifecycle callbacks were registered. +private object NotificationPermissionPrompt { + private const val REQUEST_CODE = 9412 + + private var tracking = false + private var asked = false + private var waiting = false + private var visible: WeakReference? = null + + fun track(context: Context) { + if (context is Activity && !context.isFinishing) { + val ask = synchronized(this) { + visible = WeakReference(context) + waiting.also { waiting = false } + } + if (ask) { + context.runOnUiThread { request(context) } + } + } + val app = context.applicationContext as? Application ?: return + synchronized(this) { + if (tracking) { + return + } + tracking = true + } + app.registerActivityLifecycleCallbacks( + object : Application.ActivityLifecycleCallbacks { + override fun onActivityResumed(activity: Activity) { + val ask = synchronized(this@NotificationPermissionPrompt) { + visible = WeakReference(activity) + waiting.also { waiting = false } + } + if (ask) { + request(activity) + } + } + + override fun onActivityPaused(activity: Activity) { + synchronized(this@NotificationPermissionPrompt) { + if (visible?.get() === activity) { + visible = null + } + } + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + + override fun onActivityStarted(activity: Activity) = Unit + + override fun onActivityStopped(activity: Activity) = Unit + + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + + override fun onActivityDestroyed(activity: Activity) = Unit + }, + ) + } + + fun ask(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || granted(context)) { + return + } + val activity = synchronized(this) { + if (asked) { + return + } + asked = true + visible?.get().also { + if (it == null) { + waiting = true + } + } + } ?: return + activity.runOnUiThread { request(activity) } + } + + private fun granted(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED + + private fun request(activity: Activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !granted(activity)) { + ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_CODE) + } + } +} diff --git a/library/src/main/java/io/appwrite/services/PushBackground.kt b/library/src/main/java/io/appwrite/services/PushBackground.kt index a550a55..e06dfab 100644 --- a/library/src/main/java/io/appwrite/services/PushBackground.kt +++ b/library/src/main/java/io/appwrite/services/PushBackground.kt @@ -1,5 +1,7 @@ package io.appwrite.services +import android.Manifest +import android.app.ActivityManager import android.app.AlarmManager import android.app.NotificationChannel import android.app.NotificationManager @@ -14,11 +16,14 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.ConnectivityManager import android.net.Network +import android.net.Uri import android.os.Build import android.os.PowerManager import android.os.SystemClock +import android.provider.Settings import android.util.AtomicFile import android.util.Log +import android.webkit.CookieManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -30,8 +35,10 @@ import com.hivemq.client.mqtt.mqtt5.message.publish.Mqtt5Publish import io.appwrite.exceptions.AppwriteException import org.json.JSONArray import org.json.JSONObject +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException +import java.io.InputStream import java.net.HttpURLConnection import java.net.URL import java.util.UUID @@ -53,6 +60,7 @@ internal data class PushEntry( val filter: String, val retry: Boolean, val title: String?, + val notifyInForeground: Boolean = false, ) /** @@ -96,7 +104,11 @@ internal object PushBackground { const val EXTRA_PAYLOAD = "io.appwrite.push.PAYLOAD" private const val SERVICE_CHANNEL_ID = "appwrite-push-service" - private const val JOB_ID = 0x50555348 + const val JOB_ID = 0x50555348 + const val NEXT_JOB_ID = 0x5055534A + const val EXPEDITED_JOB_ID = 0x5055534B + const val WATCHDOG_JOB_ID = 0x50555349 + private const val WATCHDOG_INTERVAL_MS = 15 * 60 * 1_000L private const val INTERVAL_MS = 15_000L private const val PRIVILEGED_INTERVAL_MS = 60_000L private const val RESTART_DELAY_MS = 3_000L @@ -116,6 +128,15 @@ internal object PushBackground { private const val HEARTBEAT_RESET_MS = 3 * 24 * 60 * 60 * 1_000L private const val REQUEST_TIMEOUT_SECONDS = 10L private const val IMAGE_TIMEOUT_MS = 5_000 + + // How long a scheduled run stays up after connecting so the broker can replay what was missed: + // until nothing arrived for DRAIN_QUIET_MS and nothing is being delivered, at most the run's cap. + const val JOB_DRAIN_MS = 15_000L + const val RECEIVER_DRAIN_MS = 5_000L + private const val DRAIN_QUIET_MS = 2_000L + private const val DRAIN_POLL_MS = 200L + private const val IMAGE_MAX_BYTES = 5 * 1024 * 1024 + private const val IMAGE_MAX_PX = 1_024 private const val CONNECT_TIMEOUT_SECONDS = 20L // How long a message waits for a listener that acknowledges it itself (the React Native and @@ -139,6 +160,16 @@ internal object PushBackground { @Volatile var errorHandler: ((Throwable) -> Boolean)? = null + // Tells the Push instance that hosts its subscriptions here when the connection opens (true) + // or closes (false). + @Volatile + var connectionHandler: ((Boolean) -> Unit)? = null + + // Whether the live connection (not a retired one) is open. + @Volatile + var isConnected = false + private set + @Volatile var serviceRunning = false @@ -164,6 +195,15 @@ internal object PushBackground { Thread(runnable, "AppwritePushBackground").apply { isDaemon = true } } private var mqtt: Mqtt5AsyncClient? = null + + // Set when [mqtt] is replaced or closed, so that client stops reconnecting and delivering. + private var mqttRetired: AtomicBoolean? = null + + // Messages being delivered, and when one last arrived or finished, for a run's drain window. + private val delivering = AtomicInteger(0) + + @Volatile + private var lastDelivery = 0L private var connectedConfig: PushConfig? = null private var connectedNetwork: Network? = null private var lastHeartbeat = 0L @@ -264,23 +304,120 @@ internal object PushBackground { } } - /** Resume saved background delivery when the app starts, without waiting for the next run. */ - fun resume(context: Context) { + /** + * Resume saved background delivery when the app starts, without waiting for the next run, with + * the app's current credential. A rotated session of the same user replaces the saved one; a + * different user drops the saved subscriptions, as does no credential when + * [signedOutWhenMissing] (the caller read the session store, so none means signed out). + * While a live Push hosts subscriptions here, it does nothing: that Push owns the connection. + */ + fun resume(context: Context, authMethod: String? = null, credential: String? = null, signedOutWhenMissing: Boolean = false) { val app = context.applicationContext - val active = synchronized(lock) { + val resume = synchronized(lock) { load(app) - isActive() + val current = config + when { + !isActive() || current == null -> null + // A live Push hosts here and owns the connection and its credential; resuming only + // reconciles saved delivery, so it leaves a live host alone. + listeners.isNotEmpty() -> null + // No credential: signed out when the caller can tell, else possibly not set yet. + authMethod == null || credential == null -> if (signedOutWhenMissing) false else null + current.authMethod == authMethod && current.credential == credential -> true + // A new credential for the same user (a rotated session) replaces the saved one, + // so the stale one is never sent and refused. + sameUser(current.authMethod, current.credential, authMethod, credential) -> { + config = current.copy(authMethod = authMethod, credential = credential) + persist(app) + true + } + // Another user signed in: the saved subscriptions were not theirs. + else -> false + } } - if (active) { - tick(app) {} + when (resume) { + true -> tick(app) {} + false -> stop(app) + null -> Unit } } + private enum class CredentialRefresh { UNCHANGED, CHANGED, GONE } + + // Bring the saved credential up to date with the session cookie the app + // signs in with, when it comes from one (see [PushConfig.sessionCookieUrl]): a rotated session of + // the same user replaces it, and no session or another user's is GONE. UNCHANGED when there is + // no cookie source or it cannot be read. + private fun refreshCredential(context: Context): CredentialRefresh { + val current = synchronized(lock) { config } ?: return CredentialRefresh.UNCHANGED + val url = current.sessionCookieUrl ?: return CredentialRefresh.UNCHANGED + val session = readSessionCookie(url, current.project) ?: return CredentialRefresh.UNCHANGED + return when { + session.isEmpty() -> CredentialRefresh.GONE + current.authMethod == "appwrite-session" && session == current.credential -> CredentialRefresh.UNCHANGED + sameUser(current.authMethod, current.credential, "appwrite-session", session) -> { + synchronized(lock) { + config = current.copy(authMethod = "appwrite-session", credential = session) + persist(context.applicationContext) + } + CredentialRefresh.CHANGED + } + else -> CredentialRefresh.GONE + } + } + + // The credential to send for a connection made with [connected]: the session re-read from the + // cookie store when one backs it (so an automatic reconnect uses a rotated session), else the + // one it connected with. Only a newer credential of the same user, for the same connection + // (broker, client id, project), is borrowed: a connection being replaced by [host] keeps its + // own. Null when the app signed out or another user signed in: authentication is aborted and + // delivery stops. + private fun currentCredential(context: Context, connected: PushConfig): String? { + if (refreshCredential(context) == CredentialRefresh.GONE) { + worker.execute { stop(context) } + return null + } + val current = synchronized(lock) { config } ?: return connected.credential + val sameConnection = current.host == connected.host && current.port == connected.port && + current.clientId == connected.clientId && current.project == connected.project && + current.authMethod == connected.authMethod + return if (sameConnection && sameUser(connected.authMethod, connected.credential, current.authMethod, current.credential)) { + current.credential + } else { + connected.credential + } + } + + // The `a_session_` cookie for [url] in the WebView cookie store, decoded; "" when there + // is none, and null when the store cannot be read. + private fun readSessionCookie(url: String, project: String): String? = runCatching { + val name = "a_session_$project" + CookieManager.getInstance().getCookie(url) + ?.split(";") + ?.map { it.trim() } + ?.firstOrNull { it.substringBefore("=") == name } + ?.substringAfter("=") + ?.let { Uri.decode(it) } + ?: "" + }.getOrNull() + + private fun sameUser(savedMethod: String, saved: String, method: String, credential: String): Boolean { + fun userId(method: String, credential: String) = + if (method == "appwrite-session") userIdFromSession(credential) else userIdFromJwt(credential) + val user = userId(method, credential) + return user.isNotEmpty() && user == userId(savedMethod, saved) + } + /** * One scheduled run: under a wakelock, reconnect if needed, send a heartbeat when one is due, * re-arm the next run, and call [done] once finished. Shuts down when nothing is saved. + * + * A run of a chained job ([jobId]) arms the next run under the other chained id: scheduling the + * id that is running would stop the run in progress. With [drainMs], the run stays up after + * connecting while the broker replays what was missed (at most that long), so a process woken + * from the background is not cached and frozen before the messages are delivered. */ - fun tick(context: Context, done: () -> Unit) { + fun tick(context: Context, jobId: Int? = null, drainMs: Long = 0, done: () -> Unit) { val app = context.applicationContext val wakeLock = acquireWakeLock(app) worker.execute { @@ -291,12 +428,17 @@ internal object PushBackground { } if (active) { // Arm the next run first, so a run cut short (killed, or a slow broker outlasting - // the wakelock) still leaves the chain armed. A refused credential in converge() - // shuts down, cancelling it again. - schedule(app, INTERVAL_MS) + // the wakelock) still leaves the chain armed; the watchdog job restarts a chain + // that was dropped anyway. A refused credential in converge() shuts down, + // cancelling them again. + schedule(app, INTERVAL_MS, runningJobId = jobId) + ensureWatchdog(app) ensureService(app) registerNetworkCallback(app) converge(app, heartbeat = true) + if (drainMs > 0 && mqtt != null) { + awaitDeliveries(drainMs) + } } else { shutdown(app) } @@ -309,9 +451,49 @@ internal object PushBackground { } } + // Worker thread only. Wait until no message arrived for DRAIN_QUIET_MS and none is being + // delivered, at most [maxMs]. Timed with the real monotonic clock ([nowMs]), which keeps moving + // while this thread sleeps. + private fun awaitDeliveries(maxMs: Long) { + val start = nowMs() + if (lastDelivery < start) { + lastDelivery = start + } + while (nowMs() - start < maxMs) { + if (delivering.get() == 0 && nowMs() - lastDelivery >= DRAIN_QUIET_MS) { + return + } + Thread.sleep(DRAIN_POLL_MS) + } + } + + private fun nowMs(): Long = System.nanoTime() / 1_000_000 + + /** + * Android 12+: hand a run to an expedited job, which starts at once with network access and + * keeps the process runnable until it finishes. A broadcast receiver woken in a cached process + * runs at background priority and can be frozen before it connects. Returns false when the + * job cannot be scheduled (older Android, or the expedited quota is used up), so the caller + * runs the tick itself. + */ + fun runExpedited(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return false + } + return try { + val job = JobInfo.Builder(EXPEDITED_JOB_ID, ComponentName(context, PushJobService::class.java)) + .setExpedited(true) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .build() + context.getSystemService(JobScheduler::class.java)?.schedule(job) == JobScheduler.RESULT_SUCCESS + } catch (e: Exception) { + false + } + } + /** Run once more shortly after the task was removed, so an OEM kill that follows is undone. */ fun scheduleRestart(context: Context) { - schedule(context.applicationContext, RESTART_DELAY_MS, jobToo = false) + schedule(context.applicationContext, RESTART_DELAY_MS, alarmOnly = true) } /** @@ -326,6 +508,7 @@ internal object PushBackground { loaded = false } errorHandler = null + connectionHandler = null unregisterNetworkCallback(context.applicationContext) worker.submit { disconnect() }.get() } @@ -361,9 +544,14 @@ internal object PushBackground { // Must hold [lock]. Every background subscription, live or saved, one per filter and title. // Subscriptions sharing a filter and title are saved once, with QoS 1 when any of them wants it. private fun entries(): List = - (saved + listeners.filter { it.background }.flatMap { listener -> listener.topics.map { PushEntry(it, listener.retry, listener.title) } }) + (saved + liveEntries()) .groupBy { it.filter to it.title } - .map { (key, group) -> PushEntry(key.first, group.any { it.retry }, key.second) } + .map { (key, group) -> PushEntry(key.first, group.any { it.retry }, key.second, group.any { it.notifyInForeground }) } + + // Must hold [lock]. + private fun liveEntries(): List = listeners.filter { it.background }.flatMap { listener -> + listener.topics.map { PushEntry(it, listener.retry, listener.title, listener.notifyInForeground) } + } // Must hold [lock]. private fun persist(context: Context) { @@ -397,6 +585,7 @@ internal object PushBackground { private fun activate(context: Context, done: ((Throwable?) -> Unit)? = null) { registerNetworkCallback(context) schedule(context, INTERVAL_MS) + ensureWatchdog(context) ensureService(context) worker.execute { converge(context, heartbeat = false, done) } } @@ -437,6 +626,11 @@ internal object PushBackground { } private fun convergeOnce(context: Context, heartbeat: Boolean) { + // Signed out, or signed in as someone else, since the subscriptions were saved. + if (refreshCredential(context) == CredentialRefresh.GONE) { + stop(context) + return + } val (current, wanted) = synchronized(lock) { config to wanted() } if (current == null || wanted.isEmpty()) { disconnect() @@ -477,24 +671,50 @@ internal object PushBackground { // A failed first connect of a waited-for converge goes to the waiting caller only. val quiet = waiting val connected = AtomicBoolean(false) + val retired = AtomicBoolean(false) val client = buildPushClient( config.copy(keepAlive = KEEP_ALIVE_SECONDS), + onConnected = { + if (!retired.get()) { + isConnected = true + connectionHandler?.invoke(true) + } + }, + onDisconnected = { + if (!retired.get()) { + isConnected = false + connectionHandler?.invoke(false) + } + }, onError = { error -> if (!quiet || connected.get()) { report(error) } }, onAuthRefused = { error -> worker.execute { refused(context, error) } }, + credential = { currentCredential(context, config) }, + retired = retired, ) // Registered before connecting, so the backlog the broker replays right after each // SUBSCRIBE reaches the app however the subscription was made (including the ones the // client restores itself after an automatic reconnect). Acknowledged by hand, once the // message was delivered (see [deliver]). - client.publishes(MqttGlobalPublishFilter.ALL, { publish -> deliver(context, publish) }, true) + client.publishes( + MqttGlobalPublishFilter.ALL, + { publish -> + if (!retired.get()) { + deliver(context, publish) + } + }, + true, + ) try { - client.sendPushConnect(config.copy(keepAlive = KEEP_ALIVE_SECONDS), CONNECT_TIMEOUT_SECONDS) + client.sendPushConnect(config.copy(keepAlive = KEEP_ALIVE_SECONDS), CONNECT_TIMEOUT_SECONDS) { + currentCredential(context, config) + } } catch (e: Exception) { val cause = (e as? ExecutionException)?.cause ?: e + retired.set(true) client.disconnect() val error = connectError(cause) when { @@ -508,6 +728,7 @@ internal object PushBackground { } connected.set(true) mqtt = client + mqttRetired = retired connectedConfig = config connectedNetwork = activeNetwork(context) lastHeartbeat = SystemClock.elapsedRealtime() @@ -575,6 +796,13 @@ internal object PushBackground { // Worker thread only. private fun disconnect() { + mqttRetired?.set(true) + mqttRetired = null + // A retired client's own disconnect is not reported, so report the live one closing here. + if (isConnected) { + isConnected = false + connectionHandler?.invoke(false) + } mqtt?.disconnect() mqtt = null connectedConfig = null @@ -586,6 +814,21 @@ internal object PushBackground { // registered the message is also saved, and the next onError registration receives it. A // waiting caller receives it instead. private fun refused(context: Context, error: Throwable) { + // The saved credential may be a session the app has since rotated: retry once with the + // current one. Refused again, it is unchanged and delivery stops below. + if (!waiting) { + when (refreshCredential(context)) { + CredentialRefresh.CHANGED -> { + converge(context, heartbeat = false) + return + } + CredentialRefresh.GONE -> { + stop(context) + return + } + CredentialRefresh.UNCHANGED -> Unit + } + } if (waiting) { fail(error) } else if (!report(error)) { @@ -596,11 +839,24 @@ internal object PushBackground { // Deliver a message, then acknowledge it: right away, or once every listener that acknowledges // messages itself has done so (at most ACK_TIMEOUT_SECONDS later). + // A message counts as being delivered until it is acknowledged: for the React Native and Flutter + // bridges, once their callback has run (or the acknowledgement times out), not when the event is + // handed over, so a run's drain window waits for it. private fun deliver(context: Context, publish: Mqtt5Publish) { + delivering.incrementAndGet() + lastDelivery = nowMs() + deliverAcknowledged(context, publish) { + lastDelivery = nowMs() + delivering.decrementAndGet() + } + } + + private fun deliverAcknowledged(context: Context, publish: Mqtt5Publish, settled: () -> Unit) { val acknowledged = AtomicBoolean(false) val acknowledge = { if (acknowledged.compareAndSet(false, true)) { runCatching { publish.acknowledge() } + settled() } } val pending = AtomicInteger(1) @@ -624,7 +880,7 @@ internal object PushBackground { settle() } if (!acknowledged.get()) { - worker.schedule({ acknowledge() }, ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ackTimer.schedule({ acknowledge() }, ACK_TIMEOUT_SECONDS, TimeUnit.SECONDS) } } @@ -656,8 +912,13 @@ internal object PushBackground { // The app's PushReceiver gets only messages no live callback received. val handled = matching.isEmpty() && deliverToReceivers(context, message) val content = notificationContent(message) - val titles = matching.filter { it.background }.map { it.title ?: message.topic } + - if (handled) emptyList() else entries.map { it.title ?: message.topic } + // Background subscriptions notify while the app is not visible; on screen, only those + // that opted in with notifyInForeground do, since the app shows the message itself. A + // message no live callback received still notifies, as the app has not shown it. + val foreground = appInForeground() + val shown = foreground && matching.isNotEmpty() + val titles = matching.filter { it.background && (!foreground || it.notifyInForeground) }.map { it.title ?: message.topic } + + if (handled) emptyList() else entries.filter { !shown || it.notifyInForeground }.map { it.title ?: message.topic } // A title the server sent replaces every subscription's, so one notification is posted. titles.map { content.title ?: it }.distinct().forEach { notify(context, message, it, content) } } finally { @@ -712,14 +973,13 @@ internal object PushBackground { builder.setLargeIcon(image) .setStyle(NotificationCompat.BigPictureStyle().bigPicture(image).bigLargeIcon(null as Bitmap?).setSummaryText(body)) } - context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch -> - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) - .putExtra(EXTRA_TOPIC, message.topic) - .putExtra(EXTRA_PAYLOAD, message.data) - builder.setContentIntent( - PendingIntent.getActivity(context, id, launch, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE), - ) - } + val open = Intent(context, PushOpenActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION) + .putExtra(EXTRA_TOPIC, message.topic) + .putExtra(EXTRA_PAYLOAD, message.data) + builder.setContentIntent( + PendingIntent.getActivity(context, id, open, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE), + ) try { manager.notify(id, builder.build()) } catch (e: SecurityException) { @@ -727,6 +987,11 @@ internal object PushBackground { } } + // Whether one of the app's activities is visible: a foreground service alone does not count. + private fun appInForeground(): Boolean = ActivityManager.RunningAppProcessInfo() + .also { ActivityManager.getMyMemoryState(it) } + .importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND + /** The server's `notification` block in [message]: nulls when the payload has none or is not JSON. */ fun notificationContent(message: PushMessage): PushNotificationContent { val notification = runCatching { JSONObject(message.data) }.getOrNull()?.optJSONObject("notification") @@ -736,6 +1001,11 @@ internal object PushBackground { } // Downloads notification images, so a slow one is abandoned without holding up delivery. + // Acknowledgement timeouts, apart from [worker] so they fire while a run's drain window holds it. + private val ackTimer = Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "AppwritePushAck").apply { isDaemon = true } + } + private val imageLoader = Executors.newCachedThreadPool { runnable -> Thread(runnable, "AppwritePushImage").apply { isDaemon = true } } @@ -746,7 +1016,7 @@ internal object PushBackground { val connection = runCatching { URL(url).openConnection() as HttpURLConnection }.getOrNull() ?: return null connection.connectTimeout = IMAGE_TIMEOUT_MS connection.readTimeout = IMAGE_TIMEOUT_MS - val download = imageLoader.submit { connection.inputStream.use { BitmapFactory.decodeStream(it) } } + val download = imageLoader.submit { connection.inputStream.use { readLimited(it, IMAGE_MAX_BYTES) }?.let { decodeImage(it) } } return try { download.get(IMAGE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS) } catch (e: Exception) { @@ -757,6 +1027,37 @@ internal object PushBackground { } } + // At most [limit] bytes of [input], or null when it holds more. + private fun readLimited(input: InputStream, limit: Int): ByteArray? { + val out = ByteArrayOutputStream() + val buffer = ByteArray(8_192) + while (true) { + val read = input.read(buffer) + if (read < 0) { + return out.toByteArray() + } + if (out.size() + read > limit) { + return null + } + out.write(buffer, 0, read) + } + } + + // Decode [bytes] downsampled so neither side exceeds IMAGE_MAX_PX, so a large photo cannot + // exhaust memory while a notification is posted. + private fun decodeImage(bytes: ByteArray): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null + } + var sample = 1 + while (bounds.outWidth / sample > IMAGE_MAX_PX || bounds.outHeight / sample > IMAGE_MAX_PX) { + sample *= 2 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) + } + /** The ongoing notification the foreground service shows, on its own quiet channel. */ fun ongoingNotification(context: Context) = NotificationCompat.Builder(context, SERVICE_CHANNEL_ID) .also { createChannels(context) } @@ -815,19 +1116,16 @@ internal object PushBackground { } // Arm the next run in [delayMs]: the job, and an alarm just after it in case the job is late. - private fun schedule(context: Context, delayMs: Long, jobToo: Boolean = true) { - val interval = if (delayMs == INTERVAL_MS && privileged(context)) PRIVILEGED_INTERVAL_MS else delayMs - if (jobToo) { - try { - val job = JobInfo.Builder(JOB_ID, ComponentName(context, PushJobService::class.java)) - .setMinimumLatency(interval) - .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) - .setPersisted(true) - .build() - context.getSystemService(JobScheduler::class.java)?.schedule(job) - } catch (e: Exception) { - report(e) + // The chained job alternates between two ids, so a run ([runningJobId]) never schedules its + // own id, which would stop it; the other id is cancelled only when it is not the one running. + private fun schedule(context: Context, delayMs: Long, alarmOnly: Boolean = false, runningJobId: Int? = null) { + val interval = interval(context, delayMs) + if (!alarmOnly) { + val next = if (runningJobId == JOB_ID) NEXT_JOB_ID else JOB_ID + if (runningJobId == null) { + context.getSystemService(JobScheduler::class.java)?.cancel(if (next == JOB_ID) NEXT_JOB_ID else JOB_ID) } + scheduleJob(context, delayMs, next) } val alarms = context.getSystemService(AlarmManager::class.java) ?: return val at = SystemClock.elapsedRealtime() + interval + 1_000L @@ -843,8 +1141,48 @@ internal object PushBackground { } } + private fun interval(context: Context, delayMs: Long): Long = + if (delayMs == INTERVAL_MS && privileged(context)) PRIVILEGED_INTERVAL_MS else delayMs + + private fun scheduleJob(context: Context, delayMs: Long, id: Int) { + try { + val job = JobInfo.Builder(id, ComponentName(context, PushJobService::class.java)) + .setMinimumLatency(interval(context, delayMs)) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build() + context.getSystemService(JobScheduler::class.java)?.schedule(job) + } catch (e: Exception) { + report(e) + } + } + + // A periodic job, independent of the chain of runs, that restarts the chain when a run was + // dropped (the process killed between runs, a deferred alarm, a throttled job). Scheduled once, + // so its period is not reset by every run. + private fun ensureWatchdog(context: Context) { + val jobs = context.getSystemService(JobScheduler::class.java) ?: return + try { + if (jobs.allPendingJobs.any { it.id == WATCHDOG_JOB_ID }) { + return + } + jobs.schedule( + JobInfo.Builder(WATCHDOG_JOB_ID, ComponentName(context, PushJobService::class.java)) + .setPeriodic(WATCHDOG_INTERVAL_MS) + .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY) + .setPersisted(true) + .build(), + ) + } catch (e: Exception) { + report(e) + } + } + private fun cancelSchedule(context: Context) { context.getSystemService(JobScheduler::class.java)?.cancel(JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(NEXT_JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(EXPEDITED_JOB_ID) + context.getSystemService(JobScheduler::class.java)?.cancel(WATCHDOG_JOB_ID) context.getSystemService(AlarmManager::class.java)?.cancel(tickIntent(context)) } @@ -855,6 +1193,63 @@ internal object PushBackground { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + /** What background delivery can rely on now; see [PushBackgroundStatus]. */ + fun backgroundStatus(context: Context): PushBackgroundStatus { + val app = context.applicationContext + return PushBackgroundStatus( + exactAlarms = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || + app.getSystemService(AlarmManager::class.java)?.canScheduleExactAlarms() == true, + ignoringBatteryOptimizations = app.getSystemService(PowerManager::class.java)?.isIgnoringBatteryOptimizations(app.packageName) == true, + foregroundService = PushStore.foreground(app), + ) + } + + /** + * Open the system screen where the user allows exact alarms (Android 12+). Returns false when + * there is nothing to ask: already allowed, older Android, or the app does not declare + * `SCHEDULE_EXACT_ALARM`. + */ + fun requestExactAlarms(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || backgroundStatus(context).exactAlarms) { + return false + } + return openSettings(context, Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, Manifest.permission.SCHEDULE_EXACT_ALARM) + } + + /** + * Ask the user to exempt the app from battery optimisation. Returns false when there is + * nothing to ask: already exempt, or the app does not declare + * `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. + */ + fun requestIgnoreBatteryOptimizations(context: Context): Boolean { + if (backgroundStatus(context).ignoringBatteryOptimizations) { + return false + } + return openSettings( + context, + Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Manifest.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + ) + } + + // Open a settings screen for this package when the app declares [permission]. + private fun openSettings(context: Context, action: String, permission: String): Boolean { + val app = context.applicationContext + val declared = runCatching { + @Suppress("DEPRECATION") + app.packageManager.getPackageInfo(app.packageName, PackageManager.GET_PERMISSIONS).requestedPermissions + }.getOrNull()?.contains(permission) == true + if (!declared) { + return false + } + return try { + app.startActivity(Intent(action, Uri.parse("package:${app.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true + } catch (e: Exception) { + false + } + } + // Allowed exact alarms or exempt from battery optimisation: the alarm is reliable, so the // runs can be spaced further apart. private fun privileged(context: Context): Boolean = @@ -945,6 +1340,7 @@ internal object PushStore { authMethod = it.getString("authMethod"), credential = it.getString("credential"), project = it.getString("project"), + sessionCookieUrl = if (it.isNull("sessionCookieUrl")) null else it.optString("sessionCookieUrl").ifEmpty { null }, ) } val list = json.getJSONArray("entries") @@ -954,6 +1350,7 @@ internal object PushStore { filter = entry.getString("filter"), retry = entry.getBoolean("retry"), title = if (entry.isNull("title")) null else entry.getString("title"), + notifyInForeground = entry.optBoolean("notifyInForeground", false), ) } config to entries @@ -973,11 +1370,12 @@ internal object PushStore { .put("keepAlive", config.keepAlive) .put("authMethod", config.authMethod) .put("credential", config.credential) - .put("project", config.project), + .put("project", config.project) + .put("sessionCookieUrl", config.sessionCookieUrl ?: JSONObject.NULL), ) .put( "entries", - JSONArray(entries.map { JSONObject().put("filter", it.filter).put("retry", it.retry).put("title", it.title ?: JSONObject.NULL) }), + JSONArray(entries.map { JSONObject().put("filter", it.filter).put("retry", it.retry).put("title", it.title ?: JSONObject.NULL).put("notifyInForeground", it.notifyInForeground) }), ) write(context, STATE_FILE, json) } diff --git a/library/src/main/java/io/appwrite/services/PushCore.kt b/library/src/main/java/io/appwrite/services/PushCore.kt index 20c5624..15f86b6 100644 --- a/library/src/main/java/io/appwrite/services/PushCore.kt +++ b/library/src/main/java/io/appwrite/services/PushCore.kt @@ -12,6 +12,7 @@ import com.hivemq.client.mqtt.mqtt5.Mqtt5ClientConfig import com.hivemq.client.mqtt.mqtt5.auth.Mqtt5EnhancedAuthMechanism import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5ConnAckException import com.hivemq.client.mqtt.mqtt5.exceptions.Mqtt5DisconnectException +import com.hivemq.client.mqtt.mqtt5.lifecycle.Mqtt5ClientReconnector import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5Auth import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5AuthBuilder import com.hivemq.client.mqtt.mqtt5.message.auth.Mqtt5EnhancedAuthBuilder @@ -30,6 +31,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import javax.net.ssl.ManagerFactoryParameters import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory @@ -49,6 +51,24 @@ data class PushMessage( get() = String(payload, Charsets.UTF_8) } +/** + * What background delivery can rely on. Without exact alarms, the scheduled wake-ups are inexact + * and Doze can defer them, so delivery is [bestEffort] unless foreground mode keeps the + * connection open. + */ +data class PushBackgroundStatus( + /** The app may schedule exact alarms (`SCHEDULE_EXACT_ALARM`, granted). */ + val exactAlarms: Boolean, + /** The app is exempt from battery optimisation. */ + val ignoringBatteryOptimizations: Boolean, + /** Foreground mode (`setForeground(true)`) keeps the connection open in a service. */ + val foregroundService: Boolean, +) { + /** Wake-ups may be deferred by Doze, so messages can arrive late while the app is closed. */ + val bestEffort: Boolean + get() = !exactAlarms && !foregroundService +} + /** Connection parameters shared between the in-process client and the foreground service. */ internal data class PushConfig( val host: String, @@ -60,6 +80,10 @@ internal data class PushConfig( val authMethod: String, val credential: String, val project: String, + // The endpoint whose `a_session_` cookie in the WebView cookie store holds the app's + // session, when its credential comes from there (React Native). Background runs read it, so + // they keep up with a rotated session while no app code runs. + val sessionCookieUrl: String? = null, ) /** @@ -110,6 +134,8 @@ internal fun buildPushClient( onDisconnected: (() -> Unit)? = null, onError: ((Throwable) -> Unit)? = null, onAuthRefused: ((Throwable) -> Unit)? = null, + credential: () -> String? = { config.credential }, + retired: AtomicBoolean? = null, ): Mqtt5AsyncClient { // config.clientId is stable per user (see buildConfig), so a reconnect rebuild reuses it // and resumes the same session. @@ -130,11 +156,25 @@ internal fun buildPushClient( // Whether this client has connected yet: until it has, a refused CONNECT is final. val connected = AtomicBoolean(false) + val client = AtomicReference() builder = builder.addConnectedListener { + // A reconnect that was already under way when the client was retired disconnects at once, + // so it never competes with its replacement for the broker session. + if (retired?.get() == true) { + client.get()?.disconnect() + return@addConnectedListener + } connected.set(true) onConnected?.invoke() } builder = builder.addDisconnectedListener { context -> + // A client its owner replaced or closed ([retired]) never reconnects: one still retrying + // in the background would otherwise come back as a second connection for the same id. + if (retired?.get() == true) { + context.reconnector.reconnect(false) + onDisconnected?.invoke() + return@addDisconnectedListener + } // A refused first CONNECT (bad credential, rate limit) is final: stop the automatic // reconnect so pushConnect fails with the broker's reason instead of retrying forever. val refused = !connected.get() && context.cause is Mqtt5ConnAckException @@ -142,8 +182,16 @@ internal fun buildPushClient( // re-auth) is final too, instead of being retried with the same credential forever. val authRefused = onAuthRefused != null && connected.get() && context.source != MqttDisconnectSource.USER && isAuthRefusal(context.cause) - if (refused || authRefused) { + // Authentication was aborted because the app has no current credential (signed out): there + // is nothing to reconnect with, so the attempt fails now instead of retrying until timeout. + val noCredential = generateSequence(context.cause) { it.cause }.any { it is PushNoCredentialException } + if (refused || authRefused || noCredential) { context.reconnector.reconnect(false) + } else if (context.reconnector.isReconnect) { + // HiveMQ's automatic reconnect sends a CONNECT it builds itself, without user + // properties: the broker then misses the project and refuses the credential. Reconnect + // with the same CONNECT this client connects with instead. + (context.reconnector as? Mqtt5ClientReconnector)?.connect(pushConnectMessage(config, credential)) } onDisconnected?.invoke() if (!refused) { @@ -168,7 +216,7 @@ internal fun buildPushClient( } } - return builder.buildAsync() + return builder.buildAsync().also { client.set(it) } } /** @@ -207,10 +255,31 @@ internal fun isAuthRefusal(error: Throwable?): Boolean = when (error) { else -> false } -/** Send the CONNECT and wait for the CONNACK, at most [timeoutSeconds] when given. */ -internal fun Mqtt5AsyncClient.sendPushConnect(config: PushConfig, timeoutSeconds: Long? = null) { - val connAck = connectWith() - .enhancedAuth(PushAuthMechanism(config.authMethod, config.credential)) +/** + * Send the CONNECT and wait for the CONNACK, at most [timeoutSeconds] when given. [credential] + * supplies the credential each time one is sent, including on the client's automatic reconnects + * and re-auths, so they can use a session rotated since this CONNECT; null aborts the attempt. + */ +internal fun Mqtt5AsyncClient.sendPushConnect( + config: PushConfig, + timeoutSeconds: Long? = null, + credential: () -> String? = { config.credential }, +) { + val connAck = connect(pushConnectMessage(config, credential)) + if (timeoutSeconds == null) { + connAck.get() + } else { + connAck.get(timeoutSeconds, TimeUnit.SECONDS) + } +} + +/** + * The CONNECT for [config]: enhanced auth with the credential from [credential], and the project + * as a user property, which the broker resolves the credential against. + */ +internal fun pushConnectMessage(config: PushConfig, credential: () -> String?): Mqtt5Connect = + Mqtt5Connect.builder() + .enhancedAuth(PushAuthMechanism(config.authMethod, config.project, credential)) // Clean start is always off, so the broker keeps this client's session and can // redeliver missed messages to QoS-1 subscriptions on reconnect. .cleanStart(false) @@ -218,13 +287,7 @@ internal fun Mqtt5AsyncClient.sendPushConnect(config: PushConfig, timeoutSeconds .userProperties() .add("projectId", config.project) .applyUserProperties() - .send() - if (timeoutSeconds == null) { - connAck.get() - } else { - connAck.get(timeoutSeconds, TimeUnit.SECONDS) - } -} + .build() /** * The error a lost connection reports, or null when it is not an error (a disconnect the app @@ -266,11 +329,14 @@ internal fun Mqtt5Publish.toPushMessage(): PushMessage = /** * Single-step MQTT 5 enhanced auth: the method and credential are placed in the CONNECT - * packet and the broker accepts them in the CONNACK, with no AUTH round-trip. + * packet and the broker accepts them in the CONNACK, with no AUTH round-trip. The credential is + * read from [credential] each time, so an automatic reconnect or re-auth sends the current one; + * when it has none, the attempt fails instead of sending a credential known to be stale. */ internal class PushAuthMechanism( private val method: String, - private val credential: String, + private val project: String, + private val credential: () -> String?, ) : Mqtt5EnhancedAuthMechanism { override fun getMethod(): MqttUtf8String = MqttUtf8String.of(method) @@ -282,7 +348,8 @@ internal class PushAuthMechanism( connect: Mqtt5Connect, authBuilder: Mqtt5EnhancedAuthBuilder, ): CompletableFuture { - authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + val current = credential() ?: return failedAuth() + authBuilder.data(current.toByteArray(Charsets.UTF_8)) return CompletableFuture.completedFuture(null) } @@ -290,7 +357,12 @@ internal class PushAuthMechanism( clientConfig: Mqtt5ClientConfig, authBuilder: Mqtt5AuthBuilder, ): CompletableFuture { - authBuilder.data(credential.toByteArray(Charsets.UTF_8)) + val current = credential() ?: return failedAuth() + // The broker resolves a re-auth against the project in its user properties too. + authBuilder.data(current.toByteArray(Charsets.UTF_8)) + .userProperties() + .add("projectId", project) + .applyUserProperties() return CompletableFuture.completedFuture(null) } @@ -317,8 +389,14 @@ internal class PushAuthMechanism( override fun onAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit override fun onReAuthError(clientConfig: Mqtt5ClientConfig, cause: Throwable) = Unit + + private fun failedAuth(): CompletableFuture = + CompletableFuture().apply { completeExceptionally(PushNoCredentialException()) } } +/** Authentication aborted because there is no current credential to send (the app signed out). */ +internal class PushNoCredentialException : IllegalStateException("No current credential to authenticate with") + /** * A [TrustManagerFactory] that accepts any certificate — used when `tlsInsecure` is set * (for a broker whose certificate does not chain to a public root). @@ -353,6 +431,8 @@ internal class PushListener( // Called instead of [callback] when set, with the acknowledgement to run once the message was // handled: the React Native and Flutter bridges acknowledge after their callback has run. val acknowledgingCallback: ((PushMessage, () -> Unit) -> Unit)? = null, + // Also post a background notification while the app is visible. + val notifyInForeground: Boolean = false, ) /** MQTT topic-filter match with '+' (single level) and '#' (multi level). */ diff --git a/library/src/main/java/io/appwrite/services/PushOpenActivity.kt b/library/src/main/java/io/appwrite/services/PushOpenActivity.kt new file mode 100644 index 0000000..354ca6f --- /dev/null +++ b/library/src/main/java/io/appwrite/services/PushOpenActivity.kt @@ -0,0 +1,57 @@ +package io.appwrite.services + +import android.app.Activity +import android.content.Intent +import android.os.Bundle + +class PushOpenActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val topic = intent.getStringExtra(PushBackground.EXTRA_TOPIC) + val payload = intent.getStringExtra(PushBackground.EXTRA_PAYLOAD) + packageManager.getLaunchIntentForPackage(packageName)?.let { launch -> + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + if (topic != null && payload != null) { + launch.putExtra(PushBackground.EXTRA_TOPIC, topic).putExtra(PushBackground.EXTRA_PAYLOAD, payload) + } + startActivity(launch) + } + // After opening the app, so a screen a tap callback opens lands on top of it. + if (topic != null && payload != null) { + PushTaps.record(PushTap(topic, payload)) + } + finish() + } +} + +internal data class PushTap(val topic: String, val payload: String) + +internal object PushTaps { + private var pending: PushTap? = null + private val listeners = mutableListOf<(PushTap) -> Unit>() + + fun record(tap: PushTap) { + val deliver = synchronized(this) { + listeners.toList().also { + if (it.isEmpty()) { + pending = tap + } + } + } + deliver.forEach { it(tap) } + } + + @Synchronized + fun take(): PushTap? = pending.also { pending = null } + + fun listen(onTap: (PushTap) -> Unit): () -> Unit { + synchronized(this) { + listeners.add(onTap) + } + return { + synchronized(this) { + listeners.remove(onTap) + } + } + } +} diff --git a/library/src/main/java/io/appwrite/services/PushWakeups.kt b/library/src/main/java/io/appwrite/services/PushWakeups.kt index 091fd52..c6d5616 100644 --- a/library/src/main/java/io/appwrite/services/PushWakeups.kt +++ b/library/src/main/java/io/appwrite/services/PushWakeups.kt @@ -12,11 +12,15 @@ import android.content.Intent */ class PushJobService : JobService() { override fun onStartJob(params: JobParameters): Boolean { - PushBackground.tick(this) { jobFinished(params, false) } + // A chained job arms the next under the other chained id; the watchdog and an expedited + // run (handed over by the alarm) only run once. Each stays up while missed messages arrive. + val chained = params.jobId.takeIf { it == PushBackground.JOB_ID || it == PushBackground.NEXT_JOB_ID } + PushBackground.tick(this, chained, PushBackground.JOB_DRAIN_MS) { jobFinished(params, false) } return true } - // The run re-arms the next one itself, so a stopped run needs no retry. + // The run re-arms the next one itself, and the watchdog repeats on its own, so a stopped run + // needs no retry. override fun onStopJob(params: JobParameters): Boolean = false } @@ -29,8 +33,13 @@ class PushAlarmReceiver : BroadcastReceiver() { if (intent.action != PushBackground.ACTION_TICK) { return } + // On Android 12+ the run goes to an expedited job, which keeps the process runnable while it + // connects and the broker replays; it runs here when the system refuses one. + if (PushBackground.runExpedited(context)) { + return + } val pending = goAsync() - PushBackground.tick(context) { pending.finish() } + PushBackground.tick(context, drainMs = PushBackground.RECEIVER_DRAIN_MS) { pending.finish() } } }