Skip to content

Commit

Permalink
feat: process push notifications received outside CIO SDK (#38)
Browse files Browse the repository at this point in the history
Co-authored-by: Shahroz Khan <[email protected]>
  • Loading branch information
mrehan27 and Shahroz16 authored Apr 26, 2023
1 parent 8b74ea1 commit 7b5cb7e
Show file tree
Hide file tree
Showing 10 changed files with 316 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.customer.customer_io

import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel

/**
* Returns the value corresponding to the given key after casting to the generic type provided, or
* null if such key is not present in the map or value cannot be casted to the given type.
*/
internal inline fun <reified T> Map<String, Any>.getAsTypeOrNull(key: String): T? {
if (containsKey(key)) {
return get(key) as? T
}
return null
}

/**
* Invokes lambda method that can be used to call matching native method conveniently. The lambda
* expression receives function parameters as arguments and should return the desired result. Any
* exception in the lambda will cause the invoked method to fail with error.
*/
internal fun <R> MethodCall.invokeNative(
result: MethodChannel.Result,
performAction: (params: Map<String, Any>) -> R,
) {
try {
@Suppress("UNCHECKED_CAST")
val params = this.arguments as? Map<String, Any> ?: emptyMap()
result.success(performAction(params))
} catch (ex: Exception) {
result.error(this.method, ex.localizedMessage, ex)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.customer.customer_io

import io.flutter.embedding.engine.plugins.FlutterPlugin

/**
* Module class corresponds to modules concept in native SDKs. Any module added to native SDKs
* should be treated as module in Flutter SDK and should be used to hold all relevant methods at
* single place.
*/
internal interface CustomerIOPluginModule {
/**
* Unique name of module to identify between other modules
*/
val moduleName: String

/**
* Called whenever root FlutterPlugin has been associated with a FlutterEngine instance.
*
* @see [FlutterPlugin.onAttachedToEngine] for more details
*/
fun onAttachedToEngine()

/**
* Called whenever root FlutterPlugin has been removed from a FlutterEngine instance.
*
* @see [FlutterPlugin.onDetachedFromEngine] for more details
*/
fun onDetachedFromEngine()
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.app.Application
import android.content.Context
import androidx.annotation.NonNull
import io.customer.customer_io.constant.Keys
import io.customer.customer_io.messagingpush.CustomerIOPushMessaging
import io.customer.messaginginapp.MessagingInAppModuleConfig
import io.customer.messaginginapp.ModuleMessagingInApp
import io.customer.messaginginapp.type.InAppEventListener
Expand Down Expand Up @@ -41,6 +42,7 @@ class CustomerIoPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var flutterCommunicationChannel: MethodChannel
private lateinit var context: Context
private var activity: WeakReference<Activity>? = null
private lateinit var pushMessagingModule: CustomerIOPushMessaging

private val logger: Logger
get() = CustomerIOShared.instance().diStaticGraph.logger
Expand All @@ -66,6 +68,8 @@ class CustomerIoPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
flutterCommunicationChannel =
MethodChannel(flutterPluginBinding.binaryMessenger, "customer_io")
flutterCommunicationChannel.setMethodCallHandler(this)
pushMessagingModule = CustomerIOPushMessaging(flutterPluginBinding)
pushMessagingModule.onAttachedToEngine()
}

private fun MethodCall.toNativeMethodCall(
Expand Down Expand Up @@ -247,6 +251,7 @@ class CustomerIoPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {

override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
flutterCommunicationChannel.setMethodCallHandler(null)
pushMessagingModule.onDetachedFromEngine()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal object Keys {
const val SET_PROFILE_ATTRIBUTES = "setProfileAttributes"
const val REGISTER_DEVICE_TOKEN = "registerDeviceToken"
const val TRACK_METRIC = "trackMetric"
const val ON_MESSAGE_RECEIVED = "onMessageReceived"
}

object Tracking {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.customer.customer_io.messagingpush

import android.content.Context
import io.customer.customer_io.CustomerIOPluginModule
import io.customer.customer_io.constant.Keys
import io.customer.customer_io.getAsTypeOrNull
import io.customer.customer_io.invokeNative
import io.customer.messagingpush.CustomerIOFirebaseMessagingService
import io.customer.sdk.CustomerIOShared
import io.customer.sdk.extensions.takeIfNotBlank
import io.customer.sdk.util.Logger
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.util.*

/**
* Flutter module implementation for messaging push module in native SDKs. All functionality
* linked with the module should be placed here.
*/
internal class CustomerIOPushMessaging(
pluginBinding: FlutterPlugin.FlutterPluginBinding,
) : CustomerIOPluginModule, MethodChannel.MethodCallHandler {
override val moduleName: String = "PushMessaging"
private val applicationContext: Context = pluginBinding.applicationContext
private val flutterCommunicationChannel: MethodChannel =
MethodChannel(pluginBinding.binaryMessenger, "customer_io_messaging_push")
private val logger: Logger
get() = CustomerIOShared.instance().diStaticGraph.logger

override fun onAttachedToEngine() {
flutterCommunicationChannel.setMethodCallHandler(this)
}

override fun onDetachedFromEngine() {
flutterCommunicationChannel.setMethodCallHandler(null)
}

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
Keys.Methods.ON_MESSAGE_RECEIVED -> {
call.invokeNative(result) { args ->
return@invokeNative onMessageReceived(
message = args.getAsTypeOrNull<Map<String, Any>>("message"),
handleNotificationTrigger = args.getAsTypeOrNull<Boolean>("handleNotificationTrigger")
)
}
}
else -> {
result.notImplemented()
}
}
}

/**
* Handles push notification received. This is helpful in processing push notifications
* received outside the CIO SDK.
*
* @param message push payload received from FCM.
* @param handleNotificationTrigger indicating if the local notification should be triggered.
*/
private fun onMessageReceived(
message: Map<String, Any>?,
handleNotificationTrigger: Boolean?,
): Boolean {
try {
if (message == null) {
throw IllegalArgumentException("Message cannot be null")
}

// Generate destination string, see docs on receiver method for more details
val destination = (message["to"] as? String)?.takeIfNotBlank()
?: UUID.randomUUID().toString()
return CustomerIOFirebaseMessagingService.onMessageReceived(
context = applicationContext,
remoteMessage = message.toFCMRemoteMessage(destination = destination),
handleNotificationTrigger = handleNotificationTrigger ?: true,
)
} catch (ex: Throwable) {
logger.error("Unable to handle push notification, reason: ${ex.message}")
throw ex
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package io.customer.customer_io.messagingpush

import com.google.firebase.messaging.RemoteMessage
import io.customer.customer_io.getAsTypeOrNull

/**
* Safely transforms any value to string
*/
private fun Any.toStringOrNull(): String? = try {
toString()
} catch (ex: Exception) {
// We don't need to print any error here as this is expected for some values and doesn't
// break anything
null
}

/**
* Extension function to build FCM [RemoteMessage] using RN map. This should be independent from
* the sender source and should be able to build a valid [RemoteMessage] for our native SDK.
*
* @param destination receiver of the message. It is mainly required for sending upstream messages,
* since we are using RemoteMessage only for broadcasting messages locally, we can use any non-empty
* string for it.
*/
internal fun Map<String, Any>.toFCMRemoteMessage(destination: String): RemoteMessage {
val notification = getAsTypeOrNull<Map<String, Any>>("notification")
val data = getAsTypeOrNull<Map<String, Any>>("data")
val messageParams = buildMap {
notification?.let { result -> putAll(result) }
// Adding `data` after `notification` so `data` params take more value as we mainly use
// `data` in rich push
data?.let { result -> putAll(result) }
}
return with(RemoteMessage.Builder(destination)) {
messageParams.let { params ->
val paramsIterator = params.iterator()
while (paramsIterator.hasNext()) {
val (key, value) = paramsIterator.next()
// Some values in notification object can be another object and may not support
// mapping to string values, transforming these values in a try-catch so the code
// doesn't break due to one bad value
value.toStringOrNull()?.let { v -> addData(key, v) }
}
}
getAsTypeOrNull<String>("messageId")?.let { id -> setMessageId(id) }
getAsTypeOrNull<String>("messageType")?.let { type -> setMessageType(type) }
getAsTypeOrNull<String>("collapseKey")?.let { key -> setCollapseKey(key) }
getAsTypeOrNull<Int>("ttl")?.let { time -> ttl = time }
return@with build()
}
}
8 changes: 8 additions & 0 deletions lib/customer_io.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import 'customer_io_config.dart';
import 'customer_io_enums.dart';
import 'customer_io_inapp.dart';
import 'customer_io_platform_interface.dart';
import 'messaging_push/platform_interface.dart';

class CustomerIO {
const CustomerIO._();

static CustomerIOPlatform get _customerIO => CustomerIOPlatform.instance;

static CustomerIOMessagingPushPlatform get _customerIOMessagingPush =>
CustomerIOMessagingPushPlatform.instance;

/// To initialize the plugin
///
/// @param config includes required and optional configs etc
Expand Down Expand Up @@ -100,4 +104,8 @@ class CustomerIO {
void Function(InAppEvent) onEvent) {
return _customerIO.subscribeToInAppEventListener(onEvent);
}

static CustomerIOMessagingPushPlatform messagingPush() {
return _customerIOMessagingPush;
}
}
3 changes: 3 additions & 0 deletions lib/customer_io_const.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class MethodConsts {
static const String setDeviceAttributes = "setDeviceAttributes";
static const String setProfileAttributes = "setProfileAttributes";
static const String registerDeviceToken = "registerDeviceToken";
static const String onMessageReceived = "onMessageReceived";
}

class TrackingConsts {
Expand All @@ -18,4 +19,6 @@ class TrackingConsts {
static const String deliveryId = "deliveryId";
static const String deliveryToken = "deliveryToken";
static const String metricEvent = "metricEvent";
static const String message = "message";
static const String handleNotificationTrigger = "handleNotificationTrigger";
}
48 changes: 48 additions & 0 deletions lib/messaging_push/method_channel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';

import '../customer_io_const.dart';
import 'platform_interface.dart';

/// An implementation of [CustomerIOMessagingPushPlatform] that uses method
/// channels.
class CustomerIOMessagingPushMethodChannel
extends CustomerIOMessagingPushPlatform {
/// The method channel used to interact with the native platform.
@visibleForTesting
final methodChannel = const MethodChannel('customer_io_messaging_push');

@override
Future<bool> onMessageReceived(Map<String, dynamic> message,
{bool handleNotificationTrigger = true}) {
if (Platform.isIOS) {
/// Since push notifications on iOS work fine with multiple notification
/// SDKs, we don't need to process them on iOS for now.
/// Resolving future to true makes it easier for callers to avoid adding
/// unnecessary platform specific checks.
return Future.value(true);
}

try {
final arguments = {
TrackingConsts.message: message,
TrackingConsts.handleNotificationTrigger: handleNotificationTrigger,
};
return methodChannel
.invokeMethod(MethodConsts.onMessageReceived, arguments)
.then((handled) => handled == true);
} on PlatformException catch (exception) {
handleException(exception);
return Future.error(
exception.message ?? "Error handling push notification");
}
}

void handleException(PlatformException exception) {
if (kDebugMode) {
print(exception);
}
}
}
54 changes: 54 additions & 0 deletions lib/messaging_push/platform_interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import 'package:plugin_platform_interface/plugin_platform_interface.dart';

import 'method_channel.dart';

/// The default instance of [CustomerIOMessagingPushPlatform] to use
///
/// Platform-specific plugins should override this with their own
/// platform-specific class that extends [CustomerIOMessagingPushPlatform]
/// when they register themselves.
///
/// Defaults to [CustomerIOMessagingPushMethodChannel]
abstract class CustomerIOMessagingPushPlatform extends PlatformInterface {
CustomerIOMessagingPushPlatform() : super(token: _token);

static final Object _token = Object();

static CustomerIOMessagingPushPlatform _instance =
CustomerIOMessagingPushMethodChannel();

static CustomerIOMessagingPushPlatform get instance => _instance;

static set instance(CustomerIOMessagingPushPlatform instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}

/// Processes push notification received outside the CIO SDK. The method
/// displays notification on device and tracks CIO metrics for push
/// notification.
///
/// [message] push payload received from FCM. The payload must contain data
/// payload received in push notification.
/// [handleNotificationTrigger] flag to indicate whether it should display the
/// notification or not.
/// `true` (default): The SDK will display the notification and track associated
/// metrics.
/// `false`: The SDK will only process the notification to track metrics but
/// will not display any notification.
/// Returns a [Future] that resolves to boolean indicating if the notification
/// was handled by the SDK or not.
Future<bool> onMessageReceived(Map<String, dynamic> message,
{bool handleNotificationTrigger = true}) {
throw UnimplementedError('onMessageReceived() has not been implemented.');
}

/// Handles push notification received when app is background. Since FCM
/// itself displays the notification when app is background, this method makes
/// it easier to determine whether the notification should be displayed or not.
///
/// @see [onMessageReceived] for more details
Future<bool> onBackgroundMessageReceived(Map<String, dynamic> message) =>
onMessageReceived(message,
handleNotificationTrigger: message['notification'] == null);
}

0 comments on commit 7b5cb7e

Please sign in to comment.