diff --git a/.gitignore b/.gitignore index 191aacd35..ced4b0230 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ project.xcworkspace .settings local.properties android.iml +*google-services.json # Cocoapods # diff --git a/README.md b/README.md index 60535d5c8..3113f9ec5 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,73 @@ We're working on testing the end-to-end installation and will provide more platf Your app must use Android `minSdkVersion = 22` to work with the `xmtp-react-native` SDK. We're working on testing the end-to-end installation and will provide more platform-specific configuration details. + +## Enable the example app to send push notifications + +You can use a Firebase Cloud Messaging server and an example push notification server to enable the `xmtp-react-native` example app to send push notifications. + +Perform this setup to understand how you might want to enable push notifications for your own app built with the `xmtp-react-native` SDK. + +### Set up a Firebase Cloud Messaging server + +For this tutorial, we'll use [Firebase Cloud Messaging](https://console.firebase.google.com/) (FCM) as a convenient way to set up a messaging server. + +1. Create an FCM project. + +2. Add the example app to the FCM project. This generates a `google-services.json` file that you need in subsequent steps. + +3. Add the `google-services.json` file to the example app's project as described in the FCM project creation process. + +4. Generate FCM credentials, which you need to run the example notification server. To do this, from the FCM dashboard, click the gear icon next to **Project Overview** and select **Project settings**. Select **Service accounts**. Select **Go** and click **Generate new private key**. + +### Run an example notification server + +Now that you have an FCM server set up, take a look at the [export-kotlin-proto-code](https://github.com/xmtp/example-notification-server-go/tree/np/export-kotlin-proto-code) branch in the `example-notifications-server-go` repo. + +This example branch can serve as the basis for what you might want to provide for your own notification server. The branch also demonstrates how to generate the proto code if you decide to perform these tasks for your own app. This proto code from the example notification server has already been generated in the `xmtp-android` example app. + +**To run a notification server based on the example branch:** + +1. Clone the [example-notification-server-go](https://github.com/xmtp/example-notification-server-go) repo. + +2. Complete the steps in [Local Setup](https://github.com/xmtp/example-notification-server-go/blob/np/export-kotlin-proto-code/README.md#local-setup). + +3. Get the FCM project ID and FCM credentials you created earlier and run: + + ```bash + YOURFCMJSON=`cat YOURFIREBASEADMINFROMSTEP4.json` + ``` + + ```bash + dev/run \ + --xmtp-listener-tls \ + --xmtp-listener \ + --api \ + -x "production.xmtp.network:5556" \ + -d "postgres://postgres:xmtp@localhost:25432/postgres?sslmode=disable" \ + --fcm-enabled \ + --fcm-credentials-json=$YOURFCMJSON \ + --fcm-project-id="YOURFCMPROJECTID" + ``` + +4. You should now be able to see push notifications coming across the local network. + +### Update the Android example app to send push notifications + +1. Add your `google-services.json` file to the `example/android/app` folder if you haven't already done it as a part of the FCM project creation process. + +2. Uncomment `apply plugin: 'com.google.gms.google-services'` in the example app's `build.gradle` file. + +3. Uncomment `classpath('com.google.gms:google-services:4.3.15')` in the top level of the example app's `build.gradle` file. + +4. Sync the gradle project. + +5. Replace `YOUR_SERVER_ADDRESS` in the `PullController.ts` file. If you're using the example notification server, it should be something like `YOURIPADDRESS:8080` since the Android emulator takes over localhost. + +6. Change the example app's environment to `production` in both places in `AuthView.tsx`. + +7. Replace `YOUR_FIREBASE_SENDER_ID` in the `PullController.ts` with your sender ID from Firebase. + +### Update the iOS example app to send push notifications + +Coming soon. diff --git a/android/build.gradle b/android/build.gradle index 400ca1bf3..fb75bfaa9 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -88,6 +88,6 @@ repositories { dependencies { implementation project(':expo-modules-core') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" - implementation "org.xmtp:android:0.1.1" + implementation "org.xmtp:android:0.1.3" implementation 'com.google.code.gson:gson:2.10.1' } diff --git a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt index 41c7b0374..24c0951de 100644 --- a/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt +++ b/android/src/main/java/expo/modules/xmtpreactnativesdk/XMTPModule.kt @@ -19,9 +19,11 @@ import org.xmtp.android.library.Conversation import org.xmtp.android.library.SigningKey import org.xmtp.android.library.XMTPEnvironment import org.xmtp.android.library.XMTPException +import org.xmtp.android.library.messages.EnvelopeBuilder import org.xmtp.android.library.messages.InvitationV1ContextBuilder import org.xmtp.android.library.messages.PrivateKeyBuilder import org.xmtp.android.library.messages.Signature +import org.xmtp.android.library.push.XMTPPush import org.xmtp.proto.message.contents.SignatureOuterClass import java.util.Date import java.util.UUID @@ -84,6 +86,7 @@ class XMTPModule : Module() { ) private var client: Client? = null + private var xmtpPush: XMTPPush? = null private var signer: ReactNativeSigner? = null private val conversations: MutableMap = mutableMapOf() private val subscriptions: MutableMap = mutableMapOf() @@ -92,7 +95,7 @@ class XMTPModule : Module() { Name("XMTP") Events("sign", "authed", "conversation", "message") - Function("address") { + Function("address") { clientAddress: String -> if (client != null) { client!!.address } else { @@ -129,7 +132,7 @@ class XMTPModule : Module() { // // Client API - AsyncFunction("listConversations") { -> + AsyncFunction("listConversations") { clientAddress: String -> if (client == null) { throw XMTPException("No client") } @@ -140,7 +143,7 @@ class XMTPModule : Module() { } } - AsyncFunction("loadMessages") { conversationTopic: String, conversationID: String?, limit: Int?, before: Long?, after: Long? -> + AsyncFunction("loadMessages") { clientAddress: String, conversationTopic: String, conversationID: String?, limit: Int?, before: Long?, after: Long? -> if (client == null) { throw XMTPException("No client") } @@ -155,7 +158,7 @@ class XMTPModule : Module() { } // TODO: Support content types - AsyncFunction("sendMessage") { conversationTopic: String, conversationID: String?, content: String -> + AsyncFunction("sendMessage") { clientAddress: String, conversationTopic: String, conversationID: String?, content: String -> if (client == null) { throw XMTPException("No client") } @@ -168,7 +171,7 @@ class XMTPModule : Module() { DecodedMessageWrapper.encode(decodedMessage) } - AsyncFunction("createConversation") { peerAddress: String, conversationID: String? -> + AsyncFunction("createConversation") { clientAddress: String, peerAddress: String, conversationID: String? -> if (client == null) { throw XMTPException("No client") } @@ -181,15 +184,44 @@ class XMTPModule : Module() { ConversationWrapper.encode(conversation) } - Function("subscribeToConversations") { subscribeToConversations() } + Function("subscribeToConversations") { clientAddress: String -> + subscribeToConversations() + } - AsyncFunction("subscribeToMessages") { topic: String, conversationID: String? -> + AsyncFunction("subscribeToMessages") { clientAddress: String, topic: String, conversationID: String? -> subscribeToMessages(topic = topic, conversationId = conversationID) } - AsyncFunction("unsubscribeFromMessages") { topic: String, conversationID: String? -> + AsyncFunction("unsubscribeFromMessages") { clientAddress: String, topic: String, conversationID: String? -> unsubscribeFromMessages(topic = topic, conversationId = conversationID) } + + Function("registerPushToken") { pushServer: String, token: String -> + xmtpPush = XMTPPush(appContext.reactContext!!, pushServer) + xmtpPush?.register(token) + } + + Function("subscribePushTopics") { topics: List -> + if (topics.isNotEmpty()) { + if (xmtpPush == null) { + throw XMTPException("Push server not registered") + } + xmtpPush?.subscribe(topics) + } + } + + AsyncFunction("decodeMessage") { topic: String, encryptedMessage: String, conversationID: String? -> + if (client == null) { + throw XMTPException("No client") + } + val encryptedMessageData = Base64.decode(encryptedMessage, Base64.NO_WRAP) + val envelope = EnvelopeBuilder.buildFromString(topic, Date(), encryptedMessageData) + val conversation = + findConversation(topic = topic, conversationId = conversationID) + ?: throw XMTPException("no conversation found for $topic") + val decodedMessage = conversation.decode(envelope) + DecodedMessageWrapper.encode(decodedMessage) + } } // diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 8e626a35b..397bea082 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -1,5 +1,6 @@ apply plugin: "com.android.application" apply plugin: "com.facebook.react" +// apply plugin: 'com.google.gms.google-services' import com.android.build.OutputFile @@ -192,6 +193,7 @@ android { dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") + implementation platform('com.google.firebase:firebase-bom:31.5.0') def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 747b4c6ca..800426965 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,34 +1,99 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/example/android/build.gradle b/example/android/build.gradle index 920667c0d..73874b1c1 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -21,6 +21,7 @@ buildscript { dependencies { classpath('com.android.tools.build:gradle:7.4.1') classpath('com.facebook.react:react-native-gradle-plugin') + // classpath('com.google.gms:google-services:4.3.15') } } diff --git a/example/package-lock.json b/example/package-lock.json index f31a6a90b..194e28ef2 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -20,6 +20,7 @@ "react-native-crypto": "^2.2.0", "react-native-get-random-values": "^1.8.0", "react-native-mmkv": "^2.8.0", + "react-native-push-notification": "^8.1.1", "react-native-randombytes": "^3.6.1", "react-native-safe-area-context": "4.5.0", "react-native-screens": "~3.20.0", @@ -5823,6 +5824,19 @@ "node": ">=8" } }, + "node_modules/@react-native-community/push-notification-ios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@react-native-community/push-notification-ios/-/push-notification-ios-1.11.0.tgz", + "integrity": "sha512-nfkUs8P2FeydOCR4r7BNmtGxAxI22YuGP6RmqWt6c8EEMUpqvIhNKWkRSFF3pHjkgJk2tpRb9wQhbezsqTyBvA==", + "peer": true, + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": ">=16.6.3", + "react-native": ">=0.58.4" + } + }, "node_modules/@react-native/assets": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@react-native/assets/-/assets-1.0.0.tgz", @@ -16553,6 +16567,15 @@ "react-native": ">=0.65.0" } }, + "node_modules/react-native-push-notification": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/react-native-push-notification/-/react-native-push-notification-8.1.1.tgz", + "integrity": "sha512-XpBtG/w+a6WXTxu6l1dNYyTiHnbgnvjoc3KxPTxYkaIABRmvuJZkFxqruyGvfCw7ELAlZEAJO+dthdTabCe1XA==", + "peerDependencies": { + "@react-native-community/push-notification-ios": "^1.10.1", + "react-native": ">=0.33" + } + }, "node_modules/react-native-randombytes": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/react-native-randombytes/-/react-native-randombytes-3.6.1.tgz", diff --git a/example/package.json b/example/package.json index eeb27d0de..3799d8fe9 100644 --- a/example/package.json +++ b/example/package.json @@ -20,6 +20,7 @@ "react-native-crypto": "^2.2.0", "react-native-get-random-values": "^1.8.0", "react-native-mmkv": "^2.8.0", + "react-native-push-notification": "^8.1.1", "react-native-randombytes": "^3.6.1", "react-native-safe-area-context": "4.5.0", "react-native-screens": "~3.20.0", diff --git a/example/src/ConversationListView.tsx b/example/src/ConversationListView.tsx index 8444e0927..0a37f66d4 100644 --- a/example/src/ConversationListView.tsx +++ b/example/src/ConversationListView.tsx @@ -1,7 +1,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import React, { useEffect, useState } from "react"; import { Text, ScrollView, RefreshControl } from "react-native"; -import { Conversation } from "xmtp-react-native-sdk"; +import { Conversation, XMTPPush } from "xmtp-react-native-sdk"; import HomeHeaderView from "./HomeHeaderView"; import { RootStackParamList } from "./HomeView"; @@ -18,6 +18,7 @@ export default function ConversationListView({ async function refreshConversations() { const conversations = await client.conversations.list(); + XMTPPush.subscribe(conversations.map((c) => c.topic)); setConversations(conversations); } diff --git a/example/src/HomeView.tsx b/example/src/HomeView.tsx index 51416fe04..1a27bd713 100644 --- a/example/src/HomeView.tsx +++ b/example/src/HomeView.tsx @@ -5,6 +5,7 @@ import { Client, Conversation } from "xmtp-react-native-sdk"; import ConversationListView from "./ConversationListView"; import ConversationView from "./ConversationView"; +import PushController from "./PushController"; export type RootStackParamList = { "Conversation List": { client: Client }; @@ -24,6 +25,7 @@ const HomeView = ({ client }: { client: Client }) => { /> + ); }; diff --git a/example/src/PushController.tsx b/example/src/PushController.tsx new file mode 100644 index 000000000..cce14ace8 --- /dev/null +++ b/example/src/PushController.tsx @@ -0,0 +1,74 @@ +import { useEffect } from "react"; +import { PushNotificationIOS } from "react-native/Libraries/PushNotificationIOS/PushNotificationIOS"; +import PushNotification, { Importance } from "react-native-push-notification"; +import { XMTPPush, Client } from "xmtp-react-native-sdk"; + +function PushController({ client }: { client: Client }) { + useEffect(() => { + PushNotification.configure({ + // (optional) Called when Token is generated (iOS and Android) + onRegister(token: any) { + XMTPPush.register("YOUR_SERVER_ADDRESS", token.token as string); + PushNotification.createChannel({ + channelId: "xmtp-react-native-example-dm", // (required) + channelName: "XMTP React Native Example", // (required) + }); + }, + // (required) Called when a remote or local notification is opened or received + onNotification(notification: any) { + const encryptedMessage = notification.data.encryptedMessage; + const topic = notification.data.topic; + + if (encryptedMessage == null || topic == null) { + return; + } + (async () => { + const conversations = await client.conversations.list(); + const conversation = conversations.find( + (c: { topic: string }) => c.topic === topic + ); + if (conversation == null) { + return; + } + + const peerAddress = conversation.peerAddress; + const decodedMessage = await conversation.decodeMessage( + encryptedMessage + ); + const body = decodedMessage.content; + + PushNotification.localNotification({ + /* Android Only Properties */ + channelId: "xmtp-react-native-example-dm", // (required) channelId, if the channel doesn't exist, notification will not trigger. + messageId: "google:message_id", // (optional) added as `message_id` to intent extras so opening push notification can find data stored by @react-native-firebase/messaging module. + + /* iOS only properties */ + category: "", // (optional) default: empty string + subtitle: "My Notification Subtitle", // (optional) smaller title below notification title + + /* iOS and Android properties */ + id: 0, // (optional) Valid unique 32 bit integer specified as string. default: Autogenerated Unique ID + title: peerAddress, // (optional) + message: body, // (required) + }); + })(); + + // process the notification here + // required on iOS only + notification.finish(PushNotificationIOS?.FetchResult.NoData); + }, + // Android only + senderID: "YOUR_FIREBASE_SENDER_ID", + // iOS only + permissions: { + alert: true, + badge: true, + sound: true, + }, + popInitialNotification: true, + requestPermissions: true, + }); + }); + return null; +} +export default PushController; diff --git a/ios/XMTPModule.swift b/ios/XMTPModule.swift index fcb2039ac..9c81ad5ee 100644 --- a/ios/XMTPModule.swift +++ b/ios/XMTPModule.swift @@ -209,6 +209,18 @@ public class XMTPModule: Module { AsyncFunction("unsubscribeFromMessages") { (clientAddress: String, topic: String, conversationID: String?) in try await unsubscribeFromMessages(clientAddress: clientAddress, topic: topic, conversationID: conversationID) } + + Function("registerPushToken") { (pushServer: String, token: String) in + // TODO + } + + Function("subscribePushTopics") { (topics: [String]) in + // TODO + } + + AsyncFunction("decodeMessage") { (topic: String, encryptedMessage: String, conversationID: String?) in + // TODO + } } // diff --git a/src/index.ts b/src/index.ts index 11446298c..673322b91 100644 --- a/src/index.ts +++ b/src/index.ts @@ -118,8 +118,27 @@ export async function unsubscribeFromMessages( ); } +export function registerPushToken(pushServer: string, token: string) { + return XMTPModule.registerPushToken(pushServer, token); +} + +export function subscribePushTopics(topics: string[]) { + return XMTPModule.subscribePushTopics(topics); +} + +export async function decodeMessage( + topic: string, + encryptedMessage: string, + conversationID?: string | undefined +): Promise { + return JSON.parse( + await XMTPModule.decodeMessage(topic, encryptedMessage, conversationID) + ); +} + export const emitter = new EventEmitter(XMTPModule ?? NativeModulesProxy.XMTP); export { Client } from "./lib/Client"; export { Conversation } from "./lib/Conversation"; export { DecodedMessage } from "./lib/DecodedMessage"; +export { XMTPPush } from "./lib/XMTPPush"; diff --git a/src/lib/Conversation.ts b/src/lib/Conversation.ts index a5a5cd2a3..ec2730e8c 100644 --- a/src/lib/Conversation.ts +++ b/src/lib/Conversation.ts @@ -59,6 +59,19 @@ export class Conversation { } } + async decodeMessage(encryptedMessage: string): Promise { + try { + return await XMTP.decodeMessage( + this.topic, + encryptedMessage, + this.conversationID + ); + } catch (e) { + console.info("ERROR in decodeMessage()", e); + throw e; + } + } + streamMessages( callback: (message: DecodedMessage) => Promise ): () => void { diff --git a/src/lib/XMTPPush.ts b/src/lib/XMTPPush.ts new file mode 100644 index 000000000..28f0a3b98 --- /dev/null +++ b/src/lib/XMTPPush.ts @@ -0,0 +1,11 @@ +import * as XMTPModule from "../index"; + +export class XMTPPush { + static register(server: string, token: string) { + XMTPModule.registerPushToken(server, token); + } + + static subscribe(topics: string[]) { + XMTPModule.subscribePushTopics(topics); + } +}