From 9687d202edf20c853587fdb8dcc53d3ba3307734 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 6 Oct 2026 11:23:28 +0000 Subject: [PATCH] chore: update React Native SDK to 1.2.0-rc.1 --- CHANGELOG.md | 8 ++ README.md | 13 +-- .../io/appwrite/services/PushBackground.kt | 60 +++++++++++-- package-lock.json | 4 +- package.json | 2 +- src/client.ts | 2 +- src/react-native-shim.d.ts | 14 +++- src/services/push.ts | 84 +++++++++++++++++-- 8 files changed, 162 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df35480f..952db61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Change log +## 1.2.0-rc.1 + +* Added: background push notifications render the server `notification` title, body, and image +* Added: Android shows notification images (BigPictureStyle) and long bodies (BigTextStyle) +* Added: the SDK requests Android 13+ `POST_NOTIFICATIONS` itself on first background subscribe +* Added: notification `data` now carries `topic` and `payload` +* Fixed: a server-sent title dedupes to one notification per topic instead of one per subscription + ## 1.2.0-rc.0 * Added: `Apps` service to manage OAuth2 apps, keys, secrets, and installations diff --git a/README.md b/README.md index b4ee0eda..b2579f08 100644 --- a/README.md +++ b/README.md @@ -42,17 +42,12 @@ between (`retry: true`). Messages no in-app callback receives are posted as noti 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 (the SDK declares it in its manifest). Without it, the -subscription still delivers to your callback but posts no notification, so ask before subscribing: +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. ```js -import { PermissionsAndroid, Platform } from 'react-native'; - -if (Platform.OS === 'android' && Platform.Version >= 33) { - await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); -} - const sub = await push.subscribe('news', (message) => console.log(message.data), { background: true, title: 'News', diff --git a/android/src/main/java/io/appwrite/services/PushBackground.kt b/android/src/main/java/io/appwrite/services/PushBackground.kt index 52517344..a550a555 100644 --- a/android/src/main/java/io/appwrite/services/PushBackground.kt +++ b/android/src/main/java/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/package-lock.json b/package-lock.json index a6215489..0a128e84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "react-native-appwrite", - "version": "1.2.0-rc.0", + "version": "1.2.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "react-native-appwrite", - "version": "1.2.0-rc.0", + "version": "1.2.0-rc.1", "license": "BSD-3-Clause", "dependencies": { "buffer": "6.0.3", diff --git a/package.json b/package.json index 90a6c48a..16c14202 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "react-native-appwrite", "homepage": "https://appwrite.io/support", "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", - "version": "1.2.0-rc.0", + "version": "1.2.0-rc.1", "license": "BSD-3-Clause", "main": "dist/cjs/sdk.js", "exports": { diff --git a/src/client.ts b/src/client.ts index b6ae3afe..605f530c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -218,7 +218,7 @@ class Client { 'x-sdk-name': 'React Native', 'x-sdk-platform': 'client', 'x-sdk-language': 'reactnative', - 'x-sdk-version': '1.2.0-rc.0', + 'x-sdk-version': '1.2.0-rc.1', 'X-Appwrite-Response-Format': '2.3.0', }; diff --git a/src/react-native-shim.d.ts b/src/react-native-shim.d.ts index da1709b2..914861ba 100644 --- a/src/react-native-shim.d.ts +++ b/src/react-native-shim.d.ts @@ -1,6 +1,14 @@ declare module 'react-native' { export const Platform: { readonly OS: string; + readonly Version: number | string; + }; + + // The slice of the permissions API Push uses to ask for POST_NOTIFICATIONS on Android 13+. + export const PermissionsAndroid: { + readonly PERMISSIONS: { readonly POST_NOTIFICATIONS: string }; + check(permission: string): Promise; + request(permission: string): Promise; }; // The slice of the native module API the Push service uses on Android. @@ -100,7 +108,11 @@ declare module 'expo-notifications' { channel: { name: string; importance: number }, ): Promise; export function scheduleNotificationAsync(request: { - content: { title: string; body: string }; + content: { + title: string; + body: string | null; + data?: Record; + }; trigger: null; }): Promise; } diff --git a/src/services/push.ts b/src/services/push.ts index c2eb8879..8ae70847 100644 --- a/src/services/push.ts +++ b/src/services/push.ts @@ -735,6 +735,9 @@ export class Push extends Service { if (subscriptions.length === 0) { return; } + if ([...nativeHosts].some((push) => push.hasBackgroundSubs())) { + requestNotificationPermission(); + } const { authMethod, credential } = this.credential(); const config = { host: this.host, @@ -1032,15 +1035,18 @@ export class Push extends Service { } /** Post a local notification for a message a background subscription matched. */ - private async notify(message: PushMessage, title?: string): Promise { + private async notify(message: PushMessage, title: string): Promise { try { /* eslint-disable @typescript-eslint/no-require-imports */ const notifications: typeof import('expo-notifications') = require('expo-notifications'); /* eslint-enable @typescript-eslint/no-require-imports */ + const content = notificationContent(message); + const raw = !content.present; await notifications.scheduleNotificationAsync({ content: { - title: title ?? message.topic, - body: message.data, + title: content.title ?? title, + body: content.body ?? (raw ? message.data : null), + data: { topic: message.topic, payload: message.data }, }, trigger: null, }); @@ -1338,16 +1344,21 @@ export class Push extends Service { payload, qos: packet.qos, }; + const serverTitle = notificationContent(message).title; + const titles = new Set(); for (const sub of this.subscriptions.values()) { if (matches(sub.topic, message.topic)) { await sub.callback(message); - // Notification is per-subscription: only subs that opted in post one, - // each with its own title. + // Notification is per-subscription: only subs that opted in post one, each + // with its own title. A title the server sent replaces theirs, so one posts. if (sub.background) { - await this.notify(message, sub.title); + titles.add(serverTitle ?? sub.title ?? message.topic); } } } + for (const title of titles) { + await this.notify(message, title); + } } } @@ -1385,6 +1396,67 @@ const nativeHosts = new Set(); const livePushes = new Set(); let nativeQueue: Promise = Promise.resolve(); +let notificationPermissionRequested = false; + +/** + * Android 13+: ask once per run for the notification permission that background messages are + * posted with. It does not wait for the answer, and a denial only leaves notifications off. + */ +function requestNotificationPermission(): void { + if ( + notificationPermissionRequested || + Platform.OS !== 'android' || + Number(Platform.Version) < 33 + ) { + return; + } + notificationPermissionRequested = true; + // Required here rather than imported: react-native-web, which web builds alias, lacks it. + /* eslint-disable @typescript-eslint/no-require-imports */ + const reactNative: typeof import('react-native') = require('react-native'); + /* eslint-enable @typescript-eslint/no-require-imports */ + const { PermissionsAndroid } = reactNative; + const permission = PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS; + PermissionsAndroid.check(permission) + .then((granted) => + granted ? undefined : PermissionsAndroid.request(permission), + ) + .catch(() => undefined); +} + +/** What a message's `notification` block asks a background notification to show. */ +interface NotificationContent { + /** Whether the message has a `notification` block at all. */ + present: boolean; + title?: string; + body?: string; + image?: string; +} + +/** The server's `notification` block in a message; empty when the payload has none or is not JSON. */ +function notificationContent(message: PushMessage): NotificationContent { + let notification: unknown; + try { + notification = (JSON.parse(message.data) as { notification?: unknown }) + ?.notification; + } catch { + return { present: false }; + } + if (typeof notification !== 'object' || notification === null) { + return { present: false }; + } + const field = (name: string): string | undefined => { + const value = (notification as Record)[name]; + return typeof value === 'string' && value !== '' ? value : undefined; + }; + return { + present: true, + title: field('title'), + body: field('body'), + image: field('image'), + }; +} + function enqueueNative(op: () => Promise): Promise { const result = nativeQueue.then(op); nativeQueue = result.catch(() => undefined);