Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Change log

## 1.2.0-rc.4

* Fixed: `host()` resolves with the live connection state instead of always `null`
* Fixed: `onConnection(false)` fires when a retired or background connection closes
* Fixed: notification taps are recorded after launching the app, so tap-opened screens land on top
* Fixed: `host()` reports `false` when there was nothing to subscribe instead of a spurious open

## 1.2.0-rc.3

* Added: `getInitialNotification()`, `onNotificationOpened()`, and the `PushNotificationOpened` type (topic + data)
* Added: live push connection open/close events surfaced via `onOpen`/`onClose`
* Fixed: notification taps delivered reliably once, via a `PushTaps` queue
* Fixed: foreground notifications de-duplicated per title
* Updated: `resume` leaves a live Push host's connection untouched

## 1.2.0-rc.2

* Added: `Push.getInitialNotification()` returns the notification whose tap launched the app
* Added: `Push.onNotificationOpened(callback)` fires on each notification tap
* Added: `Push.backgroundStatus()` reports Android exact-alarm, battery, and foreground-service state
* Added: `Push.requestExactAlarms()` and `Push.requestIgnoreBatteryOptimizations()` helpers
* Added: `SubscribeOptions.notifyInForeground` to post notifications while the app is visible
* Added: `PushBackgroundStatus` and `PushNotificationOpened` exported types
* Fixed: reliable Android background wake-ups via a periodic watchdog and alternating jobs
* Fixed: `resume` keeps subscriptions only for the same signed-in user
* Fixed: notification images are size-capped to prevent out-of-memory crashes

## 1.2.0-rc.1

* Added: background push notifications render the server `notification` title, body, and image
Expand Down
84 changes: 82 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ A subscription with `background: true` keeps delivering after the app is backgro
the device restarts, until it is unsubscribed or `push.close()` is called (do this on sign-out).
The SDK's native Android module (autolinked) saves the subscription, and a scheduled job and
alarm wake the app every 15 to 60 seconds to reconnect; the broker replays what was sent in
between (`retry: true`). Messages no in-app callback receives are posted as notifications that
open the app. It reconnects with the credential saved at subscribe time, so use a session rather
between (`retry: true`). While the app is not on screen, each message is posted as a
notification that opens the app. It reconnects with the credential saved at subscribe time, so use a session rather
than a short-lived JWT.

On Android 13 and later, the first background subscription asks the user for the
Expand All @@ -58,6 +58,86 @@ const sub = await push.subscribe('news', (message) => console.log(message.data),
await push.setForeground(true);
```

Notifications are posted while the app is backgrounded or closed. While it is on screen your
callback shows the message, so none is posted unless the subscription passes
`notifyInForeground: true`.

#### 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 above).

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
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
```

With Expo, list them under `android.permissions` in `app.json` instead.

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 `await push.backgroundStatus()`: when `bestEffort` is
true, explain why to the user, then from a user action open that screen with `push.requestExactAlarms()`, or
ask for the battery-optimisation exemption with `push.requestIgnoreBatteryOptimizations()`. 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.

Set the notification icon with
`<meta-data android:name="io.appwrite.push.notification_icon" android:resource="@drawable/..." />` in your
`<application>`; without it a generic icon is used.

```js
const status = await push.backgroundStatus(); // null outside Android
if (status?.bestEffort) {
// Explain why, then from a button press:
await push.requestExactAlarms();
}
```

Saved background delivery follows the app's current session, also while the app is closed: each
background run re-reads the session cookie, so a rotated session of the same user replaces the
saved one, and signing out (no session) or signing in as someone else stops background delivery.
If the broker still refuses the credential, delivery stops and `onError` reports it the next time
the app registers one. Still call `push.close()` on sign-out.

#### Upgrading from an earlier release candidate

The Expo config plugin is gone: the native module now declares everything background delivery
needs. Remove `"react-native-appwrite"` from the `plugins` list in `app.json`, or `expo config`
fails to load it.

#### Opening a tapped notification

Read the `data` sent with `createPush` when the user taps a background notification:

```js
// The tap that launched the app (reported once, so call it at startup).
const opened = await push.getInitialNotification();
if (opened) {
openSale(opened.data.saleId);
}

// Taps while the app is running, including in the background.
const stop = push.onNotificationOpened(({ topic, data }) => openSale(data.saleId));
```

On Android the SDK's native module reports the taps. Elsewhere they come from `expo-notifications`.

Foreground mode runs a `remoteMessaging` foreground service, which Google Play asks apps to
declare in the Play Console. Apps that never enable it can remove the service from their merged
manifest with `tools:node="remove"` on `io.appwrite.services.PushService` and
Expand Down
7 changes: 7 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />

<application>
<activity
android:name="io.appwrite.services.PushOpenActivity"
android:excludeFromRecents="true"
android:exported="false"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<service
android:name="io.appwrite.services.PushService"
android:exported="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import io.appwrite.services.PushBridge
import io.appwrite.services.PushMessage
import io.appwrite.services.PushTap
import io.appwrite.services.PushTaps
import org.json.JSONObject

/**
* The React Native side of [PushBridge]: the SDK's `Push` hosts its background subscriptions
* here on Android, and receives their messages and errors as events.
* here on Android, and receives their messages and errors as events. It also reports taps on the
* notifications they post: the one that launched the app, and later ones as events.
*
* [emit] sends an event to JS; tests replace it to observe what JS would receive.
*/
Expand Down Expand Up @@ -45,18 +49,49 @@ class AppwritePushModule internal constructor(
)

override fun onError(message: String) = emit(ERROR_EVENT, mapOf("message" to message))

override fun onConnection(connected: Boolean) = emit(CONNECTION_EVENT, mapOf("connected" to connected))
},
)

private var openedListeners = 0
private var stopOpened: (() -> Unit)? = null

override fun getName(): String = NAME

// Resolves with the tapped notification that launched the app as JSON (topic and payload),
// once, or null.
@ReactMethod
fun getInitialNotification(promise: Promise) = settle(promise) {
PushTaps.take()?.let { JSONObject(opened(it)).toString() }
}

// Emits each tap while JS listens for them; until then a tap waits for getInitialNotification.
@ReactMethod
fun listenOpened(listening: Boolean, promise: Promise) = settle(promise) {
openedListeners = (openedListeners + if (listening) 1 else -1).coerceAtLeast(0)
if (openedListeners > 0 && stopOpened == null) {
stopOpened = PushTaps.listen { tap -> emit(OPENED_EVENT, opened(tap)) }
} else if (openedListeners == 0) {
stopOpened?.invoke()
stopOpened = null
}
null
}

override fun invalidate() {
stopOpened?.invoke()
stopOpened = null
super.invalidate()
}

// Resolves once the connection is up and every filter is subscribed, or rejects with why not.
@ReactMethod
fun host(config: String, subscriptions: String, promise: Promise) {
try {
bridge.host(config, subscriptions) { error ->
if (error == null) {
promise.resolve(null)
promise.resolve(bridge.isConnected())
} else {
promise.reject(ERROR_CODE, error)
}
Expand Down Expand Up @@ -91,11 +126,20 @@ class AppwritePushModule internal constructor(
fun hasSaved(promise: Promise) = settle(promise) { bridge.hasSaved() }

@ReactMethod
fun resume(promise: Promise) = settle(promise) {
bridge.resume()
fun resume(authMethod: String?, credential: String?, signedOutWhenMissing: Boolean, promise: Promise) = settle(promise) {
bridge.resume(authMethod, credential, signedOutWhenMissing)
null
}

@ReactMethod
fun backgroundStatus(promise: Promise) = settle(promise) { bridge.backgroundStatus() }

@ReactMethod
fun requestExactAlarms(promise: Promise) = settle(promise) { bridge.requestExactAlarms() }

@ReactMethod
fun requestIgnoreBatteryOptimizations(promise: Promise) = settle(promise) { bridge.requestIgnoreBatteryOptimizations() }

@ReactMethod
fun setErrorCallback(registered: Boolean, promise: Promise) = settle(promise) { bridge.setErrorCallback(registered) }

Expand All @@ -110,6 +154,8 @@ class AppwritePushModule internal constructor(
@ReactMethod
fun removeListeners(count: Double) = Unit

private fun opened(tap: PushTap): Map<String, Any?> = mapOf("topic" to tap.topic, "payload" to tap.payload)

private fun settle(promise: Promise, block: () -> Any?) {
try {
promise.resolve(block())
Expand All @@ -122,6 +168,8 @@ class AppwritePushModule internal constructor(
const val NAME = "AppwritePush"
const val MESSAGE_EVENT = "AppwritePushMessage"
const val ERROR_EVENT = "AppwritePushError"
const val OPENED_EVENT = "AppwritePushOpened"
const val CONNECTION_EVENT = "AppwritePushConnection"
private const val ERROR_CODE = "appwrite_push"
}
}
Loading