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

## 27.1.0-rc.3

* Added: background push notifications render the server `notification` title, body, and image
* Added: `requestNotificationPermission()` on the `Push` service
Comment thread
hansi-codes[bot] marked this conversation as resolved.
* Added: the SDK requests Android 13+ `POST_NOTIFICATIONS` itself on first background subscribe
* Fixed: a server-sent title dedupes to one notification per topic instead of one per subscription

## 27.1.0-rc.2

* Added: `Apps` service to manage OAuth2 apps, keys, secrets, and installations
Expand Down
14 changes: 5 additions & 9 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.2
appwrite: ^27.1.0-rc.3
```

You can install packages from the command line:
Expand Down Expand Up @@ -57,16 +57,12 @@ seconds to reconnect; the broker replays what was sent in between (`retry: true`
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 than a short-lived JWT.

On Android 13 and later, notifications only appear once the user grants the
`POST_NOTIFICATIONS` runtime permission. Without it, the subscription still delivers to your
callback but posts no notification, so ask before subscribing, for example with
`flutter_local_notifications`:
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.

```dart
await FlutterLocalNotificationsPlugin()
.resolvePlatformSpecificImplementation<AndroidFlutterLocalNotificationsPlugin>()
?.requestNotificationsPermission();

final sub = await push.subscribe('news', (message) => print(message.data),
background: true, title: 'News');

Expand Down
2 changes: 1 addition & 1 deletion 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.2"
version = "27.1.0-rc.3"

buildscript {
ext.kotlin_version = "2.1.0"
Expand Down
41 changes: 41 additions & 0 deletions android/src/main/kotlin/io/appwrite/flutter/AppwritePushPlugin.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package io.appwrite.flutter

import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.appwrite.services.PushBridge
import io.appwrite.services.PushMessage
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
Expand All @@ -15,13 +23,15 @@ import io.flutter.plugin.common.MethodChannel
*/
class AppwritePushPlugin :
FlutterPlugin,
ActivityAware,
MethodChannel.MethodCallHandler,
EventChannel.StreamHandler {
private val main = Handler(Looper.getMainLooper())
private var methods: MethodChannel? = null
private var events: EventChannel? = null
private var sink: EventChannel.EventSink? = null
private var bridge: PushBridge? = null
private var activity: Activity? = null

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
bridge = PushBridge(
Expand Down Expand Up @@ -53,6 +63,22 @@ class AppwritePushPlugin :
sink = null
}

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
}

override fun onDetachedFromActivityForConfigChanges() {
activity = null
}

override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
}

override fun onDetachedFromActivity() {
activity = null
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val bridge = bridge ?: return result.error(ERROR_CODE, "Push plugin is not attached", null)
try {
Expand All @@ -71,6 +97,7 @@ class AppwritePushPlugin :
}
result.success(
when (call.method) {
"requestNotificationPermission" -> requestNotificationPermission()
"ack" -> bridge.ack(call.argument<String>("token")!!).let { null }
"release" -> bridge.release().let { null }
"stop" -> bridge.stop().let { null }
Expand All @@ -95,6 +122,19 @@ class AppwritePushPlugin :
sink = null
}

// Android 13+: ask for POST_NOTIFICATIONS, which background notifications are posted with.
// Returns false when it could not ask because no Activity is attached, so the caller asks again.
private fun requestNotificationPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return true
}
val activity = activity ?: return false
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.POST_NOTIFICATIONS), PERMISSION_REQUEST_CODE)
}
return true
}

// The bridge calls back on background threads; event sinks must be used on the main thread.
private fun send(event: Map<String, Any?>) {
main.post { sink?.success(event) }
Expand All @@ -104,5 +144,6 @@ class AppwritePushPlugin :
const val METHOD_CHANNEL = "appwrite.push"
const val EVENT_CHANNEL = "appwrite.push/events"
const val ERROR_CODE = "appwrite_push"
const val PERMISSION_REQUEST_CODE = 9412
}
}
60 changes: 55 additions & 5 deletions android/src/main/kotlin/io/appwrite/services/PushBackground.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.ConnectivityManager
import android.net.Network
import android.os.Build
Expand All @@ -30,6 +32,8 @@ import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.ExecutionException
Expand All @@ -41,6 +45,9 @@ import java.util.concurrent.atomic.AtomicInteger

private const val LOG_TAG = "AppwritePush"

/** What a message's `notification` block asks a background notification to show; [present] is false without one. */
internal data class PushNotificationContent(val present: Boolean, val title: String?, val body: String?, val image: String?)

/** A background subscription saved across restarts: its filter, QoS choice and notification title. */
internal data class PushEntry(
val filter: String,
Expand Down Expand Up @@ -108,6 +115,7 @@ internal object PushBackground {
private const val HEARTBEAT_FAILURES = 3
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 CONNECT_TIMEOUT_SECONDS = 20L

// How long a message waits for a listener that acknowledges it itself (the React Native and
Expand Down Expand Up @@ -647,9 +655,11 @@ internal object PushBackground {
try {
// 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 }
titles.distinct().forEach { notify(context, message, it) }
// 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 {
releaseWakeLock(wakeLock)
}
Expand Down Expand Up @@ -679,19 +689,29 @@ internal object PushBackground {
return handled
}

/** Post a notification for [message] that opens the app, with the message in its extras. */
fun notify(context: Context, message: PushMessage, title: String) {
/**
* Post a notification for [message] that opens the app, with the message in its extras. It shows
* the server's title, body and image from [content], falling back to [title] and the raw payload.
*/
fun notify(context: Context, message: PushMessage, title: String, content: PushNotificationContent = notificationContent(message)) {
val manager = NotificationManagerCompat.from(context)
if (!manager.areNotificationsEnabled()) {
return
}
createChannels(context)
val id = 31 * message.hashCode() + title.hashCode()
val body = content.body ?: message.data.takeIf { !content.present }
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title)
.setContentText(message.data)
.setContentTitle(content.title ?: title)
.setSmallIcon(notificationIcon(context))
.setAutoCancel(true)
if (body != null) {
builder.setContentText(body).setStyle(NotificationCompat.BigTextStyle().bigText(body))
}
content.image?.let { loadImage(it) }?.let { image ->
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)
Expand All @@ -707,6 +727,36 @@ internal object PushBackground {
}
}

/** 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")
?: return PushNotificationContent(false, null, null, null)
fun field(name: String) = (notification.opt(name) as? String)?.takeIf { it.isNotEmpty() }
return PushNotificationContent(true, field("title"), field("body"), field("image"))
}

// Downloads notification images, so a slow one is abandoned without holding up delivery.
private val imageLoader = Executors.newCachedThreadPool { runnable ->
Thread(runnable, "AppwritePushImage").apply { isDaemon = true }
}

// Download a notification image, or null when it cannot be fetched and decoded within
// IMAGE_TIMEOUT_MS overall. On timeout the connection is closed, which ends a read in progress.
private fun loadImage(url: String): Bitmap? {
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<Bitmap?> { connection.inputStream.use { BitmapFactory.decodeStream(it) } }
Comment thread
hansi-codes[bot] marked this conversation as resolved.
return try {
download.get(IMAGE_TIMEOUT_MS.toLong(), TimeUnit.MILLISECONDS)
} catch (e: Exception) {
download.cancel(true)
null
} finally {
connection.disconnect()
}
}

/** 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) }
Expand Down
2 changes: 1 addition & 1 deletion lib/src/client_browser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class ClientBrowser extends ClientBase with ClientMixin {
'x-sdk-name': 'Flutter',
'x-sdk-platform': 'client',
'x-sdk-language': 'flutter',
'x-sdk-version': '27.1.0-rc.2',
'x-sdk-version': '27.1.0-rc.3',
'X-Appwrite-Response-Format': '2.3.0',
};

Expand Down
2 changes: 1 addition & 1 deletion lib/src/client_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class ClientIO extends ClientBase with ClientMixin {
'x-sdk-name': 'Flutter',
'x-sdk-platform': 'client',
'x-sdk-language': 'flutter',
'x-sdk-version': '27.1.0-rc.2',
'x-sdk-version': '27.1.0-rc.3',
'X-Appwrite-Response-Format': '2.3.0',
};

Expand Down
17 changes: 12 additions & 5 deletions lib/src/mqtt_browser.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'client.dart';
import 'exception.dart';
import 'mqtt.dart';
import 'mqtt_message.dart';
import 'mqtt_notification.dart';

Push createPush(Client client) => PushWeb(client);

Expand Down Expand Up @@ -617,17 +618,23 @@ class PushWeb implements Push {
payload: payload,
qos: publish.header?.qos.index ?? 0,
);
final content = PushNotificationContent.of(message);
final titles = <String>{};
for (final subscription in _subscriptions.values) {
if (_matches(subscription.filter, topic)) {
subscription.callback(message);
// Notification is per-subscription: only subs that opted in show one, each
// with its own title.
if (subscription.background &&
html.Notification.permission == 'granted') {
html.Notification(subscription.title ?? topic, body: message.data);
// Notification is per-subscription: only subs that opted in show one, each with
// its own title. A title the server sent replaces theirs, so one is shown.
if (subscription.background) {
titles.add(content.titleOr(subscription.title ?? topic));
}
}
}
if (html.Notification.permission == 'granted') {
Comment thread
hansi-codes[bot] marked this conversation as resolved.
for (final title in titles) {
html.Notification(title, body: content.bodyFor(message));
}
}
}
}

Expand Down
6 changes: 4 additions & 2 deletions lib/src/mqtt_foreground.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:path_provider/path_provider.dart';
import 'client.dart';
import 'exception.dart';
import 'mqtt.dart';
import 'mqtt_notification.dart';

/// Android/iOS background delivery for the native (dart:io) push service.
///
Expand Down Expand Up @@ -249,10 +250,11 @@ Future<void> pushForegroundEntry(ServiceInstance service) async {
// Notify only for filters that opted into background, each with its own title.
final title = notify[filter];
if (title != null) {
final content = PushNotificationContent.of(message);
notifications.show(
message.topic.hashCode,
title,
message.data,
content.titleOr(title),
content.bodyFor(message),
const NotificationDetails(
android: AndroidNotificationDetails(
_channelId,
Expand Down
13 changes: 13 additions & 0 deletions lib/src/mqtt_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ class PushIO implements Push {
// Every Push with live subscriptions, so the native host can take over their connections.
static final Set<PushIO> _livePushes = {};
static Future<void> _nativeQueue = Future<void>.value();
// Whether this run already asked for the notification permission background delivery posts with.
static bool _notificationPermissionRequested = false;

static Future<T> _enqueueNative<T>(Future<T> Function() op) {
final result = _nativeQueue.then((_) => op());
Expand Down Expand Up @@ -440,6 +442,17 @@ class PushIO implements Push {
if (subscriptions.isEmpty) {
return;
}
if (!_notificationPermissionRequested &&
_nativeHosts.any((push) => push._backgroundWanted)) {
// Counted as asked only once an Activity was there to ask from.
_notificationPermissionRequested = true;
unawaited(
native
.requestNotificationPermission()
.then((asked) => _notificationPermissionRequested = asked)
.catchError((_) => _notificationPermissionRequested = false),
);
}
final (authMethod, credential) = _credential();
final config = {
'host': _host,
Expand Down
6 changes: 6 additions & 0 deletions lib/src/mqtt_native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ class PushNative {
'subscriptions': subscriptionsJson,
});

/// Ask for the notification permission background messages are posted with (Android 13+),
/// without waiting for the answer. False when no Activity was attached to ask from.
Future<bool> requestNotificationPermission() async =>
await _methods.invokeMethod<bool>('requestNotificationPermission') ??
false;

/// Acknowledge a message once its callback has run.
Future<void> ack(String token) =>
_methods.invokeMethod<void>('ack', {'token': token});
Expand Down
Loading
Loading