From d4f95d37dcd4339f3a4e6f2d1596ce91e927066d Mon Sep 17 00:00:00 2001 From: Ryan Lepinski Date: Tue, 1 Oct 2024 11:01:02 -0700 Subject: [PATCH] Add Live Update & fallback (#597) --- android/gradle.properties | 2 +- .../reactnative/AirshipExtender.kt | 3 +- .../urbanairship/reactnative/AirshipModule.kt | 58 +++- .../reactnative/ReactAutopilot.kt | 20 +- .../urbanairship/reactnative/AirshipSpec.kt | 26 +- .../android/app/src/main/AndroidManifest.xml | 39 ++- .../urbanairship/sample/AirshipExtender.kt | 69 ++++ .../drawable-anydpi-v24/ic_notification.xml | 15 + .../res/drawable-hdpi/ic_notification.png | Bin 0 -> 400 bytes .../res/drawable-mdpi/ic_notification.png | Bin 0 -> 263 bytes .../res/drawable-xhdpi/ic_notification.png | Bin 0 -> 508 bytes .../res/drawable-xxhdpi/ic_notification.png | Bin 0 -> 769 bytes example/ios/Podfile.lock | 8 +- example/package-lock.json | 12 +- example/package.json | 3 +- example/src/screens/HomeScreen.tsx | 169 ++++++---- ios/AirshipReactNative.swift | 21 +- ios/RTNAirship.mm | 50 ++- react-native-airship.podspec | 2 +- src/AirshipLiveActivityManager.ts | 15 +- src/AirshipLiveUpdateManager.ts | 59 ++++ src/AirshipPush.ts | 9 +- src/AirshipRoot.ts | 15 + src/NativeRTNAirship.ts | 14 +- src/types.ts | 308 ++++++++++++++---- 25 files changed, 734 insertions(+), 183 deletions(-) create mode 100644 example/android/app/src/main/java/com/urbanairship/sample/AirshipExtender.kt create mode 100644 example/android/app/src/main/res/drawable-anydpi-v24/ic_notification.xml create mode 100644 example/android/app/src/main/res/drawable-hdpi/ic_notification.png create mode 100644 example/android/app/src/main/res/drawable-mdpi/ic_notification.png create mode 100644 example/android/app/src/main/res/drawable-xhdpi/ic_notification.png create mode 100644 example/android/app/src/main/res/drawable-xxhdpi/ic_notification.png create mode 100644 src/AirshipLiveUpdateManager.ts diff --git a/android/gradle.properties b/android/gradle.properties index 781b120d..db3db310 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -3,4 +3,4 @@ Airship_minSdkVersion=21 Airship_targetSdkVersion=34 Airship_compileSdkVersion=34 Airship_ndkversion=26.1.10909125 -Airship_airshipProxyVersion=8.3.0 +Airship_airshipProxyVersion=9.1.3 diff --git a/android/src/main/java/com/urbanairship/reactnative/AirshipExtender.kt b/android/src/main/java/com/urbanairship/reactnative/AirshipExtender.kt index 7e5cc367..0ee2ed2f 100644 --- a/android/src/main/java/com/urbanairship/reactnative/AirshipExtender.kt +++ b/android/src/main/java/com/urbanairship/reactnative/AirshipExtender.kt @@ -10,6 +10,7 @@ import com.urbanairship.UAirship * Register the extender fully qualified class name in the manifest under the key * `com.urbanairship.reactnative.AIRSHIP_EXTENDER`. */ -interface AirshipExtender { +@Deprecated("Use com.urbanairship.android.framework.proxy.AirshipPluginExtender instead and register it under the manifest key `com.urbanairship.plugin.extender`") +interface AirshipExtender { fun onAirshipReady(context: Context, airship: UAirship) } \ No newline at end of file diff --git a/android/src/main/java/com/urbanairship/reactnative/AirshipModule.kt b/android/src/main/java/com/urbanairship/reactnative/AirshipModule.kt index e333864f..5a55566f 100644 --- a/android/src/main/java/com/urbanairship/reactnative/AirshipModule.kt +++ b/android/src/main/java/com/urbanairship/reactnative/AirshipModule.kt @@ -8,10 +8,13 @@ import com.urbanairship.PendingResult import com.urbanairship.actions.ActionResult import com.urbanairship.actions.ActionValue import com.urbanairship.android.framework.proxy.EventType +import com.urbanairship.android.framework.proxy.NotificationConfig import com.urbanairship.android.framework.proxy.ProxyLogger import com.urbanairship.android.framework.proxy.events.EventEmitter import com.urbanairship.android.framework.proxy.proxies.AirshipProxy +import com.urbanairship.android.framework.proxy.proxies.EnableUserNotificationsArgs import com.urbanairship.android.framework.proxy.proxies.FeatureFlagProxy +import com.urbanairship.android.framework.proxy.proxies.LiveUpdateRequest import com.urbanairship.android.framework.proxy.proxies.SuspendingPredicate import com.urbanairship.json.JsonMap import com.urbanairship.json.JsonSerializable @@ -238,9 +241,12 @@ class AirshipModule internal constructor(val context: ReactApplicationContext) : } @ReactMethod - override fun pushEnableUserNotifications(promise: Promise) { + override fun pushEnableUserNotifications(options: ReadableMap?, promise: Promise) { promise.resolveSuspending(scope) { - proxy.push.enableUserPushNotifications() + val args = options?.let { + EnableUserNotificationsArgs.fromJson(Utils.convertMap(it).toJsonValue()) + } + proxy.push.enableUserPushNotifications(args = args) } } @@ -693,6 +699,12 @@ class AirshipModule internal constructor(val context: ReactApplicationContext) : } } + override fun liveActivityListAll(promise: Promise) { + promise.resolveResult { + throw IllegalStateException("Not supported on Android") + } + } + override fun liveActivityList(request: ReadableMap?, promise: Promise) { promise.resolveResult { throw IllegalStateException("Not supported on Android") @@ -717,6 +729,48 @@ class AirshipModule internal constructor(val context: ReactApplicationContext) : } } + override fun liveUpdateListAll(promise: Promise) { + promise.resolveSuspending(scope) { + proxy.liveUpdateManager.listAll().let { + JsonValue.wrapOpt(it) + } + } + } + + override fun liveUpdateList(request: ReadableMap?, promise: Promise) { + promise.resolveSuspending(scope) { + proxy.liveUpdateManager.list( + LiveUpdateRequest.List.fromJson(Utils.convertMap(requireNotNull(request)).toJsonValue()) + ).let { + JsonValue.wrapOpt(it) + } + } + } + + override fun liveUpdateCreate(request: ReadableMap?, promise: Promise) { + promise.resolveSuspending(scope) { + proxy.liveUpdateManager.create( + LiveUpdateRequest.Create.fromJson(Utils.convertMap(requireNotNull(request)).toJsonValue()) + ) + } + } + + override fun liveUpdateUpdate(request: ReadableMap?, promise: Promise) { + promise.resolveSuspending(scope) { + proxy.liveUpdateManager.update( + LiveUpdateRequest.Update.fromJson(Utils.convertMap(requireNotNull(request)).toJsonValue()) + ) + } + } + + override fun liveUpdateEnd(request: ReadableMap?, promise: Promise) { + promise.resolveSuspending(scope) { + proxy.liveUpdateManager.end( + LiveUpdateRequest.End.fromJson(Utils.convertMap(requireNotNull(request)).toJsonValue()) + ) + } + } + private fun notifyPending() { if (context.hasActiveReactInstance()) { val appEventEmitter = context.getJSModule(RCTNativeAppEventEmitter::class.java) diff --git a/android/src/main/java/com/urbanairship/reactnative/ReactAutopilot.kt b/android/src/main/java/com/urbanairship/reactnative/ReactAutopilot.kt index 194ac959..a6868e3a 100644 --- a/android/src/main/java/com/urbanairship/reactnative/ReactAutopilot.kt +++ b/android/src/main/java/com/urbanairship/reactnative/ReactAutopilot.kt @@ -17,7 +17,9 @@ import com.urbanairship.embedded.AirshipEmbeddedInfo import com.urbanairship.embedded.AirshipEmbeddedObserver import com.urbanairship.json.JsonMap import com.urbanairship.json.jsonMapOf -import kotlinx.coroutines.MainScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch @@ -26,14 +28,12 @@ import kotlinx.coroutines.launch */ class ReactAutopilot : BaseAutopilot() { - override fun onAirshipReady(airship: UAirship) { - super.onAirshipReady(airship) + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + override fun onReady(context: Context, airship: UAirship) { ProxyLogger.info("Airship React Native version: %s, SDK version: %s", BuildConfig.AIRSHIP_MODULE_VERSION, UAirship.getVersion()) - val context = UAirship.getApplicationContext() - - MainScope().launch { + scope.launch { EventEmitter.shared().pendingEventListener .filter { !it.type.isForeground() } .collect { @@ -41,18 +41,19 @@ class ReactAutopilot : BaseAutopilot() { } } - MainScope().launch { + scope.launch { AirshipEmbeddedObserver(filter = { true }).embeddedViewInfoFlow.collect { EventEmitter.shared().addEvent(PendingEmbeddedUpdated(it)) } } - // Set our custom notification providerr + // Set our custom notification provider val notificationProvider = ReactNotificationProvider(context, airship.airshipConfigOptions) airship.pushManager.notificationProvider = notificationProvider airship.analytics.registerSDKExtension(Extension.REACT_NATIVE, BuildConfig.AIRSHIP_MODULE_VERSION) + // Legacy extender val extender = createExtender(context) extender?.onAirshipReady(context, airship) } @@ -61,6 +62,7 @@ class ReactAutopilot : BaseAutopilot() { DataMigrator(context).migrateData(proxyStore) } + @Suppress("deprecation") private fun createExtender(context: Context): AirshipExtender? { val ai: ApplicationInfo try { @@ -77,7 +79,7 @@ class ReactAutopilot : BaseAutopilot() { try { val extenderClass = Class.forName(classname) - return extenderClass.newInstance() as AirshipExtender + return extenderClass.getDeclaredConstructor().newInstance() as AirshipExtender } catch (e: Exception) { ProxyLogger.error(e, "Unable to create extender: $classname") } diff --git a/android/src/oldarch/java/com/urbanairship/reactnative/AirshipSpec.kt b/android/src/oldarch/java/com/urbanairship/reactnative/AirshipSpec.kt index cd8d914d..27a0fd42 100644 --- a/android/src/oldarch/java/com/urbanairship/reactnative/AirshipSpec.kt +++ b/android/src/oldarch/java/com/urbanairship/reactnative/AirshipSpec.kt @@ -98,7 +98,7 @@ abstract class AirshipSpec internal constructor(context: ReactApplicationContext @ReactMethod @com.facebook.proguard.annotations.DoNotStrip - abstract fun pushEnableUserNotifications(promise: Promise) + abstract fun pushEnableUserNotifications(options: ReadableMap?, promise: Promise) @ReactMethod @com.facebook.proguard.annotations.DoNotStrip @@ -405,6 +405,10 @@ abstract class AirshipSpec internal constructor(context: ReactApplicationContext @com.facebook.proguard.annotations.DoNotStrip abstract fun featureFlagManagerTrackInteraction(flag: ReadableMap?, promise: Promise) + @ReactMethod + @com.facebook.proguard.annotations.DoNotStrip + abstract fun liveActivityListAll(promise: Promise) + @ReactMethod @com.facebook.proguard.annotations.DoNotStrip abstract fun liveActivityList(request: ReadableMap?, promise: Promise) @@ -420,6 +424,24 @@ abstract class AirshipSpec internal constructor(context: ReactApplicationContext @ReactMethod @com.facebook.proguard.annotations.DoNotStrip abstract fun liveActivityEnd(request: ReadableMap?, promise: Promise) -} + @ReactMethod + @com.facebook.proguard.annotations.DoNotStrip + abstract fun liveUpdateListAll(promise: Promise) + + @ReactMethod + @com.facebook.proguard.annotations.DoNotStrip + abstract fun liveUpdateList(request: ReadableMap?, promise: Promise) + + @ReactMethod + @com.facebook.proguard.annotations.DoNotStrip + abstract fun liveUpdateCreate(request: ReadableMap?, promise: Promise) + @ReactMethod + @com.facebook.proguard.annotations.DoNotStrip + abstract fun liveUpdateUpdate(request: ReadableMap?, promise: Promise) + + @ReactMethod + @com.facebook.proguard.annotations.DoNotStrip + abstract fun liveUpdateEnd(request: ReadableMap?, promise: Promise) +} diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 4122f36a..0ab88c50 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -3,23 +3,28 @@ - - - - - - + android:roundIcon="@mipmap/ic_launcher_round" + android:theme="@style/AppTheme"> + + + + + + + + + diff --git a/example/android/app/src/main/java/com/urbanairship/sample/AirshipExtender.kt b/example/android/app/src/main/java/com/urbanairship/sample/AirshipExtender.kt new file mode 100644 index 00000000..269ebb57 --- /dev/null +++ b/example/android/app/src/main/java/com/urbanairship/sample/AirshipExtender.kt @@ -0,0 +1,69 @@ +package com.urbanairship.sample + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.Keep +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.urbanairship.UAirship +import com.urbanairship.android.framework.proxy.AirshipPluginExtender +import com.urbanairship.json.requireField +import com.urbanairship.liveupdate.LiveUpdate +import com.urbanairship.liveupdate.LiveUpdateEvent +import com.urbanairship.liveupdate.LiveUpdateManager +import com.urbanairship.liveupdate.LiveUpdateResult +import com.urbanairship.liveupdate.SuspendLiveUpdateNotificationHandler + + +@Keep +public final class AirshipExtender: AirshipPluginExtender { + override fun onAirshipReady(context: Context, airship: UAirship) { + LiveUpdateManager.shared().register("Example", ExampleLiveUpdateHandler()) + } +} + +public final class ExampleLiveUpdateHandler: SuspendLiveUpdateNotificationHandler() { + override suspend fun onUpdate( + context: Context, + event: LiveUpdateEvent, + update: LiveUpdate + ): LiveUpdateResult { + + if (event == LiveUpdateEvent.END) { + // Dismiss the live update on END. The default behavior will leave the Live Update + // in the notification tray until the dismissal time is reached or the user dismisses it. + return LiveUpdateResult.cancel() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = NotificationChannel("emoji-example", "Emoji example", importance) + channel.description = "Emoji example" + NotificationManagerCompat.from(context).createNotificationChannel(channel) + } + + val launchIntent = context.packageManager + .getLaunchIntentForPackage(context.packageName) + ?.addCategory(update.name) + ?.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + ?.setPackage(null) + + val contentIntent = PendingIntent.getActivity( + context, 0, launchIntent, PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, "emoji-example") + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setCategory(NotificationCompat.CATEGORY_EVENT) + .setContentTitle("Example Live Update") + .setContentText(update.content.requireField("emoji")) + .setContentIntent(contentIntent) + + return LiveUpdateResult.ok(notification) + } +} \ No newline at end of file diff --git a/example/android/app/src/main/res/drawable-anydpi-v24/ic_notification.xml b/example/android/app/src/main/res/drawable-anydpi-v24/ic_notification.xml new file mode 100644 index 00000000..6f92d40c --- /dev/null +++ b/example/android/app/src/main/res/drawable-anydpi-v24/ic_notification.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/example/android/app/src/main/res/drawable-hdpi/ic_notification.png b/example/android/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..8e46a11c9d8a440db92b01c5e6c3405dcddd31ed GIT binary patch literal 400 zcmV;B0dM|^P)Y5QY;YsjS3G0$NxJLP%$738b_3FW71!wu1ITYRlM|CO<%IwGbqTKR`RbKoqO< zU7a(NmrLB;9M?Dx3>@>$JiEJZhF+?D%wy%uUl<`!l5 zZfKkm$mT`rcvRni@vOcV*c%OGH6@N^iM>%kw8!Qt*kykZ5S+0w2l{-D7g#)DQ;u}` zyoq3;&Ia&-df}!Auc&u!f+nAv2?l3*6I`Rpk{@w6fn(Vzqi#3^)zQ;4z)**`z?RBG z{ujP)Rsym3&3c$X#`eyon?3i;{N-@Q@-8C1$p- zRmYsHlc`QNQg?ln4oVUl+xOIKdh0aQ@7(oKS`^;@$_M$9;r+<(cmg_Vp^0~3&Z8a_ z4tTLajTmi$Yldrs>xk>ZmV92f+IY-d%9vc;65F}28-y}1r~6xOz_|^IytOz4RDQey@J*WGAkb9wFa8Y5bl=> z51pZlJ?o=km0dmE60_)OwD|gXGA0O1$1cL!>wTDo{C|JV~TKk+|s^r%J>*?%M`V ylnvas4IC&NxNi$&UMP1ZuYTNHQ4~c{{w+WD0K?;5U@pA?00001xa`HsqZ8+$xP4CkaNyC=bUrSIp>^n&N=6t z``?8S)~lYYKC0I6d{r!q>XT|-HOlkRX!z&w&r6=Kie*XUTHyI;H2iuZh}p)T*py*A zGR?9l8Vw&;{YixR&7RmSLn4o58)r{c3UEFV$Q)-+EH)ci&atNw3i2}%%6@}u*;6)~ z*`D3;gFTf|n8`#i^N>Advzf>+$(~9n(02r!U=M}XzvHc{eX3)snReONcFfLYzI;&6 zruNK^MfOxqp*A4oE1VxyO{mU>^iv@(yXH)2t&w_dqwn9OTfMGAC~DQjSw zIV+(cvrJYa*O)gd1$e+jHFTf3qX?~gErVj7`AemN&zN8UJmo%0p+Q$Ud4OEuehQ@l z)0{Yfrns*{XyFmnFHY;>z3Mg(mdIw=%sgxlBi!Grg{AM-yLla!zL7l5rUIS9Ec+0_ zGGzIdyl&OB;9gGXqUY_+P|nMNInFUUzA>b41NX9r*REK|_vq<)4@T#Xvbg|9IHyDB z7TMfIF^{5<|IO!eKcK^GF2Etq=?M9LKxT&5_Dzd?<*Gf{!@BE@DjmPa<^tT}9HZl7 zA>UKXeBt$d(E?kzVunWnw@CH<6q^c^`j;JAw{V}ZDZ}RE8hz2l@3>k{cT^X7uq5{P z@q+3uPvrEL`_7FkH5K=ri>}+vg&J%Z_n%1@UFIqcc8UAXq>C1~N`rmi{xflfrsMuI zae=1e{+AW%@%dJ4_4*{lIp>^n&N=6tb1U>0sIm73YTcz>00000NkvXXu0mjfpfG=10.0.0", + "npm": ">=6.0.0" + } + }, "node_modules/react-native-vector-icons": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.0.3.tgz", diff --git a/example/package.json b/example/package.json index 007c27a1..91027d3f 100644 --- a/example/package.json +++ b/example/package.json @@ -12,7 +12,8 @@ "babel-plugin-module-resolver": "^5.0.0", "moment": "^2.30.1", "react": "18.2.0", - "react-native": "0.73.4" + "react-native": "0.73.4", + "react-native-uuid": "^2.0.2" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 835cf737..d0ae546d 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -6,6 +6,7 @@ */ import React, { useState, useEffect, useCallback } from 'react'; +import uuid from 'react-native-uuid'; import { View, Text, @@ -82,49 +83,28 @@ export default function HomeScreen() { [refreshTags] ); - const handleNotificationsEnabled = useCallback((enabled: boolean) => { - Airship.push.setUserNotificationsEnabled(enabled); - setNotificationsEnabled(enabled); + const handleNotificationsEnabled = useCallback(async (enabled: boolean) => { + if (enabled) { + await Airship.push.enableUserNotifications({ + fallback: 'systemSettings', + }); + } else { + Airship.push.setUserNotificationsEnabled(false); + } }, []); useEffect(() => { - // Add takeOff here - - Airship.push - .getNotificationStatus() - .then((id) => { - console.log(id); - }) - .catch((error) => { - console.error('Error getting notification status:', error); - }); - setEmbeddedReady(Airship.inApp.isEmbeddedReady('test')); - Airship.push.iOS - .getAuthorizedNotificationSettings() - .then((id) => { - console.log(id); - }) - .catch((error) => { - console.error('Error getting notification settings:', error); - }); - - Airship.push.iOS.getAuthorizedNotificationStatus().then((id) => { - console.log(id); - }); - - Airship.push.getNotificationStatus().then((id) => { - console.log(id); - }); - Airship.channel.getChannelId().then((id) => { if (id) { setChannelId(id); } }); - Airship.push.isUserNotificationsEnabled().then(setNotificationsEnabled); + Airship.push + .getNotificationStatus() + .then((status) => setNotificationsEnabled(status.isUserOptedIn)); const fetchTags = async () => { const fetchedTags = await Airship.channel.getTags(); @@ -140,20 +120,32 @@ export default function HomeScreen() { fetchNamedUser(); - let subscription = Airship.addListener( + let channelListener = Airship.addListener( EventType.ChannelCreated, (event) => { setChannelId(event.channelId); } ); - Airship.inApp.addEmbeddedReadyListener('test', (isReady) => { - console.log('Test ' + isReady); - setEmbeddedReady(isReady); - }); + let embeddedListener = Airship.inApp.addEmbeddedReadyListener( + 'test', + (isReady) => { + setEmbeddedReady(isReady); + } + ); + + let optInListener = Airship.addListener( + EventType.PushNotificationStatusChangedStatus, + (event) => { + console.log('Event', event); + setNotificationsEnabled(event.status.isUserOptedIn); + } + ); return () => { - subscription.remove(); + channelListener.remove(); + embeddedListener.remove(); + optInListener.remove(); }; }, []); @@ -183,10 +175,20 @@ export default function HomeScreen() { )} - {Platform.OS === 'ios' ? ( - -