diff --git a/android/build.gradle b/android/build.gradle index 4128117..df27579 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -20,6 +20,7 @@ def isNewArchitectureEnabled() { apply plugin: "com.android.library" apply plugin: "kotlin-android" +apply plugin: 'kotlin-parcelize' if (isNewArchitectureEnabled()) { apply plugin: "com.facebook.react" @@ -92,5 +93,6 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation(platform("com.google.firebase:firebase-bom:32.7.2")) implementation "com.google.firebase:firebase-messaging" + implementation 'androidx.core:core-ktx:1.12.0' } diff --git a/android/src/main/AndroidManifestNew.xml b/android/src/main/AndroidManifestNew.xml index 0e5c990..874082c 100644 --- a/android/src/main/AndroidManifestNew.xml +++ b/android/src/main/AndroidManifestNew.xml @@ -1,4 +1,6 @@ + + ): String { - val json = JSONObject() - for ((key, value) in map) { - json.put(key, value) + override fun handleIntent(intent: Intent) { + val extras = intent.extras ?: Bundle() + extras.getString("gcm.notification.body")?.let { + // Use the notification body here + // message contains push notification payload, show notification +// onMessageReceived(it) + Log.d(TAG, "**** Message: ${it}") + } ?: run { + Log.d(TAG, "Ignore intents that don't contain push notification payload") + super.handleIntent(intent) } - return json.toString() } - // TODO: make this actually work - private fun getAppState(): String { - val reactContext = ContextHolder.getInstance().getApplicationContext() - val currentActivity = reactContext?.currentActivity - return if (currentActivity == null) { - "background" + private fun onMessageReceived(payload: NotificationPayload) { + if (utils.isAppInForeground()) { + Log.d(TAG, "Send foreground message received event") + PushNotificationEventManager.sendEvent( + PushNotificationEventType.FOREGROUND_MESSAGE_RECEIVED, Arguments.createMap().apply { + putString("content", payload.rawData.toString()) + } + ) } else { - "foreground" - } - } + Log.d( + TAG, "App is in background, try to create notification and start headless service" + ) - override fun onNewToken(token: String) { - Log.d(TAG,token) - RNEventEmitter.sendEvent(deviceTokenReceived, token) - } + utils.showNotification(payload) - companion object { - private const val TAG = "MyFirebaseMsgService" - const val notificationReceived = "notificationReceived" - const val deviceTokenReceived = "deviceTokenReceived" - const val errorReceived = "errorReceived" + try { + val serviceIntent = + Intent(baseContext, PushNotificationHeadlessTaskService::class.java) + serviceIntent.putExtra("NotificationPayload", payload) + if (baseContext.startService(serviceIntent) != null) { + HeadlessJsTaskService.acquireWakeLockNow(baseContext) + } + } catch (exception: Exception) { + Log.e( + TAG, "Something went wrong while starting headless task: ${exception.message}" + ) + } + } } + } diff --git a/android/src/main/java/com/candlefinance/push/NotificationUtils.kt b/android/src/main/java/com/candlefinance/push/NotificationUtils.kt index f81eb69..dec02ed 100644 --- a/android/src/main/java/com/candlefinance/push/NotificationUtils.kt +++ b/android/src/main/java/com/candlefinance/push/NotificationUtils.kt @@ -1,73 +1,249 @@ package com.candlefinance.push +import android.Manifest import android.annotation.SuppressLint +import android.app.ActivityManager import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import com.google.android.gms.common.internal.ResourceUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.Locale +import android.os.Parcelable +import androidx.core.app.ActivityCompat +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.net.URL +class PushNotificationsConstants { + companion object { + const val PINPOINT_PREFIX = "pinpoint" // pinpoint + const val NOTIFICATION_PREFIX = "$PINPOINT_PREFIX.notification." // pinpoint.notification. + const val CAMPAIGN_PREFIX = "$PINPOINT_PREFIX.campaign." // pinpoint.campaign. + const val OPENAPP = "openApp" // openApp + const val URL = "url" // url + const val DEEPLINK = "deeplink" // deeplink + const val TITLE = "title" // title + const val MESSAGE = "message" // message + const val IMAGEURL = "imageUrl" // imageUrl + const val JOURNEY = "journey" // journey + const val JOURNEY_ID = "journey_id" // journey_id + const val JOURNEY_ACTIVITY_ID = "journey_activity_id" // journey_activity_id + const val PINPOINT_OPENAPP = "$PINPOINT_PREFIX.$OPENAPP" // pinpoint.openApp + const val PINPOINT_URL = "$PINPOINT_PREFIX.$URL" // pinpoint.url + const val PINPOINT_DEEPLINK = "$PINPOINT_PREFIX.$DEEPLINK" // pinpoint.deeplink + const val PINPOINT_NOTIFICATION_TITLE = "$NOTIFICATION_PREFIX$TITLE" // pinpoint.notification.title + const val PINPOINT_NOTIFICATION_BODY = "${NOTIFICATION_PREFIX}body" // pinpoint.notification.body + const val PINPOINT_NOTIFICATION_IMAGEURL = "$NOTIFICATION_PREFIX$IMAGEURL" // pinpoint.notification.imageUrl + // pinpoint.notification.silentPush + const val PINPOINT_NOTIFICATION_SILENTPUSH = "${NOTIFICATION_PREFIX}silentPush" + const val CAMPAIGN_ID = "campaign_id" // campaign_id + const val CAMPAIGN_ACTIVITY_ID = "campaign_activity_id" // campaign_activity_id + const val PINPOINT_CAMPAIGN_CAMPAIGN_ID = "$CAMPAIGN_PREFIX$CAMPAIGN_ID" // pinpoint.campaign.campaign_id + // pinpoint.campaign.campaign_activity_id + const val PINPOINT_CAMPAIGN_CAMPAIGN_ACTIVITY_ID = "$CAMPAIGN_PREFIX$CAMPAIGN_ACTIVITY_ID" + const val DEFAULT_NOTIFICATION_CHANNEL_ID = "PINPOINT.NOTIFICATION" // PINPOINT.NOTIFICATION + const val DIRECT_CAMPAIGN_SEND = "_DIRECT" // _DIRECT + } +} -object NotificationUtils { +class PushNotificationsUtils( + private val context: Context, + private val channelId: String = PushNotificationsConstants.DEFAULT_NOTIFICATION_CHANNEL_ID +) { + init { + retrieveNotificationChannel() + } - private const val NOTIFICATION_ID = 123321 + @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O) + private fun isNotificationChannelSupported() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O - fun createNotificationChannel( - context: Context, - channelId: String, - channelName: String, - channelDescription: String - ) { - val notificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private fun retrieveNotificationChannel(): NotificationChannel? { + var channel: NotificationChannel? = null + val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java) + if (isNotificationChannelSupported()) { + channel = notificationManager?.getNotificationChannel(channelId) + } + return channel ?: createDefaultNotificationChannel(channelId) + } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( + // create before notification trigger for API 32 or lower + @SuppressLint("NewApi") + private fun createDefaultNotificationChannel(channelId: String): NotificationChannel? { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (isNotificationChannelSupported()) { + val notificationManager = ContextCompat.getSystemService(context, NotificationManager::class.java) + val defaultChannel = NotificationChannel( channelId, - channelName, - NotificationManager.IMPORTANCE_HIGH + "Default channel", + NotificationManager.IMPORTANCE_DEFAULT ) - channel.description = channelDescription - notificationManager.createNotificationChannel(channel) + // Register the channel with the system + notificationManager?.createNotificationChannel(defaultChannel) + return defaultChannel } + return null } - fun createDefaultChannelForFCM(context:Context){ - val defaultChannelId= context.resources.getString(R.string.default_notification_channel_id) - val defaultChannelName = context.resources.getString(R.string.default_notification_channel_name) - this - .createNotificationChannel(context, defaultChannelId, defaultChannelName,"Default channel created by RN Push module to All FCM notification") - } - fun sendNotification(context: Context, title: String, message: String) { - val defaultChannelId= context.resources.getString(R.string.default_notification_channel_id) - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val builder = NotificationCompat.Builder(context, defaultChannelId) - .setSmallIcon(getResourceIdByName("ic_default_notification","drawable")) - .setContentTitle(title) - .setContentText(message) - .setPriority(NotificationCompat.PRIORITY_DEFAULT) - - notificationManager.notify(NOTIFICATION_ID, builder.build()) + private suspend fun downloadImage(url: String): Bitmap? = withContext(Dispatchers.IO) { + BitmapFactory.decodeStream(URL(url).openConnection().getInputStream()) } - @SuppressLint("DiscouragedApi") - fun getResourceIdByName(name: String?, type: String): Int { - var name = name - if (name.isNullOrEmpty()) { - return 0 + fun isAppInForeground(): Boolean { + // Gets a list of running processes. + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val processes = am.runningAppProcesses + + // On some versions of android the first item in the list is what runs in the foreground, but this is not true + // on all versions. Check the process importance to see if the app is in the foreground. + val packageName = context.applicationContext.packageName + for (appProcess in processes) { + val processName = appProcess.processName + if (ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND == appProcess.importance && packageName == processName) { + return true + } } - name = name.lowercase(Locale.getDefault()).replace("-", "_") - synchronized(ResourceUtils::class.java) { + return false + } + + fun areNotificationsEnabled(): Boolean { + // check for app level opt out + return NotificationManagerCompat.from(context).areNotificationsEnabled() + } - val context = ContextHolder.getInstance().getApplicationContext() - val packageName = context?.packageName - if (context != null) { - return context.resources.getIdentifier(name, type, packageName) + @Suppress("DEPRECATION") + @SuppressLint("NewApi") + fun showNotification( + notificationId: Int, + payload: NotificationPayload, + targetClass: Class<*>? + ) { + CoroutineScope(Dispatchers.IO).launch { +// val largeImageIcon = payload.imageUrl?.let { downloadImage(it) } + val notificationIntent = Intent(context, payload.targetClass ?: targetClass) + notificationIntent.putExtra("amplifyNotificationPayload", payload) + notificationIntent.putExtra("notificationId", notificationId) + val pendingIntent = PendingIntent.getActivity( + context, + notificationId, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val notificationChannel = retrieveNotificationChannel() + val builder = if (isNotificationChannelSupported() && notificationChannel != null) { + NotificationCompat.Builder(context, payload.channelId ?: notificationChannel.id) } else { - return -1 + NotificationCompat.Builder(context) } + + builder.apply { +// setContentTitle(payload.title) +// setContentText(payload.body) + setSmallIcon(R.drawable.ic_default_notification) + setContentIntent(pendingIntent) + setPriority(NotificationCompat.PRIORITY_DEFAULT) +// setLargeIcon(largeImageIcon) + setAutoCancel(true) + } + + with(NotificationManagerCompat.from(context)) { +// if (ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { +// // TODO: Consider calling +// // ActivityCompat#requestPermissions +// // here to request the missing permissions, and then overriding +// // public void onRequestPermissionsResult(int requestCode, String[] permissions, +// // int[] grantResults) +// // to handle the case where the user grants the permission. See the documentation +// // for ActivityCompat#requestPermissions for more details. +// } else { +// notify(notificationId, builder.build()) +// } + } + } + } +} + +class PushNotificationUtils(context: Context) { + private val utils = PushNotificationsUtils(context) + + fun showNotification( + payload: NotificationPayload + ) { + // TODO: + } + + fun isAppInForeground(): Boolean { + return utils.isAppInForeground() + } +} + +@Parcelize +open class NotificationContentProvider internal constructor(open val content: Map) : Parcelable { + @Parcelize + class FCM(override val content: Map) : NotificationContentProvider(content) +} + +@Parcelize +open class NotificationPayload( + val contentProvider: NotificationContentProvider, + val channelId: String? = null, + val targetClass: Class<*>? = null +) : Parcelable { + + @IgnoredOnParcel + val rawData: Map = extractRawData() + + internal constructor(builder: Builder) : this(builder.contentProvider, builder.channelId, builder.targetClass) + + private fun extractRawData() = when (contentProvider) { + is NotificationContentProvider.FCM -> contentProvider.content + else -> mapOf() + } + + companion object { + @JvmStatic + fun builder(contentProvider: NotificationContentProvider) = Builder(contentProvider) + + inline operator fun invoke( + contentProvider: NotificationContentProvider, + block: Builder.() -> Unit + ) = Builder(contentProvider).apply(block).build() + + @JvmStatic + fun fromIntent(intent: Intent?): NotificationPayload? { + return intent?.getParcelableExtra("amplifyNotificationPayload") } } + + class Builder(val contentProvider: NotificationContentProvider) { + var channelId: String? = null + private set + var targetClass: Class<*>? = null + private set + + fun notificationChannelId(channelId: String?) = apply { this.channelId = channelId } + + fun targetClass(targetClass: Class<*>?) = apply { this.targetClass = targetClass } + + fun build() = NotificationPayload(this) + } +} + +sealed interface PermissionRequestResult { + object Granted : PermissionRequestResult + data class NotGranted(val shouldShowRationale: Boolean) : PermissionRequestResult } diff --git a/android/src/main/java/com/candlefinance/push/PushModule.kt b/android/src/main/java/com/candlefinance/push/PushModule.kt index ce01b7d..9d06588 100644 --- a/android/src/main/java/com/candlefinance/push/PushModule.kt +++ b/android/src/main/java/com/candlefinance/push/PushModule.kt @@ -1,88 +1,74 @@ package com.candlefinance.push -import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build +import android.os.Bundle +import android.provider.Settings import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.LifecycleEventListener 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.PermissionAwareActivity -import com.facebook.react.modules.core.PermissionListener +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import java.util.UUID -class PushModule(reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { +private val TAG = PushModule::class.java.simpleName +private const val PERMISSION = "android.permission.POST_NOTIFICATIONS" +private const val PREF_FILE_KEY = "com.candlefinance.push.pushnotification" +private const val PREF_PREVIOUSLY_DENIED = "wasPermissionPreviouslyDenied" + +enum class PushNotificationPermissionStatus { + NotDetermined, + Authorized, + Denied, +} +class PushModule( + reactContext: ReactApplicationContext, + dispatcher: CoroutineDispatcher = Dispatchers.Main +) : ReactContextBaseJavaModule(reactContext), ActivityEventListener, LifecycleEventListener { override fun getName(): String { return NAME } - override fun initialize() { - super.initialize() - NotificationUtils.createDefaultChannelForFCM(reactApplicationContext) - ContextHolder.getInstance().setApplicationContext(reactApplicationContext) - } - - - @ReactMethod - fun getAuthorizationStatus(promise: Promise) { - val context = reactApplicationContext.baseContext - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED - ) { - promise.resolve(2) // authorized - } else { - promise.resolve(1) // denied - } - } else { - promise.resolve(2) // authorized - } + companion object { + const val NAME = "Push" } - @ReactMethod - fun requestPermissions(promise: Promise) { - val context = reactApplicationContext.baseContext - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED - ) { - registerForToken(promise) - } else { - val activity = reactApplicationContext.currentActivity - - if (activity is PermissionAwareActivity) { - val currentRequestCode = 83834 - - val listener = PermissionListener { requestCode: Int, _: Array, grantResults: IntArray -> - if (requestCode == currentRequestCode) { - val isPermissionGranted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED - if (isPermissionGranted) { - registerForToken(promise) - return@PermissionListener true - } - return@PermissionListener false - } - return@PermissionListener false - } - - // Replace this with the appropriate permission for push notifications - activity.requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), currentRequestCode, listener) - } else { - promise.reject("NO_ACTIVITY", "No PermissionAwareActivity was found! Make sure the app has launched before calling this function.") - } - } + private var isAppLaunch: Boolean = true + private var launchNotification: WritableMap? = null + private val sharedPreferences = reactContext.getSharedPreferences(PREF_FILE_KEY, MODE_PRIVATE) + private val scope = CoroutineScope(dispatcher) - } else { - promise.resolve(true) - } + init { + reactContext.addActivityEventListener(this) + reactContext.addLifecycleEventListener(this) } @ReactMethod @@ -90,39 +76,260 @@ class PushModule(reactContext: ReactApplicationContext) : FirebaseMessaging.getInstance().isAutoInitEnabled=true; FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> if (!task.isSuccessful) { - RNEventEmitter.sendEvent(FirebaseMessagingService.errorReceived, "Fetching FCM registration token failed ${task.exception?.message}") + Log.d(TAG,"Fetching FCM registration token failed ${task.exception?.message}") return@OnCompleteListener } val token = task.result - RNEventEmitter.sendEvent(FirebaseMessagingService.deviceTokenReceived, token) + PushNotificationEventManager.sendEvent(PushNotificationEventType.TOKEN_RECEIVED, Arguments.createMap().apply { + putString("token", token) + }) }) promise.resolve(true) } @ReactMethod - fun isRegisteredForRemoteNotifications(promise: Promise) { - FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task -> - if (!task.isSuccessful) { - RNEventEmitter.sendEvent(FirebaseMessagingService.errorReceived, "Fetching FCM registration token failed ${task.exception?.message}") - promise.reject("NO_TOKEN", "No token found ${task.exception?.message}") - return@OnCompleteListener + fun getLaunchNotification(promise: Promise) { + launchNotification?.let { + promise.resolve(launchNotification) + launchNotification = null + } ?: promise.resolve(null) + } + + @ReactMethod + fun getPermissionStatus(promise: Promise) { + val permission = PushNotificationPermission(reactApplicationContext) + // If permission has already been granted + if (permission.hasRequiredPermission) { + return promise.resolve(PushNotificationPermissionStatus.Authorized.name) + } + // If the shouldShowRequestPermissionRationale flag is true, permission must have been + // denied once (and only once) previously + if (shouldShowRequestPermissionRationale()) { + return promise.resolve(PushNotificationPermissionStatus.NotDetermined.name) + } + // If the shouldShowRequestPermissionRationale flag is false and the permission was + // already previously denied then user has denied permissions twice + if (sharedPreferences.getBoolean(PREF_PREVIOUSLY_DENIED, false)) { + return promise.resolve(PushNotificationPermissionStatus.Denied.name) + } + // Otherwise it's never been requested (or user could have dismissed the request without + // explicitly denying) + promise.resolve(PushNotificationPermissionStatus.NotDetermined.name) + } + + @ReactMethod + fun requestPermissions( + @Suppress("UNUSED_PARAMETER") permissions: ReadableMap, + promise: Promise + ) { + scope.launch { + val permission = PushNotificationPermission(reactApplicationContext) + val result = permission.requestPermission() + if (result is PermissionRequestResult.Granted) { + promise.resolve(true) + } else { + // If permission was not granted and the shouldShowRequestPermissionRationale flag + // is true then user must have denied for the first time. We will set the + // wasPermissionPreviouslyDenied value to true only in this scenario since it's + // possible to dismiss the permission request without explicitly denying as well. + if (shouldShowRequestPermissionRationale()) { + with(sharedPreferences.edit()) { + putBoolean(PREF_PREVIOUSLY_DENIED, true) + apply() + } + } + promise.resolve(false) } - val token = task.result - promise.resolve(true) - }) + } } @ReactMethod - fun addListener(type: String?) { - // Keep: Required for RN built in Event Emitter Calls. + fun addListener(@Suppress("UNUSED_PARAMETER") eventName: String) { + // noop - only required for RN NativeEventEmitter } @ReactMethod - fun removeListeners(type: Int?) { - // Keep: Required for RN built in Event Emitter Calls. + fun removeListeners(@Suppress("UNUSED_PARAMETER") count: Int) { + // noop - only required for RN NativeEventEmitter } - companion object { - const val NAME = "Push" + override fun getConstants(): MutableMap = hashMapOf( + "NativeEvent" to PushNotificationEventType.values() + .associateBy({ it.name }, { it.value }), + "NativeHeadlessTaskKey" to PushNotificationHeadlessTaskService.HEADLESS_TASK_KEY + ) + + override fun onActivityResult(p0: Activity?, p1: Int, p2: Int, p3: Intent?) { + // noop - only overridden as this class implements ActivityEventListener + } + + /** + * Send notification opened app event to JS layer if the app is in a background state + */ + override fun onNewIntent(intent: Intent) { + val payload = NotificationPayload.fromIntent(intent) + if (payload != null) { +// PushNotificationEventManager.sendEvent( +// PushNotificationEventType.NOTIFICATION_OPENED, payload.toWritableMap() +// ) + } + } + + /** + * On every app resume (including launch), send the current device token to JS layer. Also + * store the app launching notification if app is in a quit state + */ + override fun onHostResume() { + if (isAppLaunch) { + isAppLaunch = false + PushNotificationEventManager.init(reactApplicationContext) + val firebaseInstance = FirebaseMessaging.getInstance() + firebaseInstance.token.addOnCompleteListener(OnCompleteListener { task -> + if (!task.isSuccessful) { + Log.w(TAG, "Fetching FCM registration token failed") + return@OnCompleteListener + } + val params = Arguments.createMap().apply { + putString("token", task.result) + } + Log.d(TAG, "Send device token event") + PushNotificationEventManager.sendEvent( + PushNotificationEventType.TOKEN_RECEIVED, + params + ) + }) + currentActivity?.intent?.let { + val payload = NotificationPayload.fromIntent(it) + if (payload != null) { +// launchNotification = payload.toWritableMap() +// // Launch notification opened event is emitted for internal use only +// PushNotificationEventManager.sendEvent( +// PushNotificationEventType.LAUNCH_NOTIFICATION_OPENED, +// payload.toWritableMap() +// ) + } + } + } else { + // Wipe the launching notification as app was re-opened by some other means + launchNotification = null + } + } + + override fun onHostPause() { + // noop - only overridden as this class implements LifecycleEventListener + } + + override fun onHostDestroy() { + scope.cancel() + } + + private fun shouldShowRequestPermissionRationale(): Boolean { + return ActivityCompat.shouldShowRequestPermissionRationale(currentActivity!!, PERMISSION) + } +} + +internal const val PermissionRequiredApiLevel = 33 +internal const val PermissionName = "android.permission.POST_NOTIFICATIONS" +internal const val PermissionRequestId = "com.push.permissions.requestId" + +class PushNotificationPermission(private val context: Context) { + + val hasRequiredPermission: Boolean + get() = Build.VERSION.SDK_INT < PermissionRequiredApiLevel || + ContextCompat.checkSelfPermission(context, PermissionName) == PackageManager.PERMISSION_GRANTED + + /** + * Launches an Activity to request notification permissions and suspends until the user makes a selection or + * dismisses the dialog. The behavior of this function depends on the device, current permission status, and + * build configuration. + * + * 1. If the device API level is < 33 then this will immediately return [PermissionRequestResult.Granted] because + * no permission is required on this device. + * 2. If the device API level is >= 33 but the application is targeting API level < 33 then this function will not + * show a permission dialog, but will return the current status of the notification permission. The permission + * request dialog will instead appear whenever the app tries to create a notification channel. + * 3. Otherwise, the dialog will be shown or not as per normal runtime permission request rules + * See https://developer.android.com/develop/ui/views/notifications/notification-permission for details + */ + suspend fun requestPermission(): PermissionRequestResult { + if (hasRequiredPermission) { + return PermissionRequestResult.Granted + } + + val requestId = UUID.randomUUID().toString() + + // Start the activity + val intent = Intent(context, PermissionsRequestActivity::class.java).apply { + putExtra(PermissionRequestId, requestId) + } + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(intent) + + // Listen for the result + return PermissionRequestChannel.listen(requestId).first() + } + + /** + * Opens the application's settings page, where the user can manually grant/revoke application permissions + */ + fun openSettings() { + // Build a uri like "package:com.example.myapplication". See docs for ACTION_APPLICATION_DETAILS_SETTINGS. + val uri = Uri.fromParts("package", context.packageName, null) + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri) + context.startActivity(intent) + } +} + +internal object PermissionRequestChannel { + private class IdAndResult(val requestId: String, val result: PermissionRequestResult) + + private val flow = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + /** + * Get a flow for the result of a particular permission request + */ + fun listen(requestId: String) = flow.filter { it.requestId == requestId }.map { it.result } + + /** + * Send the result of a permission request + */ + fun send(requestId: String, result: PermissionRequestResult) = flow.tryEmit(IdAndResult(requestId, result)) +} + +class PermissionsRequestActivity : ComponentActivity() { + + @RequiresApi(Build.VERSION_CODES.M) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val requestId = intent.extras?.getString(PermissionRequestId) + if (requestId != null) { + launchPermissionRequest(requestId) + } else { + finishWithNoAnimation() + } + } + + @RequiresApi(Build.VERSION_CODES.M) + private fun launchPermissionRequest(requestId: String) { + val launcher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val result = if (granted) { + PermissionRequestResult.Granted + } else { + PermissionRequestResult.NotGranted(shouldShowRequestPermissionRationale(PermissionName)) + } + PermissionRequestChannel.send(requestId, result) + finishWithNoAnimation() + } + + launcher.launch(PermissionName) + } + + private fun finishWithNoAnimation() { + finish() + overridePendingTransition(0, 0) } } diff --git a/android/src/main/java/com/candlefinance/push/RNEventEmitter.kt b/android/src/main/java/com/candlefinance/push/RNEventEmitter.kt index eaa6572..9d5bce2 100644 --- a/android/src/main/java/com/candlefinance/push/RNEventEmitter.kt +++ b/android/src/main/java/com/candlefinance/push/RNEventEmitter.kt @@ -1,15 +1,46 @@ package com.candlefinance.push -import android.util.Log +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableMap import com.facebook.react.modules.core.DeviceEventManagerModule -object RNEventEmitter { - fun sendEvent(eventName: String?, eventMap: String?) { - val reactContext = ContextHolder.getInstance().getApplicationContext() - try { - reactContext?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)?.emit(eventName!!, eventMap) - } catch (e: Exception) { - Log.e("SEND_EVENT", "", e) - } +enum class PushNotificationEventType(val value: String) { + FOREGROUND_MESSAGE_RECEIVED("ForegroundMessageReceived"), + LAUNCH_NOTIFICATION_OPENED("LaunchNotificationOpened"), + NOTIFICATION_OPENED("NotificationOpened"), + TOKEN_RECEIVED("TokenReceived") +} + +class PushNotificationEvent(val type: PushNotificationEventType, val params: WritableMap?) + +object PushNotificationEventManager { + private lateinit var reactContext: ReactApplicationContext + private var isInitialized: Boolean = false + private val eventQueue: MutableList = mutableListOf() + + fun init(reactContext: ReactApplicationContext) { + this.reactContext = reactContext + isInitialized = true + flushEventQueue() + } + + fun sendEvent(type: PushNotificationEventType, params: WritableMap?) { + if (!isInitialized) { + eventQueue.add(PushNotificationEvent(type, params)) + } else { + sendJSEvent(type, params) + } + } + + private fun sendJSEvent(type: PushNotificationEventType, params: WritableMap?) { + reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit(type.value, params) + } + + private fun flushEventQueue() { + eventQueue.forEach { + sendJSEvent(it.type, it.params) } + eventQueue.clear() + } } diff --git a/example/src/App.tsx b/example/src/App.tsx index f499e72..5abe0aa 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { StyleSheet, View, Text, Button } from 'react-native'; +import { StyleSheet, View, Text, Button, Platform } from 'react-native'; import type { PushNotificationPermissionStatus } from '@candlefinance/push'; import { module as Push } from '@candlefinance/push'; @@ -49,11 +49,13 @@ export default function App() { } ); return () => { - Push.removeListeners(NativeEvent.TOKEN_RECEIVED); - Push.removeListeners(NativeEvent.BACKGROUND_MESSAGE_RECEIVED); - Push.removeListeners(NativeEvent.NOTIFICATION_OPENED); - Push.removeListeners(NativeEvent.FOREGROUND_MESSAGE_RECEIVED); - Push.removeListeners(NativeEvent.LAUNCH_NOTIFICATION_OPENED); + if (Platform.OS === 'ios') { + Push.removeListeners(NativeEvent.TOKEN_RECEIVED); + Push.removeListeners(NativeEvent.BACKGROUND_MESSAGE_RECEIVED); + Push.removeListeners(NativeEvent.NOTIFICATION_OPENED); + Push.removeListeners(NativeEvent.FOREGROUND_MESSAGE_RECEIVED); + Push.removeListeners(NativeEvent.LAUNCH_NOTIFICATION_OPENED); + } }; }, [isGranted]);