diff --git a/.github/workflows/build-sample-apps.yml b/.github/workflows/build-sample-apps.yml index 6cfb64c..5669fe3 100644 --- a/.github/workflows/build-sample-apps.yml +++ b/.github/workflows/build-sample-apps.yml @@ -112,13 +112,12 @@ jobs: run: | cp ".env.example" ".env" sd 'SITE_ID=.*' "SITE_ID=${{ secrets[format('CUSTOMERIO_{0}_WORKSPACE_SITE_ID', matrix.sample-app)] }}" ".env" - sd 'API_KEY=.*' "API_KEY=${{ secrets[format('CUSTOMERIO_{0}_WORKSPACE_API_KEY', matrix.sample-app)] }}" ".env" + sd 'CDP_API_KEY=.*' "CDP_API_KEY=${{ secrets[format('CUSTOMERIO_{0}_WORKSPACE_CDP_API_KEY', matrix.sample-app)] }}" ".env" - name: Setup workspace credentials in iOS environment files run: | cp "ios/Env.swift.example" "ios/Env.swift" - sd 'siteId: String = ".*"' "siteId: String = \"${{ secrets[format('CUSTOMERIO_{0}_WORKSPACE_SITE_ID', matrix.sample-app)] }}\"" "ios/Env.swift" - sd 'apiKey: String = ".*"' "apiKey: String = \"${{ secrets[format('CUSTOMERIO_{0}_WORKSPACE_API_KEY', matrix.sample-app)] }}\"" "ios/Env.swift" + sd 'cdpApiKey: String = ".*"' "cdpApiKey: String = \"${{ secrets[format('CUSTOMERIO_{0}_WORKSPACE_CDP_API_KEY', matrix.sample-app)] }}\"" "ios/Env.swift" # Make sure to fetch dependencies only after updating the version numbers and workspace credentials diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 2138ae2..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Flutter Build - -on: - push: - branches: [main] -# Cancel jobs and just run the last one -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - working-directory: apps/amiapp_flutter - -jobs: - build_ios: - name: Build iOS - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: '17' - - name: Install CLI tools used in CI script - run: | - brew install sd # used in CI script as an easier to use sed CLI. Replaces text in files. - - name: Setup workspace credentials in iOS environment files - run: | - cp "ios/Env.swift.example" "ios/Env.swift" - sd 'siteId: String = ".*"' "siteId: String = \"${{ secrets.CUSTOMERIO_AMIAPP_FLUTTER_WORKSPACE_SITE_ID }}\"" "ios/Env.swift" - sd 'siteId: String = ".*"' "siteId: String = \"${{ secrets.CUSTOMERIO_AMIAPP_FLUTTER_WORKSPACE_API_KEY }}\"" "ios/Env.swift" - - uses: ./.github/actions/setup-flutter - - run: flutter build ios --release --no-codesign - - build_android: - name: Build Android - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: '17' - - uses: ./.github/actions/setup-flutter - - run: flutter build apk --release diff --git a/.releaserc.json b/.releaserc.json index 9d9a93a..aa684c1 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -42,7 +42,8 @@ "assets": [ "CHANGELOG.md", "pubspec.yaml", - "lib/customer_io_plugin_version.dart" + "lib/customer_io_plugin_version.dart", + "android/src/main/res/values/customer_io_config.xml" ], "message": "chore: prepare for ${nextRelease.version}\n\n${nextRelease.notes}" } diff --git a/android/build.gradle b/android/build.gradle index 3f52931..ecc0112 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -18,7 +18,6 @@ rootProject.allprojects { repositories { google() mavenCentral() - maven { url 'https://maven.gist.build' } } } @@ -59,8 +58,8 @@ android { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" // Customer.io SDK - def cioVersion = "3.11.2" - implementation "io.customer.android:tracking:$cioVersion" + def cioVersion = "4.4.1" + implementation "io.customer.android:datapipelines:$cioVersion" implementation "io.customer.android:messaging-push-fcm:$cioVersion" implementation "io.customer.android:messaging-in-app:$cioVersion" } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index cc947c5..9b3b143 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1 +1,15 @@ - + + + + + + + + diff --git a/android/src/main/kotlin/io/customer/customer_io/CustomerIOExtensions.kt b/android/src/main/kotlin/io/customer/customer_io/CustomerIOExtensions.kt deleted file mode 100644 index 138a65a..0000000 --- a/android/src/main/kotlin/io/customer/customer_io/CustomerIOExtensions.kt +++ /dev/null @@ -1,38 +0,0 @@ -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 Map.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 MethodCall.invokeNative( - result: MethodChannel.Result, - performAction: (params: Map) -> R, -) { - try { - @Suppress("UNCHECKED_CAST") - val params = this.arguments as? Map ?: emptyMap() - val actionResult = performAction(params) - if (actionResult is Unit) { - result.success(true) - } else { - result.success(actionResult) - } - } catch (ex: Exception) { - result.error(this.method, ex.localizedMessage, ex) - } -} diff --git a/android/src/main/kotlin/io/customer/customer_io/CustomerIOPlugin.kt b/android/src/main/kotlin/io/customer/customer_io/CustomerIOPlugin.kt new file mode 100644 index 0000000..773960c --- /dev/null +++ b/android/src/main/kotlin/io/customer/customer_io/CustomerIOPlugin.kt @@ -0,0 +1,274 @@ +package io.customer.customer_io + +import android.app.Application +import android.content.Context +import androidx.annotation.NonNull +import io.customer.customer_io.bridge.NativeModuleBridge +import io.customer.customer_io.bridge.nativeMapArgs +import io.customer.customer_io.bridge.nativeNoArgs +import io.customer.customer_io.messaginginapp.CustomerIOInAppMessaging +import io.customer.customer_io.messagingpush.CustomerIOPushMessaging +import io.customer.customer_io.utils.getAs +import io.customer.sdk.CustomerIO +import io.customer.sdk.CustomerIOBuilder +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.CioLogLevel +import io.customer.sdk.core.util.Logger +import io.customer.sdk.data.model.Region +import io.customer.sdk.events.Metric +import io.customer.sdk.events.TrackMetric +import io.customer.sdk.events.serializedName +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result + +/** + * Android implementation of plugin that will let Flutter developers to + * interact with a Android platform + * */ +class CustomerIOPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private lateinit var flutterCommunicationChannel: MethodChannel + private lateinit var context: Context + + private lateinit var modules: List + + private val logger: Logger = SDKComponent.logger + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + flutterCommunicationChannel = + MethodChannel(flutterPluginBinding.binaryMessenger, "customer_io") + flutterCommunicationChannel.setMethodCallHandler(this) + + // Initialize modules + modules = listOf( + CustomerIOPushMessaging(flutterPluginBinding), + CustomerIOInAppMessaging(flutterPluginBinding) + ) + + // Attach modules to engine + modules.forEach { + it.onAttachedToEngine() + } + } + + override fun onMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "clearIdentify" -> call.nativeNoArgs(result, ::clearIdentify) + "identify" -> call.nativeMapArgs(result, ::identify) + "initialize" -> call.nativeMapArgs(result, ::initialize) + "registerDeviceToken" -> call.nativeMapArgs(result, ::registerDeviceToken) + "screen" -> call.nativeMapArgs(result, ::screen) + "setDeviceAttributes" -> call.nativeMapArgs(result, ::setDeviceAttributes) + "setProfileAttributes" -> call.nativeMapArgs(result, ::setProfileAttributes) + "track" -> call.nativeMapArgs(result, ::track) + "trackMetric" -> call.nativeMapArgs(result, ::trackMetric) + else -> result.notImplemented() + } + } + + private fun clearIdentify() { + CustomerIO.instance().clearIdentify() + } + + private fun identify(params: Map) { + val userId = params.getAs(Args.USER_ID) + val traits = params.getAs>(Args.TRAITS) ?: emptyMap() + + if (userId == null && traits.isEmpty()) { + logger.error("Please provide either an ID or traits to identify.") + return + } + + if (userId != null && traits.isNotEmpty()) { + CustomerIO.instance().identify(userId, traits) + } else if (userId != null) { + CustomerIO.instance().identify(userId) + } else { + CustomerIO.instance().profileAttributes = traits + } + } + + private fun track(params: Map) { + val name = requireNotNull(params.getAs(Args.NAME)) { + "Event name is missing in params: $params" + } + val properties = params.getAs>(Args.PROPERTIES) + + if (properties.isNullOrEmpty()) { + CustomerIO.instance().track(name) + } else { + CustomerIO.instance().track(name, properties) + } + } + + private fun registerDeviceToken(params: Map) { + val token = requireNotNull(params.getAs(Args.TOKEN)) { + "Device token is missing in params: $params" + } + CustomerIO.instance().registerDeviceToken(token) + } + + private fun trackMetric(params: Map) { + val deliveryId = params.getAs(Args.DELIVERY_ID) + val deliveryToken = params.getAs(Args.DELIVERY_TOKEN) + val eventName = params.getAs(Args.METRIC_EVENT) + + if (deliveryId == null || deliveryToken == null || eventName == null) { + throw IllegalArgumentException("Missing required parameters") + } + + val event = Metric.values().find { it.serializedName.equals(eventName, true) } + ?: throw IllegalArgumentException("Invalid metric event name") + + CustomerIO.instance().trackMetric( + event = TrackMetric.Push( + deliveryId = deliveryId, + deviceToken = deliveryToken, + metric = event + ) + ) + } + + private fun setDeviceAttributes(params: Map) { + val attributes = params.getAs>(Args.ATTRIBUTES) + + if (attributes.isNullOrEmpty()) { + logger.error("Device attributes are missing in params: $params") + return + } + + CustomerIO.instance().deviceAttributes = attributes + } + + private fun setProfileAttributes(params: Map) { + val attributes = params.getAs>(Args.ATTRIBUTES) + + if (attributes.isNullOrEmpty()) { + logger.error("Profile attributes are missing in params: $params") + return + } + + CustomerIO.instance().profileAttributes = attributes + } + + private fun screen(params: Map) { + val title = requireNotNull(params.getAs(Args.TITLE)) { + "Screen title is missing in params: $params" + } + val properties = params.getAs>(Args.PROPERTIES) + + if (properties.isNullOrEmpty()) { + CustomerIO.instance().screen(title) + } else { + CustomerIO.instance().screen(title, properties) + } + } + + private fun initialize(args: Map): kotlin.Result = runCatching { + val application: Application = context.applicationContext as Application + val cdpApiKey = requireNotNull(args.getAs("cdpApiKey")) { + "CDP API Key is required to initialize Customer.io" + } + + val logLevelRawValue = args.getAs("logLevel") + val regionRawValue = args.getAs("region") + val givenRegion = regionRawValue.let { Region.getRegion(it) } + + CustomerIOBuilder( + applicationContext = application, + cdpApiKey = cdpApiKey + ).apply { + logLevelRawValue?.let { logLevel(CioLogLevel.getLogLevel(it)) } + regionRawValue?.let { region(givenRegion) } + + args.getAs("migrationSiteId")?.let(::migrationSiteId) + args.getAs("autoTrackDeviceAttributes")?.let(::autoTrackDeviceAttributes) + args.getAs("trackApplicationLifecycleEvents") + ?.let(::trackApplicationLifecycleEvents) + + args.getAs("flushAt")?.let(::flushAt) + args.getAs("flushInterval")?.let(::flushInterval) + + args.getAs("apiHost")?.let(::apiHost) + args.getAs("cdnHost")?.let(::cdnHost) + // Configure in-app messaging module based on config provided by customer app + args.getAs>(key = "inApp")?.let { inAppConfig -> + modules.filterIsInstance().forEach { + it.configureModule( + builder = this, + config = inAppConfig.plus("region" to givenRegion.code), + ) + } + } + // Configure push messaging module based on config provided by customer app + args.getAs>(key = "push").let { pushConfig -> + modules.filterIsInstance().forEach { + it.configureModule( + builder = this, + config = pushConfig ?: emptyMap() + ) + } + } + }.build() + + logger.info("Customer.io instance initialized successfully from app") + }.onFailure { ex -> + logger.error("Failed to initialize Customer.io instance from app, ${ex.message}") + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + flutterCommunicationChannel.setMethodCallHandler(null) + + modules.forEach { + it.onDetachedFromEngine() + } + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + modules.forEach { + it.onAttachedToActivity(binding) + } + } + + override fun onDetachedFromActivityForConfigChanges() { + modules.forEach { + it.onDetachedFromActivityForConfigChanges() + } + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + modules.forEach { + it.onReattachedToActivityForConfigChanges(binding) + } + } + + override fun onDetachedFromActivity() { + modules.forEach { + it.onDetachedFromActivity() + } + } + + companion object { + object Args { + const val ATTRIBUTES = "attributes" + const val DELIVERY_ID = "deliveryId" + const val DELIVERY_TOKEN = "deliveryToken" + const val METRIC_EVENT = "metricEvent" + const val NAME = "name" + const val PROPERTIES = "properties" + const val TITLE = "title" + const val TOKEN = "token" + const val TRAITS = "traits" + const val USER_ID = "userId" + } + } +} diff --git a/android/src/main/kotlin/io/customer/customer_io/CustomerIoPlugin.kt b/android/src/main/kotlin/io/customer/customer_io/CustomerIoPlugin.kt deleted file mode 100644 index 9d2f950..0000000 --- a/android/src/main/kotlin/io/customer/customer_io/CustomerIoPlugin.kt +++ /dev/null @@ -1,345 +0,0 @@ -package io.customer.customer_io - -import android.app.Activity -import android.app.Application -import android.content.Context -import androidx.annotation.NonNull -import io.customer.customer_io.constant.Keys -import io.customer.customer_io.messaginginapp.CustomerIOInAppMessaging -import io.customer.customer_io.messagingpush.CustomerIOPushMessaging -import io.customer.messaginginapp.MessagingInAppModuleConfig -import io.customer.messaginginapp.ModuleMessagingInApp -import io.customer.messaginginapp.type.InAppEventListener -import io.customer.messaginginapp.type.InAppMessage -import io.customer.messagingpush.MessagingPushModuleConfig -import io.customer.messagingpush.ModuleMessagingPushFCM -import io.customer.messagingpush.config.PushClickBehavior -import io.customer.sdk.CustomerIO -import io.customer.sdk.CustomerIOConfig -import io.customer.sdk.CustomerIOShared -import io.customer.sdk.data.model.Region -import io.customer.sdk.data.request.MetricEvent -import io.customer.sdk.extensions.getProperty -import io.customer.sdk.extensions.getString -import io.customer.sdk.extensions.takeIfNotBlank -import io.customer.sdk.util.Logger -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.embedding.engine.plugins.activity.ActivityAware -import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import io.flutter.plugin.common.MethodChannel.Result -import java.lang.ref.WeakReference - -/** - * Android implementation of plugin that will let Flutter developers to - * interact with a Android platform - * */ -class CustomerIoPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { - /// The MethodChannel that will the communication between Flutter and native Android - /// - /// This local reference serves to register the plugin with the Flutter Engine and unregister it - /// when the Flutter Engine is detached from the Activity - private lateinit var flutterCommunicationChannel: MethodChannel - private lateinit var context: Context - private var activity: WeakReference? = null - - private lateinit var modules: List - - private val logger: Logger - get() = CustomerIOShared.instance().diStaticGraph.logger - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - this.activity = WeakReference(binding.activity) - } - - override fun onDetachedFromActivityForConfigChanges() { - onDetachedFromActivity() - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - onAttachedToActivity(binding) - } - - override fun onDetachedFromActivity() { - this.activity = null - } - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - flutterCommunicationChannel = - MethodChannel(flutterPluginBinding.binaryMessenger, "customer_io") - flutterCommunicationChannel.setMethodCallHandler(this) - - // Initialize modules - modules = listOf( - CustomerIOPushMessaging(flutterPluginBinding), - CustomerIOInAppMessaging(flutterPluginBinding) - ) - - // Attach modules to engine - modules.forEach { - it.onAttachedToEngine() - } - } - - private fun MethodCall.toNativeMethodCall( - result: Result, performAction: (params: Map) -> Unit - ) { - try { - val params = this.arguments as? Map ?: emptyMap() - performAction(params) - result.success(true) - } catch (e: Exception) { - result.error(this.method, e.localizedMessage, null) - } - } - - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { - when (call.method) { - Keys.Methods.INITIALIZE -> { - call.toNativeMethodCall(result) { - initialize(it) - } - } - - Keys.Methods.IDENTIFY -> { - call.toNativeMethodCall(result) { - identify(it) - } - } - - Keys.Methods.SCREEN -> { - call.toNativeMethodCall(result) { - screen(it) - } - } - - Keys.Methods.TRACK -> { - call.toNativeMethodCall(result) { - track(it) - } - } - - Keys.Methods.TRACK_METRIC -> { - call.toNativeMethodCall(result) { - trackMetric(it) - } - } - - Keys.Methods.REGISTER_DEVICE_TOKEN -> { - call.toNativeMethodCall(result) { - registerDeviceToken(it) - } - } - - Keys.Methods.SET_DEVICE_ATTRIBUTES -> { - call.toNativeMethodCall(result) { - setDeviceAttributes(it) - } - } - - Keys.Methods.SET_PROFILE_ATTRIBUTES -> { - call.toNativeMethodCall(result) { - setProfileAttributes(it) - } - } - - Keys.Methods.CLEAR_IDENTIFY -> { - clearIdentity() - } - - else -> { - result.notImplemented() - } - } - } - - private fun clearIdentity() { - CustomerIO.instance().clearIdentify() - } - - private fun identify(params: Map) { - val identifier = params.getString(Keys.Tracking.IDENTIFIER) - val attributes = - params.getProperty>(Keys.Tracking.ATTRIBUTES) ?: emptyMap() - CustomerIO.instance().identify(identifier, attributes) - } - - private fun track(params: Map) { - val name = params.getString(Keys.Tracking.EVENT_NAME) - val attributes = - params.getProperty>(Keys.Tracking.ATTRIBUTES) ?: emptyMap() - - if (attributes.isEmpty()) { - CustomerIO.instance().track(name) - } else { - CustomerIO.instance().track(name, attributes) - } - } - - private fun registerDeviceToken(params: Map) { - val token = params.getString(Keys.Tracking.TOKEN) - CustomerIO.instance().registerDeviceToken(token) - } - - private fun trackMetric(params: Map) { - val deliveryId = params.getString(Keys.Tracking.DELIVERY_ID) - val deliveryToken = params.getString(Keys.Tracking.DELIVERY_TOKEN) - val eventName = params.getProperty(Keys.Tracking.METRIC_EVENT) - val event = MetricEvent.getEvent(eventName) - - if (event == null) { - logger.info("metric event type null. Possible issue with SDK? Given: $eventName") - return - } - - CustomerIO.instance().trackMetric( - deliveryID = deliveryId, deviceToken = deliveryToken, event = event - ) - } - - private fun setDeviceAttributes(params: Map) { - val attributes = params.getProperty>(Keys.Tracking.ATTRIBUTES) ?: return - - CustomerIO.instance().deviceAttributes = attributes - } - - private fun setProfileAttributes(params: Map) { - val attributes = params.getProperty>(Keys.Tracking.ATTRIBUTES) ?: return - - CustomerIO.instance().profileAttributes = attributes - } - - private fun screen(params: Map) { - val name = params.getString(Keys.Tracking.EVENT_NAME) - val attributes = - params.getProperty>(Keys.Tracking.ATTRIBUTES) ?: emptyMap() - - if (attributes.isEmpty()) { - CustomerIO.instance().screen(name) - } else { - CustomerIO.instance().screen(name, attributes) - } - } - - private fun initialize(configData: Map) { - val application: Application = context.applicationContext as Application - val siteId = configData.getString(Keys.Environment.SITE_ID) - val apiKey = configData.getString(Keys.Environment.API_KEY) - val region = configData.getProperty( - Keys.Environment.REGION - )?.takeIfNotBlank() - val enableInApp = configData.getProperty( - Keys.Environment.ENABLE_IN_APP - ) - - // Checks if SDK was initialized before, which means lifecycle callbacks are already - // registered as well - val isLifecycleCallbacksRegistered = kotlin.runCatching { CustomerIO.instance() }.isSuccess - - val customerIO = CustomerIO.Builder( - siteId = siteId, - apiKey = apiKey, - region = Region.getRegion(region), - appContext = application, - config = configData - ).apply { - addCustomerIOModule(module = configureModuleMessagingPushFCM(configData)) - if (enableInApp == true) { - addCustomerIOModule( - module = ModuleMessagingInApp( - config = MessagingInAppModuleConfig.Builder() - .setEventListener(CustomerIOInAppEventListener { method, args -> - this@CustomerIoPlugin.activity?.get()?.runOnUiThread { - flutterCommunicationChannel.invokeMethod(method, args) - } - }).build(), - ) - ) - } - }.build() - logger.info("Customer.io instance initialized successfully") - - // Request lifecycle events for first initialization only as relaunching app - // in wrapper SDKs may result in reinitialization of SDK and lifecycle listener - // will already be attached in this case as they are registered to application object. - if (!isLifecycleCallbacksRegistered) { - activity?.get()?.let { activity -> - logger.info("Requesting delayed activity lifecycle events") - val lifecycleCallbacks = customerIO.diGraph.activityLifecycleCallbacks - lifecycleCallbacks.postDelayedEventsForNonNativeActivity(activity) - } - } - } - - private fun configureModuleMessagingPushFCM(config: Map?): ModuleMessagingPushFCM { - return ModuleMessagingPushFCM( - config = MessagingPushModuleConfig.Builder().apply { - config?.getProperty(CustomerIOConfig.Companion.Keys.AUTO_TRACK_PUSH_EVENTS) - ?.let { value -> - setAutoTrackPushEvents(autoTrackPushEvents = value) - } - config?.getProperty(CustomerIOConfig.Companion.Keys.PUSH_CLICK_BEHAVIOR_ANDROID) - ?.takeIfNotBlank() - ?.let { value -> - val behavior = kotlin.runCatching { - enumValueOf(value) - }.getOrNull() - if (behavior != null) { - setPushClickBehavior(pushClickBehavior = behavior) - } - } - }.build(), - ) - } - - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - flutterCommunicationChannel.setMethodCallHandler(null) - - modules.forEach { - it.onDetachedFromEngine() - } - } -} - -class CustomerIOInAppEventListener(private val invokeMethod: (String, Any?) -> Unit) : - InAppEventListener { - override fun errorWithMessage(message: InAppMessage) { - invokeMethod( - "errorWithMessage", mapOf( - "messageId" to message.messageId, "deliveryId" to message.deliveryId - ) - ) - } - - override fun messageActionTaken( - message: InAppMessage, actionValue: String, actionName: String - ) { - invokeMethod( - "messageActionTaken", mapOf( - "messageId" to message.messageId, - "deliveryId" to message.deliveryId, - "actionValue" to actionValue, - "actionName" to actionName - ) - ) - } - - override fun messageDismissed(message: InAppMessage) { - invokeMethod( - "messageDismissed", mapOf( - "messageId" to message.messageId, "deliveryId" to message.deliveryId - ) - ) - } - - override fun messageShown(message: InAppMessage) { - invokeMethod( - "messageShown", mapOf( - "messageId" to message.messageId, "deliveryId" to message.deliveryId - ) - ) - } -} diff --git a/android/src/main/kotlin/io/customer/customer_io/bridge/MethodCallExtensions.kt b/android/src/main/kotlin/io/customer/customer_io/bridge/MethodCallExtensions.kt new file mode 100644 index 0000000..9510c8f --- /dev/null +++ b/android/src/main/kotlin/io/customer/customer_io/bridge/MethodCallExtensions.kt @@ -0,0 +1,56 @@ +package io.customer.customer_io.bridge + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Handles native method call by transforming the arguments and invoking the handler. + * + * @param result The result object to send the response back to Flutter. + * @param transformer A function to transform the incoming arguments. + * @param handler A function to handle the transformed arguments and produce a result. + * + * - If the handler returns `Unit`, it sends `true` to Flutter to avoid errors. + * - Catches and sends any exceptions as errors to Flutter. + */ +internal fun MethodCall.native( + result: MethodChannel.Result, + transformer: (Any?) -> Arguments, + handler: (Arguments) -> Result, +) = runCatching { + val args = transformer(arguments) + val response = handler(args) + // If the result is Unit, then return true to the Flutter side + // As returning Unit will throw an error on the Flutter side + result.success( + when (response) { + is Unit -> true + else -> response + } + ) +}.onFailure { ex -> + result.error(method, ex.localizedMessage, ex) +} + +/** + * Handles a native method call that requires no arguments. + * + * @param result The result object to send the response back to Flutter. + * @param handler A function to handle the call and produce a result. + */ +internal fun MethodCall.nativeNoArgs( + result: MethodChannel.Result, + handler: () -> Result, +) = native(result, { }, { handler() }) + +/** + * Handles a native method call with arguments passed as a map. + * + * @param result The result object to send the response back to Flutter. + * @param handler A function to handle the map arguments and produce a result. + */ +@Suppress("UNCHECKED_CAST") +internal fun MethodCall.nativeMapArgs( + result: MethodChannel.Result, + handler: (Map) -> Result, +) = native(result, { it as? Map ?: emptyMap() }, handler) diff --git a/android/src/main/kotlin/io/customer/customer_io/CustomerIOPluginModule.kt b/android/src/main/kotlin/io/customer/customer_io/bridge/NativeModuleBridge.kt similarity index 52% rename from android/src/main/kotlin/io/customer/customer_io/CustomerIOPluginModule.kt rename to android/src/main/kotlin/io/customer/customer_io/bridge/NativeModuleBridge.kt index 403a339..5eb193f 100644 --- a/android/src/main/kotlin/io/customer/customer_io/CustomerIOPluginModule.kt +++ b/android/src/main/kotlin/io/customer/customer_io/bridge/NativeModuleBridge.kt @@ -1,6 +1,10 @@ -package io.customer.customer_io +package io.customer.customer_io.bridge +import io.customer.sdk.CustomerIOBuilder import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel /** @@ -8,7 +12,7 @@ import io.flutter.plugin.common.MethodChannel * should be treated as module in Flutter SDK and should be used to hold all relevant methods at * single place. */ -internal interface CustomerIOPluginModule : MethodChannel.MethodCallHandler { +internal interface NativeModuleBridge : MethodChannel.MethodCallHandler, ActivityAware { /** * Unique name of module to identify between other modules */ @@ -36,4 +40,22 @@ internal interface CustomerIOPluginModule : MethodChannel.MethodCallHandler { fun onDetachedFromEngine() { flutterCommunicationChannel.setMethodCallHandler(null) } + + /** + * Handles incoming method calls from Flutter and invokes the appropriate native method handler. + * If the method is not implemented, the result is marked as not implemented. + */ + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + result.notImplemented() + } + + fun configureModule(builder: CustomerIOBuilder, config: Map) + + override fun onDetachedFromActivity() {} + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {} + + override fun onDetachedFromActivityForConfigChanges() {} + + override fun onAttachedToActivity(binding: ActivityPluginBinding) {} } diff --git a/android/src/main/kotlin/io/customer/customer_io/constant/Keys.kt b/android/src/main/kotlin/io/customer/customer_io/constant/Keys.kt deleted file mode 100644 index 4f09b1a..0000000 --- a/android/src/main/kotlin/io/customer/customer_io/constant/Keys.kt +++ /dev/null @@ -1,35 +0,0 @@ -package io.customer.customer_io.constant - -internal object Keys { - - object Methods { - const val INITIALIZE = "initialize" - const val IDENTIFY = "identify" - const val CLEAR_IDENTIFY = "clearIdentify" - const val TRACK = "track" - const val SCREEN = "screen" - const val SET_DEVICE_ATTRIBUTES = "setDeviceAttributes" - const val SET_PROFILE_ATTRIBUTES = "setProfileAttributes" - const val REGISTER_DEVICE_TOKEN = "registerDeviceToken" - const val TRACK_METRIC = "trackMetric" - const val ON_MESSAGE_RECEIVED = "onMessageReceived" - const val DISMISS_MESSAGE = "dismissMessage" - } - - object Tracking { - const val IDENTIFIER = "identifier" - const val ATTRIBUTES = "attributes" - const val EVENT_NAME = "eventName" - const val TOKEN = "token" - const val DELIVERY_ID = "deliveryId" - const val DELIVERY_TOKEN = "deliveryToken" - const val METRIC_EVENT = "metricEvent" - } - - object Environment { - const val SITE_ID = "siteId" - const val API_KEY = "apiKey" - const val REGION = "region" - const val ENABLE_IN_APP = "enableInApp" - } -} diff --git a/android/src/main/kotlin/io/customer/customer_io/messaginginapp/CustomerIOInAppMessaging.kt b/android/src/main/kotlin/io/customer/customer_io/messaginginapp/CustomerIOInAppMessaging.kt index 53cb5a4..ca28b5e 100644 --- a/android/src/main/kotlin/io/customer/customer_io/messaginginapp/CustomerIOInAppMessaging.kt +++ b/android/src/main/kotlin/io/customer/customer_io/messaginginapp/CustomerIOInAppMessaging.kt @@ -1,13 +1,24 @@ package io.customer.customer_io.messaginginapp -import io.customer.customer_io.CustomerIOPluginModule -import io.customer.customer_io.constant.Keys -import io.customer.customer_io.invokeNative +import android.app.Activity +import io.customer.customer_io.bridge.NativeModuleBridge +import io.customer.customer_io.bridge.nativeNoArgs +import io.customer.customer_io.utils.getAs +import io.customer.messaginginapp.MessagingInAppModuleConfig +import io.customer.messaginginapp.ModuleMessagingInApp import io.customer.messaginginapp.di.inAppMessaging +import io.customer.messaginginapp.type.InAppEventListener +import io.customer.messaginginapp.type.InAppMessage import io.customer.sdk.CustomerIO +import io.customer.sdk.CustomerIOBuilder +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.data.model.Region import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.lang.ref.WeakReference /** * Flutter module implementation for messaging in-app module in native SDKs. All functionality @@ -15,23 +26,107 @@ import io.flutter.plugin.common.MethodChannel */ internal class CustomerIOInAppMessaging( pluginBinding: FlutterPlugin.FlutterPluginBinding, -) : CustomerIOPluginModule, MethodChannel.MethodCallHandler { +) : NativeModuleBridge, MethodChannel.MethodCallHandler, ActivityAware { override val moduleName: String = "InAppMessaging" override val flutterCommunicationChannel: MethodChannel = MethodChannel(pluginBinding.binaryMessenger, "customer_io_messaging_in_app") + private var activity: WeakReference? = null + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + this.activity = WeakReference(binding.activity) + } + + override fun onDetachedFromActivityForConfigChanges() { + onDetachedFromActivity() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + onAttachedToActivity(binding) + } + + override fun onDetachedFromActivity() { + this.activity = null + } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - Keys.Methods.DISMISS_MESSAGE -> { - call.invokeNative(result) { - CustomerIO.instance().inAppMessaging().dismissMessage() - } - } - - else -> { - result.notImplemented() - } + "dismissMessage" -> call.nativeNoArgs(result, ::dismissMessage) + else -> super.onMethodCall(call, result) } } + private fun dismissMessage() { + CustomerIO.instance().inAppMessaging().dismissMessage() + } + + /** + * Adds in-app module to native Android SDK based on the configuration provided by + * customer app. + * + * @param builder instance of CustomerIOBuilder to add push messaging module. + * @param config configuration provided by customer app for in-app messaging module. + */ + override fun configureModule( + builder: CustomerIOBuilder, + config: Map + ) { + val siteId = config.getAs("siteId") + val regionRawValue = config.getAs("region") + val givenRegion = regionRawValue.let { Region.getRegion(it) } + + if (siteId.isNullOrBlank()) { + SDKComponent.logger.error("Site ID is required to initialize InAppMessaging module") + return + } + val module = ModuleMessagingInApp( + MessagingInAppModuleConfig.Builder(siteId = siteId, region = givenRegion) + .setEventListener(CustomerIOInAppEventListener { method, args -> + this.activity?.get()?.runOnUiThread { + flutterCommunicationChannel.invokeMethod(method, args) + } + }) + .build(), + ) + builder.addCustomerIOModule(module) + } +} + +class CustomerIOInAppEventListener(private val invokeMethod: (String, Any?) -> Unit) : + InAppEventListener { + override fun errorWithMessage(message: InAppMessage) { + invokeMethod( + "errorWithMessage", mapOf( + "messageId" to message.messageId, "deliveryId" to message.deliveryId + ) + ) + } + + override fun messageActionTaken( + message: InAppMessage, actionValue: String, actionName: String + ) { + invokeMethod( + "messageActionTaken", mapOf( + "messageId" to message.messageId, + "deliveryId" to message.deliveryId, + "actionValue" to actionValue, + "actionName" to actionName + ) + ) + } + + override fun messageDismissed(message: InAppMessage) { + invokeMethod( + "messageDismissed", mapOf( + "messageId" to message.messageId, "deliveryId" to message.deliveryId + ) + ) + } + + override fun messageShown(message: InAppMessage) { + invokeMethod( + "messageShown", mapOf( + "messageId" to message.messageId, "deliveryId" to message.deliveryId + ) + ) + } } \ No newline at end of file diff --git a/android/src/main/kotlin/io/customer/customer_io/messagingpush/CustomerIOPushMessaging.kt b/android/src/main/kotlin/io/customer/customer_io/messagingpush/CustomerIOPushMessaging.kt index e32618b..947a0bd 100644 --- a/android/src/main/kotlin/io/customer/customer_io/messagingpush/CustomerIOPushMessaging.kt +++ b/android/src/main/kotlin/io/customer/customer_io/messagingpush/CustomerIOPushMessaging.kt @@ -1,18 +1,23 @@ 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.customer_io.bridge.NativeModuleBridge +import io.customer.customer_io.bridge.nativeMapArgs +import io.customer.customer_io.bridge.nativeNoArgs +import io.customer.customer_io.utils.getAs +import io.customer.customer_io.utils.takeIfNotBlank import io.customer.messagingpush.CustomerIOFirebaseMessagingService -import io.customer.sdk.CustomerIOShared -import io.customer.sdk.extensions.takeIfNotBlank -import io.customer.sdk.util.Logger +import io.customer.messagingpush.MessagingPushModuleConfig +import io.customer.messagingpush.ModuleMessagingPushFCM +import io.customer.messagingpush.config.PushClickBehavior +import io.customer.sdk.CustomerIO +import io.customer.sdk.CustomerIOBuilder +import io.customer.sdk.core.di.SDKComponent +import io.customer.sdk.core.util.Logger import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel -import java.util.* +import java.util.UUID /** * Flutter module implementation for messaging push module in native SDKs. All functionality @@ -20,50 +25,43 @@ import java.util.* */ internal class CustomerIOPushMessaging( pluginBinding: FlutterPlugin.FlutterPluginBinding, -) : CustomerIOPluginModule, MethodChannel.MethodCallHandler { +) : NativeModuleBridge, MethodChannel.MethodCallHandler { override val moduleName: String = "PushMessaging" private val applicationContext: Context = pluginBinding.applicationContext override val flutterCommunicationChannel: MethodChannel = MethodChannel(pluginBinding.binaryMessenger, "customer_io_messaging_push") - private val logger: Logger - get() = CustomerIOShared.instance().diStaticGraph.logger + private val logger: Logger = SDKComponent.logger 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>("message"), - handleNotificationTrigger = args.getAsTypeOrNull("handleNotificationTrigger") - ) - } - } - - else -> { - result.notImplemented() - } + "getRegisteredDeviceToken" -> call.nativeNoArgs(result, ::getRegisteredDeviceToken) + "onMessageReceived" -> call.nativeMapArgs(result, ::onMessageReceived) + else -> super.onMethodCall(call, result) } } + private fun getRegisteredDeviceToken(): String? { + return CustomerIO.instance().registeredDeviceToken + } + /** * 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?, - handleNotificationTrigger: Boolean?, - ): Boolean { + private fun onMessageReceived(args: Map): Boolean { try { + // Push payload received from FCM + val message = args.getAs>("message") + // Flag to indicate if local notification should be triggered + val handleNotificationTrigger = args.getAs("handleNotificationTrigger") + 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() + val destination = + (message["to"] as? String)?.takeIfNotBlank() ?: UUID.randomUUID().toString() return CustomerIOFirebaseMessagingService.onMessageReceived( context = applicationContext, remoteMessage = message.toFCMRemoteMessage(destination = destination), @@ -74,4 +72,34 @@ internal class CustomerIOPushMessaging( throw ex } } + + /** + * Adds push messaging module to native Android SDK based on the configuration provided by + * customer app. + * + * @param builder instance of CustomerIOBuilder to add push messaging module. + * @param config configuration provided by customer app for push messaging module. + */ + override fun configureModule( + builder: CustomerIOBuilder, + config: Map + ) { + val androidConfig = config.getAs>(key = "android") ?: emptyMap() + // Prefer `android` object for push configurations as it's more specific to Android + // For common push configurations, use `config` object instead of `android` + + // Default push click behavior is to prevent restart of activity in Flutter apps + val pushClickBehavior = androidConfig.getAs("pushClickBehavior") + ?.takeIfNotBlank() + ?.let { value -> + runCatching { enumValueOf(value) }.getOrNull() + } ?: PushClickBehavior.ACTIVITY_PREVENT_RESTART + + val module = ModuleMessagingPushFCM( + moduleConfig = MessagingPushModuleConfig.Builder().apply { + setPushClickBehavior(pushClickBehavior = pushClickBehavior) + }.build(), + ) + builder.addCustomerIOModule(module) + } } diff --git a/android/src/main/kotlin/io/customer/customer_io/messagingpush/Extensions.kt b/android/src/main/kotlin/io/customer/customer_io/messagingpush/PushMessagingExtensions.kt similarity index 78% rename from android/src/main/kotlin/io/customer/customer_io/messagingpush/Extensions.kt rename to android/src/main/kotlin/io/customer/customer_io/messagingpush/PushMessagingExtensions.kt index b51fa91..6b1f071 100644 --- a/android/src/main/kotlin/io/customer/customer_io/messagingpush/Extensions.kt +++ b/android/src/main/kotlin/io/customer/customer_io/messagingpush/PushMessagingExtensions.kt @@ -1,7 +1,7 @@ package io.customer.customer_io.messagingpush import com.google.firebase.messaging.RemoteMessage -import io.customer.customer_io.getAsTypeOrNull +import io.customer.customer_io.utils.getAs /** * Safely transforms any value to string @@ -23,8 +23,8 @@ private fun Any.toStringOrNull(): String? = try { * string for it. */ internal fun Map.toFCMRemoteMessage(destination: String): RemoteMessage { - val notification = getAsTypeOrNull>("notification") - val data = getAsTypeOrNull>("data") + val notification = getAs>("notification") + val data = getAs>("data") val messageParams = buildMap { notification?.let { result -> putAll(result) } // Adding `data` after `notification` so `data` params take more value as we mainly use @@ -42,10 +42,10 @@ internal fun Map.toFCMRemoteMessage(destination: String): RemoteMes value.toStringOrNull()?.let { v -> addData(key, v) } } } - getAsTypeOrNull("messageId")?.let { id -> setMessageId(id) } - getAsTypeOrNull("messageType")?.let { type -> setMessageType(type) } - getAsTypeOrNull("collapseKey")?.let { key -> setCollapseKey(key) } - getAsTypeOrNull("ttl")?.let { time -> ttl = time } + getAs("messageId")?.let { id -> setMessageId(id) } + getAs("messageType")?.let { type -> setMessageType(type) } + getAs("collapseKey")?.let { key -> setCollapseKey(key) } + getAs("ttl")?.let { time -> ttl = time } return@with build() } } diff --git a/android/src/main/kotlin/io/customer/customer_io/utils/MapExtensions.kt b/android/src/main/kotlin/io/customer/customer_io/utils/MapExtensions.kt new file mode 100644 index 0000000..d0a2785 --- /dev/null +++ b/android/src/main/kotlin/io/customer/customer_io/utils/MapExtensions.kt @@ -0,0 +1,12 @@ +package io.customer.customer_io.utils + +/** + * 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 Map.getAs(key: String): T? { + if (containsKey(key)) { + return get(key) as? T + } + return null +} diff --git a/android/src/main/kotlin/io/customer/customer_io/utils/StringExtensions.kt b/android/src/main/kotlin/io/customer/customer_io/utils/StringExtensions.kt new file mode 100644 index 0000000..42ac132 --- /dev/null +++ b/android/src/main/kotlin/io/customer/customer_io/utils/StringExtensions.kt @@ -0,0 +1,6 @@ +package io.customer.customer_io.utils + +/** + * Extension function to return the string if it is not null or blank. + */ +internal fun String?.takeIfNotBlank(): String? = takeIf { !it.isNullOrBlank() } diff --git a/android/src/main/res/values/customer_io_config.xml b/android/src/main/res/values/customer_io_config.xml new file mode 100644 index 0000000..325d399 --- /dev/null +++ b/android/src/main/res/values/customer_io_config.xml @@ -0,0 +1,13 @@ + + + + Flutter + + 2.0.0 + diff --git a/apps/amiapp_flutter/.env.example b/apps/amiapp_flutter/.env.example index 0bbd9a5..6678d50 100644 --- a/apps/amiapp_flutter/.env.example +++ b/apps/amiapp_flutter/.env.example @@ -1,2 +1,2 @@ SITE_ID=siteid -API_KEY=apikey +CDP_API_KEY=cdpapikey diff --git a/apps/amiapp_flutter/android/app/build.gradle b/apps/amiapp_flutter/android/app/build.gradle index 0ec6c3b..3c5fecd 100644 --- a/apps/amiapp_flutter/android/app/build.gradle +++ b/apps/amiapp_flutter/android/app/build.gradle @@ -79,7 +79,7 @@ dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.2' // Adding customer.io android sdk dependencies so we can use them in native code // These are not generally needed and should be avoided - implementation "io.customer.android:tracking" + implementation "io.customer.android:datapipelines" implementation "io.customer.android:messaging-push-fcm" implementation "io.customer.android:messaging-in-app" } diff --git a/apps/amiapp_flutter/ios/Env.swift.example b/apps/amiapp_flutter/ios/Env.swift.example index a18f7b4..e94cf0e 100644 --- a/apps/amiapp_flutter/ios/Env.swift.example +++ b/apps/amiapp_flutter/ios/Env.swift.example @@ -1,6 +1,5 @@ import Foundation class Env { - static let siteId: String = "siteid" - static let apiKey: String = "apikey" + static let cdpApiKey: String = "cdpApiKey" } diff --git a/apps/amiapp_flutter/ios/NotificationServiceExtension/NotificationService.swift b/apps/amiapp_flutter/ios/NotificationServiceExtension/NotificationService.swift index 40dcda7..96274b5 100644 --- a/apps/amiapp_flutter/ios/NotificationServiceExtension/NotificationService.swift +++ b/apps/amiapp_flutter/ios/NotificationServiceExtension/NotificationService.swift @@ -4,7 +4,6 @@ // import CioMessagingPushFCM -import CioTracking class NotificationService: UNNotificationServiceExtension { @@ -13,11 +12,12 @@ class NotificationService: UNNotificationServiceExtension { override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { print("NotificationService didReceive called") - - CustomerIO.initialize(siteId: Env.siteId, apiKey: Env.apiKey, region: .US) { config in - config.autoTrackDeviceAttributes = true - config.logLevel = .debug - } + + MessagingPushFCM.initializeForExtension( + withConfig: MessagingPushConfigBuilder(cdpApiKey: Env.cdpApiKey) + .logLevel(.debug) + .build() + ) MessagingPush.shared.didReceive(request, withContentHandler: contentHandler) } diff --git a/apps/amiapp_flutter/ios/Podfile b/apps/amiapp_flutter/ios/Podfile index 1595e27..b9d338d 100644 --- a/apps/amiapp_flutter/ios/Podfile +++ b/apps/amiapp_flutter/ios/Podfile @@ -43,7 +43,7 @@ target 'Runner' do use_modular_headers! # Uncomment only 1 of the lines below to install a version of the iOS SDK - pod 'CustomerIO/MessagingPushFCM', '~> 2.14' # install production build + pod 'customer_io/fcm', :path => '.symlinks/plugins/customer_io/ios' # install podspec bundled with the plugin # install_non_production_ios_sdk_local_path(local_path: '~/code/customerio-ios/', is_app_extension: false, push_service: "fcm") # install_non_production_ios_sdk_git_branch(branch_name: 'levi/v2-multiple-push-handlers', is_app_extension: false, push_service: "fcm") @@ -52,8 +52,9 @@ end target 'NotificationServiceExtension' do use_frameworks! - # Uncomment only 1 of the lines below to install a version of the iOS SDK - pod 'CustomerIO/MessagingPushFCM', '~> 2.14' # install production build + # Ideally, installing non-production SDK to main target should be enough + # We may not need to install non-production SDK to app extension separately + pod 'customer_io_richpush/fcm', :path => '.symlinks/plugins/customer_io/ios' # install podspec bundled with the plugin # install_non_production_ios_sdk_local_path(local_path: '~/code/customerio-ios/', is_app_extension: true, push_service: "fcm") # install_non_production_ios_sdk_git_branch(branch_name: 'levi/v2-multiple-push-handlers', is_app_extension: true, push_service: "fcm") end diff --git a/apps/amiapp_flutter/ios/Runner.xcodeproj/project.pbxproj b/apps/amiapp_flutter/ios/Runner.xcodeproj/project.pbxproj index dd67a68..18dfe0e 100644 --- a/apps/amiapp_flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/amiapp_flutter/ios/Runner.xcodeproj/project.pbxproj @@ -513,6 +513,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_STYLE = Manual; @@ -767,6 +768,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_STYLE = Manual; @@ -796,6 +798,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = "$(inherited)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; diff --git a/apps/amiapp_flutter/ios/Runner/AppDelegate.swift b/apps/amiapp_flutter/ios/Runner/AppDelegate.swift index 71909e2..0822c94 100644 --- a/apps/amiapp_flutter/ios/Runner/AppDelegate.swift +++ b/apps/amiapp_flutter/ios/Runner/AppDelegate.swift @@ -1,7 +1,6 @@ import UIKit import Flutter import CioMessagingPushFCM -import CioTracking import FirebaseMessaging import FirebaseCore @@ -21,11 +20,10 @@ import FirebaseCore Messaging.messaging().delegate = self - CustomerIO.initialize(siteId: Env.siteId, apiKey: Env.apiKey, region: .US) { config in - config.autoTrackDeviceAttributes = true - config.logLevel = .debug - } - MessagingPushFCM.initialize(configOptions: nil) + MessagingPushFCM.initialize( + withConfig: MessagingPushConfigBuilder() + .build() + ) // Sets a 3rd party push event handler for the app besides the Customer.io SDK and FlutterFire. // Setting the AppDelegate to be the handler will internally use `flutter_local_notifications` to handle the push event. diff --git a/apps/amiapp_flutter/lib/main.dart b/apps/amiapp_flutter/lib/main.dart index b848d9e..f7cf8d7 100644 --- a/apps/amiapp_flutter/lib/main.dart +++ b/apps/amiapp_flutter/lib/main.dart @@ -31,7 +31,11 @@ void main() async { // Setup flutter_local_notifications plugin to send local notifications and receive callbacks for them. var initSettingsAndroid = const AndroidInitializationSettings("app_icon"); // The default settings will show local push notifications while app in foreground with plugin. - var initSettingsIOS = const DarwinInitializationSettings(); + var initSettingsIOS = const DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ); var initSettings = InitializationSettings( android: initSettingsAndroid, iOS: initSettingsIOS, @@ -41,7 +45,7 @@ void main() async { onDidReceiveNotificationResponse: (NotificationResponse notificationResponse) async { // Callback from `flutter_local_notifications` plugin for when a local notification is clicked. // Unfortunately, we are only able to get the payload object for the local push, not anything else such as title or body. - CustomerIO.track(name: "local push notification clicked", attributes: {"payload": notificationResponse.payload}); + CustomerIO.instance.track(name: "local push notification clicked", properties: {"payload": notificationResponse.payload}); } ); diff --git a/apps/amiapp_flutter/lib/src/app.dart b/apps/amiapp_flutter/lib/src/app.dart index 18721f0..5ac08e5 100644 --- a/apps/amiapp_flutter/lib/src/app.dart +++ b/apps/amiapp_flutter/lib/src/app.dart @@ -79,7 +79,7 @@ class _AmiAppState extends State { onLogin: (user) { _auth.login(user).then((signedIn) { if (signedIn) { - CustomerIO.identify(identifier: user.email, attributes: { + CustomerIO.instance.identify(userId: user.email, traits: { "first_name": user.displayName, "email": user.email, "is_guest": user.isGuest, @@ -119,8 +119,8 @@ class _AmiAppState extends State { path: Screen.settings.path, builder: (context, state) => SettingsScreen( auth: _auth, + cdpApiKeyInitialValue: state.uri.queryParameters['cdp_api_key'], siteIdInitialValue: state.uri.queryParameters['site_id'], - apiKeyInitialValue: state.uri.queryParameters['api_key'], ), ), GoRoute( @@ -216,14 +216,14 @@ class _AmiAppState extends State { if (_customerIOSDK.sdkConfig?.screenTrackingEnabled == true) { final Screen? screen = _router.currentLocation().toAppScreen(); if (screen != null) { - CustomerIO.screen(name: screen.name); + CustomerIO.instance.screen(title: screen.name); } } } void _handleAuthStateChanged() { if (_auth.signedIn == false) { - CustomerIO.clearIdentify(); + CustomerIO.instance.clearIdentify(); _auth.clearUserState(); } } diff --git a/apps/amiapp_flutter/lib/src/customer_io.dart b/apps/amiapp_flutter/lib/src/customer_io.dart index 5dd60ec..9c8d398 100644 --- a/apps/amiapp_flutter/lib/src/customer_io.dart +++ b/apps/amiapp_flutter/lib/src/customer_io.dart @@ -53,22 +53,26 @@ class CustomerIOSDK extends ChangeNotifier { } else { logLevel = CioLogLevel.debug; } + + final InAppConfig? inAppConfig; + final migrationSiteId = _sdkConfig?.migrationSiteId; + if (migrationSiteId != null) { + inAppConfig = InAppConfig(siteId: migrationSiteId); + } else { + inAppConfig = null; + } return CustomerIO.initialize( config: CustomerIOConfig( - siteId: _sdkConfig?.siteId ?? '', - apiKey: _sdkConfig?.apiKey ?? '', - enableInApp: true, + cdpApiKey: _sdkConfig?.cdpApiKey ?? 'INVALID', + migrationSiteId: migrationSiteId, region: Region.us, - //config options go here - trackingApiUrl: _sdkConfig?.trackingUrl ?? '', - autoTrackDeviceAttributes: - _sdkConfig?.deviceAttributesTrackingEnabled ?? true, - autoTrackPushEvents: true, - backgroundQueueMinNumberOfTasks: - _sdkConfig?.backgroundQueueMinNumOfTasks ?? 10, - backgroundQueueSecondsDelay: - _sdkConfig?.backgroundQueueSecondsDelay ?? 30.0, logLevel: logLevel, + autoTrackDeviceAttributes: _sdkConfig?.autoTrackDeviceAttributes, + apiHost: _sdkConfig?.apiHost, + cdnHost: _sdkConfig?.cdnHost, + flushAt: _sdkConfig?.flushAt, + flushInterval: _sdkConfig?.flushInterval?.toInt(), + inAppConfig: inAppConfig, ), ); } catch (ex) { @@ -123,10 +127,6 @@ extension AmiAppSDKExtensions on CustomerIOSDK { return null; } } - - Future getDeviceToken() async { - return null; - } } /// Customer.io SDK extensions to save/retrieve configurations to/from preferences. diff --git a/apps/amiapp_flutter/lib/src/data/config.dart b/apps/amiapp_flutter/lib/src/data/config.dart index 3c45552..21450b0 100644 --- a/apps/amiapp_flutter/lib/src/data/config.dart +++ b/apps/amiapp_flutter/lib/src/data/config.dart @@ -1,58 +1,89 @@ +import 'package:customer_io/config/in_app_config.dart'; +import 'package:customer_io/config/push_config.dart'; +import 'package:customer_io/customer_io_enums.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:shared_preferences/shared_preferences.dart'; class CustomerIOSDKConfig { - String siteId; - String apiKey; - String? trackingUrl; - double? backgroundQueueSecondsDelay; - int? backgroundQueueMinNumOfTasks; - bool screenTrackingEnabled; - bool deviceAttributesTrackingEnabled; - bool debugModeEnabled; + final String cdpApiKey; + final String? migrationSiteId; + final Region? region; + final bool debugModeEnabled; + final bool screenTrackingEnabled; + final bool? autoTrackDeviceAttributes; + final String? apiHost; + final String? cdnHost; + final int? flushAt; + final int? flushInterval; + final InAppConfig? inAppConfig; + final PushConfig pushConfig; CustomerIOSDKConfig({ - required this.siteId, - required this.apiKey, - this.trackingUrl = "https://track-sdk.customer.io/", - this.backgroundQueueSecondsDelay = 30.0, - this.backgroundQueueMinNumOfTasks = 10, - this.screenTrackingEnabled = true, - this.deviceAttributesTrackingEnabled = true, + required this.cdpApiKey, + this.migrationSiteId, + this.region, this.debugModeEnabled = true, - }); + this.screenTrackingEnabled = true, + this.autoTrackDeviceAttributes, + this.apiHost, + this.cdnHost, + this.flushAt, + this.flushInterval, + this.inAppConfig, + PushConfig? pushConfig, + }) : pushConfig = pushConfig ?? PushConfig(); factory CustomerIOSDKConfig.fromEnv() => CustomerIOSDKConfig( - siteId: dotenv.env[_PreferencesKey.siteId]!, - apiKey: dotenv.env[_PreferencesKey.apiKey]!); + cdpApiKey: dotenv.env[_PreferencesKey.cdpApiKey] ?? 'INVALID', + migrationSiteId: dotenv.env[_PreferencesKey.migrationSiteId], + ); factory CustomerIOSDKConfig.fromPrefs(SharedPreferences prefs) { - final siteId = prefs.getString(_PreferencesKey.siteId); - final apiKey = prefs.getString(_PreferencesKey.apiKey); + final cdpApiKey = prefs.getString(_PreferencesKey.cdpApiKey); - if (siteId == null) { - throw ArgumentError('siteId cannot be null'); - } else if (apiKey == null) { - throw ArgumentError('apiKey cannot be null'); + if (cdpApiKey == null) { + throw ArgumentError('cdpApiKey cannot be null'); } + final region = prefs.getString(_PreferencesKey.region) != null + ? Region.values.firstWhere( + (e) => e.name == prefs.getString(_PreferencesKey.region)) + : null; return CustomerIOSDKConfig( - siteId: siteId, - apiKey: apiKey, - trackingUrl: prefs.getString(_PreferencesKey.trackingUrl), - backgroundQueueSecondsDelay: - prefs.getDouble(_PreferencesKey.backgroundQueueSecondsDelay), - backgroundQueueMinNumOfTasks: - prefs.getInt(_PreferencesKey.backgroundQueueMinNumOfTasks), - screenTrackingEnabled: - prefs.getBool(_PreferencesKey.screenTrackingEnabled) != false, - deviceAttributesTrackingEnabled: - prefs.getBool(_PreferencesKey.deviceAttributesTrackingEnabled) != - false, + cdpApiKey: cdpApiKey, + migrationSiteId: prefs.getString(_PreferencesKey.migrationSiteId), + region: region, debugModeEnabled: prefs.getBool(_PreferencesKey.debugModeEnabled) != false, + screenTrackingEnabled: + prefs.getBool(_PreferencesKey.screenTrackingEnabled) != false, + autoTrackDeviceAttributes: + prefs.getBool(_PreferencesKey.autoTrackDeviceAttributes), + apiHost: prefs.getString(_PreferencesKey.apiHost), + cdnHost: prefs.getString(_PreferencesKey.cdnHost), + flushAt: prefs.getInt(_PreferencesKey.flushAt), + flushInterval: prefs.getInt(_PreferencesKey.flushInterval), + inAppConfig: InAppConfig( + siteId: prefs.getString(_PreferencesKey.migrationSiteId) ?? ""), ); } + + Map toMap() { + return { + 'cdpApiKey': cdpApiKey, + 'migrationSiteId': migrationSiteId, + 'region': region?.name, + 'logLevel': debugModeEnabled, + 'screenTrackingEnabled': screenTrackingEnabled, + 'autoTrackDeviceAttributes': autoTrackDeviceAttributes, + 'apiHost': apiHost, + 'cdnHost': cdnHost, + 'flushAt': flushAt, + 'flushInterval': flushInterval, + 'inAppConfig': inAppConfig?.toMap(), + 'pushConfig': pushConfig.toMap(), + }; + } } extension ConfigurationPreferencesExtensions on SharedPreferences { @@ -66,10 +97,6 @@ extension ConfigurationPreferencesExtensions on SharedPreferences { return value != null ? setInt(key, value) : remove(key); } - Future setOrRemoveDouble(String key, double? value) { - return value != null ? setDouble(key, value) : remove(key); - } - Future setOrRemoveBool(String key, bool? value) { return value != null ? setBool(key, value) : remove(key); } @@ -77,39 +104,43 @@ extension ConfigurationPreferencesExtensions on SharedPreferences { Future saveSDKConfigState(CustomerIOSDKConfig config) async { bool result = true; result = result && - await setOrRemoveString(_PreferencesKey.siteId, config.siteId); - result = result && - await setOrRemoveString(_PreferencesKey.apiKey, config.apiKey); + await setOrRemoveString(_PreferencesKey.cdpApiKey, config.cdpApiKey); result = result && await setOrRemoveString( - _PreferencesKey.trackingUrl, config.trackingUrl); + _PreferencesKey.migrationSiteId, config.migrationSiteId); result = result && - await setOrRemoveDouble(_PreferencesKey.backgroundQueueSecondsDelay, - config.backgroundQueueSecondsDelay); + await setOrRemoveString(_PreferencesKey.region, config.region?.name); + result = result && + await setOrRemoveBool( + _PreferencesKey.debugModeEnabled, config.debugModeEnabled); result = result && - await setOrRemoveInt(_PreferencesKey.backgroundQueueMinNumOfTasks, - config.backgroundQueueMinNumOfTasks); + await setOrRemoveBool(_PreferencesKey.autoTrackDeviceAttributes, + config.autoTrackDeviceAttributes); result = result && await setOrRemoveBool(_PreferencesKey.screenTrackingEnabled, config.screenTrackingEnabled); result = result && - await setOrRemoveBool(_PreferencesKey.deviceAttributesTrackingEnabled, - config.deviceAttributesTrackingEnabled); + await setOrRemoveString(_PreferencesKey.apiHost, config.apiHost); result = result && - await setOrRemoveBool( - _PreferencesKey.debugModeEnabled, config.debugModeEnabled); + await setOrRemoveString(_PreferencesKey.cdnHost, config.cdnHost); + result = + result && await setOrRemoveInt(_PreferencesKey.flushAt, config.flushAt); + result = result && + await setOrRemoveInt( + _PreferencesKey.flushInterval, config.flushInterval); return result; } } class _PreferencesKey { - static const siteId = 'SITE_ID'; - static const apiKey = 'API_KEY'; - static const trackingUrl = 'TRACKING_URL'; - static const backgroundQueueSecondsDelay = 'BACKGROUND_QUEUE_SECONDS_DELAY'; - static const backgroundQueueMinNumOfTasks = - 'BACKGROUND_QUEUE_MIN_NUMBER_OF_TASKS'; - static const screenTrackingEnabled = 'TRACK_SCREENS'; - static const deviceAttributesTrackingEnabled = 'TRACK_DEVICE_ATTRIBUTES'; + static const cdpApiKey = 'CDP_API_KEY'; + static const migrationSiteId = 'SITE_ID'; + static const region = 'REGION'; static const debugModeEnabled = 'DEBUG_MODE'; + static const screenTrackingEnabled = 'SCREEN_TRACKING'; + static const autoTrackDeviceAttributes = 'AUTO_TRACK_DEVICE_ATTRIBUTES'; + static const apiHost = 'API_HOST'; + static const cdnHost = 'CDN_HOST'; + static const flushAt = 'FLUSH_AT'; + static const flushInterval = 'FLUSH_INTERVAL'; } diff --git a/apps/amiapp_flutter/lib/src/screens/attributes.dart b/apps/amiapp_flutter/lib/src/screens/attributes.dart index 06fe50d..aa13119 100644 --- a/apps/amiapp_flutter/lib/src/screens/attributes.dart +++ b/apps/amiapp_flutter/lib/src/screens/attributes.dart @@ -155,11 +155,11 @@ class _AttributesScreenState extends State { }; switch (widget._attributeType) { case _attributeTypeDevice: - CustomerIO.setDeviceAttributes( + CustomerIO.instance.setDeviceAttributes( attributes: attributes); break; case _attributeTypeProfile: - CustomerIO.setProfileAttributes( + CustomerIO.instance.setProfileAttributes( attributes: attributes); break; } diff --git a/apps/amiapp_flutter/lib/src/screens/dashboard.dart b/apps/amiapp_flutter/lib/src/screens/dashboard.dart index 42ee056..a61fde6 100644 --- a/apps/amiapp_flutter/lib/src/screens/dashboard.dart +++ b/apps/amiapp_flutter/lib/src/screens/dashboard.dart @@ -53,18 +53,18 @@ class _DashboardScreenState extends State { .getBuildInfo() .then((value) => setState(() => _buildInfo = value)); - inAppMessageStreamSubscription = - CustomerIO.subscribeToInAppEventListener(handleInAppEvent); + inAppMessageStreamSubscription = CustomerIO.inAppMessaging + .subscribeToEventsListener(handleInAppEvent); // Setup 3rd party SDK, flutter-fire. // We install this SDK into sample app to make sure the CIO SDK behaves as expected when there is another SDK installed that handles push notifications. FirebaseMessaging.instance.getInitialMessage().then((initialMessage) { - CustomerIO.track(name: "push clicked", attributes: {"push": initialMessage?.notification?.title, "app-state": "killed"}); + CustomerIO.instance.track(name: "push clicked", properties: {"push": initialMessage?.notification?.title, "app-state": "killed"}); }); // ...while app was in the background (but not killed). FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { - CustomerIO.track(name: "push clicked", attributes: {"push": message.notification?.title, "app-state": "background"}); + CustomerIO.instance.track(name: "push clicked", properties: {"push": message.notification?.title, "app-state": "background"}); }); // Important that a 3rd party SDK can receive callbacks when a push is received while app in background. @@ -72,7 +72,7 @@ class _DashboardScreenState extends State { // Note: A push will not be shown on the device while app is in foreground. This is a FCM behavior, not a CIO SDK behavior. // If you send a push using Customer.io with the FCM service setup in Customer.io, the push will be shown on the device. FirebaseMessaging.onMessage.listen((RemoteMessage message) { - CustomerIO.track(name: "push received", attributes: {"push": message.notification?.title, "app-state": "foreground"}); + CustomerIO.instance.track(name: "push received", properties: {"push": message.notification?.title, "app-state": "foreground"}); }); super.initState(); @@ -111,9 +111,9 @@ class _DashboardScreenState extends State { }; attributes.addAll(arguments); - CustomerIO.track( + CustomerIO.instance.track( name: 'In-App Event', - attributes: attributes, + properties: attributes, ); } @@ -174,9 +174,9 @@ class _ActionList extends StatelessWidget { final eventName = event.key; final attributes = event.value; if (attributes == null) { - CustomerIO.track(name: eventName); + CustomerIO.instance.track(name: eventName); } else { - CustomerIO.track(name: eventName, attributes: attributes); + CustomerIO.instance.track(name: eventName, properties: attributes); } context.showSnackBar('Event sent successfully'); } diff --git a/apps/amiapp_flutter/lib/src/screens/events.dart b/apps/amiapp_flutter/lib/src/screens/events.dart index 98f3b48..1f5551b 100644 --- a/apps/amiapp_flutter/lib/src/screens/events.dart +++ b/apps/amiapp_flutter/lib/src/screens/events.dart @@ -110,9 +110,9 @@ class _CustomEventScreenState extends State { attributes = propertyName.isEmpty ? {} : {propertyName: _propertyValueController.text}; - CustomerIO.track( + CustomerIO.instance.track( name: _eventNameController.text, - attributes: attributes); + properties: attributes); _onEventTracked(); } }, diff --git a/apps/amiapp_flutter/lib/src/screens/settings.dart b/apps/amiapp_flutter/lib/src/screens/settings.dart index 5899d82..a9d4629 100644 --- a/apps/amiapp_flutter/lib/src/screens/settings.dart +++ b/apps/amiapp_flutter/lib/src/screens/settings.dart @@ -1,3 +1,5 @@ +import 'package:customer_io/config/in_app_config.dart'; +import 'package:customer_io/customer_io.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -15,13 +17,13 @@ import '../widgets/settings_form_field.dart'; class SettingsScreen extends StatefulWidget { final AmiAppAuth auth; + final String? cdpApiKeyInitialValue; final String? siteIdInitialValue; - final String? apiKeyInitialValue; const SettingsScreen({ required this.auth, + this.cdpApiKeyInitialValue, this.siteIdInitialValue, - this.apiKeyInitialValue, super.key, }); @@ -35,11 +37,12 @@ class _SettingsScreenState extends State { final _formKey = GlobalKey(); late final TextEditingController _deviceTokenValueController; - late final TextEditingController _trackingURLValueController; + late final TextEditingController _cdpApiKeyValueController; late final TextEditingController _siteIDValueController; - late final TextEditingController _apiKeyValueController; - late final TextEditingController _bqSecondsDelayValueController; - late final TextEditingController _bqMinNumberOfTasksValueController; + late final TextEditingController _apiHostValueController; + late final TextEditingController _cdnHostValueController; + late final TextEditingController _flushAtValueController; + late final TextEditingController _flushIntervalValueController; late bool _featureTrackScreens; late bool _featureTrackDeviceAttributes; @@ -47,24 +50,24 @@ class _SettingsScreenState extends State { @override void initState() { - widget._customerIOSDK.getDeviceToken().then((value) => + CustomerIO.pushMessaging.getRegisteredDeviceToken().then((value) => setState(() => _deviceTokenValueController.text = value ?? '')); final cioConfig = widget._customerIOSDK.sdkConfig; _deviceTokenValueController = TextEditingController(); - _trackingURLValueController = - TextEditingController(text: cioConfig?.trackingUrl); + _cdpApiKeyValueController = TextEditingController( + text: widget.cdpApiKeyInitialValue ?? cioConfig?.cdpApiKey); _siteIDValueController = TextEditingController( - text: widget.siteIdInitialValue ?? cioConfig?.siteId); - _apiKeyValueController = TextEditingController( - text: widget.apiKeyInitialValue ?? cioConfig?.apiKey); - _bqSecondsDelayValueController = TextEditingController( - text: cioConfig?.backgroundQueueSecondsDelay?.toTrimmedString()); - _bqMinNumberOfTasksValueController = TextEditingController( - text: cioConfig?.backgroundQueueMinNumOfTasks?.toString()); + text: widget.siteIdInitialValue ?? cioConfig?.migrationSiteId); + _apiHostValueController = TextEditingController(text: cioConfig?.apiHost); + _cdnHostValueController = TextEditingController(text: cioConfig?.cdnHost); + _flushAtValueController = + TextEditingController(text: cioConfig?.flushAt?.toString()); + _flushIntervalValueController = + TextEditingController(text: cioConfig?.flushInterval?.toString()); _featureTrackScreens = cioConfig?.screenTrackingEnabled ?? true; _featureTrackDeviceAttributes = - cioConfig?.deviceAttributesTrackingEnabled ?? true; + cioConfig?.autoTrackDeviceAttributes ?? true; _featureDebugMode = cioConfig?.debugModeEnabled ?? true; super.initState(); @@ -76,16 +79,16 @@ class _SettingsScreenState extends State { } final newConfig = CustomerIOSDKConfig( - siteId: _siteIDValueController.text.trim(), - apiKey: _apiKeyValueController.text.trim(), - trackingUrl: _trackingURLValueController.text.trim(), - backgroundQueueSecondsDelay: - _bqSecondsDelayValueController.text.trim().toDoubleOrNull(), - backgroundQueueMinNumOfTasks: - _bqMinNumberOfTasksValueController.text.trim().toIntOrNull(), + cdpApiKey: _cdpApiKeyValueController.text.trim(), + migrationSiteId: _siteIDValueController.text.trim().nullIfEmpty(), + apiHost: _apiHostValueController.text.trim().nullIfEmpty(), + cdnHost: _cdnHostValueController.text.trim().nullIfEmpty(), + flushAt: _flushAtValueController.text.trim().toIntOrNull(), + flushInterval: _flushIntervalValueController.text.trim().toIntOrNull(), screenTrackingEnabled: _featureTrackScreens, - deviceAttributesTrackingEnabled: _featureTrackDeviceAttributes, + autoTrackDeviceAttributes: _featureTrackDeviceAttributes, debugModeEnabled: _featureDebugMode, + inAppConfig: InAppConfig(siteId: _siteIDValueController.text.trim()) ); widget._customerIOSDK.saveConfigToPreferences(newConfig).then((success) { if (!context.mounted) { @@ -109,16 +112,16 @@ class _SettingsScreenState extends State { } setState(() { - _siteIDValueController.text = defaultConfig.siteId; - _apiKeyValueController.text = defaultConfig.apiKey; - _trackingURLValueController.text = defaultConfig.trackingUrl ?? ''; - _bqSecondsDelayValueController.text = - defaultConfig.backgroundQueueSecondsDelay?.toTrimmedString() ?? ''; - _bqMinNumberOfTasksValueController.text = - defaultConfig.backgroundQueueMinNumOfTasks?.toString() ?? ''; + _cdpApiKeyValueController.text = defaultConfig.cdpApiKey; + _siteIDValueController.text = defaultConfig.migrationSiteId ?? ''; + _apiHostValueController.text = defaultConfig.apiHost ?? ''; + _cdnHostValueController.text = defaultConfig.cdnHost ?? ''; + _flushAtValueController.text = defaultConfig.flushAt?.toString() ?? ''; + _flushIntervalValueController.text = + defaultConfig.flushInterval?.toString() ?? ''; _featureTrackScreens = defaultConfig.screenTrackingEnabled; _featureTrackDeviceAttributes = - defaultConfig.deviceAttributesTrackingEnabled; + defaultConfig.autoTrackDeviceAttributes ?? true; _featureDebugMode = defaultConfig.debugModeEnabled; _saveSettings(context); }); @@ -160,6 +163,7 @@ class _SettingsScreenState extends State { TextSettingsFormField( labelText: 'Device Token', semanticsLabel: 'Device Token Input', + hintText: 'Fetching...', valueController: _deviceTokenValueController, readOnly: true, suffixIcon: IconButton( @@ -178,20 +182,11 @@ class _SettingsScreenState extends State { }, ), ), - const SizedBox(height: 16), - TextSettingsFormField( - labelText: 'CIO Track URL', - semanticsLabel: 'Track URL Input', - valueController: _trackingURLValueController, - validator: (value) => value?.isValidUrl() != false - ? null - : 'Please enter formatted url e.g. https://tracking.cio/', - ), const SizedBox(height: 32), TextSettingsFormField( - labelText: 'Site Id', - semanticsLabel: 'Site ID Input', - valueController: _siteIDValueController, + labelText: 'CDP API Key', + semanticsLabel: 'CDP API Key Input', + valueController: _cdpApiKeyValueController, validator: (value) => value?.trim().isNotEmpty == true ? null @@ -199,32 +194,48 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 16), TextSettingsFormField( - labelText: 'API Key', - semanticsLabel: 'API Key Input', - valueController: _apiKeyValueController, - validator: (value) => - value?.trim().isNotEmpty == true - ? null - : 'This field cannot be blank', + labelText: 'Site Id', + semanticsLabel: 'Site ID Input', + valueController: _siteIDValueController, ), const SizedBox(height: 32), TextSettingsFormField( - labelText: 'backgroundQueueSecondsDelay', - semanticsLabel: 'BQ Seconds Delay Input', - valueController: _bqSecondsDelayValueController, - keyboardType: const TextInputType.numberWithOptions( - decimal: true), + labelText: 'API Host', + semanticsLabel: 'API Host Input', + hintText: 'cdp.customer.io/v1', + valueController: _apiHostValueController, + validator: (value) => value?.isEmptyOrValidUrl() != + false + ? null + : 'Please enter url e.g. cdp.customer.io/v1 (without https)', + ), + const SizedBox(height: 16), + TextSettingsFormField( + labelText: 'CDN Host', + semanticsLabel: 'CDN Host Input', + hintText: 'cdp.customer.io/v1', + valueController: _cdnHostValueController, + validator: (value) => value?.isEmptyOrValidUrl() != + false + ? null + : 'Please enter url e.g. cdp.customer.io/v1 (without https)', + ), + const SizedBox(height: 32), + TextSettingsFormField( + labelText: 'Flush At', + semanticsLabel: 'BQ Min Number of Tasks Input', + hintText: '20', + valueController: _flushAtValueController, + keyboardType: TextInputType.number, validator: (value) { bool isBlank = value?.trim().isNotEmpty != true; - if (isBlank) { - return 'This field cannot be blank'; - } - - double minValue = 1.0; - bool isInvalid = - value?.isValidDouble(min: minValue) != true; - if (isInvalid) { - return 'The value must be greater than or equal to $minValue'; + if (!isBlank) { + int minValue = 1; + bool isInvalid = + value?.isValidInt(min: minValue) != true; + if (isInvalid) { + return 'The value must be greater than or equal to $minValue'; + } } return null; @@ -232,21 +243,20 @@ class _SettingsScreenState extends State { ), const SizedBox(height: 16), TextSettingsFormField( - labelText: 'backgroundQueueMinNumberOfTasks', - semanticsLabel: 'BQ Min Number of Tasks Input', - valueController: _bqMinNumberOfTasksValueController, + labelText: 'Flush Interval', + semanticsLabel: 'BQ Seconds Delay Input', + hintText: '30', + valueController: _flushIntervalValueController, keyboardType: TextInputType.number, validator: (value) { bool isBlank = value?.trim().isNotEmpty != true; - if (isBlank) { - return 'This field cannot be blank'; - } - - int minValue = 1; - bool isInvalid = - value?.isValidInt(min: minValue) != true; - if (isInvalid) { - return 'The value must be greater than or equal to $minValue'; + if (!isBlank) { + int minValue = 1; + bool isInvalid = + value?.isValidInt(min: minValue) != true; + if (isInvalid) { + return 'The value must be greater than or equal to $minValue'; + } } return null; diff --git a/apps/amiapp_flutter/lib/src/utils/extensions.dart b/apps/amiapp_flutter/lib/src/utils/extensions.dart index 1e5b085..7d573cf 100644 --- a/apps/amiapp_flutter/lib/src/utils/extensions.dart +++ b/apps/amiapp_flutter/lib/src/utils/extensions.dart @@ -32,6 +32,10 @@ extension AmiAppExtensions on BuildContext { extension AmiAppStringExtensions on String { bool equalsIgnoreCase(String? other) => toLowerCase() == other?.toLowerCase(); + String? nullIfEmpty() { + return isEmpty ? null : this; + } + int? toIntOrNull() { if (isNotEmpty) { return int.tryParse(this); @@ -58,23 +62,35 @@ extension AmiAppStringExtensions on String { } } - bool isValidUrl() { + bool isEmptyOrValidUrl() { String url = trim(); - // Empty text is not considered valid. + // Empty text is considered valid if (url.isEmpty) { + return true; + } + // If the URL contains a scheme, it is considered invalid + if (url.contains("://")) { return false; } - - // Currently only Android fails on URLs with empty host, still adding - // validation for all platforms to keep it consistent for app users - final Uri? uri = Uri.tryParse(url); + // Ensure the URL is prefixed with "https://" so that it can be parsed + final prefixedUrl = "https://$url"; + // If the URL is not parsable, it is considered invalid + final Uri? uri = Uri.tryParse(prefixedUrl); if (uri == null) { return false; } - // Valid URL with a host and http/https scheme - return uri.hasAuthority && - (uri.scheme == 'http' || uri.scheme == 'https') && - uri.path.endsWith("/"); + + // Check if the last character is alphanumeric + final isLastCharValid = RegExp(r'[a-zA-Z0-9]$').hasMatch(url); + + // Check validity conditions: + // - URL should not end with a slash + // - URL should contain a domain (e.g., cdp.customer.io) + // - URL should not contain a query or fragment + return isLastCharValid && + uri.host.contains('.') && + uri.query.isEmpty && + uri.fragment.isEmpty; } bool isValidInt({int? min, int? max}) { @@ -92,15 +108,6 @@ extension AmiAppStringExtensions on String { } } -extension AmiAppDoubleExtensions on double { - String? toTrimmedString() { - if (this % 1.0 != 0.0) { - return toString(); - } - return toStringAsFixed(0); - } -} - extension LocationExtensions on GoRouter { // Get location of current route // This is a workaround to get the current location as location property @@ -109,7 +116,9 @@ extension LocationExtensions on GoRouter { // https://flutter.dev/go/go-router-v9-breaking-changes String currentLocation() { final RouteMatch lastMatch = routerDelegate.currentConfiguration.last; - final RouteMatchList matchList = lastMatch is ImperativeRouteMatch ? lastMatch.matches : routerDelegate.currentConfiguration; + final RouteMatchList matchList = lastMatch is ImperativeRouteMatch + ? lastMatch.matches + : routerDelegate.currentConfiguration; return matchList.uri.toString(); } -} \ No newline at end of file +} diff --git a/apps/amiapp_flutter/lib/src/widgets/settings_form_field.dart b/apps/amiapp_flutter/lib/src/widgets/settings_form_field.dart index 6374911..79d068d 100644 --- a/apps/amiapp_flutter/lib/src/widgets/settings_form_field.dart +++ b/apps/amiapp_flutter/lib/src/widgets/settings_form_field.dart @@ -62,6 +62,9 @@ class TextSettingsFormField extends StatelessWidget { semanticsLabel: semanticsLabel, ), hintText: hintText, + hintStyle: TextStyle( + color: Theme.of(context).colorScheme.outline, + ), isDense: true, floatingLabelBehavior: floatingLabelBehavior, ), diff --git a/apps/amiapp_flutter/pubspec.lock b/apps/amiapp_flutter/pubspec.lock index e3501bf..519fb57 100644 --- a/apps/amiapp_flutter/pubspec.lock +++ b/apps/amiapp_flutter/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: b1595874fbc8f7a50da90f5d8f327bb0bfd6a95dc906c390efe991540c3b54aa + sha256: "5534e701a2c505fed1f0799e652dd6ae23bd4d2c4cf797220e5ced5764a7c1c2" url: "https://pub.dev" source: hosted - version: "1.3.40" + version: "1.3.44" archive: dependency: transitive description: @@ -21,10 +21,10 @@ packages: dependency: transitive description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: transitive description: @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: crypto - sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -103,7 +103,7 @@ packages: path: "../.." relative: true source: path - version: "1.5.1" + version: "1.5.2" dbus: dependency: transitive description: @@ -132,66 +132,66 @@ packages: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" file: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" firebase_core: dependency: "direct main" description: name: firebase_core - sha256: "3187f4f8e49968573fd7403011dca67ba95aae419bc0d8131500fae160d94f92" + sha256: "51dfe2fbf3a984787a2e7b8592f2f05c986bfedd6fdacea3f9e0a7beb334de96" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.6.0" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: "3c3a1e92d6f4916c32deea79c4a7587aa0e9dbbe5889c7a16afcf005a485ee02" + sha256: e30da58198a6d4b49d5bce4e852f985c32cb10db329ebef9473db2b9f09ce810 url: "https://pub.dev" source: hosted - version: "5.2.0" + version: "5.3.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: e8d1e22de72cb21cdcfc5eed7acddab3e99cd83f3b317f54f7a96c32f25fd11e + sha256: f967a7138f5d2ffb1ce15950e2a382924239eaa521150a8f144af34e68b3b3e5 url: "https://pub.dev" source: hosted - version: "2.17.4" + version: "2.18.1" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "1b0a4f9ecbaf9007771bac152afad738ddfacc4b8431a7591c00829480d99553" + sha256: eb6e28a3a35deda61fe8634967c84215efc19133ba58d8e0fc6c9a2af2cba05e url: "https://pub.dev" source: hosted - version: "15.0.4" + version: "15.1.3" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: c5a6443e66ae064fe186901d740ee7ce648ca2a6fd0484b8c5e963849ac0fc28 + sha256: b316c4ee10d93d32c033644207afc282d9b2b4372f3cf9c6022f3558b3873d2d url: "https://pub.dev" source: hosted - version: "4.5.42" + version: "4.5.46" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "232ef63b986467ae5b5577a09c2502b26e2e2aebab5b85e6c966a5ca9b038b89" + sha256: d7f0147a1a9fe4313168e20154a01fd5cf332898de1527d3930ff77b8c7f5387 url: "https://pub.dev" source: hosted - version: "3.8.12" + version: "3.9.2" flutter: dependency: "direct main" description: flutter @@ -201,10 +201,10 @@ packages: dependency: "direct main" description: name: flutter_dotenv - sha256: "9357883bdd153ab78cbf9ffa07656e336b8bbb2b5a3ca596b0b27e119f7c7d77" + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "5.2.1" flutter_launcher_icons: dependency: "direct dev" description: @@ -225,10 +225,10 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: c500d5d9e7e553f06b61877ca6b9c8b92c570a4c8db371038702e8ce57f8a50f + sha256: "674173fd3c9eda9d4c8528da2ce0ea69f161577495a9cc835a2a4ecd7eadeb35" url: "https://pub.dev" source: hosted - version: "17.2.2" + version: "17.2.4" flutter_local_notifications_linux: dependency: transitive description: @@ -259,10 +259,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: ddc16d34b0d74cb313986918c0f0885a7ba2fc24d8fb8419de75f0015144ccfe + sha256: "6f1b756f6e863259a99135ff3c95026c3cdca17d10ebef2bba2261a25ddc8bbc" url: "https://pub.dev" source: hosted - version: "14.2.3" + version: "14.3.0" http: dependency: transitive description: @@ -283,10 +283,10 @@ packages: dependency: transitive description: name: image - sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d url: "https://pub.dev" source: hosted - version: "4.2.0" + version: "4.3.0" json_annotation: dependency: transitive description: @@ -331,10 +331,10 @@ packages: dependency: transitive description: name: logging - sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.0" matcher: dependency: transitive description: @@ -363,10 +363,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "4de6c36df77ffbcef0a5aefe04669d33f2d18397fea228277b852a2d4e58e860" + sha256: df3eb3e0aed5c1107bb0fdb80a8e82e778114958b1c5ac5644fb1ac9cae8a998 url: "https://pub.dev" source: hosted - version: "8.0.1" + version: "8.1.0" package_info_plus_platform_interface: dependency: transitive description: @@ -419,10 +419,10 @@ packages: dependency: transitive description: name: permission_handler_android - sha256: eaf2a1ec4472775451e88ca6a7b86559ef2f1d1ed903942ed135e38ea0097dca + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" url: "https://pub.dev" source: hosted - version: "12.0.8" + version: "12.0.13" permission_handler_apple: dependency: transitive description: @@ -435,18 +435,18 @@ packages: dependency: transitive description: name: permission_handler_html - sha256: "6cac773d389e045a8d4f85418d07ad58ef9e42a56e063629ce14c4c26344de24" + sha256: af26edbbb1f2674af65a8f4b56e1a6f526156bc273d0e65dd8075fab51c78851 url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.3+2" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: fe0ffe274d665be8e34f9c59705441a7d248edebbe5d9e3ec2665f88b79358ea + sha256: e9c8eadee926c4532d0305dff94b85bf961f16759c3af791486613152af4b4f9 url: "https://pub.dev" source: hosted - version: "4.2.2" + version: "4.2.3" permission_handler_windows: dependency: transitive description: @@ -467,10 +467,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -483,34 +483,34 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: c272f9cabca5a81adc9b0894381e9c1def363e980f960fa903c604c471b22f68 + sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "041be4d9d2dc6079cf342bc8b761b03787e3b71192d658220a56cac9c04a0294" + sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "671e7a931f55a08aa45be2a13fe7247f2a41237897df434b30d2012388191833" + sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.3" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "2ba0510d3017f91655b7543e9ee46d48619de2a2af38e5c790423f7007c7ccc1" + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" shared_preferences_platform_interface: dependency: transitive description: @@ -523,18 +523,18 @@ packages: dependency: transitive description: name: shared_preferences_web - sha256: "59dc807b94d29d52ddbb1b3c0d3b9d0a67fc535a64e62a5542c8db0513fcb6c2" + sha256: d2ca4132d3946fec2184261726b355836a82c33d7d5b67af32692aff18a4684e url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "398084b47b7f92110683cac45c6dc4aae853db47e470e5ddcd52cab7f7196ab2" + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" sky_engine: dependency: transitive description: flutter @@ -600,10 +600,10 @@ packages: dependency: transitive description: name: typed_data - sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" vector_math: dependency: transitive description: @@ -624,26 +624,26 @@ packages: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.0" win32: dependency: transitive description: name: win32 - sha256: "015002c060f1ae9f41a818f2d5640389cc05283e368be19dc8d77cecb43c40c9" + sha256: "10169d3934549017f0ae278ccb07f828f9d6ea21573bab0fb77b0e1ef0fce454" url: "https://pub.dev" source: hosted - version: "5.5.3" + version: "5.7.2" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" xml: dependency: transitive description: @@ -661,5 +661,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.22.0" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/customer_io.iml b/customer_io.iml index a8bedeb..1f1e608 100644 --- a/customer_io.iml +++ b/customer_io.iml @@ -20,6 +20,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Classes/Bridge/CustomerIOSDKConfigMapper.swift b/ios/Classes/Bridge/CustomerIOSDKConfigMapper.swift new file mode 100644 index 0000000..b0e6487 --- /dev/null +++ b/ios/Classes/Bridge/CustomerIOSDKConfigMapper.swift @@ -0,0 +1,59 @@ +import CioDataPipelines + +enum SDKConfigBuilderError: Error { + case missingCdpApiKey +} + +extension SDKConfigBuilder { + private enum Config: String { + case migrationSiteId + case cdpApiKey + case region + case logLevel + case autoTrackDeviceAttributes + case trackApplicationLifecycleEvents + case flushAt + case flushInterval + case apiHost + case cdnHost + } + + @available(iOSApplicationExtension, unavailable) + static func create(from config: [String: Any?]) throws -> SDKConfigBuilder { + guard let cdpApiKey = config[Config.cdpApiKey.rawValue] as? String else { + throw SDKConfigBuilderError.missingCdpApiKey + } + + let builder = SDKConfigBuilder(cdpApiKey: cdpApiKey) + Config.migrationSiteId.ifNotNil(in: config, thenPassItTo: builder.migrationSiteId) + Config.region.ifNotNil(in: config, thenPassItTo: builder.region, transformingBy: Region.getRegion) + Config.logLevel.ifNotNil(in: config, thenPassItTo: builder.logLevel, transformingBy: CioLogLevel.getLogLevel) + Config.autoTrackDeviceAttributes.ifNotNil(in: config, thenPassItTo: builder.autoTrackDeviceAttributes) + Config.trackApplicationLifecycleEvents.ifNotNil(in: config, thenPassItTo: builder.trackApplicationLifecycleEvents) + Config.flushAt.ifNotNil(in: config, thenPassItTo: builder.flushAt) { (value: NSNumber) in value.intValue } + Config.flushInterval.ifNotNil(in: config, thenPassItTo: builder.flushInterval) { (value: NSNumber) in value.doubleValue } + Config.apiHost.ifNotNil(in: config, thenPassItTo: builder.apiHost) + Config.cdnHost.ifNotNil(in: config, thenPassItTo: builder.cdnHost) + + return builder + } +} + +extension RawRepresentable where RawValue == String { + func ifNotNil( + in config: [String: Any?]?, + thenPassItTo handler: (Raw) -> Any + ) { + ifNotNil(in: config, thenPassItTo: handler) { $0 } + } + + func ifNotNil( + in config: [String: Any?]?, + thenPassItTo handler: (Transformed) -> Any, + transformingBy transform: (Raw) -> Transformed? + ) { + if let value = config?[rawValue] as? Raw, let result = transform(value) { + _ = handler(result) + } + } +} diff --git a/ios/Classes/Bridge/FlutterMethodCall+Native.swift b/ios/Classes/Bridge/FlutterMethodCall+Native.swift new file mode 100644 index 0000000..c8a202c --- /dev/null +++ b/ios/Classes/Bridge/FlutterMethodCall+Native.swift @@ -0,0 +1,63 @@ +import Flutter + +extension FlutterMethodCall { + /// Handles native method call with argument transformation and response handling. + /// + /// - Parameters: + /// - result: `FlutterResult` to send the response back to Flutter. + /// - transform: Closure to transform the method arguments. + /// - handler: Closure to process the transformed arguments and return a result. + func native( + result: FlutterResult, + transform: (Any?) throws -> Arguments, + handler: (Arguments) throws -> Result + ) { + do { + let args: Arguments + do { + args = try transform(arguments) + } catch { + result(FlutterError(code: method, message: "params not available", details: nil)) + return + } + + let response = try handler(args) + if response is Void { + // If the result is Unit, then return true to the Flutter side + // As returning Void may throw an error on the Flutter side + result(true) + } else { + result(response) + } + } catch { + // Handle exceptions and send error to Flutter + result(FlutterError(code: method, message: "Unexpected error: \(error).", details: nil)) + } + } + + /// Handles native method call with no arguments. + /// + /// - Parameters: + /// - result: `FlutterResult` to send the response back to Flutter. + /// - handler: Closure to process the call and return a result. + func nativeNoArgs( + result: FlutterResult, + handler: () throws -> Result + ) { + native(result: result, transform: { _ in () }) { _ in try handler() } + } + + /// Handles native method call with map arguments. + /// + /// - Parameters: + /// - result: `FlutterResult` to send the response back to Flutter. + /// - handler: Closure to process the map arguments and return a result. + func nativeMapArgs( + result: FlutterResult, + handler: ([String: AnyHashable]) throws -> Result + ) { + native(result: result, transform: { + $0 as? [String: AnyHashable] ?? [:] + }, handler: handler) + } +} diff --git a/ios/Classes/Bridge/SdkClientConfiguration.swift b/ios/Classes/Bridge/SdkClientConfiguration.swift new file mode 100644 index 0000000..9cf10d2 --- /dev/null +++ b/ios/Classes/Bridge/SdkClientConfiguration.swift @@ -0,0 +1,28 @@ +import CioInternalCommon + +/// Extension on `SdkClient` to provide configuration functionality. +/// +/// **Note**: Due to Swift limitations with static methods in protocol extensions, static functions +/// in this extension should be called using `CustomerIOSdkClient.` to ensure correct behavior. +extension SdkClient { + /// Configures and overrides the shared `SdkClient` instance with provided parameters. + /// + /// - Parameters: + /// - using: Dictionary containing values required for `SdkClient` protocol. + /// - Returns: Configured `SdkClient` instance. Returns the existing shared client if required parameters are missing. + @available(iOSApplicationExtension, unavailable) + @discardableResult + static func configure(using args: [String: Any?]) -> SdkClient { + guard let source = args["source"] as? String, + let version = args["version"] as? String + else { + DIGraphShared.shared.logger.error("Missing required parameters for SdkClient configuration in args: \(args)") + return DIGraphShared.shared.sdkClient + } + + let client = CustomerIOSdkClient(source: source, sdkVersion: version) + DIGraphShared.shared.override(value: client, forType: SdkClient.self) + + return DIGraphShared.shared.sdkClient + } +} diff --git a/ios/Classes/CustomerIOInAppMessaging.swift b/ios/Classes/CustomerIOInAppMessaging.swift deleted file mode 100644 index 82ec4ed..0000000 --- a/ios/Classes/CustomerIOInAppMessaging.swift +++ /dev/null @@ -1,44 +0,0 @@ -import Foundation -import Flutter -import CioMessagingInApp - -public class CusomterIOInAppMessaging: NSObject, FlutterPlugin { - - private var methodChannel: FlutterMethodChannel? - - public static func register(with registrar: FlutterPluginRegistrar) { - } - - init(with registrar: FlutterPluginRegistrar) { - super.init() - - methodChannel = FlutterMethodChannel(name: "customer_io_messaging_in_app", binaryMessenger: registrar.messenger()) - - guard let methodChannel = methodChannel else { - print("customer_io_messaging_in_app methodChannel is nil") - return - } - - registrar.addMethodCallDelegate(self, channel: methodChannel) - } - - - deinit { - methodChannel?.setMethodCallHandler(nil) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - // Handle method calls for this method channel - switch(call.method) { - case Keys.Methods.dismissMessage: - MessagingInApp.shared.dismissMessage() - default: - result(FlutterMethodNotImplemented) - } - } - - func detachFromEngine() { - methodChannel?.setMethodCallHandler(nil) - methodChannel = nil - } -} diff --git a/ios/Classes/CustomerIOPlugin.h b/ios/Classes/CustomerIOPlugin.h new file mode 100644 index 0000000..c9d15df --- /dev/null +++ b/ios/Classes/CustomerIOPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface CustomerIOPlugin : NSObject +@end diff --git a/ios/Classes/CustomerIoPlugin.m b/ios/Classes/CustomerIOPlugin.m similarity index 78% rename from ios/Classes/CustomerIoPlugin.m rename to ios/Classes/CustomerIOPlugin.m index a3353ca..498db87 100644 --- a/ios/Classes/CustomerIoPlugin.m +++ b/ios/Classes/CustomerIOPlugin.m @@ -1,4 +1,4 @@ -#import "CustomerIoPlugin.h" +#import "CustomerIOPlugin.h" #if __has_include() #import #else @@ -8,8 +8,8 @@ #import "customer_io-Swift.h" #endif -@implementation CustomerIoPlugin +@implementation CustomerIOPlugin + (void)registerWithRegistrar:(NSObject*)registrar { - [SwiftCustomerIoPlugin registerWithRegistrar:registrar]; + [SwiftCustomerIOPlugin registerWithRegistrar:registrar]; } @end diff --git a/ios/Classes/CustomerIoPlugin.h b/ios/Classes/CustomerIoPlugin.h deleted file mode 100644 index c98ba4b..0000000 --- a/ios/Classes/CustomerIoPlugin.h +++ /dev/null @@ -1,4 +0,0 @@ -#import - -@interface CustomerIoPlugin : NSObject -@end diff --git a/ios/Classes/Keys.swift b/ios/Classes/Keys.swift deleted file mode 100644 index 06f1f62..0000000 --- a/ios/Classes/Keys.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation - -struct Keys { - - struct Methods{ - static let initialize = "initialize" - static let identify = "identify" - static let clearIdentify = "clearIdentify" - static let track = "track" - static let screen = "screen" - static let setDeviceAttributes = "setDeviceAttributes" - static let setProfileAttributes = "setProfileAttributes" - static let registerDeviceToken = "registerDeviceToken" - static let trackMetric = "trackMetric" - static let dismissMessage = "dismissMessage" - } - - struct Tracking { - static let identifier = "identifier" - static let attributes = "attributes" - static let eventName = "eventName" - static let token = "token" - static let deliveryId = "deliveryId" - static let deliveryToken = "deliveryToken" - static let metricEvent = "metricEvent" - } - - struct Environment{ - static let siteId = "siteId" - static let apiKey = "apiKey" - static let region = "region" - static let enableInApp = "enableInApp" - } - -} diff --git a/ios/Classes/MessagingInApp/CustomerIOInAppEventListener.swift b/ios/Classes/MessagingInApp/CustomerIOInAppEventListener.swift new file mode 100644 index 0000000..cd179fc --- /dev/null +++ b/ios/Classes/MessagingInApp/CustomerIOInAppEventListener.swift @@ -0,0 +1,32 @@ +import CioMessagingInApp + +class CustomerIOInAppEventListener { + private let invokeDartMethod: (String, Any?) -> Void + + init(invokeDartMethod: @escaping (String, Any?) -> Void) { + self.invokeDartMethod = invokeDartMethod + } +} + +extension CustomerIOInAppEventListener: InAppEventListener { + func errorWithMessage(message: InAppMessage) { + invokeDartMethod("errorWithMessage", ["messageId": message.messageId, "deliveryId": message.deliveryId]) + } + + func messageActionTaken(message: InAppMessage, actionValue: String, actionName: String) { + invokeDartMethod("messageActionTaken", [ + "messageId": message.messageId, + "deliveryId": message.deliveryId, + "actionValue": actionValue, + "actionName": actionName + ]) + } + + func messageDismissed(message: InAppMessage) { + invokeDartMethod("messageDismissed", ["messageId": message.messageId, "deliveryId": message.deliveryId]) + } + + func messageShown(message: InAppMessage) { + invokeDartMethod("messageShown", ["messageId": message.messageId, "deliveryId": message.deliveryId]) + } +} diff --git a/ios/Classes/MessagingInApp/CustomerIOInAppMessaging.swift b/ios/Classes/MessagingInApp/CustomerIOInAppMessaging.swift new file mode 100644 index 0000000..80ee094 --- /dev/null +++ b/ios/Classes/MessagingInApp/CustomerIOInAppMessaging.swift @@ -0,0 +1,60 @@ +import CioInternalCommon +import CioMessagingInApp +import Flutter +import Foundation + +public class CustomerIOInAppMessaging: NSObject, FlutterPlugin { + private var methodChannel: FlutterMethodChannel? + + public static func register(with _: FlutterPluginRegistrar) {} + + init(with registrar: FlutterPluginRegistrar) { + super.init() + + methodChannel = FlutterMethodChannel(name: "customer_io_messaging_in_app", binaryMessenger: registrar.messenger()) + + guard let methodChannel = methodChannel else { + print("customer_io_messaging_in_app methodChannel is nil") + return + } + + registrar.addMethodCallDelegate(self, channel: methodChannel) + } + + deinit { + methodChannel?.setMethodCallHandler(nil) + methodChannel = nil + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // Handle method calls for this method channel + switch call.method { + case "dismissMessage": + call.nativeNoArgs(result: result) { + MessagingInApp.shared.dismissMessage() + } + + default: + result(FlutterMethodNotImplemented) + } + } + + func configureModule(params: [String: AnyHashable]) { + if let inAppConfig = try? MessagingInAppConfigBuilder.build(from: params) { + MessagingInApp.initialize(withConfig: inAppConfig) + MessagingInApp.shared.setEventListener(CustomerIOInAppEventListener(invokeDartMethod: invokeDartMethod)) + } + } + + func invokeDartMethod(_ method: String, _ args: Any?) { + // When sending messages from native code to Flutter, it's required to do it on main thread. + // Learn more: + // * https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading + // * https://linear.app/customerio/issue/MBL-358/ + DIGraphShared.shared.threadUtil.runMain { [weak self] in + guard let self else { return } + + self.methodChannel?.invokeMethod(method, arguments: args) + } + } +} diff --git a/ios/Classes/MessagingPush/CustomerIOMessagingPush.swift b/ios/Classes/MessagingPush/CustomerIOMessagingPush.swift new file mode 100644 index 0000000..59839d4 --- /dev/null +++ b/ios/Classes/MessagingPush/CustomerIOMessagingPush.swift @@ -0,0 +1,39 @@ +import CioDataPipelines +import Flutter +import Foundation + +public class CustomerIOMessagingPush: NSObject, FlutterPlugin { + private let channelName: String = "customer_io_messaging_push" + + public static func register(with _: FlutterPluginRegistrar) {} + + private var methodChannel: FlutterMethodChannel? + + init(with registrar: FlutterPluginRegistrar) { + super.init() + + methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: registrar.messenger()) + guard let methodChannel = methodChannel else { + print("\(channelName) methodChannel is nil") + return + } + + registrar.addMethodCallDelegate(self, channel: methodChannel) + } + + deinit { + methodChannel?.setMethodCallHandler(nil) + methodChannel = nil + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + // Handle method calls for this method channel + switch call.method { + case "getRegisteredDeviceToken": + call.nativeNoArgs(result: result) { CustomerIO.shared.registeredDeviceToken } + + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios/Classes/SwiftCustomerIOPlugin.swift b/ios/Classes/SwiftCustomerIOPlugin.swift new file mode 100644 index 0000000..7027a40 --- /dev/null +++ b/ios/Classes/SwiftCustomerIOPlugin.swift @@ -0,0 +1,175 @@ +import CioDataPipelines +import CioInternalCommon +import CioMessagingInApp +import Flutter +import UIKit + +public class SwiftCustomerIOPlugin: NSObject, FlutterPlugin { + private var methodChannel: FlutterMethodChannel! + private var inAppMessagingChannelHandler: CustomerIOInAppMessaging! + private var messagingPushChannelHandler: CustomerIOMessagingPush! + + private let logger: CioInternalCommon.Logger = DIGraphShared.shared.logger + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = SwiftCustomerIOPlugin() + + instance.methodChannel = FlutterMethodChannel(name: "customer_io", binaryMessenger: registrar.messenger()) + registrar.addMethodCallDelegate(instance, channel: instance.methodChannel) + + instance.inAppMessagingChannelHandler = CustomerIOInAppMessaging(with: registrar) + instance.messagingPushChannelHandler = CustomerIOMessagingPush(with: registrar) + } + + deinit { + self.methodChannel.setMethodCallHandler(nil) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "clearIdentify": + call.nativeNoArgs(result: result, handler: clearIdentify) + + case "identify": + call.nativeMapArgs(result: result, handler: identify) + + case "initialize": + call.nativeMapArgs(result: result, handler: initialize) + + case "setDeviceAttributes": + call.nativeMapArgs(result: result, handler: setDeviceAttributes) + + case "setProfileAttributes": + call.nativeMapArgs(result: result, handler: setProfileAttributes) + + case "registerDeviceToken": + call.nativeMapArgs(result: result, handler: registerDeviceToken) + + case "screen": + call.nativeMapArgs(result: result, handler: screen) + + case "track": + call.nativeMapArgs(result: result, handler: track) + + case "trackMetric": + call.nativeMapArgs(result: result, handler: trackMetric) + + default: + result(FlutterMethodNotImplemented) + } + } + + private func identify(params: [String: AnyHashable]) { + let userId = params[Args.userId] as? String + let traits = params[Args.traits] as? [String: AnyHashable] ?? [:] + + if userId == nil, traits.isEmpty { + logger.error("Please provide either an ID or traits to identify.") + return + } + + if let userId = userId, !traits.isEmpty { + CustomerIO.shared.identify(userId: userId, traits: traits) + } else if let userId = userId { + CustomerIO.shared.identify(userId: userId) + } else { + CustomerIO.shared.profileAttributes = traits + } + } + + private func clearIdentify() { + CustomerIO.shared.clearIdentify() + } + + private func track(params: [String: AnyHashable]) { + guard let name: String = params.require(Args.name) else { + return + } + + guard let properties = params[Args.properties] as? [String: AnyHashable] else { + CustomerIO.shared.track(name: name) + return + } + + CustomerIO.shared.track(name: name, properties: properties) + } + + func screen(params: [String: AnyHashable]) { + guard let title: String = params.require(Args.title) else { + return + } + + guard let properties = params[Args.properties] as? [String: AnyHashable] else { + CustomerIO.shared.screen(title: title) + return + } + + CustomerIO.shared.screen(title: title, properties: properties) + } + + private func setDeviceAttributes(params: [String: AnyHashable]) { + guard let attributes: [String: AnyHashable] = params.require(Args.attributes) else { + return + } + + CustomerIO.shared.deviceAttributes = attributes + } + + private func setProfileAttributes(params: [String: AnyHashable]) { + guard let attributes: [String: AnyHashable] = params.require(Args.attributes) else { + return + } + + CustomerIO.shared.profileAttributes = attributes + } + + private func registerDeviceToken(params: [String: AnyHashable]) { + guard let token: String = params.require(Args.token) else { + return + } + + CustomerIO.shared.registerDeviceToken(token) + } + + private func trackMetric(params: [String: AnyHashable]) { + guard let deliveryId: String = params.require(Args.deliveryId), + let deviceToken: String = params.require(Args.deliveryToken), + let metricEvent: String = params.require(Args.metricEvent), + let event = Metric.getEvent(from: metricEvent) + else { + return + } + + CustomerIO.shared.trackMetric(deliveryID: deliveryId, event: event, deviceToken: deviceToken) + } + + private func initialize(params: [String: AnyHashable]) { + do { + // Configure and override SdkClient for Flutter before initializing native SDK + CustomerIOSdkClient.configure(using: params) + // Initialize native SDK with provided config + let sdkConfigBuilder = try SDKConfigBuilder.create(from: params) + CustomerIO.initialize(withConfig: sdkConfigBuilder.build()) + + // Initialize in-app messaging with provided config + inAppMessagingChannelHandler.configureModule(params: params) + + logger.debug("Customer.io SDK initialized with config: \(params)") + } catch { + logger.error("Initializing Customer.io SDK failed with error: \(error)") + } + } + + enum Args { + static let attributes = "attributes" + static let deliveryId = "deliveryId" + static let deliveryToken = "deliveryToken" + static let metricEvent = "metricEvent" + static let name = "name" + static let properties = "properties" + static let title = "title" + static let token = "token" + static let traits = "traits" + static let userId = "userId" + } +} diff --git a/ios/Classes/SwiftCustomerIoPlugin.swift b/ios/Classes/SwiftCustomerIoPlugin.swift deleted file mode 100644 index 107a56d..0000000 --- a/ios/Classes/SwiftCustomerIoPlugin.swift +++ /dev/null @@ -1,253 +0,0 @@ -import Flutter -import UIKit -import CioTracking -import CioInternalCommon -import CioMessagingInApp - -public class SwiftCustomerIoPlugin: NSObject, FlutterPlugin { - - private var methodChannel: FlutterMethodChannel! - private var inAppMessagingChannelHandler: CusomterIOInAppMessaging! - - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = SwiftCustomerIoPlugin() - instance.methodChannel = FlutterMethodChannel(name: "customer_io", binaryMessenger: registrar.messenger()) - registrar.addMethodCallDelegate(instance, channel: instance.methodChannel) - - instance.inAppMessagingChannelHandler = CusomterIOInAppMessaging(with: registrar) - } - - deinit { - self.methodChannel.setMethodCallHandler(nil) - self.inAppMessagingChannelHandler.detachFromEngine() - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch(call.method) { - case Keys.Methods.initialize: - call.toNativeMethodCall( - result: result) { - initialize(params: $0) - } - case Keys.Methods.clearIdentify: - clearIdentify() - case Keys.Methods.track: - call.toNativeMethodCall( - result: result) { - track(params: $0) - } - case Keys.Methods.screen: - call.toNativeMethodCall( - result: result) { - screen(params: $0) - } - case Keys.Methods.identify: - call.toNativeMethodCall( - result: result) { - identify(params: $0) - } - case Keys.Methods.setProfileAttributes: - call.toNativeMethodCall(result: result) { - setProfileAttributes(params: $0) - } - case Keys.Methods.setDeviceAttributes: - call.toNativeMethodCall(result: result) { - setDeviceAttributes(params: $0) - } - case Keys.Methods.registerDeviceToken: - call.toNativeMethodCall(result: result) { - registerDeviceToken(params: $0) - } - case Keys.Methods.trackMetric: - call.toNativeMethodCall(result: result) { - trackMetric(params: $0) - } - default: - result(FlutterMethodNotImplemented) - } - } - - private func identify(params : Dictionary){ - guard let identifier = params[Keys.Tracking.identifier] as? String - else { - return - } - - guard let attributes = params[Keys.Tracking.attributes] as? Dictionary else{ - CustomerIO.shared.identify(identifier: identifier) - return - } - - CustomerIO.shared.identify(identifier: identifier, body: attributes) - } - - private func clearIdentify() { - CustomerIO.shared.clearIdentify() - } - - private func track(params : Dictionary) { - guard let name = params[Keys.Tracking.eventName] as? String - else { - return - } - - guard let attributes = params[Keys.Tracking.attributes] as? Dictionary else{ - CustomerIO.shared.track(name: name) - return - } - - CustomerIO.shared.track(name: name, data: attributes) - - } - - func screen(params : Dictionary) { - guard let name = params[Keys.Tracking.eventName] as? String - else { - return - } - - guard let attributes = params[Keys.Tracking.attributes] as? Dictionary else{ - CustomerIO.shared.screen(name: name) - return - } - - CustomerIO.shared.screen(name: name, data: attributes) - } - - - private func setDeviceAttributes(params : Dictionary){ - guard let attributes = params[Keys.Tracking.attributes] as? Dictionary - else { - return - } - CustomerIO.shared.deviceAttributes = attributes - } - - private func setProfileAttributes(params : Dictionary){ - guard let attributes = params[Keys.Tracking.attributes] as? Dictionary - else { - return - } - CustomerIO.shared.profileAttributes = attributes - } - - private func registerDeviceToken(params : Dictionary){ - guard let token = params[Keys.Tracking.token] as? String - else { - return - } - - CustomerIO.shared.registerDeviceToken(token) - } - - private func trackMetric(params : Dictionary){ - guard let deliveryId = params[Keys.Tracking.deliveryId] as? String, - let deviceToken = params[Keys.Tracking.deliveryToken] as? String, - let metricEvent = params[Keys.Tracking.metricEvent] as? String, - let event = Metric.getEvent(from: metricEvent) - else { - return - } - - CustomerIO.shared.trackMetric(deliveryID: deliveryId, - event: event, - deviceToken: deviceToken) - } - - private func initialize(params : Dictionary){ - guard let siteId = params[Keys.Environment.siteId] as? String, - let apiKey = params[Keys.Environment.apiKey] as? String, - let regionStr = params[Keys.Environment.region] as? String - else { - return - } - - let region = Region.getRegion(from: regionStr) - - CustomerIO.initialize(siteId: siteId, apiKey: apiKey, region: region){ - config in - config.modify(params: params) - } - - - if let enableInApp = params[Keys.Environment.enableInApp] as? Bool { - if enableInApp{ - initializeInApp() - } - } - - } - - /** - Initialize in-app using customerio plugin - */ - private func initializeInApp(){ - DispatchQueue.main.async { - MessagingInApp.shared.initialize(eventListener: CustomerIOInAppEventListener( - invokeMethod: {method,args in - self.invokeMethod(method, args) - }) - ) - } - } - - func invokeMethod(_ method: String, _ args: Any?) { - // When sending messages from native code to Flutter, it's required to do it on main thread. - // Learn more: - // * https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading - // * https://linear.app/customerio/issue/MBL-358/ - DispatchQueue.main.async { - self.methodChannel.invokeMethod(method, arguments: args) - } - } - -} - -private extension FlutterMethodCall { - func toNativeMethodCall( result: @escaping FlutterResult, - method: (_: Dictionary) throws -> Void) { - do { - if let attributes = self.arguments as? Dictionary { - print(attributes) - try method(attributes) - result(true) - } else{ - result(FlutterError(code: self.method, message: "params not available", details: nil)) - } - } catch { - result(FlutterError(code: self.method, message: "Unexpected error: \(error).", details: nil)) - } - - } -} - -class CustomerIOInAppEventListener { - private let invokeMethod: (String, Any?) -> Void - - init(invokeMethod: @escaping (String, Any?) -> Void) { - self.invokeMethod = invokeMethod - } -} - -extension CustomerIOInAppEventListener: InAppEventListener { - func errorWithMessage(message: InAppMessage) { - invokeMethod("errorWithMessage", ["messageId": message.messageId, "deliveryId": message.deliveryId]) - } - - func messageActionTaken(message: InAppMessage, actionValue: String, actionName: String) { - invokeMethod("messageActionTaken", [ - "messageId": message.messageId, - "deliveryId": message.deliveryId, - "actionValue": actionValue, - "actionName": actionName - ]) - } - - func messageDismissed(message: InAppMessage) { - invokeMethod("messageDismissed", ["messageId": message.messageId, "deliveryId": message.deliveryId]) - } - - func messageShown(message: InAppMessage) { - invokeMethod("messageShown", ["messageId": message.messageId, "deliveryId": message.deliveryId]) - } -} diff --git a/ios/Classes/Utilities/DictionaryExtensions.swift b/ios/Classes/Utilities/DictionaryExtensions.swift new file mode 100644 index 0000000..25a4696 --- /dev/null +++ b/ios/Classes/Utilities/DictionaryExtensions.swift @@ -0,0 +1,23 @@ +import CioInternalCommon + +extension Dictionary where Key == String, Value == AnyHashable { + /// Retrieves a value from dictionary for given key and casts it to given type. + /// + /// - Parameters: + /// - key: Key to look up in the dictionary. + /// - onFailure: An optional closure executed if the key is missing or the value cannot be cast. + /// Defaults to `nil`, in which case no additional action is taken. + /// - Returns: The value associated with the key, cast to given type, or `nil` if the key is missing or the cast fails. + func require(_ key: String, onFailure: (() -> Void)? = nil) -> T? { + guard let value = self[key] as? T else { + // Using if-else for increased readability + if let onFailure = onFailure { + onFailure() + } else { + DIGraphShared.shared.logger.error("Missing or invalid value for key: \(key) in: \(self)") + } + return nil + } + return value + } +} diff --git a/ios/customer_io.podspec b/ios/customer_io.podspec index fff69ec..9b426a0 100755 --- a/ios/customer_io.podspec +++ b/ios/customer_io.podspec @@ -5,6 +5,8 @@ require 'yaml' podspec_config = YAML.load_file('../pubspec.yaml') +# The native_sdk_version is the version of iOS native SDK that the Flutter plugin is compatible with. +native_sdk_version = podspec_config['flutter']['plugin']['platforms']['ios']['native_sdk_version'] Pod::Spec.new do |s| s.name = podspec_config['name'] @@ -17,8 +19,26 @@ Pod::Spec.new do |s| s.source_files = 'Classes/**/*' s.dependency 'Flutter' s.platform = :ios, '13.0' - s.dependency "CustomerIO/Tracking", '~> 2' - s.dependency "CustomerIO/MessagingInApp", '~> 2' + + # Native SDK dependencies that are required for the Flutter plugin to work. + s.dependency "CustomerIO/DataPipelines", native_sdk_version + s.dependency "CustomerIO/MessagingInApp", native_sdk_version + + # If we do not specify a default_subspec, then *all* dependencies inside of *all* the subspecs will be downloaded by cocoapods. + # We want customers to opt into push dependencies especially because the FCM subpsec downloads Firebase dependencies. + # APN customers should not install Firebase dependencies at all. + s.default_subspec = "nopush" + + s.subspec 'nopush' do |ss| + # This is the default subspec designed to not install any push dependencies. Customer should choose APN or FCM. + # The SDK at runtime currently requires the MessagingPush module so we do include it here. + ss.dependency "CustomerIO/MessagingPush", native_sdk_version + end + + # Note: Subspecs inherit all dependencies specified the parent spec (this file). + s.subspec 'fcm' do |ss| + ss.dependency "CustomerIO/MessagingPushFCM", native_sdk_version + end # Flutter.framework does not contain a i386 slice. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } diff --git a/ios/customer_io_richpush.podspec b/ios/customer_io_richpush.podspec new file mode 100755 index 0000000..f6d6ed7 --- /dev/null +++ b/ios/customer_io_richpush.podspec @@ -0,0 +1,37 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint customer_io_richpush.podspec` to validate before publishing. +# +require 'yaml' + +podspec_config = YAML.load_file('../pubspec.yaml') +# The native_sdk_version is the version of iOS native SDK that the Flutter plugin is compatible with. +native_sdk_version = podspec_config['flutter']['plugin']['platforms']['ios']['native_sdk_version'] + +# Used by customers to install native iOS dependencies inside their Notification Service Extension (NSE) target to setup rich push. +# Note: We need a unique podspec for rich push because the other podspecs in this project install too many dependencies that should not be installed inside of a NSE target. +# We need this podspec which installs minimal dependencies that are only included in the NSE target. +Pod::Spec.new do |s| + s.name = "customer_io_richpush" + s.version = podspec_config['version'] + s.summary = podspec_config['description'] + s.homepage = podspec_config['homepage'] + s.license = { :file => '../LICENSE' } + s.author = { "CustomerIO Team" => "win@customer.io" } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '13.0' + + # Careful when declaring dependencies here. All dependencies will be included in the App Extension target in Xcode, not the host iOS app. + # s.dependency "X", "X" + + # Subspecs allow customers to choose between multiple options of what type of version of this rich push package they would like to install. + s.subspec 'fcm' do |ss| + ss.dependency "CustomerIO/MessagingPushFCM", native_sdk_version + end + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end diff --git a/lib/config/customer_io_config.dart b/lib/config/customer_io_config.dart new file mode 100644 index 0000000..0e4ee82 --- /dev/null +++ b/lib/config/customer_io_config.dart @@ -0,0 +1,56 @@ +import '../customer_io_enums.dart'; +import '../customer_io_plugin_version.dart' as plugin_info show version; +import 'in_app_config.dart'; +import 'push_config.dart'; + +class CustomerIOConfig { + final String source = 'Flutter'; + final String version = plugin_info.version; + + final String cdpApiKey; + final String? migrationSiteId; + final Region? region; + final CioLogLevel? logLevel; + final bool? trackApplicationLifecycleEvents; + final bool? autoTrackDeviceAttributes; + final String? apiHost; + final String? cdnHost; + final int? flushAt; + final int? flushInterval; + final InAppConfig? inAppConfig; + final PushConfig pushConfig; + + CustomerIOConfig({ + required this.cdpApiKey, + this.migrationSiteId, + this.region, + this.logLevel, + this.autoTrackDeviceAttributes, + this.trackApplicationLifecycleEvents, + this.apiHost, + this.cdnHost, + this.flushAt, + this.flushInterval, + this.inAppConfig, + PushConfig? pushConfig, + }) : pushConfig = pushConfig ?? PushConfig(); + + Map toMap() { + return { + 'cdpApiKey': cdpApiKey, + 'migrationSiteId': migrationSiteId, + 'region': region?.name, + 'logLevel': logLevel?.name, + 'autoTrackDeviceAttributes': autoTrackDeviceAttributes, + 'trackApplicationLifecycleEvents': trackApplicationLifecycleEvents, + 'apiHost': apiHost, + 'cdnHost': cdnHost, + 'flushAt': flushAt, + 'flushInterval': flushInterval, + 'inApp': inAppConfig?.toMap(), + 'push': pushConfig.toMap(), + 'version': version, + 'source': source + }; + } +} diff --git a/lib/config/in_app_config.dart b/lib/config/in_app_config.dart new file mode 100644 index 0000000..12d17dc --- /dev/null +++ b/lib/config/in_app_config.dart @@ -0,0 +1,11 @@ +class InAppConfig { + final String siteId; + + InAppConfig({required this.siteId}); + + Map toMap() { + return { + 'siteId': siteId, + }; + } +} diff --git a/lib/config/push_config.dart b/lib/config/push_config.dart new file mode 100644 index 0000000..3ae21ad --- /dev/null +++ b/lib/config/push_config.dart @@ -0,0 +1,28 @@ +import 'package:customer_io/customer_io_enums.dart'; + +class PushConfig { + PushConfigAndroid pushConfigAndroid; + + PushConfig({PushConfigAndroid? android}) + : pushConfigAndroid = android ?? PushConfigAndroid(); + + Map toMap() { + return { + 'android': pushConfigAndroid.toMap(), + }; + } +} + +class PushConfigAndroid { + PushClickBehaviorAndroid pushClickBehavior; + + PushConfigAndroid( + {this.pushClickBehavior = + PushClickBehaviorAndroid.activityPreventRestart}); + + Map toMap() { + return { + 'pushClickBehavior': pushClickBehavior.rawValue, + }; + } +} diff --git a/lib/customer_io.dart b/lib/customer_io.dart index 41bc593..8f803be 100644 --- a/lib/customer_io.dart +++ b/lib/customer_io.dart @@ -1,119 +1,161 @@ import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/cupertino.dart'; import 'customer_io_config.dart'; import 'customer_io_enums.dart'; -import 'customer_io_inapp.dart'; -import 'customer_io_platform_interface.dart'; +import 'data_pipelines/customer_io_platform_interface.dart'; +import 'extensions/map_extensions.dart'; import 'messaging_in_app/platform_interface.dart'; import 'messaging_push/platform_interface.dart'; class CustomerIO { - const CustomerIO._(); + static CustomerIO? _instance; + + final CustomerIOPlatform _platform; + final CustomerIOMessagingPushPlatform _pushMessaging; + final CustomerIOMessagingInAppPlatform _inAppMessaging; + + /// Private constructor to enforce singleton pattern + CustomerIO._({ + CustomerIOPlatform? platform, + CustomerIOMessagingPushPlatform? pushMessaging, + CustomerIOMessagingInAppPlatform? inAppMessaging, + }) : _platform = platform ?? CustomerIOPlatform.instance, + _pushMessaging = + pushMessaging ?? CustomerIOMessagingPushPlatform.instance, + _inAppMessaging = + inAppMessaging ?? CustomerIOMessagingInAppPlatform.instance; + + /// Get the singleton instance of CustomerIO + static CustomerIO get instance { + if (_instance == null) { + throw StateError( + 'CustomerIO SDK must be initialized before accessing instance.\n' + 'Call CustomerIO.initialize() first.', + ); + } + return _instance!; + } - static CustomerIOPlatform get _customerIO => CustomerIOPlatform.instance; + /// For testing: create a new instance with mock implementations + @visibleForTesting + static CustomerIO createInstance({ + CustomerIOPlatform? platform, + CustomerIOMessagingPushPlatform? pushMessaging, + CustomerIOMessagingInAppPlatform? inAppMessaging, + }) { + _instance = CustomerIO._( + platform: platform, + pushMessaging: pushMessaging, + inAppMessaging: inAppMessaging, + ); + return _instance!; + } - static CustomerIOMessagingPushPlatform get _customerIOMessagingPush => - CustomerIOMessagingPushPlatform.instance; + @visibleForTesting + static void reset() { + _instance = null; + } - static CustomerIOMessagingInAppPlatform get _customerIOMessagingInApp => - CustomerIOMessagingInAppPlatform.instance; + /// Access push messaging functionality + static CustomerIOMessagingPushPlatform get pushMessaging { + return _instance?._pushMessaging ?? CustomerIOMessagingPushPlatform.instance; + } + + /// Access in-app messaging functionality + static CustomerIOMessagingInAppPlatform get inAppMessaging { + return _instance?._inAppMessaging ?? CustomerIOMessagingInAppPlatform.instance; + } /// To initialize the plugin /// /// @param config includes required and optional configs etc - static Future initialize({ - required CustomerIOConfig config, - }) { - return _customerIO.initialize(config: config); + static Future initialize({required CustomerIOConfig config}) async { + // Check if already initialized + if (_instance == null) { + // Create new instance if not initialized + _instance = CustomerIO._(); + // Initialize the platform + await _instance!._platform.initialize(config: config); + } else { + log('CustomerIO SDK has already been initialized'); + } } - /// Identify a person using a unique identifier, eg. email id. + /// Identify a person using a unique userId, eg. email id. /// Note that you can identify only 1 profile at a time. In case, multiple /// identifiers are attempted to be identified, then the last identified profile /// will be removed automatically. /// - /// @param identifier unique identifier for a profile - /// @param attributes (Optional) params to set profile attributes - static void identify( - {required String identifier, - Map attributes = const {}}) { - return _customerIO.identify(identifier: identifier, attributes: attributes); + /// @param userId unique identifier for a profile + /// @param traits (Optional) params to set profile attributes + void identify( + {required String userId, Map traits = const {}}) { + return _platform.identify( + userId: userId, traits: traits.excludeNullValues()); } /// Call this function to stop identifying a person. /// /// If a profile exists, clearIdentify will stop identifying the profile. /// If no profile exists, request to clearIdentify will be ignored. - static void clearIdentify() { - _customerIO.clearIdentify(); + void clearIdentify() { + return _platform.clearIdentify(); } /// To track user events like loggedIn, addedItemToCart etc. /// You may also track events with additional yet optional data. /// /// @param name event name to be tracked - /// @param attributes (Optional) params to be sent with event - static void track( - {required String name, Map attributes = const {}}) { - return _customerIO.track(name: name, attributes: attributes); + /// @param properties (Optional) params to be sent with event + void track( + {required String name, Map properties = const {}}) { + return _platform.track( + name: name, properties: properties.excludeNullValues()); } /// Track a push metric - static void trackMetric( + void trackMetric( {required String deliveryID, required String deviceToken, required MetricEvent event}) { - return _customerIO.trackMetric( + return _platform.trackMetric( deliveryID: deliveryID, deviceToken: deviceToken, event: event); } /// Register a new device token with Customer.io, associated with the current active customer. If there /// is no active customer, this will fail to register the device - static void registerDeviceToken({required String deviceToken}) { - return _customerIO.registerDeviceToken(deviceToken: deviceToken); + void registerDeviceToken({required String deviceToken}) { + return _platform.registerDeviceToken(deviceToken: deviceToken); } /// Track screen events to record the screens a user visits /// /// @param name name of the screen user visited /// @param attributes (Optional) params to be sent with event - static void screen( - {required String name, Map attributes = const {}}) { - return _customerIO.screen(name: name, attributes: attributes); + void screen( + {required String title, Map properties = const {}}) { + return _platform.screen( + title: title, properties: properties.excludeNullValues()); } /// Use this function to send custom device attributes /// such as app preferences, timezone etc /// /// @param attributes device attributes - static void setDeviceAttributes({required Map attributes}) { - return _customerIO.setDeviceAttributes(attributes: attributes); + void setDeviceAttributes({required Map attributes}) { + return _platform.setDeviceAttributes(attributes: attributes); } /// Set custom user profile information such as user preference, specific /// user actions etc /// /// @param attributes additional attributes for a user profile - static void setProfileAttributes({required Map attributes}) { - return _customerIO.setProfileAttributes(attributes: attributes); - } - - /// Subscribes to an in-app event listener. - /// - /// [onEvent] - A callback function that will be called every time an in-app event occurs. - /// The callback returns [InAppEvent]. - /// - /// Returns a [StreamSubscription] that can be used to subscribe/unsubscribe from the event listener. - static StreamSubscription subscribeToInAppEventListener( - void Function(InAppEvent) onEvent) { - return _customerIO.subscribeToInAppEventListener(onEvent); - } - - static CustomerIOMessagingPushPlatform messagingPush() { - return _customerIOMessagingPush; - } - - static CustomerIOMessagingInAppPlatform messagingInApp() { - return _customerIOMessagingInApp; + void setProfileAttributes( + {required Map attributes}) { + return _platform.setProfileAttributes( + attributes: attributes.excludeNullValues()); } } diff --git a/lib/customer_io_config.dart b/lib/customer_io_config.dart index 0f5457a..01dc046 100644 --- a/lib/customer_io_config.dart +++ b/lib/customer_io_config.dart @@ -1,55 +1,3 @@ -import 'customer_io_enums.dart'; - -/// Configure plugin using CustomerIOConfig -class CustomerIOConfig { - final String siteId; - final String apiKey; - Region region; - String organizationId; - CioLogLevel logLevel; - bool autoTrackDeviceAttributes; - String trackingApiUrl; - bool autoTrackPushEvents; - int backgroundQueueMinNumberOfTasks; - double backgroundQueueSecondsDelay; - PushClickBehaviorAndroid pushClickBehaviorAndroid; - - bool enableInApp; - - String version; - - CustomerIOConfig( - {required this.siteId, - required this.apiKey, - this.region = Region.us, - @Deprecated("organizationId is deprecated and isn't required anymore, use enableInApp instead. This field will be removed in the next release.") - this.organizationId = "", - this.logLevel = CioLogLevel.debug, - this.autoTrackDeviceAttributes = true, - this.trackingApiUrl = "", - this.autoTrackPushEvents = true, - this.backgroundQueueMinNumberOfTasks = 10, - this.backgroundQueueSecondsDelay = 30.0, - this.pushClickBehaviorAndroid = PushClickBehaviorAndroid.activityPreventRestart, - this.enableInApp = false, - this.version = ""}); - - Map toMap() { - return { - 'siteId': siteId, - 'apiKey': apiKey, - 'region': region.name, - 'organizationId': organizationId, - 'logLevel': logLevel.name, - 'autoTrackDeviceAttributes': autoTrackDeviceAttributes, - 'trackingApiUrl': trackingApiUrl, - 'autoTrackPushEvents': autoTrackPushEvents, - 'backgroundQueueMinNumberOfTasks': backgroundQueueMinNumberOfTasks, - 'backgroundQueueSecondsDelay': backgroundQueueSecondsDelay, - 'pushClickBehaviorAndroid': pushClickBehaviorAndroid.rawValue, - 'enableInApp': enableInApp, - 'version': version, - 'source': "Flutter" - }; - } -} +export 'config/customer_io_config.dart'; +export 'config/in_app_config.dart'; +export 'config/push_config.dart'; diff --git a/lib/customer_io_enums.dart b/lib/customer_io_enums.dart index 3a62435..fe875d7 100644 --- a/lib/customer_io_enums.dart +++ b/lib/customer_io_enums.dart @@ -8,7 +8,7 @@ enum CioLogLevel { none, error, info, debug } enum Region { us, eu } /// Enum to specify the type of metric for tracking -enum MetricEvent { delivered, opened, converted, clicked } +enum MetricEvent { delivered, opened, converted } /// Enum to specify the click behavior of push notification for Android enum PushClickBehaviorAndroid { diff --git a/lib/customer_io_method_channel.dart b/lib/customer_io_method_channel.dart deleted file mode 100644 index fde2a5a..0000000 --- a/lib/customer_io_method_channel.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'dart:async'; - -import 'package:customer_io/customer_io_enums.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -import 'customer_io_config.dart'; -import 'customer_io_const.dart'; -import 'customer_io_inapp.dart'; -import 'customer_io_platform_interface.dart'; -import 'customer_io_plugin_version.dart'; - -/// An implementation of [CustomerIOPlatform] that uses method channels. -class CustomerIOMethodChannel extends CustomerIOPlatform { - /// The method channel used to interact with the native platform. - @visibleForTesting - final methodChannel = const MethodChannel('customer_io'); - - final _inAppEventStreamController = StreamController.broadcast(); - - CustomerIOMethodChannel() { - methodChannel.setMethodCallHandler(_onMethodCall); - } - - /// Method to subscribe to the In-App event listener. - /// - /// The `onEvent` function will be called whenever an In-App event occurs. - /// Returns a [StreamSubscription] object that can be used to unsubscribe from the stream. - @override - StreamSubscription subscribeToInAppEventListener( - void Function(InAppEvent) onEvent) { - StreamSubscription subscription = - _inAppEventStreamController.stream.listen(onEvent); - return subscription; - } - - /// Method call handler to handle events from native bindings - Future _onMethodCall(MethodCall call) async { - /// Cast the arguments to a map of strings to dynamic values. - final arguments = - (call.arguments as Map).cast(); - - switch (call.method) { - case "messageShown": - _inAppEventStreamController - .add(InAppEvent.fromMap(EventType.messageShown, arguments)); - break; - case "messageDismissed": - _inAppEventStreamController - .add(InAppEvent.fromMap(EventType.messageDismissed, arguments)); - break; - case "errorWithMessage": - _inAppEventStreamController - .add(InAppEvent.fromMap(EventType.errorWithMessage, arguments)); - break; - case "messageActionTaken": - _inAppEventStreamController - .add(InAppEvent.fromMap(EventType.messageActionTaken, arguments)); - break; - } - } - - /// To initialize the plugin - @override - Future initialize({ - required CustomerIOConfig config, - }) async { - try { - config.version = version; - if (!config.enableInApp && config.organizationId.isNotEmpty) { - config.enableInApp = true; - } - await methodChannel.invokeMethod(MethodConsts.initialize, config.toMap()); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// Identify a person using a unique identifier, eg. email id. - /// Note that you can identify only 1 profile at a time. In case, multiple - /// identifiers are attempted to be identified, then the last identified profile - /// will be removed automatically. - @override - void identify( - {required String identifier, - Map attributes = const {}}) async { - try { - final payload = { - TrackingConsts.identifier: identifier, - TrackingConsts.attributes: attributes - }; - methodChannel.invokeMethod(MethodConsts.identify, payload); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// To track user events like loggedIn, addedItemToCart etc. - /// You may also track events with additional yet optional data. - @override - void track( - {required String name, - Map attributes = const {}}) async { - try { - final payload = { - TrackingConsts.eventName: name, - TrackingConsts.attributes: attributes - }; - methodChannel.invokeMethod(MethodConsts.track, payload); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// Track a push metric - @override - void trackMetric( - {required String deliveryID, - required String deviceToken, - required MetricEvent event}) async { - try { - final payload = { - TrackingConsts.deliveryId: deliveryID, - TrackingConsts.deliveryToken: deviceToken, - TrackingConsts.metricEvent: event.name, - }; - methodChannel.invokeMethod(MethodConsts.trackMetric, payload); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// Track screen events to record the screens a user visits - @override - void screen( - {required String name, - Map attributes = const {}}) async { - try { - final payload = { - TrackingConsts.eventName: name, - TrackingConsts.attributes: attributes - }; - methodChannel.invokeMethod(MethodConsts.screen, payload); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// Register a new device token with Customer.io, associated with the current active customer. If there - /// is no active customer, this will fail to register the device - @override - void registerDeviceToken({required String deviceToken}) async { - try { - final payload = { - TrackingConsts.token: deviceToken, - }; - methodChannel.invokeMethod(MethodConsts.registerDeviceToken, payload); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// Call this function to stop identifying a person. - @override - void clearIdentify() { - try { - methodChannel.invokeMethod(MethodConsts.clearIdentify); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// Set custom user profile information such as user preference, specific - /// user actions etc - @override - void setProfileAttributes({required Map attributes}) { - try { - final payload = {TrackingConsts.attributes: attributes}; - methodChannel.invokeMethod(MethodConsts.setProfileAttributes, payload); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - /// Use this function to send custom device attributes - /// such as app preferences, timezone etc - @override - void setDeviceAttributes({required Map attributes}) { - try { - final payload = {TrackingConsts.attributes: attributes}; - methodChannel.invokeMethod(MethodConsts.setDeviceAttributes, payload); - } on PlatformException catch (exception) { - handleException(exception); - } - } - - void handleException(PlatformException exception) { - if (kDebugMode) { - print(exception); - } - } -} diff --git a/lib/customer_io_plugin_version.dart b/lib/customer_io_plugin_version.dart index 98d7f0f..e691fbf 100755 --- a/lib/customer_io_plugin_version.dart +++ b/lib/customer_io_plugin_version.dart @@ -1,2 +1,2 @@ // Don't modify this line - it's automatically updated -const version = "1.5.2"; +const version = "2.0.0"; diff --git a/lib/customer_io_const.dart b/lib/data_pipelines/_native_constants.dart similarity index 65% rename from lib/customer_io_const.dart rename to lib/data_pipelines/_native_constants.dart index b7dfa58..5605452 100644 --- a/lib/customer_io_const.dart +++ b/lib/data_pipelines/_native_constants.dart @@ -1,25 +1,26 @@ -class MethodConsts { - static const String initialize = "initialize"; - static const String identify = "identify"; +/// Methods specific to Data Pipelines module. +class NativeMethods { static const String clearIdentify = "clearIdentify"; - static const String track = "track"; - static const String trackMetric = "trackMetric"; + static const String identify = "identify"; + static const String initialize = "initialize"; static const String screen = "screen"; static const String setDeviceAttributes = "setDeviceAttributes"; static const String setProfileAttributes = "setProfileAttributes"; static const String registerDeviceToken = "registerDeviceToken"; - static const String onMessageReceived = "onMessageReceived"; - static const String dismissMessage = "dismissMessage"; + static const String track = "track"; + static const String trackMetric = "trackMetric"; } -class TrackingConsts { - static const String identifier = "identifier"; +/// Method parameters specific to DataPipelines module. +class NativeMethodParams { static const String attributes = "attributes"; - static const String eventName = "eventName"; - static const String token = "token"; 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"; + static const String name = "name"; + static const String properties = "properties"; + static const String title = "title"; + static const String token = "token"; + static const String traits = "traits"; + static const String userId = "userId"; } diff --git a/lib/data_pipelines/customer_io_method_channel.dart b/lib/data_pipelines/customer_io_method_channel.dart new file mode 100644 index 0000000..a0469ed --- /dev/null +++ b/lib/data_pipelines/customer_io_method_channel.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../customer_io_config.dart'; +import '../customer_io_enums.dart'; +import '../extensions/method_channel_extensions.dart'; +import '_native_constants.dart'; +import 'customer_io_platform_interface.dart'; + +/// An implementation of [CustomerIOPlatform] that uses method channels. +class CustomerIOMethodChannel extends CustomerIOPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('customer_io'); + + /// To initialize the plugin + @override + Future initialize({required CustomerIOConfig config}) { + return methodChannel.invokeNativeMethod( + NativeMethods.initialize, config.toMap()); + } + + /// Identify a person using a unique userId, eg. email id. + /// Note that you can identify only 1 profile at a time. In case, multiple + /// identifiers are attempted to be identified, then the last identified profile + /// will be removed automatically. + @override + void identify( + {required String userId, Map traits = const {}}) { + return methodChannel.invokeNativeMethodVoid(NativeMethods.identify, { + NativeMethodParams.userId: userId, + NativeMethodParams.traits: traits, + }); + } + + /// To track user events like loggedIn, addedItemToCart etc. + /// You may also track events with additional yet optional data. + @override + void track( + {required String name, Map properties = const {}}) { + return methodChannel.invokeNativeMethodVoid(NativeMethods.track, { + NativeMethodParams.name: name, + NativeMethodParams.properties: properties, + }); + } + + /// Track a push metric + @override + void trackMetric( + {required String deliveryID, + required String deviceToken, + required MetricEvent event}) { + return methodChannel.invokeNativeMethodVoid(NativeMethods.trackMetric, { + NativeMethodParams.deliveryId: deliveryID, + NativeMethodParams.deliveryToken: deviceToken, + NativeMethodParams.metricEvent: event.name, + }); + } + + /// Track screen events to record the screens a user visits + @override + void screen( + {required String title, Map properties = const {}}) { + return methodChannel.invokeNativeMethodVoid(NativeMethods.screen, { + NativeMethodParams.title: title, + NativeMethodParams.properties: properties, + }); + } + + /// Register a new device token with Customer.io, associated with the current active customer. If there + /// is no active customer, this will fail to register the device + @override + void registerDeviceToken({required String deviceToken}) { + return methodChannel + .invokeNativeMethodVoid(NativeMethods.registerDeviceToken, { + NativeMethodParams.token: deviceToken, + }); + } + + /// Call this function to stop identifying a person. + @override + void clearIdentify() { + return methodChannel.invokeNativeMethodVoid(NativeMethods.clearIdentify); + } + + /// Set custom user profile information such as user preference, specific + /// user actions etc + @override + void setProfileAttributes( + {required Map attributes}) { + return methodChannel + .invokeNativeMethodVoid(NativeMethods.setProfileAttributes, { + NativeMethodParams.attributes: attributes, + }); + } + + /// Use this function to send custom device attributes + /// such as app preferences, timezone etc + @override + void setDeviceAttributes({required Map attributes}) { + return methodChannel + .invokeNativeMethodVoid(NativeMethods.setDeviceAttributes, { + NativeMethodParams.attributes: attributes, + }); + } +} diff --git a/lib/customer_io_platform_interface.dart b/lib/data_pipelines/customer_io_platform_interface.dart similarity index 75% rename from lib/customer_io_platform_interface.dart rename to lib/data_pipelines/customer_io_platform_interface.dart index 2ab9be0..ad65102 100644 --- a/lib/customer_io_platform_interface.dart +++ b/lib/data_pipelines/customer_io_platform_interface.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'package:customer_io/customer_io_config.dart'; -import 'package:customer_io/customer_io_enums.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import 'customer_io_inapp.dart'; +import '../customer_io_config.dart'; +import '../customer_io_enums.dart'; import 'customer_io_method_channel.dart'; /// The default instance of [CustomerIOPlatform] to use @@ -35,8 +34,7 @@ abstract class CustomerIOPlatform extends PlatformInterface { } void identify( - {required String identifier, - Map attributes = const {}}) { + {required String userId, Map traits = const {}}) { throw UnimplementedError('identify() has not been implemented.'); } @@ -45,7 +43,7 @@ abstract class CustomerIOPlatform extends PlatformInterface { } void track( - {required String name, Map attributes = const {}}) { + {required String name, Map properties = const {}}) { throw UnimplementedError('track() has not been implemented.'); } @@ -61,7 +59,7 @@ abstract class CustomerIOPlatform extends PlatformInterface { } void screen( - {required String name, Map attributes = const {}}) { + {required String title, Map properties = const {}}) { throw UnimplementedError('screen() has not been implemented.'); } @@ -69,14 +67,9 @@ abstract class CustomerIOPlatform extends PlatformInterface { throw UnimplementedError('setDeviceAttributes() has not been implemented.'); } - void setProfileAttributes({required Map attributes}) { + void setProfileAttributes( + {required Map attributes}) { throw UnimplementedError( 'setProfileAttributes() has not been implemented.'); } - - StreamSubscription subscribeToInAppEventListener( - void Function(InAppEvent) onEvent) { - throw UnimplementedError( - 'subscribeToInAppEventListener() has not been implemented.'); - } } diff --git a/lib/extensions/map_extensions.dart b/lib/extensions/map_extensions.dart new file mode 100644 index 0000000..5b0c5a7 --- /dev/null +++ b/lib/extensions/map_extensions.dart @@ -0,0 +1,7 @@ +/// Extensions for [Map] class that provide additional functionality and convenience methods. +extension CustomerIOMapExtensions on Map { + /// Returns a new map with entries that have non-null values, excluding null values. + Map excludeNullValues() { + return Map.fromEntries(entries.where((entry) => entry.value != null)); + } +} diff --git a/lib/extensions/method_channel_extensions.dart b/lib/extensions/method_channel_extensions.dart new file mode 100644 index 0000000..67f9bd1 --- /dev/null +++ b/lib/extensions/method_channel_extensions.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +extension CustomerIOMethodChannelExtensions on MethodChannel { + /// Invokes a native method and returns the result. + /// Logs exceptions internally without propagating them. + Future invokeNativeMethod(String method, + [Map arguments = const {}]) async { + try { + return await invokeMethod(method, arguments); + } on PlatformException catch (ex) { + // Log the exception + if (kDebugMode) { + print("Error invoking native method '$method': ${ex.message}"); + } + // Return null on failure + return null; + } catch (ex) { + // Catch any other exceptions + if (kDebugMode) { + print("Unexpected error invoking native method '$method': $ex"); + } + // Return null on unexpected errors + return null; + } + } + + /// Simplifies invoking a native method that doesn't return a value. + void invokeNativeMethodVoid(String method, + [Map arguments = const {}]) { + invokeNativeMethod(method, arguments); + } +} diff --git a/lib/messaging_in_app/_native_constants.dart b/lib/messaging_in_app/_native_constants.dart new file mode 100644 index 0000000..fa2eb31 --- /dev/null +++ b/lib/messaging_in_app/_native_constants.dart @@ -0,0 +1,8 @@ +/// Methods specific to In-App module. +class NativeMethods { + static const String dismissMessage = "dismissMessage"; +} + +/// Method parameters specific to In-App module. +class NativeMethodParams { +} diff --git a/lib/messaging_in_app/method_channel.dart b/lib/messaging_in_app/method_channel.dart index cd5a046..5da6860 100644 --- a/lib/messaging_in_app/method_channel.dart +++ b/lib/messaging_in_app/method_channel.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import '../customer_io_const.dart'; +import '../customer_io_inapp.dart'; +import '../extensions/method_channel_extensions.dart'; +import '_native_constants.dart'; import 'platform_interface.dart'; /// An implementation of [CustomerIOMessagingInAppPlatform] that uses method @@ -11,19 +15,52 @@ class CustomerIOMessagingInAppMethodChannel /// The method channel used to interact with the native platform. @visibleForTesting final methodChannel = const MethodChannel('customer_io_messaging_in_app'); + final _inAppEventStreamController = StreamController.broadcast(); @override - void dismissMessage() async { - try { - methodChannel.invokeMethod(MethodConsts.dismissMessage); - } on PlatformException catch (e) { - handleException(e); - } + void dismissMessage() { + return methodChannel.invokeNativeMethodVoid(NativeMethods.dismissMessage); + } + + /// Method to subscribe to the In-App event listener. + /// + /// The `onEvent` function will be called whenever an In-App event occurs. + /// Returns a [StreamSubscription] object that can be used to unsubscribe from the stream. + @override + StreamSubscription subscribeToEventsListener( + void Function(InAppEvent) onEvent) { + StreamSubscription subscription = + _inAppEventStreamController.stream.listen(onEvent); + return subscription; + } + + CustomerIOMessagingInAppMethodChannel() { + methodChannel.setMethodCallHandler(_onMethodCall); } - void handleException(PlatformException exception) { - if (kDebugMode) { - print(exception); + /// Method call handler to handle events from native bindings + Future _onMethodCall(MethodCall call) async { + /// Cast the arguments to a map of strings to dynamic values. + final arguments = + (call.arguments as Map).cast(); + + switch (call.method) { + case "messageShown": + _inAppEventStreamController + .add(InAppEvent.fromMap(EventType.messageShown, arguments)); + break; + case "messageDismissed": + _inAppEventStreamController + .add(InAppEvent.fromMap(EventType.messageDismissed, arguments)); + break; + case "errorWithMessage": + _inAppEventStreamController + .add(InAppEvent.fromMap(EventType.errorWithMessage, arguments)); + break; + case "messageActionTaken": + _inAppEventStreamController + .add(InAppEvent.fromMap(EventType.messageActionTaken, arguments)); + break; } } } diff --git a/lib/messaging_in_app/platform_interface.dart b/lib/messaging_in_app/platform_interface.dart index b05daf7..701b7f2 100644 --- a/lib/messaging_in_app/platform_interface.dart +++ b/lib/messaging_in_app/platform_interface.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../customer_io_inapp.dart'; import 'method_channel.dart'; /// The default instance of [CustomerIOMessagingInAppPlatform] to use @@ -27,4 +30,10 @@ abstract class CustomerIOMessagingInAppPlatform extends PlatformInterface { void dismissMessage() { throw UnimplementedError('dismissMessage() has not been implemented.'); } + + StreamSubscription subscribeToEventsListener( + void Function(InAppEvent) onEvent) { + throw UnimplementedError( + 'subscribeToEventsListener() has not been implemented.'); + } } diff --git a/lib/messaging_push/_native_constants.dart b/lib/messaging_push/_native_constants.dart new file mode 100644 index 0000000..458b4be --- /dev/null +++ b/lib/messaging_push/_native_constants.dart @@ -0,0 +1,11 @@ +/// Methods specific to Push module. +class NativeMethods { + static const String getRegisteredDeviceToken = "getRegisteredDeviceToken"; + static const String onMessageReceived = "onMessageReceived"; +} + +/// Method parameters specific to Push module. +class NativeMethodParams { + static const String message = "message"; + static const String handleNotificationTrigger = "handleNotificationTrigger"; +} diff --git a/lib/messaging_push/method_channel.dart b/lib/messaging_push/method_channel.dart index ce8d5ad..cfc526a 100644 --- a/lib/messaging_push/method_channel.dart +++ b/lib/messaging_push/method_channel.dart @@ -3,7 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; -import '../customer_io_const.dart'; +import '../extensions/method_channel_extensions.dart'; +import '_native_constants.dart'; import 'platform_interface.dart'; /// An implementation of [CustomerIOMessagingPushPlatform] that uses method @@ -14,6 +15,12 @@ class CustomerIOMessagingPushMethodChannel @visibleForTesting final methodChannel = const MethodChannel('customer_io_messaging_push'); + @override + Future getRegisteredDeviceToken() { + return methodChannel + .invokeNativeMethod(NativeMethods.getRegisteredDeviceToken); + } + @override Future onMessageReceived(Map message, {bool handleNotificationTrigger = true}) { @@ -25,24 +32,10 @@ class CustomerIOMessagingPushMethodChannel 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); - } + return methodChannel + .invokeNativeMethod(NativeMethods.onMessageReceived, { + NativeMethodParams.message: message, + NativeMethodParams.handleNotificationTrigger: handleNotificationTrigger, + }).then((handled) => handled == true); } } diff --git a/lib/messaging_push/platform_interface.dart b/lib/messaging_push/platform_interface.dart index d3e1b0d..7c26929 100644 --- a/lib/messaging_push/platform_interface.dart +++ b/lib/messaging_push/platform_interface.dart @@ -24,6 +24,13 @@ abstract class CustomerIOMessagingPushPlatform extends PlatformInterface { _instance = instance; } + /// Method to get the device token registered with the Customer.io SDK. + /// Returns a [Future] that resolves to the device token registered with + /// Customer.io SDK. + Future getRegisteredDeviceToken() { + throw UnimplementedError('getRegisteredDeviceToken() has not been implemented.'); + } + /// Processes push notification received outside the CIO SDK. The method /// displays notification on device and tracks CIO metrics for push /// notification. diff --git a/pubspec.yaml b/pubspec.yaml index 062087b..769a8f8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ flutter: platforms: android: package: io.customer.customer_io - pluginClass: CustomerIoPlugin + pluginClass: CustomerIOPlugin ios: - pluginClass: CustomerIoPlugin + pluginClass: CustomerIOPlugin + native_sdk_version: 3.6.0 diff --git a/scripts/update-plugin.sh b/scripts/update-plugin.sh deleted file mode 100755 index 8a153c1..0000000 --- a/scripts/update-plugin.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -# Script that updates the pubspec.yaml file in the SDK to newest semantic version. -# -# Designed to be run from CI server or manually. -# -# Use script: ./scripts/update-plugin.sh "0.1.1" - -set -e - -NEW_VERSION="$1" - -echo "Updating files to new version: $NEW_VERSION" - -echo "Updating customer_io_plugin_version.dart" -# Given line: `const version = "1.0.0-alpha.4";` -# Regex string will match the line of the file that we can then substitute. -sd 'const version = "(.*)"' "const version = \"$NEW_VERSION\"" "./lib/customer_io_plugin_version.dart" - -echo "Check file, you should see version inside has been updated!" \ No newline at end of file diff --git a/scripts/update-version.sh b/scripts/update-version.sh index 99c98e5..7a3b2bb 100755 --- a/scripts/update-version.sh +++ b/scripts/update-version.sh @@ -10,12 +10,38 @@ set -e NEW_VERSION="$1" -echo "Updating files to new version: $NEW_VERSION" +echo "Starting version update to: $NEW_VERSION" -echo "Updating pubspec.yaml" -sd 'version: (.*)' "version: $NEW_VERSION" pubspec.yaml +# Helper function to update version in a file and display the diff +update_version_in_file() { + # Parameters: + # $1: file_path: The path to the file to update + # $2: pattern: The regex pattern to match the line to update + # $3: replacement: The new version to replace the matched line with + local file_path=$1 + local pattern=$2 + local replacement=$3 -echo "Check file, you should see version inside has been updated!" + echo -e "\nUpdating version in $file_path..." + sd "$pattern" "$replacement" "$file_path" -echo "Now, updating plugin...." -./scripts/update-plugin.sh "$NEW_VERSION" + echo "Done! Showing changes in $file_path:" + git diff "$file_path" +} + +# Update version in pubspec.yaml +# Given line: `version: 1.3.5` +# Note: We are using ^ to match the start of the line to avoid matching other lines with version in them. +# e.g. `native_sdk_version: 3.5.7` should not be matched by this regex. +update_version_in_file "pubspec.yaml" "^(version: .*)" "version: $NEW_VERSION" + +# Update version in customer_io_plugin_version.dart +# Given line: `const version = "1.3.5";` +update_version_in_file "./lib/customer_io_plugin_version.dart" "const version = \"(.*)\"" "const version = \"$NEW_VERSION\"" + +# Update version in customer_io_config.xml +SDK_CONFIG_CLIENT_VERSION_KEY="customer_io_wrapper_sdk_client_version" +# Given line: `1.3.5` +update_version_in_file "android/src/main/res/values/customer_io_config.xml" ".*" "$NEW_VERSION" + +echo -e "\nVersion update complete for targeted files." diff --git a/test/customer_io_config_test.dart b/test/customer_io_config_test.dart new file mode 100644 index 0000000..13c1165 --- /dev/null +++ b/test/customer_io_config_test.dart @@ -0,0 +1,212 @@ +import 'package:customer_io/config/customer_io_config.dart'; +import 'package:customer_io/config/in_app_config.dart'; +import 'package:customer_io/config/push_config.dart'; +import 'package:customer_io/customer_io_enums.dart'; +import 'package:customer_io/customer_io_plugin_version.dart' as plugin_info; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CustomerIOConfig', () { + test('should initialize with required parameters and default values', () { + final config = CustomerIOConfig(cdpApiKey: 'testApiKey'); + + expect(config.cdpApiKey, 'testApiKey'); + expect(config.migrationSiteId, isNull); + expect(config.region, isNull); + expect(config.logLevel, isNull); + expect(config.autoTrackDeviceAttributes, isNull); + expect(config.apiHost, isNull); + expect(config.cdnHost, isNull); + expect(config.flushAt, isNull); + expect(config.flushInterval, isNull); + + expect(config.inAppConfig, isNull); + + final pushConfig = config.pushConfig; + expect(pushConfig, isNotNull); + final pushConfigAndroid = pushConfig.pushConfigAndroid; + expect(pushConfigAndroid, isNotNull); + expect(pushConfigAndroid.pushClickBehavior, + PushClickBehaviorAndroid.activityPreventRestart); + + expect(config.source, 'Flutter'); + expect(config.version, plugin_info.version); + }); + + test('should initialize with all parameters', () { + final inAppConfig = InAppConfig(siteId: 'testSiteId'); + final pushConfig = PushConfig( + android: PushConfigAndroid( + pushClickBehavior: + PushClickBehaviorAndroid.activityPreventRestart)); + + final config = CustomerIOConfig( + cdpApiKey: 'testApiKey', + migrationSiteId: 'testMigrationSiteId', + region: Region.us, + logLevel: CioLogLevel.debug, + autoTrackDeviceAttributes: true, + apiHost: 'https://api.example.com', + cdnHost: 'https://cdn.example.com', + flushAt: 15, + flushInterval: 45, + inAppConfig: inAppConfig, + pushConfig: pushConfig, + ); + + expect(config.cdpApiKey, 'testApiKey'); + expect(config.migrationSiteId, 'testMigrationSiteId'); + expect(config.region, Region.us); + expect(config.logLevel, CioLogLevel.debug); + expect(config.autoTrackDeviceAttributes, isTrue); + expect(config.apiHost, 'https://api.example.com'); + expect(config.cdnHost, 'https://cdn.example.com'); + expect(config.flushAt, 15); + expect(config.flushInterval, 45); + expect(config.inAppConfig, inAppConfig); + expect(config.pushConfig, pushConfig); + expect(config.source, 'Flutter'); + expect(config.version, plugin_info.version); + }); + + test('should return correct map from toMap()', () { + final inAppConfig = InAppConfig(siteId: 'testSiteId'); + final pushConfig = PushConfig( + android: PushConfigAndroid( + pushClickBehavior: + PushClickBehaviorAndroid.activityPreventRestart)); + + final config = CustomerIOConfig( + cdpApiKey: 'testApiKey', + migrationSiteId: 'testMigrationSiteId', + region: Region.eu, + logLevel: CioLogLevel.info, + autoTrackDeviceAttributes: false, + trackApplicationLifecycleEvents: false, + apiHost: 'https://api.example.com', + cdnHost: 'https://cdn.example.com', + flushAt: 25, + flushInterval: 55, + inAppConfig: inAppConfig, + pushConfig: pushConfig, + ); + + final expectedMap = { + 'cdpApiKey': 'testApiKey', + 'migrationSiteId': 'testMigrationSiteId', + 'region': 'eu', + 'logLevel': 'info', + 'autoTrackDeviceAttributes': false, + 'trackApplicationLifecycleEvents': false, + 'apiHost': 'https://api.example.com', + 'cdnHost': 'https://cdn.example.com', + 'flushAt': 25, + 'flushInterval': 55, + 'inApp': inAppConfig.toMap(), + 'push': pushConfig.toMap(), + 'version': config.version, + 'source': config.source, + }; + + expect(config.toMap(), expectedMap); + }); + + test('should initialize default pushConfig when not provided', () { + final config = CustomerIOConfig(cdpApiKey: 'testApiKey'); + + expect(config.pushConfig.pushConfigAndroid.pushClickBehavior, + PushClickBehaviorAndroid.activityPreventRestart); + }); + }); + + group('CustomerIOConfig with Region', () { + for (var region in Region.values) { + test('should initialize with region $region and verify map value', () { + final config = CustomerIOConfig( + cdpApiKey: 'testApiKey', + region: region, + ); + + // Check initialization value + expect(config.region, region); + + // Verify only the region entry in toMap output + final map = config.toMap(); + expect(map['region'], region.name); + }); + } + }); + + group('CustomerIOConfig with LogLevel', () { + for (var logLevel in CioLogLevel.values) { + test('should initialize with log level $logLevel and verify map value', + () { + final config = CustomerIOConfig( + cdpApiKey: 'testApiKey', + logLevel: logLevel, + ); + + // Check initialization value + expect(config.logLevel, logLevel); + + // Verify only the logLevel entry in toMap output + final map = config.toMap(); + expect(map['logLevel'], logLevel.name); + }); + } + }); + + group('InAppConfig', () { + test('should return correct map from toMap()', () { + final inAppConfig = InAppConfig(siteId: 'testSiteId'); + final expectedMap = {'siteId': 'testSiteId'}; + + expect(inAppConfig.toMap(), expectedMap); + }); + }); + + group('PushConfig', () { + test('should initialize with default PushConfigAndroid', () { + final pushConfig = PushConfig(); + + expect(pushConfig.pushConfigAndroid, isNotNull); + expect(pushConfig.pushConfigAndroid.pushClickBehavior, + PushClickBehaviorAndroid.activityPreventRestart); + }); + + test('should return correct map from toMap()', () { + final pushConfig = PushConfig( + android: PushConfigAndroid( + pushClickBehavior: PushClickBehaviorAndroid.activityNoFlags, + ), + ); + + final expectedMap = { + 'android': { + 'pushClickBehavior': 'ACTIVITY_NO_FLAGS', + }, + }; + + expect(pushConfig.toMap(), expectedMap); + }); + }); + + group('PushConfigAndroid with PushClickBehaviorAndroid', () { + for (var pushClickBehavior in PushClickBehaviorAndroid.values) { + test( + 'should initialize with pushConfigAndroid $pushClickBehavior and verify map value', + () { + final config = PushConfigAndroid( + pushClickBehavior: pushClickBehavior, + ); + + // Check initialization value + expect(config.pushClickBehavior, pushClickBehavior); + + // Verify only the logLevel entry in toMap output + final map = config.toMap(); + expect(map['pushClickBehavior'], pushClickBehavior.rawValue); + }); + } + }); +} diff --git a/test/customer_io_method_channel_test.dart b/test/customer_io_method_channel_test.dart index aaf5e66..de459bc 100644 --- a/test/customer_io_method_channel_test.dart +++ b/test/customer_io_method_channel_test.dart @@ -1,6 +1,6 @@ import 'package:customer_io/customer_io_config.dart'; import 'package:customer_io/customer_io_enums.dart'; -import 'package:customer_io/customer_io_method_channel.dart'; +import 'package:customer_io/data_pipelines/customer_io_method_channel.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -52,36 +52,36 @@ void main() { test('initialize() should call platform method with correct arguments', () async { final customerIO = CustomerIOMethodChannel(); - final config = CustomerIOConfig(siteId: 'site_id', apiKey: 'api_key'); + final config = CustomerIOConfig(cdpApiKey: 'cdp_api_key'); await customerIO.initialize(config: config); expectMethodInvocationArguments( - 'initialize', {'siteId': config.siteId, 'apiKey': config.apiKey}); + 'initialize', {'cdpApiKey': config.cdpApiKey}); }); test('identify() should call platform method with correct arguments', () async { final Map args = { - 'identifier': 'Customer 1', - 'attributes': {'email': 'customer@email.com'} + 'userId': 'Customer 1', + 'traits': {'email': 'customer@email.com'} }; final customerIO = CustomerIOMethodChannel(); customerIO.identify( - identifier: args['identifier'] as String, - attributes: args['attributes']); + userId: args['userId'] as String, + traits: args['traits']); expectMethodInvocationArguments('identify', args); }); test('track() should call platform method with correct arguments', () async { final Map args = { - 'eventName': 'test_event', - 'attributes': {'eventData': 2} + 'name': 'test_event', + 'properties': {'eventData': 2} }; final customerIO = CustomerIOMethodChannel(); - customerIO.track(name: args['eventName'], attributes: args['attributes']); + customerIO.track(name: args['name'], properties: args['properties']); expectMethodInvocationArguments('track', args); }); @@ -91,7 +91,7 @@ void main() { final Map args = { 'deliveryId': '123', 'deliveryToken': 'asdf', - 'metricEvent': 'clicked' + 'metricEvent': 'opened' }; final customerIO = CustomerIOMethodChannel(); @@ -105,12 +105,12 @@ void main() { test('screen() should call platform method with correct arguments', () async { final Map args = { - 'eventName': 'screen_event', - 'attributes': {'screenName': '你好'} + 'title': 'screen_event', + 'properties': {'screenName': '你好'} }; final customerIO = CustomerIOMethodChannel(); - customerIO.screen(name: args['eventName'], attributes: args['attributes']); + customerIO.screen(title: args['title'], properties: args['properties']); expectMethodInvocationArguments('screen', args); }); diff --git a/test/customer_io_test.dart b/test/customer_io_test.dart index aba1676..667b40f 100644 --- a/test/customer_io_test.dart +++ b/test/customer_io_test.dart @@ -1,7 +1,7 @@ import 'package:customer_io/customer_io.dart'; import 'package:customer_io/customer_io_config.dart'; import 'package:customer_io/customer_io_enums.dart'; -import 'package:customer_io/customer_io_platform_interface.dart'; +import 'package:customer_io/data_pipelines/customer_io_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -22,8 +22,6 @@ class TestCustomerIoPlatform extends Mock } } -// The following test suite makes sure when any CustomerIO class method is called, -// the correct corresponding platform methods are called and with the correct arguments. @GenerateMocks([TestCustomerIoPlatform]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -32,186 +30,172 @@ void main() { late MockTestCustomerIoPlatform mockPlatform; setUp(() { + // Reset singleton state before each test + CustomerIO.reset(); + mockPlatform = MockTestCustomerIoPlatform(); CustomerIOPlatform.instance = mockPlatform; }); - // initialize - test('initialize() calls platform', () async { - final config = CustomerIOConfig(siteId: '123', apiKey: '456'); - await CustomerIO.initialize(config: config); + group('initialization', () { + test('throws when accessing instance before initialization', () { + expect(() => CustomerIO.instance, throwsStateError); + }); - verify(mockPlatform.initialize(config: config)).called(1); - }); + test('initialize() succeeds first time', () async { + final config = CustomerIOConfig(cdpApiKey: '123'); + await CustomerIO.initialize(config: config); + expect(() => CustomerIO.instance, isNot(throwsStateError)); + }); - test('initialize() correct arguments are passed', () async { - final givenConfig = CustomerIOConfig( - siteId: '123', - apiKey: '456', - region: Region.eu, - autoTrackPushEvents: false); - await CustomerIO.initialize(config: givenConfig); - expect( - verify(mockPlatform.initialize(config: captureAnyNamed("config"))) - .captured - .single, - givenConfig); - }); + test('subsequent initialize() calls are ignored', () async { + final config = CustomerIOConfig(cdpApiKey: '123'); - // identify - test('identify() calls platform', () { - const givenIdentifier = 'user@example.com'; - final givenAttributes = {'name': 'John Doe'}; - CustomerIO.identify( - identifier: givenIdentifier, attributes: givenAttributes); + // First initialization + await CustomerIO.initialize(config: config); + verify(mockPlatform.initialize(config: config)).called(1); - verify(mockPlatform.identify( - identifier: givenIdentifier, attributes: givenAttributes)) - .called(1); - }); + // Second initialization should be ignored + await CustomerIO.initialize(config: config); - test('identify() correct arguments are passed', () { - const givenIdentifier = 'user@example.com'; - final givenAttributes = {'name': 'John Doe'}; - CustomerIO.identify( - identifier: givenIdentifier, attributes: givenAttributes); - expect( - verify(mockPlatform.identify( - identifier: captureAnyNamed("identifier"), - attributes: captureAnyNamed("attributes"))) - .captured, - [givenIdentifier, givenAttributes]); - }); + // Platform initialize should still only be called once + verifyNever(mockPlatform.initialize(config: config)); + }); - // clearIdentify - test('clearIdentify() calls platform', () { - CustomerIO.clearIdentify(); - verify(mockPlatform.clearIdentify()).called(1); - }); + test('initialize() calls platform', () async { + final config = CustomerIOConfig(cdpApiKey: '123'); + await CustomerIO.initialize(config: config); + verify(mockPlatform.initialize(config: config)).called(1); + }); - // track - test('track() calls platform', () { - const name = 'itemAddedToCart'; - final attributes = {'item': 'shoes'}; - CustomerIO.track(name: name, attributes: attributes); - verify(mockPlatform.track(name: name, attributes: attributes)).called(1); - }); - - test('track() correct arguments are passed', () { - const name = 'itemAddedToCart'; - final givenAttributes = {'name': 'John Doe'}; - CustomerIO.track(name: name, attributes: givenAttributes); - expect( - verify(mockPlatform.track( - name: captureAnyNamed("name"), - attributes: captureAnyNamed("attributes"))) - .captured, - [name, givenAttributes]); - }); - - // trackMetric - test('trackMetric() calls platform', () { - const deliveryID = '123'; - const deviceToken = 'abc'; - const event = MetricEvent.opened; - CustomerIO.trackMetric( - deliveryID: deliveryID, deviceToken: deviceToken, event: event); - verify(mockPlatform.trackMetric( - deliveryID: deliveryID, deviceToken: deviceToken, event: event)) - .called(1); - }); - - test('trackMetric() correct arguments are passed', () { - const deliveryID = '123'; - const deviceToken = 'abc'; - const event = MetricEvent.opened; - CustomerIO.trackMetric( - deliveryID: deliveryID, deviceToken: deviceToken, event: event); - expect( - verify(mockPlatform.trackMetric( - deliveryID: captureAnyNamed("deliveryID"), - deviceToken: captureAnyNamed("deviceToken"), - event: captureAnyNamed("event"))) - .captured, - [deliveryID, deviceToken, event]); - }); - - // registerDeviceToken - test('registerDeviceToken() calls platform', () { - const deviceToken = 'token'; - CustomerIO.registerDeviceToken(deviceToken: deviceToken); - verify(mockPlatform.registerDeviceToken(deviceToken: deviceToken)) - .called(1); - }); - - test('registerDeviceToken() correct arguments are passed', () { - const deviceToken = 'token'; - CustomerIO.registerDeviceToken(deviceToken: deviceToken); - expect( - verify(mockPlatform.registerDeviceToken( - deviceToken: captureAnyNamed("deviceToken"))) + test('initialize() correct arguments are passed', () async { + final givenConfig = CustomerIOConfig( + cdpApiKey: '123', + migrationSiteId: '456', + region: Region.eu, + autoTrackDeviceAttributes: false, + ); + await CustomerIO.initialize(config: givenConfig); + expect( + verify(mockPlatform.initialize(config: captureAnyNamed("config"))) .captured - .first, - deviceToken); - }); - - // screen - test('screen() calls platform', () { - const name = 'home'; - final givenAttributes = {'user': 'John Doe'}; - CustomerIO.screen(name: name, attributes: givenAttributes); - verify(mockPlatform.screen(name: name, attributes: givenAttributes)) - .called(1); - }); - - test('screen() correct arguments are passed', () { - const name = 'itemAddedToCart'; - final givenAttributes = {'name': 'John Doe'}; - CustomerIO.screen(name: name, attributes: givenAttributes); - expect( + .single, + givenConfig, + ); + }); + }); + + group('methods requiring initialization', () { + late CustomerIOConfig config; + + setUp(() async { + config = CustomerIOConfig(cdpApiKey: '123'); + await CustomerIO.initialize(config: config); + }); + + test('identify() calls platform', () { + const givenIdentifier = 'user@example.com'; + final givenAttributes = {'name': 'John Doe'}; + CustomerIO.instance.identify( + userId: givenIdentifier, + traits: givenAttributes, + ); + + verify(mockPlatform.identify( + userId: givenIdentifier, + traits: givenAttributes, + )).called(1); + }); + + test('identify() correct arguments are passed', () { + const givenIdentifier = 'user@example.com'; + final givenAttributes = {'name': 'John Doe'}; + CustomerIO.instance.identify( + userId: givenIdentifier, + traits: givenAttributes, + ); + expect( + verify(mockPlatform.identify( + userId: captureAnyNamed("userId"), + traits: captureAnyNamed("traits"), + )).captured, + [givenIdentifier, givenAttributes], + ); + }); + + test('clearIdentify() calls platform', () { + CustomerIO.instance.clearIdentify(); + verify(mockPlatform.clearIdentify()).called(1); + }); + + test('track() calls platform', () { + const name = 'itemAddedToCart'; + final attributes = {'item': 'shoes'}; + CustomerIO.instance.track(name: name, properties: attributes); + verify(mockPlatform.track(name: name, properties: attributes)) + .called(1); + }); + + test('registerDeviceToken() calls platform', () { + const token = 'abc'; + CustomerIO.instance.registerDeviceToken(deviceToken: token); + verify(mockPlatform.registerDeviceToken(deviceToken: token)).called(1); + }); + + test('track() correct arguments are passed', () { + const name = 'itemAddedToCart'; + final givenAttributes = {'name': 'John Doe'}; + CustomerIO.instance.track(name: name, properties: givenAttributes); + expect( + verify(mockPlatform.track( + name: captureAnyNamed("name"), + properties: captureAnyNamed("properties"), + )).captured, + [name, givenAttributes], + ); + }); + + test('screen() correct arguments are passed', () { + const title = 'checkout'; + final givenProperties = {'source': 'push'}; + CustomerIO.instance.screen(title: title, properties: givenProperties); + expect( verify(mockPlatform.screen( - name: captureAnyNamed("name"), - attributes: captureAnyNamed("attributes"))) - .captured, - [name, givenAttributes]); - }); - - // setDeviceAttributes - test('setDeviceAttributes() calls platform', () { - final givenAttributes = {'area': 'US'}; - CustomerIO.setDeviceAttributes(attributes: givenAttributes); - verify(mockPlatform.setDeviceAttributes(attributes: givenAttributes)) - .called(1); - }); - - test('setDeviceAttributes() correct arguments are passed', () { - final givenAttributes = {'area': 'US'}; - CustomerIO.setDeviceAttributes(attributes: givenAttributes); - expect( - verify(mockPlatform.setDeviceAttributes( - attributes: captureAnyNamed("attributes"))) - .captured - .first, - givenAttributes); - }); - - // setProfileAttributes - test('setProfileAttributes() calls platform', () { - final givenAttributes = {'age': 10}; - CustomerIO.setProfileAttributes(attributes: givenAttributes); - verify(mockPlatform.setProfileAttributes(attributes: givenAttributes)) - .called(1); - }); - - test('setProfileAttributes() correct arguments are passed', () { - final givenAttributes = {'age': 10}; - CustomerIO.setProfileAttributes(attributes: givenAttributes); - expect( + title: captureAnyNamed("title"), + properties: captureAnyNamed("properties"), + )).captured, + [title, givenProperties], + ); + }); + + test('trackMetric() calls platform', () { + const deliveryID = '123'; + const deviceToken = 'abc'; + const event = MetricEvent.opened; + CustomerIO.instance.trackMetric( + deliveryID: deliveryID, + deviceToken: deviceToken, + event: event, + ); + verify(mockPlatform.trackMetric( + deliveryID: deliveryID, + deviceToken: deviceToken, + event: event, + )).called(1); + }); + + // ... rest of the existing tests, but moved inside this group ... + + test('setProfileAttributes() correct arguments are passed', () { + final givenAttributes = {'age': 10}; + CustomerIO.instance.setProfileAttributes(attributes: givenAttributes); + expect( verify(mockPlatform.setProfileAttributes( - attributes: captureAnyNamed("attributes"))) - .captured - .first, - givenAttributes); + attributes: captureAnyNamed("attributes"), + )).captured.first, + givenAttributes, + ); + }); }); }); } diff --git a/test/customer_io_test.mocks.dart b/test/customer_io_test.mocks.dart index b745f01..051a2d2 100644 --- a/test/customer_io_test.mocks.dart +++ b/test/customer_io_test.mocks.dart @@ -7,7 +7,6 @@ import 'dart:async' as _i2; import 'package:customer_io/customer_io_config.dart' as _i4; import 'package:customer_io/customer_io_enums.dart' as _i5; -import 'package:customer_io/customer_io_inapp.dart' as _i6; import 'package:mockito/mockito.dart' as _i1; import 'customer_io_test.dart' as _i3; @@ -23,17 +22,6 @@ import 'customer_io_test.dart' as _i3; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class -class _FakeStreamSubscription_0 extends _i1.SmartFake - implements _i2.StreamSubscription { - _FakeStreamSubscription_0( - Object parent, - Invocation parentInvocation, - ) : super( - parent, - parentInvocation, - ); -} - /// A class which mocks [TestCustomerIoPlatform]. /// /// See the documentation for Mockito's code generation for more information. @@ -56,32 +44,32 @@ class MockTestCustomerIoPlatform extends _i1.Mock ) as _i2.Future); @override void identify({ - required String? identifier, - Map? attributes = const {}, + required String? userId, + Map? traits = const {}, }) => super.noSuchMethod( Invocation.method( #identify, [], { - #identifier: identifier, - #attributes: attributes, + #userId: userId, + #traits: traits, }, ), returnValueForMissingStub: null, ); @override void clearIdentify() => super.noSuchMethod( - Invocation.method( - #clearIdentify, - [], - ), - returnValueForMissingStub: null, - ); + Invocation.method( + #clearIdentify, + [], + ), + returnValueForMissingStub: null, + ); @override void track({ required String? name, - Map? attributes = const {}, + Map? properties = const {}, }) => super.noSuchMethod( Invocation.method( @@ -89,7 +77,7 @@ class MockTestCustomerIoPlatform extends _i1.Mock [], { #name: name, - #attributes: attributes, + #properties: properties, }, ), returnValueForMissingStub: null, @@ -124,16 +112,16 @@ class MockTestCustomerIoPlatform extends _i1.Mock ); @override void screen({ - required String? name, - Map? attributes = const {}, + required String? title, + Map? properties = const {}, }) => super.noSuchMethod( Invocation.method( #screen, [], { - #name: name, - #attributes: attributes, + #title: title, + #properties: properties, }, ), returnValueForMissingStub: null, @@ -158,20 +146,4 @@ class MockTestCustomerIoPlatform extends _i1.Mock ), returnValueForMissingStub: null, ); - @override - _i2.StreamSubscription subscribeToInAppEventListener( - void Function(_i6.InAppEvent)? onEvent) => - (super.noSuchMethod( - Invocation.method( - #subscribeToInAppEventListener, - [onEvent], - ), - returnValue: _FakeStreamSubscription_0( - this, - Invocation.method( - #subscribeToInAppEventListener, - [onEvent], - ), - ), - ) as _i2.StreamSubscription); }