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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Change Log

## 27.1.0-rc.7

* Fixed: `host()` reports the live connection state instead of always resolving open
* Fixed: a disconnect event now fires when a retired or dropped background connection closes
* Fixed: notification taps are recorded after launching the app, so tap-opened screens land on top
* Fixed: subscribing on a `Push` closed mid-connect now throws instead of silently proceeding

## 27.1.0-rc.6

* Added: `getInitialNotification()`, `onNotificationOpened()`, and the `PushNotificationOpened` type (topic + data)
* Fixed: Android `onOpen`/`onClose` now fire for `background` subscriptions
* Fixed: `subscribe` falls back to the session the client signed in with
* Fixed: Android notifications no longer suppressed when no live callback received the message
* Fixed: `close()` during subscribe/resume no longer re-subscribes; `resume` leaves a live `Push` host untouched
* Updated: notification taps routed through a translucent `PushOpenActivity`

## 27.1.0-rc.5

* Updated: regenerated with sdk-generator 5.5.0; no SDK code changes

## 27.1.0-rc.3

* Added: background push notifications render the server `notification` title, body, and image
Expand Down
63 changes: 60 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Add this to your package's `pubspec.yaml` file:

```yml
dependencies:
appwrite: ^27.1.0-rc.3
appwrite: ^27.1.0-rc.7
```

You can install packages from the command line:
Expand Down Expand Up @@ -53,15 +53,19 @@ dependencies {
A subscription with `background: true` keeps delivering after the app is backgrounded, killed or
the device restarts, until it is unsubscribed or `push.close()` is called (do this on sign-out).
The plugin 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
seconds to reconnect; the broker replays what was sent in 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
`POST_NOTIFICATIONS` runtime permission. If they decline, the subscription still delivers to your
callback but posts no notification. Notifications show the title, body and image sent with
`createPush`, and fall back to the subscription's `title` and the raw payload for other messages.

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`.

```dart
final sub = await push.subscribe('news', (message) => print(message.data),
background: true, title: 'News');
Expand All @@ -76,6 +80,59 @@ declare in the Play Console. Apps that never enable it can remove the service fr
manifest with `tools:node="remove"` on `io.appwrite.services.PushService` and
`android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING`.

#### 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" />
```

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.

### Opening a tapped notification

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

```dart
// The tap that launched the app (reported once, so call it at startup).
final opened = await push.getInitialNotification();
if (opened != null) openSale(opened.data['saleId']);

// Taps while the app is running, including in the background.
final stop = push.onNotificationOpened((opened) => openSale(opened.data['saleId']));
```

On Android the SDK's native plugin reports the taps, on iOS `flutter_local_notifications`, and
on the web the notification's click while the page is open.


## Getting Started

Expand Down
4 changes: 2 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The native half of Push on Android: background delivery that survives the process being
// killed, a reboot and an app update, shared with the Appwrite Android SDK.
group = "io.appwrite.flutter"
version = "27.1.0-rc.3"
version = "27.1.0-rc.7"

buildscript {
ext.kotlin_version = "2.1.0"
Expand All @@ -28,7 +28,7 @@ apply plugin: "kotlin-android"

android {
namespace = "io.appwrite.flutter"
compileSdk = 35
compileSdk = 36

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
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
28 changes: 26 additions & 2 deletions android/src/main/kotlin/io/appwrite/flutter/AppwritePushPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.appwrite.services.PushBridge
import io.appwrite.services.PushMessage
import io.appwrite.services.PushTaps
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
Expand All @@ -30,6 +31,8 @@ class AppwritePushPlugin :
private var methods: MethodChannel? = null
private var events: EventChannel? = null
private var sink: EventChannel.EventSink? = null
private var openedListeners = 0
private var stopOpened: (() -> Unit)? = null
private var bridge: PushBridge? = null
private var activity: Activity? = null

Expand All @@ -49,6 +52,8 @@ class AppwritePushPlugin :
)

override fun onError(message: String) = send(mapOf("type" to "error", "message" to message))

override fun onConnection(connected: Boolean) = send(mapOf("type" to "connection", "connected" to connected))
},
)
methods = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL).also { it.setMethodCallHandler(this) }
Expand All @@ -61,6 +66,9 @@ class AppwritePushPlugin :
methods = null
events = null
sink = null
stopOpened?.invoke()
stopOpened = null
openedListeners = 0
}

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
Expand All @@ -87,7 +95,7 @@ class AppwritePushPlugin :
bridge.host(call.argument<String>("config")!!, call.argument<String>("subscriptions")!!) { error ->
main.post {
if (error == null) {
result.success(null)
result.success(bridge.isConnected())
} else {
result.error(ERROR_CODE, error, null)
}
Expand All @@ -103,9 +111,14 @@ class AppwritePushPlugin :
"stop" -> bridge.stop().let { null }
"setForeground" -> bridge.setForeground(call.argument<Boolean>("enabled") == true).let { null }
"hasSaved" -> bridge.hasSaved()
"resume" -> bridge.resume().let { null }
"resume" -> bridge.resume(call.argument<String>("authMethod"), call.argument<String>("credential"), false).let { null }
"backgroundStatus" -> bridge.backgroundStatus()
"requestExactAlarms" -> bridge.requestExactAlarms()
"requestIgnoreBatteryOptimizations" -> bridge.requestIgnoreBatteryOptimizations()
"setErrorCallback" -> bridge.setErrorCallback(call.argument<Boolean>("registered") == true)
"defaultClientId" -> bridge.defaultClientId(call.argument<String>("authMethod")!!, call.argument<String>("credential")!!)
"getInitialNotification" -> PushTaps.take()?.let { mapOf("topic" to it.topic, "payload" to it.payload) }
"listenOpened" -> listenOpened(call.argument<Boolean>("listening") == true).let { null }
else -> return result.notImplemented()
},
)
Expand Down Expand Up @@ -140,6 +153,17 @@ class AppwritePushPlugin :
main.post { sink?.success(event) }
}

// Sends each tap while Dart listens for them; until then a tap waits for getInitialNotification.
private fun listenOpened(listening: Boolean) {
openedListeners = (openedListeners + if (listening) 1 else -1).coerceAtLeast(0)
if (openedListeners > 0 && stopOpened == null) {
stopOpened = PushTaps.listen { tap -> send(mapOf("type" to "opened", "topic" to tap.topic, "payload" to tap.payload)) }
} else if (openedListeners == 0) {
stopOpened?.invoke()
stopOpened = null
}
}

private companion object {
const val METHOD_CHANNEL = "appwrite.push"
const val EVENT_CHANNEL = "appwrite.push/events"
Expand Down
Loading
Loading