Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: push permission prompt #101

Merged
merged 38 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
05b5141
method to show push permissions prompt
ami-aman Feb 20, 2023
3ab0477
enum with possible authorization status
ami-aman Feb 20, 2023
f284358
get status
ami-aman Feb 20, 2023
b54a680
check if prompt works from javascript
ami-aman Feb 20, 2023
7ed40a8
function to show prompt later in app
ami-aman Feb 20, 2023
98a9cc9
comments
ami-aman Feb 20, 2023
3facddf
configurable options
ami-aman Feb 20, 2023
4c47773
add sound & badge only if configured true
ami-aman Feb 20, 2023
9261f8e
handling promise
ami-aman Feb 20, 2023
948aa94
method with resolver and rejector promise
ami-aman Feb 20, 2023
8e6ccc5
shooting promises from native
ami-aman Feb 20, 2023
3fc5213
completing callback
ami-aman Feb 23, 2023
50dccfd
fixing minor issue
ami-aman Feb 23, 2023
8b563b2
updating to not determined
ami-aman Feb 23, 2023
a53d30d
send current status as string if action of push permission has alread…
ami-aman Feb 23, 2023
010ea97
modulated showPromptForPushNotifications
ami-aman Feb 23, 2023
9446e0b
added granted as push status
ami-aman Feb 23, 2023
763ff12
adding getPushPermissionStatus method
ami-aman Feb 24, 2023
42aa796
callback method for status
ami-aman Feb 24, 2023
2559412
added method in js
ami-aman Feb 24, 2023
bc3a5bb
comments on js functions
ami-aman Feb 24, 2023
09538c6
comments
ami-aman Feb 24, 2023
f2e187e
Merge branch 'main' into aman/push-notifications
mrehan27 Feb 24, 2023
8abcb6e
feat: push permission prompt [android] (#104)
mrehan27 Mar 1, 2023
43b55a6
push config options
ami-aman Mar 2, 2023
7f63d0c
Revert "push config options"
ami-aman Mar 2, 2023
45d35d6
push config options
ami-aman Mar 2, 2023
805245a
index file
ami-aman Mar 2, 2023
b11b6fc
removing push config
ami-aman Mar 2, 2023
203efad
simplifying push permission status
ami-aman Mar 2, 2023
4a2414f
updated permission status handler to promise for android
mrehan27 Mar 2, 2023
2091998
removing callback, adding promise in js
ami-aman Mar 2, 2023
5a9bf2c
callback to promise in ios
ami-aman Mar 2, 2023
91a245e
updating comment
ami-aman Mar 2, 2023
f7e4845
fixing objc error
ami-aman Mar 2, 2023
0af13ec
fix
ami-aman Mar 2, 2023
13d2f5b
not determined as default
ami-aman Mar 2, 2023
bf640df
fix: replace any data types with strongly typed objects (#107)
levibostian Mar 3, 2023
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
2 changes: 2 additions & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.customer.reactnative.sdk">

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

</manifest>
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
package io.customer.reactnative.sdk

import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.*
import io.customer.reactnative.sdk.extension.toMap
import io.customer.reactnative.sdk.messagingpush.RNCIOPushMessaging
import io.customer.sdk.CustomerIO
import io.customer.sdk.CustomerIOShared
import io.customer.sdk.util.Logger

class CustomerIOReactNativeModule(
reactContext: ReactApplicationContext,
private val pushMessagingModule: RNCIOPushMessaging,
private val inAppMessagingModule: RNCIOInAppMessaging,
) : ReactContextBaseJavaModule(reactContext) {
private val logger: Logger
Expand Down Expand Up @@ -111,6 +110,16 @@ class CustomerIOReactNativeModule(
customerIO.registerDeviceToken(token)
}

@ReactMethod
fun getPushPermissionStatus(promise: Promise) {
pushMessagingModule.getPushPermissionStatus(promise)
}

@ReactMethod
fun showPromptForPushNotifications(pushConfigurationOptions: ReadableMap?, promise: Promise) {
pushMessagingModule.showPromptForPushNotifications(pushConfigurationOptions, promise)
}

companion object {
internal const val MODULE_NAME = "CustomerioReactnative"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
import io.customer.reactnative.sdk.messagingpush.RNCIOPushMessaging

class CustomerIOReactNativePackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
val pushMessagingModule = RNCIOPushMessaging(reactContext)
val inAppMessagingModule = RNCIOInAppMessaging(reactContext)
return listOf(
inAppMessagingModule,
CustomerIOReactNativeModule(
reactContext = reactContext,
pushMessagingModule = pushMessagingModule,
inAppMessagingModule = inAppMessagingModule,
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.customer.reactnative.sdk.messagingpush

/**
* Enum class for wrapping status of Android app permissions.
*/
enum class PermissionStatus {
Granted,
Denied,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package io.customer.reactnative.sdk.messagingpush

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.PermissionAwareActivity
import com.facebook.react.modules.core.PermissionListener

/**
* ReactNative module to hold push messages features in a single place to bridge with native code.
*/
class RNCIOPushMessaging(
private val reactContext: ReactApplicationContext,
) : ReactContextBaseJavaModule(reactContext), PermissionListener {
/**
* Temporarily holds reference for notification request as the request is dependent on Android
* lifecycle and cannot be completed instantly.
*/
private var notificationRequestPromise: Promise? = null

@ReactMethod
fun getPushPermissionStatus(promise: Promise) {
promise.resolve(checkPushPermissionStatus().toReactNativeResult)
}

/**
* To request push notification permissions using native apis. Push notifications doesn't
* require permissions for Android versions older than 13, so the results are returned instantly.
* For newer versions, the permission is requested and the promise is resolved after the request
* has been completed.
*
* @param pushConfigurationOptions configurations options for push notifications, required for
* iOS only, unused on Android.
* @param promise to resolve and return the results.
*/
@ReactMethod
fun showPromptForPushNotifications(pushConfigurationOptions: ReadableMap?, promise: Promise) {
// Skip requesting permissions when already granted
if (checkPushPermissionStatus() == PermissionStatus.Granted) {
promise.resolve(PermissionStatus.Granted.toReactNativeResult)
return
}

try {
val activity = currentActivity
val permissionAwareActivity = activity as? PermissionAwareActivity
if (permissionAwareActivity == null) {
promise.reject(
"E_ACTIVITY_DOES_NOT_EXIST",
"Permission cannot be requested because activity doesn't exist. Please make sure to request permission from UI components only"
)
return
}

notificationRequestPromise = promise
permissionAwareActivity.requestPermissions(
arrayOf(POST_NOTIFICATIONS_PERMISSION_NAME),
POST_NOTIFICATIONS_PERMISSION_REQUEST,
this,
)
} catch (ex: Throwable) {
promise.reject(ex)
notificationRequestPromise = null
}
}

/**
* Checks current permission of push notification permission
*/
private fun checkPushPermissionStatus(): PermissionStatus =
// Skip requesting permissions for older versions where not required
if (Build.VERSION.SDK_INT < BUILD_VERSION_CODE_TIRAMISU || ContextCompat.checkSelfPermission(
reactContext, POST_NOTIFICATIONS_PERMISSION_NAME,
) == PackageManager.PERMISSION_GRANTED
) PermissionStatus.Granted
else PermissionStatus.Denied

/**
* Resolves and clears promise with the provided permission status
*/
private fun resolvePermissionPromise(status: PermissionStatus) {
notificationRequestPromise?.resolve(status.toReactNativeResult)
notificationRequestPromise = null
}

override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults: IntArray,
): Boolean = when (requestCode) {
POST_NOTIFICATIONS_PERMISSION_REQUEST -> {
// If request is cancelled, the result arrays are empty.
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
resolvePermissionPromise(PermissionStatus.Granted)
} else {
resolvePermissionPromise(PermissionStatus.Denied)
}
true // as this permission listener can be removed now
}
else -> false // desired permission not yet granted, so we will keep the listener
}

override fun getName(): String = "CustomerioPushMessaging"

/**
* Maps native class to react native supported type so the result can be passed on to JS/TS classes.
*/
private val PermissionStatus.toReactNativeResult: Any
get() = this.name

companion object {
/**
* Copying value os [Manifest.permission.POST_NOTIFICATIONS] as constant so we don't have to
* force newer compile sdk versions
*/
private const val POST_NOTIFICATIONS_PERMISSION_NAME =
"android.permission.POST_NOTIFICATIONS"
private const val BUILD_VERSION_CODE_TIRAMISU = 33
private const val POST_NOTIFICATIONS_PERMISSION_REQUEST = 24676
}
}
5 changes: 5 additions & 0 deletions ios/CustomerioReactnative.m
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,10 @@ @interface RCT_EXTERN_MODULE(CustomerioReactnative, NSObject)

RCT_EXTERN_METHOD(registerDeviceToken : (nonnull NSString *) token)

RCT_EXTERN_METHOD(showPromptForPushNotifications: (NSDictionary *) options
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)

RCT_EXTERN_METHOD(getPushPermissionStatus: (RCTPromiseResolveBlock) resolver
rejecter:(RCTPromiseRejectBlock)rejecter)
@end
89 changes: 88 additions & 1 deletion ios/CustomerioReactnative.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import Foundation
import CioTracking
import Common
import CioMessagingInApp
import UserNotifications

enum PushPermissionStatus : String {
levibostian marked this conversation as resolved.
Show resolved Hide resolved
case denied = "Denied"
case notDetermined = "NotDetermined"
case unknown = "Unknown"
case granted = "Granted"
}
@objc(CustomerioReactnative)
class CustomerioReactnative: NSObject {

Expand Down Expand Up @@ -43,6 +50,11 @@ class CustomerioReactnative: NSObject {
if let isEnableInApp = configData["enableInApp"] as? Bool, isEnableInApp {
initializeInApp()
}

// Register app for push notifications if not done already
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}

/**
Expand Down Expand Up @@ -120,6 +132,81 @@ class CustomerioReactnative: NSObject {
func registerDeviceToken(token: String) -> Void {
CustomerIO.shared.registerDeviceToken(token)
}

/**
To show push notification prompt if current authorization status is not determined
*/
@objc(showPromptForPushNotifications:resolver:rejecter:)
func showPromptForPushNotifications(options : Dictionary<String, AnyHashable>, resolver resolve: @escaping(RCTPromiseResolveBlock), rejecter reject: @escaping(RCTPromiseRejectBlock)) -> Void {

// Show prompt if status is not determined
getPushNotificationPermissionStatus { status in
levibostian marked this conversation as resolved.
Show resolved Hide resolved
if status == .notDetermined {
self.requestPushAuthorization(options: options) { permissionStatus in

guard let status = permissionStatus as? Bool else {
reject("[CIO]", "Error requesting push notification permission.", permissionStatus as? Error)
return
}
resolve(status ? PushPermissionStatus.granted.rawValue : PushPermissionStatus.denied.rawValue)
}
} else {
resolve(status.rawValue)
}
}
}

/**
This functions gets the current status of push notification permission and returns as a promise
*/
@objc(getPushPermissionStatus:rejecter:)
func getPushPermissionStatus(resolver resolve: @escaping(RCTPromiseResolveBlock), rejecter reject: @escaping(RCTPromiseRejectBlock)) -> Void {
getPushNotificationPermissionStatus { status in
resolve(status.rawValue)
}
}

private func requestPushAuthorization(options: [String: Any], onComplete : @escaping(Any) -> Void
) {
let current = UNUserNotificationCenter.current()
var notificationOptions : UNAuthorizationOptions = [.alert]
if let ios = options["ios"] as? [String: Any] {

if let soundOption = ios["sound"] as? Bool, soundOption {
notificationOptions.insert(.sound)
}
if let bagdeOption = ios["badge"] as? Bool, bagdeOption {
notificationOptions.insert(.badge)
}
}
current.requestAuthorization(options: notificationOptions) { isGranted, error in
if let error = error {
onComplete(error)
return
}
onComplete(isGranted)
}
}

private func getPushNotificationPermissionStatus(completionHandler: @escaping(PushPermissionStatus) -> Void) {
var status = PushPermissionStatus.unknown
let current = UNUserNotificationCenter.current()
current.getNotificationSettings(completionHandler: { permission in
switch permission.authorizationStatus {
case .authorized:
status = .granted
case .denied:
status = .denied
case .notDetermined:
status = .notDetermined
default:
status = .unknown
}
levibostian marked this conversation as resolved.
Show resolved Hide resolved
completionHandler(status)
})
}

// MARK: - Push Notifications - End
/**
Initialize in-app using customerio package
*/
Expand All @@ -144,7 +231,7 @@ extension CustomerioReactnative: InAppEventListener {
body["actionName"] = actionName
}
CustomerioInAppMessaging.shared?.sendEvent(
withName: "InAppEventListener",
withName: "InAppEventListener",
body: body
)
}
Expand Down
29 changes: 29 additions & 0 deletions src/CustomerioTracking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,35 @@ class CustomerIO {
}
CustomerioReactnative.registerDeviceToken(token)
}

/**
* Request to show prompt for push notification permissions.
* Prompt will only be shown if the current status is - not determined.
* In other cases, this function will return current status of permission.
* @param options
* @returns Success & Failure promises
*/
static async showPromptForPushNotifications(options?: any) : Promise<any>{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why any is used for options parameter?

What about using a typescript interface instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point. I used any to prevent customer from importing another interface or config class. But this can be changed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I can see how not needing to include another import could be nice. However, I believe that if a customer is using typescript in their project, they are OK with adding more imports for types in their code. Also, they probably prefer a strongly typed API when interacting with our SDK. With this in mind, I do believe it's preferred that we replace any with a strongly typed interface.

If we were to organize all of the interfaces in the project, I think the import statements for our SDK could look nice in a customer's app.

Here is an example of the imports could look like in a customer's app:

import type { PushPermissionOptions, Foo, Bar } from "customerio-reactnative/types" 
import { CustomerIOTracking } from "customerio-reactnative" 
...

I do not see this as a blocker for this PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small idea to share, I think changing the function name to something such as:

Suggested change
static async showPromptForPushNotifications(options?: any) : Promise<any>{
static async askForPushPermission(options?: any) : Promise<any>{

would match the function naming of getPushPermissionStatus.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. This is a great suggestion. I will add this in a separate PR. I appreciate the suggestion.

  2. I used "prompt" to make it specific that calling this method would show a prompt on the screen and not do something in the background.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I am OK with that while I am also OK with adding the changes to this PR.
  2. I am OK with either name. I hope using the word "prompt" doesn't confuse anyone where there are some cases where a prompt will not show up when this function is called. That's one reason why I suggested the name change.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I tried implementing customerio-reactnative/types and found that it has to be a completely new library inside our customerio-reactnative with it's own package.json, index.ts and other important files. Since we are waiting to release this feature on priority, I do not want to take more time learning & implementing it at the moment. But I really loved the suggestion and will make sure to add it in the future updates. I would also like to add other configs like CioConfigOptions to /types so that the importing customerio-reactive could get reclustered as we release new features in future.

  2. I still lean towards having promt in the function name as it makes things clear to me as a user. I discussed with Rehan and we both had same POV.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. My code sample might have been bad as the solution I am thinking shouldn't be that complex. Here is a start to this work that I am testing now to see if we can sneak it into this release. I think our typescript customers will appreciate the strong types.
  2. Good idea to ask others. If other people are behind it, I am for it 👍

let pushConfigurationOptions = {
"ios": {
"badge" : true,
"sound" : true
}
}
if (typeof options !== 'undefined'){
pushConfigurationOptions = options
}

return CustomerioReactnative.showPromptForPushNotifications(pushConfigurationOptions)
}

/**
* Get status of push permission for the app
* @returns Promise with status of push permission as a string
*/
static getPushPermissionStatus() : Promise<any> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
static getPushPermissionStatus() : Promise<any> {
static getPushPermissionStatus() : Promise<PushPermissionStatus> {

Going along with the comment about a strongly typed parameter for showPromptForPushNotifications, we know the data type that is being returned from the Promise.

We should be providing customers with a data type return value that they can easily parse in their code.

return CustomerioReactnative.getPushPermissionStatus()
}
}

export { CustomerIO, Region };