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

## 1.2.0-rc.0

* Added: `Apps` service to manage OAuth2 apps, keys, secrets, and installations
* Added: `Oauth2` service for authorization, consent, device, PAR, and token flows
* Added: `Push` service for MQTT realtime push with Android background delivery
* Added: `Topic` builder and native TCP modules for push
* Added: `Avatars.updatePhoto()` and `Avatars.deletePhoto()` methods
* Added: `App`, `Oauth2*`, and `Account` models
* Added: `Webflow` to the `OAuthProvider` enum
* Fixed: cookie session fallback is guarded to Expo web where `localStorage` exists

## 1.1.0

* Added: `Account.createRecoveryOTP` and `Account.updateRecoveryOTP` for code-based password recovery
Expand Down
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,54 @@ To install
npx expo install react-native-appwrite react-native-url-polyfill
```

### Push

`Push` connects over a raw TCP socket, so it needs `react-native-tcp-socket` and a development
or standalone build (native modules do not run in Expo Go).

On Expo SDK 52 and older, enable package exports in `metro.config.js` so `mqtt` resolves to its
React Native build (Expo SDK 53 and later do this by default):

```js
config.resolver.unstable_enablePackageExports = true;
```

#### Background delivery on Android

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 SDK's native Android module (autolinked) 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 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:

```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',
});
Comment thread
hansi-codes[bot] marked this conversation as resolved.

// Optional: immediate delivery even after a kill and during Doze, with a quiet ongoing
// notification (call while the app is in the foreground).
await push.setForeground(true);
```

Foreground mode runs a `remoteMessaging` foreground service, which Google Play asks apps to
declare in the Play Console. Apps that never enable it can remove the service from their merged
manifest with `tools:node="remove"` on `io.appwrite.services.PushService` and
`android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING`.


## Getting Started

Expand Down
39 changes: 39 additions & 0 deletions android/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
root = true

[*.{kt,kts}]
ktlint_code_style = ktlint_official
ktlint_standard_annotation = disabled
ktlint_standard_argument-list-wrapping = disabled
ktlint_standard_blank-line-before-declaration = disabled
ktlint_standard_blank-line-between-when-conditions = disabled
ktlint_standard_chain-method-continuation = disabled
ktlint_standard_class-signature = disabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-signature = disabled
ktlint_standard_function-start-of-body-spacing = disabled
ktlint_standard_if-else-bracing = disabled
ktlint_standard_if-else-wrapping = disabled
ktlint_standard_max-line-length = disabled
ktlint_standard_multiline-expression-wrapping = disabled
ktlint_standard_multiline-if-else = disabled
ktlint_standard_no-blank-line-before-rbrace = disabled
ktlint_standard_no-blank-line-in-list = disabled
ktlint_standard_no-empty-first-line-in-class-body = disabled
ktlint_standard_no-line-break-before-assignment = disabled
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_parameter-list-wrapping = disabled
ktlint_standard_spacing-between-declarations-with-annotations = disabled
ktlint_standard_spacing-between-declarations-with-comments = disabled
ktlint_standard_string-template = disabled
ktlint_standard_trailing-comma-on-call-site = disabled
ktlint_standard_trailing-comma-on-declaration-site = disabled
ktlint_standard_when-entry-bracing = disabled
ktlint_standard_wrapping = disabled

[**/Channel.kt]
# Marker types intentionally use leading underscores to distinguish channel states.
ktlint_standard_class-naming = disabled

[**/models/*.kt]
# Model imports follow schema property order so generated declarations stay deterministic.
ktlint_standard_import-ordering = disabled
40 changes: 40 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// 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.
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}

apply plugin: "com.android.library"
apply plugin: "kotlin-android"

android {
namespace "io.appwrite.reactnative"
compileSdkVersion safeExtGet("compileSdkVersion", 35)

defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 24)
targetSdkVersion safeExtGet("targetSdkVersion", 35)
// R8 rules for the bundled MQTT client, applied to the app.
consumerProguardFiles "consumer-rules.pro"
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "17"
}
}

repositories {
google()
mavenCentral()
}

dependencies {
implementation "com.facebook.react:react-android"
implementation "com.hivemq:hivemq-mqtt-client-shaded:1.3.6"
implementation "androidx.core:core-ktx:1.13.1"
}
27 changes: 27 additions & 0 deletions android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Push: rules applied to apps that shrink with R8.
#
# The shaded HiveMQ MQTT client relocates Netty and JCTools under
# com.hivemq.client.internal.shaded. Netty references optional classes that are never loaded
# on Android: native transports, the HTTP/WebSocket codecs, proxy handlers, alternative TLS
# providers (tcnative, BouncyCastle, Conscrypt, Jetty ALPN) and logging backends.
-dontwarn com.hivemq.client.internal.shaded.io.netty.channel.epoll.**
-dontwarn com.hivemq.client.internal.shaded.io.netty.channel.kqueue.**
-dontwarn com.hivemq.client.internal.shaded.io.netty.handler.codec.http.**
-dontwarn com.hivemq.client.internal.shaded.io.netty.handler.proxy.**
-dontwarn com.hivemq.client.internal.shaded.io.netty.internal.tcnative.**
-dontwarn org.bouncycastle.**
-dontwarn org.conscrypt.**
-dontwarn org.eclipse.jetty.**
-dontwarn org.apache.log4j.**
-dontwarn org.apache.logging.log4j.**
-dontwarn org.slf4j.**
-dontwarn reactor.blockhound.**

# Netty and JCTools reach their own fields by name, through atomic field updaters and Unsafe offsets.
-keepclassmembernames class com.hivemq.client.internal.shaded.io.netty.** { <fields>; }
-keepclassmembers class com.hivemq.client.internal.shaded.org.jctools.** { <fields>; }

# Netty's leak detector looks these methods up by name when its classes load.
-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.buffer.AbstractByteBufAllocator { *** toLeakAwareBuffer(...); }
-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.buffer.AdvancedLeakAwareByteBuf { *** recordLeakNonRefCountingOperation(...); }
-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.util.ReferenceCountUtil { *** touch(...); }
41 changes: 41 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Background Push delivery (subscribe(background = true)): scheduled runs that reconnect
under a short wakelock, on the current network, and resume after a reboot. Apps can also
declare SCHEDULE_EXACT_ALARM (punctual runs) and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
(a direct exemption prompt); both are optional and subject to Google Play policy. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Foreground-service background Push delivery (Push.setForeground). Apps that never enable it can
remove PushService and these two permissions with tools:node="remove". -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />

<application>
<service
android:name="io.appwrite.services.PushService"
android:exported="false"
android:foregroundServiceType="remoteMessaging"
android:stopWithTask="false" />
<service
android:name="io.appwrite.services.PushJobService"
android:exported="false"
android:permission="android.permission.BIND_JOB_SERVICE" />
<receiver
android:name="io.appwrite.services.PushAlarmReceiver"
android:exported="false" />
<receiver
android:name="io.appwrite.services.PushBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package io.appwrite.exceptions

/** A push error. When the broker explained a refusal or a disconnect, that is its message. */
class AppwriteException(
override val message: String? = null,
) : Exception(message)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package io.appwrite.reactnative

import android.webkit.CookieManager
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod

class AppwriteCookiesModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = NAME

@ReactMethod
fun session(url: String, project: String, promise: Promise) {
val name = "a_session_$project"
try {
val value = CookieManager.getInstance().getCookie(url)
?.split(";")
?.map { it.trim() }
?.firstOrNull { it.substringBefore("=") == name }
?.substringAfter("=")
promise.resolve(value)
} catch (e: Exception) {
promise.reject(ERROR_CODE, e.message, e)
}
}

companion object {
const val NAME = "AppwriteCookies"
private const val ERROR_CODE = "appwrite_cookies"
}
}
127 changes: 127 additions & 0 deletions android/src/main/java/io/appwrite/reactnative/AppwritePushModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package io.appwrite.reactnative

import android.util.Base64
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.modules.core.DeviceEventManagerModule
import io.appwrite.services.PushBridge
import io.appwrite.services.PushMessage

/**
* The React Native side of [PushBridge]: the SDK's `Push` hosts its background subscriptions
* here on Android, and receives their messages and errors as events.
*
* [emit] sends an event to JS; tests replace it to observe what JS would receive.
*/
class AppwritePushModule internal constructor(
private val reactContext: ReactApplicationContext,
private val emit: (String, Map<String, Any?>) -> Unit,
) : ReactContextBaseJavaModule(reactContext) {
constructor(reactContext: ReactApplicationContext) : this(
reactContext,
{ event, body ->
if (reactContext.hasActiveReactInstance()) {
reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(event, Arguments.makeNativeMap(body))
}
},
)

private val bridge = PushBridge(
reactContext,
object : PushBridge.Events {
override fun onMessage(subscriptionId: String, message: PushMessage, ackToken: String) = emit(
MESSAGE_EVENT,
mapOf(
"id" to subscriptionId,
"topic" to message.topic,
"payload" to Base64.encodeToString(message.payload, Base64.NO_WRAP),
"qos" to message.qos,
"ackToken" to ackToken,
),
)

override fun onError(message: String) = emit(ERROR_EVENT, mapOf("message" to message))
},
)

override fun getName(): String = NAME

// Resolves once the connection is up and every filter is subscribed, or rejects with why not.
@ReactMethod
fun host(config: String, subscriptions: String, promise: Promise) {
try {
bridge.host(config, subscriptions) { error ->
if (error == null) {
promise.resolve(null)
} else {
promise.reject(ERROR_CODE, error)
}
}
} catch (e: Exception) {
promise.reject(ERROR_CODE, e.message, e)
}
}

@ReactMethod
fun ack(token: String) = bridge.ack(token)

@ReactMethod
fun release(promise: Promise) = settle(promise) {
bridge.release()
null
}

@ReactMethod
fun stop(promise: Promise) = settle(promise) {
bridge.stop()
null
}

@ReactMethod
fun setForeground(enabled: Boolean, promise: Promise) = settle(promise) {
bridge.setForeground(enabled)
null
}

@ReactMethod
fun hasSaved(promise: Promise) = settle(promise) { bridge.hasSaved() }

@ReactMethod
fun resume(promise: Promise) = settle(promise) {
bridge.resume()
null
}

@ReactMethod
fun setErrorCallback(registered: Boolean, promise: Promise) = settle(promise) { bridge.setErrorCallback(registered) }

@ReactMethod
fun defaultClientId(authMethod: String, credential: String, promise: Promise) =
settle(promise) { bridge.defaultClientId(authMethod, credential) }

// Required by NativeEventEmitter; events are emitted whether or not anyone listens.
@ReactMethod
fun addListener(eventName: String) = Unit

@ReactMethod
fun removeListeners(count: Double) = Unit

private fun settle(promise: Promise, block: () -> Any?) {
try {
promise.resolve(block())
} catch (e: Exception) {
promise.reject(ERROR_CODE, e.message, e)
}
}

companion object {
const val NAME = "AppwritePush"
const val MESSAGE_EVENT = "AppwritePushMessage"
const val ERROR_EVENT = "AppwritePushError"
private const val ERROR_CODE = "appwrite_push"
}
}
Loading