From 76ff4bb8919d410f1acfed9a987cf5d384d8220b Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 6 Oct 2026 11:22:28 +0000 Subject: [PATCH] chore: update Flutter SDK to 27.1.0-rc.3 --- CHANGELOG.md | 7 +++ README.md | 14 ++--- android/build.gradle | 2 +- .../io/appwrite/flutter/AppwritePushPlugin.kt | 41 +++++++++++++ .../io/appwrite/services/PushBackground.kt | 60 +++++++++++++++++-- lib/src/client_browser.dart | 2 +- lib/src/client_io.dart | 2 +- lib/src/mqtt_browser.dart | 17 ++++-- lib/src/mqtt_foreground.dart | 6 +- lib/src/mqtt_io.dart | 13 ++++ lib/src/mqtt_native.dart | 6 ++ lib/src/mqtt_notification.dart | 49 +++++++++++++++ pubspec.yaml | 2 +- 13 files changed, 196 insertions(+), 25 deletions(-) create mode 100644 lib/src/mqtt_notification.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a07b53..a74749a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 +* 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 diff --git a/README.md b/README.md index 0f96133a..f8d33ae5 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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() - ?.requestNotificationsPermission(); - final sub = await push.subscribe('news', (message) => print(message.data), background: true, title: 'News'); diff --git a/android/build.gradle b/android/build.gradle index 165542c6..6770cc69 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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" diff --git a/android/src/main/kotlin/io/appwrite/flutter/AppwritePushPlugin.kt b/android/src/main/kotlin/io/appwrite/flutter/AppwritePushPlugin.kt index c68a60c6..e840f5b4 100644 --- a/android/src/main/kotlin/io/appwrite/flutter/AppwritePushPlugin.kt +++ b/android/src/main/kotlin/io/appwrite/flutter/AppwritePushPlugin.kt @@ -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 @@ -15,6 +23,7 @@ import io.flutter.plugin.common.MethodChannel */ class AppwritePushPlugin : FlutterPlugin, + ActivityAware, MethodChannel.MethodCallHandler, EventChannel.StreamHandler { private val main = Handler(Looper.getMainLooper()) @@ -22,6 +31,7 @@ class AppwritePushPlugin : 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( @@ -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 { @@ -71,6 +97,7 @@ class AppwritePushPlugin : } result.success( when (call.method) { + "requestNotificationPermission" -> requestNotificationPermission() "ack" -> bridge.ack(call.argument("token")!!).let { null } "release" -> bridge.release().let { null } "stop" -> bridge.stop().let { null } @@ -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) { main.post { sink?.success(event) } @@ -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 } } diff --git a/android/src/main/kotlin/io/appwrite/services/PushBackground.kt b/android/src/main/kotlin/io/appwrite/services/PushBackground.kt index 52517344..a550a555 100644 --- a/android/src/main/kotlin/io/appwrite/services/PushBackground.kt +++ b/android/src/main/kotlin/io/appwrite/services/PushBackground.kt @@ -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 @@ -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 @@ -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, @@ -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 @@ -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) } @@ -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) @@ -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 { connection.inputStream.use { BitmapFactory.decodeStream(it) } } + 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) } diff --git a/lib/src/client_browser.dart b/lib/src/client_browser.dart index de4019ef..4caa7081 100644 --- a/lib/src/client_browser.dart +++ b/lib/src/client_browser.dart @@ -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', }; diff --git a/lib/src/client_io.dart b/lib/src/client_io.dart index 5ae8d8b9..5b4dcde9 100644 --- a/lib/src/client_io.dart +++ b/lib/src/client_io.dart @@ -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', }; diff --git a/lib/src/mqtt_browser.dart b/lib/src/mqtt_browser.dart index 4e291b43..dfba4b78 100644 --- a/lib/src/mqtt_browser.dart +++ b/lib/src/mqtt_browser.dart @@ -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); @@ -617,17 +618,23 @@ class PushWeb implements Push { payload: payload, qos: publish.header?.qos.index ?? 0, ); + final content = PushNotificationContent.of(message); + final titles = {}; 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') { + for (final title in titles) { + html.Notification(title, body: content.bodyFor(message)); + } + } } } diff --git a/lib/src/mqtt_foreground.dart b/lib/src/mqtt_foreground.dart index 8636f355..7086bf08 100644 --- a/lib/src/mqtt_foreground.dart +++ b/lib/src/mqtt_foreground.dart @@ -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. /// @@ -249,10 +250,11 @@ Future 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, diff --git a/lib/src/mqtt_io.dart b/lib/src/mqtt_io.dart index de665f10..a213fc0a 100644 --- a/lib/src/mqtt_io.dart +++ b/lib/src/mqtt_io.dart @@ -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 _livePushes = {}; static Future _nativeQueue = Future.value(); + // Whether this run already asked for the notification permission background delivery posts with. + static bool _notificationPermissionRequested = false; static Future _enqueueNative(Future Function() op) { final result = _nativeQueue.then((_) => op()); @@ -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, diff --git a/lib/src/mqtt_native.dart b/lib/src/mqtt_native.dart index 11509936..2e060782 100644 --- a/lib/src/mqtt_native.dart +++ b/lib/src/mqtt_native.dart @@ -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 requestNotificationPermission() async => + await _methods.invokeMethod('requestNotificationPermission') ?? + false; + /// Acknowledge a message once its callback has run. Future ack(String token) => _methods.invokeMethod('ack', {'token': token}); diff --git a/lib/src/mqtt_notification.dart b/lib/src/mqtt_notification.dart new file mode 100644 index 00000000..2ed24a6f --- /dev/null +++ b/lib/src/mqtt_notification.dart @@ -0,0 +1,49 @@ +import 'dart:convert'; + +import 'mqtt_message.dart'; + +/// What a message's `notification` block asks a background notification to show: nulls when +/// the payload has none or is not JSON. +class PushNotificationContent { + /// Whether the message has a `notification` block at all. + final bool present; + final String? title; + final String? body; + final String? image; + + const PushNotificationContent._( + this.present, + this.title, + this.body, + this.image, + ); + + factory PushNotificationContent.of(PushMessage message) { + Object? notification; + try { + final json = jsonDecode(message.data); + notification = json is Map ? json['notification'] : null; + } catch (_) { + notification = null; + } + String? field(String name) { + final value = notification is Map ? notification[name] : null; + return value is String && value.isNotEmpty ? value : null; + } + + return PushNotificationContent._( + notification is Map, + field('title'), + field('body'), + field('image'), + ); + } + + /// The title to show: the server's, else [fallback]. + String titleOr(String fallback) => title ?? fallback; + + /// The body to show: the server's, else the raw payload when the message carries no + /// notification block at all. + String? bodyFor(PushMessage message) => + body ?? (present ? null : message.data); +} diff --git a/pubspec.yaml b/pubspec.yaml index 55ed3795..78cf462f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: appwrite -version: 27.1.0-rc.2 +version: 27.1.0-rc.3 description: Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API homepage: https://appwrite.io repository: https://github.com/appwrite/sdk-for-flutter