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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 4 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
60 changes: 55 additions & 5 deletions android/src/main/java/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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};

Expand Down
14 changes: 13 additions & 1 deletion src/react-native-shim.d.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
request(permission: string): Promise<string>;
};

// The slice of the native module API the Push service uses on Android.
Expand Down Expand Up @@ -100,7 +108,11 @@ declare module 'expo-notifications' {
channel: { name: string; importance: number },
): Promise<unknown>;
export function scheduleNotificationAsync(request: {
content: { title: string; body: string };
content: {
title: string;
body: string | null;
data?: Record<string, unknown>;
};
trigger: null;
}): Promise<string>;
}
84 changes: 78 additions & 6 deletions src/services/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
private async notify(message: PushMessage, title: string): Promise<void> {
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,
});
Expand Down Expand Up @@ -1338,16 +1344,21 @@ export class Push extends Service {
payload,
qos: packet.qos,
};
const serverTitle = notificationContent(message).title;
const titles = new Set<string>();
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);
}
Comment thread
hansi-codes[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -1385,6 +1396,67 @@ const nativeHosts = new Set<Push>();
const livePushes = new Set<Push>();
let nativeQueue: Promise<unknown> = 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<string, unknown>)[name];
return typeof value === 'string' && value !== '' ? value : undefined;
};
return {
present: true,
title: field('title'),
body: field('body'),
image: field('image'),
};
}

function enqueueNative<T>(op: () => Promise<T>): Promise<T> {
const result = nativeQueue.then(op);
nativeQueue = result.catch(() => undefined);
Expand Down
Loading