From c79d15d4ecfd6f3545796500cba8611d87582a93 Mon Sep 17 00:00:00 2001 From: xiaoweii Date: Fri, 20 Oct 2023 09:09:52 +0800 Subject: [PATCH] feat: finish swift SDK integrate. --- .gitmodules | 3 + .../ClickstreamFlutterPlugin.kt | 246 ++++++++++++++++-- example/android/app/build.gradle | 1 + .../app/FlutterMultiDexApplication.java | 12 +- .../plugin_integration_test.dart | 11 +- example/ios/Podfile.lock | 24 +- example/ios/Runner.xcodeproj/project.pbxproj | 4 +- example/ios/Runner/Info.plist | 5 + example/lib/main.dart | 128 +++++++-- .../Assets}/amplifyconfiguration.json | 0 ios/Classes/ClickstreamFlutterPlugin.swift | 165 +++++++++++- ios/Clickstream | 1 + ios/amplifyconfiguration.json | 15 ++ ios/clickstream_flutter.podspec | 11 +- lib/clickstream_flutter.dart | 84 +++++- lib/clickstream_flutter_method_channel.dart | 44 +++- ...lickstream_flutter_platform_interface.dart | 48 +++- ...ickstream_flutter_method_channel_test.dart | 104 +++++++- test/clickstream_flutter_test.dart | 112 ++++++-- 19 files changed, 900 insertions(+), 118 deletions(-) create mode 100644 .gitmodules rename {android/src/main/res/raw => ios/Assets}/amplifyconfiguration.json (100%) create mode 160000 ios/Clickstream create mode 100644 ios/amplifyconfiguration.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6bae918 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ios/Clickstream"] + path = ios/Clickstream + url = https://github.com/awslabs/clickstream-swift diff --git a/android/src/main/kotlin/software/aws/solution/clickstream_flutter/ClickstreamFlutterPlugin.kt b/android/src/main/kotlin/software/aws/solution/clickstream_flutter/ClickstreamFlutterPlugin.kt index ebb1b2f..1adc22b 100644 --- a/android/src/main/kotlin/software/aws/solution/clickstream_flutter/ClickstreamFlutterPlugin.kt +++ b/android/src/main/kotlin/software/aws/solution/clickstream_flutter/ClickstreamFlutterPlugin.kt @@ -1,6 +1,11 @@ package software.aws.solution.clickstream_flutter import android.app.Activity +import com.amazonaws.logging.Log +import com.amazonaws.logging.LogFactory +import com.amplifyframework.AmplifyException +import com.amplifyframework.core.Amplify +import com.amplifyframework.core.AmplifyConfiguration import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -8,7 +13,16 @@ 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 org.json.JSONObject +import software.aws.solution.clickstream.AWSClickstreamPlugin import software.aws.solution.clickstream.ClickstreamAnalytics +import software.aws.solution.clickstream.ClickstreamAttribute +import software.aws.solution.clickstream.ClickstreamEvent +import software.aws.solution.clickstream.ClickstreamUserAttribute +import software.aws.solution.clickstream.client.util.ThreadUtil +import java.util.Objects +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors /** ClickstreamFlutterPlugin */ @@ -17,6 +31,12 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware /// /// 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 val cachedThreadPool: ExecutorService by lazy { Executors.newCachedThreadPool() } + + private val log: Log = LogFactory.getLog( + ClickstreamFlutterPlugin::class.java + ) private lateinit var channel: MethodChannel private var mActivity: Activity? = null @@ -27,26 +47,222 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware } override fun onMethodCall(call: MethodCall, result: Result) { - if (call.method == "getPlatformVersion") { - result.success("Android ${android.os.Build.VERSION.RELEASE}") - } else if (call.method == "init") { - if (mActivity != null) { - ClickstreamAnalytics.init(mActivity!!.applicationContext) - ClickstreamAnalytics.getClickStreamConfiguration() - .withAppId("shopping") - .withEndpoint("http://Clicks-Inges-m6f4WJ0DDSWv-478806672.us-east-1.elb.amazonaws.com/collect") - .withLogEvents(true) - result.success(true) - } else { - result.success(false) + val arguments = call.arguments() as HashMap? + when (call.method) { + "init" -> { + result.success(initSDK(arguments!!)) + } + + "record" -> { + recordEvent(arguments) + } + + "setUserId" -> { + setUserId(arguments) + } + + "setUserAttributes" -> { + setUserAttributes(arguments) + } + + "setGlobalAttributes" -> { + setGlobalAttributes(arguments) + } + + "deleteGlobalAttributes" -> { + deleteGlobalAttributes(arguments) + } + + "updateConfigure" -> { + updateConfigure(arguments) } - } else if (call.method == "record") { - ClickstreamAnalytics.recordEvent(call.arguments.toString()) + + "flushEvents" -> { + ClickstreamAnalytics.flushEvents() + } + + else -> { + result.notImplemented() + } + } + } + + + private fun initSDK(arguments: HashMap): Boolean { + if (getIsInitialized()) return false + if (mActivity != null) { + val context = mActivity!!.applicationContext + if (ThreadUtil.notInMainThread()) { + log.error("Clickstream SDK initialization failed, please initialize in the main thread") + return false + } + val amplifyObject = JSONObject() + val analyticsObject = JSONObject() + val pluginsObject = JSONObject() + val awsClickstreamPluginObject = JSONObject() + awsClickstreamPluginObject.put("appId", arguments["appId"]) + awsClickstreamPluginObject.put("endpoint", arguments["endpoint"]) + pluginsObject.put("awsClickstreamPlugin", awsClickstreamPluginObject) + analyticsObject.put("plugins", pluginsObject) + amplifyObject.put("analytics", analyticsObject) + val configure = AmplifyConfiguration.fromJson(amplifyObject) + try { + Amplify.addPlugin(AWSClickstreamPlugin(context)) + Amplify.configure(configure, context) + } catch (exception: AmplifyException) { + log.error("Clickstream SDK initialization failed with error: " + exception.message) + return false + } + val sessionTimeoutDuration = arguments["sessionTimeoutDuration"] + .let { (it as? Int)?.toLong() ?: (it as Long) } + val sendEventsInterval = arguments["sendEventsInterval"] + .let { (it as? Int)?.toLong() ?: (it as Long) } + ClickstreamAnalytics.getClickStreamConfiguration() + .withLogEvents(arguments["isLogEvents"] as Boolean) + .withTrackScreenViewEvents(arguments["isTrackScreenViewEvents"] as Boolean) + .withTrackUserEngagementEvents(arguments["isTrackUserEngagementEvents"] as Boolean) + .withTrackAppExceptionEvents(arguments["isTrackAppExceptionEvents"] as Boolean) + .withSendEventsInterval(sendEventsInterval) + .withSessionTimeoutDuration(sessionTimeoutDuration) + .withCompressEvents(arguments["isCompressEvents"] as Boolean) + .withAuthCookie(arguments["authCookie"] as String) + return true } else { - result.notImplemented() + return false + } + } + + private fun recordEvent(arguments: HashMap?) { + cachedThreadPool.execute { + arguments?.let { + val eventName = it["eventName"] as String + val attributes = it["attributes"] as HashMap<*, *> + val eventBuilder = ClickstreamEvent.builder().name(eventName) + for ((key, value) in attributes) { + if (value is String) { + eventBuilder.add(key.toString(), value) + } else if (value is Double) { + eventBuilder.add(key.toString(), value) + } else if (value is Boolean) { + eventBuilder.add(key.toString(), value) + } else if (value is Int) { + eventBuilder.add(key.toString(), value) + } else if (value is Long) { + eventBuilder.add(key.toString(), value) + } + } + ClickstreamAnalytics.recordEvent(eventBuilder.build()) + } + } + } + + + private fun setUserId(arguments: java.util.HashMap?) { + arguments?.let { + val userId = arguments["userId"] + if (userId == null) { + ClickstreamAnalytics.setUserId(null) + } else { + ClickstreamAnalytics.setUserId(userId.toString()) + } + } + } + + private fun setUserAttributes(arguments: java.util.HashMap?) { + arguments?.let { + val builder = ClickstreamUserAttribute.Builder() + for ((key, value) in arguments) { + if (value is String) { + builder.add(key, value) + } else if (value is Double) { + builder.add(key, value) + } else if (value is Boolean) { + builder.add(key, value) + } else if (value is Int) { + builder.add(key, value) + } else if (value is Long) { + builder.add(key, value) + } + } + ClickstreamAnalytics.addUserAttributes(builder.build()) } } + private fun setGlobalAttributes(arguments: java.util.HashMap?) { + arguments?.let { + val builder = ClickstreamAttribute.Builder() + for ((key, value) in arguments) { + if (value is String) { + builder.add(key, value) + } else if (value is Double) { + builder.add(key, value) + } else if (value is Boolean) { + builder.add(key, value) + } else if (value is Int) { + builder.add(key, value) + } else if (value is Long) { + builder.add(key, value) + } + } + ClickstreamAnalytics.addGlobalAttributes(builder.build()) + } + } + + private fun deleteGlobalAttributes(arguments: java.util.HashMap?) { + arguments?.let { + @Suppress("UNCHECKED_CAST") + val attributes = arguments["attributes"] as ArrayList + ClickstreamAnalytics.deleteGlobalAttributes(*attributes.toTypedArray()) + } + } + + private fun updateConfigure(arguments: java.util.HashMap?) { + arguments?.let { + val configure = ClickstreamAnalytics.getClickStreamConfiguration() + arguments["appId"]?.let { + configure.withAppId(it as String) + } + arguments["endpoint"]?.let { + configure.withEndpoint(it as String) + } + arguments["isLogEvents"]?.let { + configure.withLogEvents(it as Boolean) + } + arguments["isTrackScreenViewEvents"]?.let { + configure.withTrackScreenViewEvents(it as Boolean) + } + arguments["isTrackUserEngagementEvents"]?.let { + configure.withTrackUserEngagementEvents(it as Boolean) + } + arguments["isTrackAppExceptionEvents"]?.let { + configure.withTrackAppExceptionEvents(it as Boolean) + } + arguments["sessionTimeoutDuration"]?.let { + val sessionTimeoutDuration = arguments["sessionTimeoutDuration"] + .let { (it as? Int)?.toLong() ?: (it as Long) } + configure.withSessionTimeoutDuration(sessionTimeoutDuration) + } + arguments["isCompressEvents"]?.let { + configure.withCompressEvents(it as Boolean) + } + arguments["authCookie"]?.let { + configure.withAuthCookie(it as String) + } + } + } + + private fun getIsInitialized(): Boolean { + return invokeSuperMethod(Amplify.Analytics, "isConfigured") as Boolean + } + + @Throws(Exception::class) + fun invokeSuperMethod(`object`: Any, methodName: String): Any? { + val method = + Objects.requireNonNull(`object`.javaClass.superclass).getDeclaredMethod(methodName) + method.isAccessible = true + return method.invoke(`object`) + } + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 52a1ddc..f44b255 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -49,6 +49,7 @@ android { targetSdkVersion flutter.targetSdkVersion versionCode flutterVersionCode.toInteger() versionName flutterVersionName + multiDexEnabled true } buildTypes { diff --git a/example/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java b/example/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java index 752fc18..6c5d303 100644 --- a/example/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java +++ b/example/android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java @@ -10,16 +10,14 @@ import android.app.Application; import android.content.Context; import androidx.annotation.CallSuper; -import androidx.multidex.MultiDex; /** * Extension of {@link android.app.Application}, adding multidex support. */ public class FlutterMultiDexApplication extends Application { - @Override - @CallSuper - protected void attachBaseContext(Context base) { - super.attachBaseContext(base); - MultiDex.install(this); - } + @Override + @CallSuper + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + } } diff --git a/example/integration_test/plugin_integration_test.dart b/example/integration_test/plugin_integration_test.dart index 9a3690f..953f468 100644 --- a/example/integration_test/plugin_integration_test.dart +++ b/example/integration_test/plugin_integration_test.dart @@ -15,11 +15,10 @@ import 'package:clickstream_flutter/clickstream_flutter.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - testWidgets('getPlatformVersion test', (WidgetTester tester) async { - final ClickstreamFlutter plugin = ClickstreamFlutter(); - final String? version = await plugin.getPlatformVersion(); - // The version string depends on the host platform running the test, so - // just assert that some non-empty string is returned. - expect(version?.isNotEmpty, true); + testWidgets('test init SDK success', (WidgetTester tester) async { + final ClickstreamAnalytics analytics = ClickstreamAnalytics(); + final bool result = await analytics.init( + appId: "testAppId", endpoint: "https://example.com/collect"); + expect(result, true); }); } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index fb5dd2e..e74f0d5 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,15 +1,34 @@ PODS: + - Amplify (1.30.3): + - Amplify/Default (= 1.30.3) + - Amplify/Default (1.30.3) - clickstream_flutter (0.0.1): + - clickstream_flutter/Clickstream (= 0.0.1) - Flutter + - clickstream_flutter/Clickstream (0.0.1): + - Amplify (= 1.30.3) + - Flutter + - GzipSwift (= 5.1.1) + - SQLite.swift (= 0.13.2) - Flutter (1.0.0) + - GzipSwift (5.1.1) - integration_test (0.0.1): - Flutter + - SQLite.swift (0.13.2): + - SQLite.swift/standard (= 0.13.2) + - SQLite.swift/standard (0.13.2) DEPENDENCIES: - clickstream_flutter (from `.symlinks/plugins/clickstream_flutter/ios`) - Flutter (from `Flutter`) - integration_test (from `.symlinks/plugins/integration_test/ios`) +SPEC REPOS: + trunk: + - Amplify + - GzipSwift + - SQLite.swift + EXTERNAL SOURCES: clickstream_flutter: :path: ".symlinks/plugins/clickstream_flutter/ios" @@ -19,9 +38,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" SPEC CHECKSUMS: - clickstream_flutter: 2df0c1ea137129668289dc8ab4d4a1394ee246b9 + Amplify: 516e5da5f256f62841b6bc659e1644bc999d7b6e + clickstream_flutter: d5dd3ad04cb641b27c1014c23b4cdbc98f6956dc Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa integration_test: 13825b8a9334a850581300559b8839134b124670 + SQLite.swift: 4fc2be46c36392e3b87afe6fe7f1801c1daa07ef PODFILE CHECKSUM: 70d9d25280d0dd177a5f637cdb0f0b0b12c6a189 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index d518381..f6eb55d 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -105,7 +105,6 @@ BE1462DFA5ED46118019E3B4 /* Pods-RunnerTests.release.xcconfig */, 6BFEB1AFE6CE2C78605A0A8C /* Pods-RunnerTests.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -471,6 +470,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -649,6 +649,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -671,6 +672,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index b111518..4a5ecce 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/example/lib/main.dart b/example/lib/main.dart index 07721f2..22777b5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -16,21 +16,13 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - String _platformVersion = 'Unknown'; - final analytics = ClickstreamFlutter(); + String testEndpoint = "https://example.com/collect"; + final analytics = ClickstreamAnalytics(); @override void initState() { super.initState(); initPlatformState(); - initSDK(); - } - - Future initSDK() async { - bool initResult = await analytics.init(); - if (initResult) { - analytics.record("testEvent"); - } } // Platform messages are asynchronous, so we initialize in an async method. @@ -39,8 +31,7 @@ class _MyAppState extends State { // Platform messages may fail, so we use a try/catch PlatformException. // We also handle the message potentially returning null. try { - platformVersion = await analytics.getPlatformVersion() ?? - 'Unknown platform version'; + platformVersion = 'Unknown platform version'; } on PlatformException { platformVersion = 'Failed to get platform version.'; } @@ -49,10 +40,6 @@ class _MyAppState extends State { // message was in flight, we want to discard the reply rather than calling // setState to update our non-existent appearance. if (!mounted) return; - - setState(() { - _platformVersion = platformVersion; - }); } @override @@ -60,10 +47,113 @@ class _MyAppState extends State { return MaterialApp( home: Scaffold( appBar: AppBar( - title: const Text('Plugin example app'), + title: const Text('Clickstream Flutter SDK API'), ), - body: Center( - child: Text('Running on: $_platformVersion\n'), + body: ListView( + children: [ + ListTile( + leading: const Icon(Icons.not_started_outlined), + title: const Text('initSDK'), + onTap: () async { + var result = await analytics.init( + appId: "shopping", + endpoint: testEndpoint, + isLogEvents: true, + isCompressEvents: false); + print("init SDK result is:$result"); + }, + minLeadingWidth: 0, + ), + ListTile( + leading: const Icon(Icons.touch_app), + title: const Text('recordEvent'), + onTap: () async { + analytics.record(name: "testEventWithName"); + analytics.record(name: "testEvent", attributes: { + "category": 'shoes', + "currency": 'CNY', + "intValue": 13, + "longValue": 9999999913991919, + "doubleValue": 11.1234567890121213, + "boolValue": true, + "value": 279.9 + }); + print("recorded testEvent and testEventWithName"); + }, + minLeadingWidth: 0, + ), + ListTile( + leading: const Icon(Icons.account_circle), + title: const Text('setUserId'), + onTap: () async { + analytics.setUserId("12345"); + analytics.setUserId(null); + print("setUserId"); + }, + minLeadingWidth: 0, + ), + ListTile( + leading: const Icon(Icons.manage_accounts), + title: const Text('setUserAttributes'), + onTap: () async { + analytics.setUserAttributes( + {"category": 'shoes', "currency": 'CNY', "value": 279.9}); + analytics.setUserAttributes({}); + analytics.setUserAttributes({"testNull": null}); + print("setUserAttributes"); + }, + minLeadingWidth: 0, + ), + ListTile( + leading: const Icon(Icons.add_circle), + title: const Text('addGlobalAttributes'), + onTap: () async { + analytics.addGlobalAttributes({ + "_channel": "Samsung", + "Class": 5, + "isTrue": true, + "Score": 24.32 + }); + print("addGlobalAttributes"); + }, + minLeadingWidth: 0, + ), + ListTile( + leading: const Icon(Icons.delete_rounded), + title: const Text('deleteGlobalAttributes'), + onTap: () async { + analytics.deleteGlobalAttributes(["Score", "_channel"]); + print("deleteGlobalAttributes Score and _channel"); + }, + minLeadingWidth: 0, + ), + ListTile( + leading: const Icon(Icons.update), + title: const Text('updateConfigure'), + onTap: () async { + analytics.updateConfigure( + isLogEvents: true, + isCompressEvents: false, + sessionTimeoutDuration: 100000, + isTrackUserEngagementEvents: false, + isTrackAppExceptionEvents: false, + authCookie: "test cookie", + isTrackScreenViewEvents: false); + analytics.updateConfigure(); + print("updateConfigure"); + }, + minLeadingWidth: 0, + ), + ListTile( + leading: const Icon(Icons.send), + title: const Text('flushEvents'), + onTap: () async { + analytics.flushEvents(); + print("flushEvents"); + }, + minLeadingWidth: 0, + ), + ], ), ), ); diff --git a/android/src/main/res/raw/amplifyconfiguration.json b/ios/Assets/amplifyconfiguration.json similarity index 100% rename from android/src/main/res/raw/amplifyconfiguration.json rename to ios/Assets/amplifyconfiguration.json diff --git a/ios/Classes/ClickstreamFlutterPlugin.swift b/ios/Classes/ClickstreamFlutterPlugin.swift index 2b1b25e..f78a6a6 100644 --- a/ios/Classes/ClickstreamFlutterPlugin.swift +++ b/ios/Classes/ClickstreamFlutterPlugin.swift @@ -1,19 +1,156 @@ +import Amplify import Flutter import UIKit public class ClickstreamFlutterPlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "clickstream_flutter", binaryMessenger: registrar.messenger()) - let instance = ClickstreamFlutterPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "getPlatformVersion": - result("iOS " + UIDevice.current.systemVersion) - default: - result(FlutterMethodNotImplemented) - } - } + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "clickstream_flutter", binaryMessenger: registrar.messenger()) + let instance = ClickstreamFlutterPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + case "init": + result(initSDK(call.arguments as! [String: Any])) + case "record": + recordEvent(call.arguments as! [String: Any]) + case "setUserId": + setUserId(call.arguments as! [String: Any]) + case "setUserAttributes": + setUserAttributes(call.arguments as! [String: Any]) + case "addGlobalAttributes": + addGlobalAttributes(call.arguments as! [String: Any]) + case "deleteGlobalAttributes": + deleteGlobalAttributes(call.arguments as! [String: Any]) + case "updateConfigure": + updateConfigure(call.arguments as! [String: Any]) + case "flushEvents": + ClickstreamAnalytics.flushEvents() + default: + result(FlutterMethodNotImplemented) + } + } + + func initSDK(_ arguments: [String: Any]) -> Bool { + do { + let plugins: [String: JSONValue] = [ + "awsClickstreamPlugin": [ + "appId": JSONValue.string(arguments["appId"] as! String), + "endpoint": JSONValue.string(arguments["endpoint"] as! String), + "isCompressEvents": JSONValue.boolean(arguments["isCompressEvents"] as! Bool), + "autoFlushEventsInterval": JSONValue.number(arguments["sendEventsInterval"] as! Double), + "isTrackAppExceptionEvents": JSONValue.boolean(arguments["isTrackAppExceptionEvents"] as! Bool) + ] + ] + let analyticsConfiguration = AnalyticsCategoryConfiguration(plugins: plugins) + let config = AmplifyConfiguration(analytics: analyticsConfiguration) + try Amplify.add(plugin: AWSClickstreamPlugin()) + try Amplify.configure(config) + let configure = try ClickstreamAnalytics.getClickstreamConfiguration() + configure.isLogEvents = arguments["isLogEvents"] as! Bool + configure.isTrackScreenViewEvents = arguments["isTrackScreenViewEvents"] as! Bool + configure.isTrackUserEngagementEvents = arguments["isTrackUserEngagementEvents"] as! Bool + configure.sessionTimeoutDuration = arguments["sessionTimeoutDuration"] as! Int64 + configure.authCookie = arguments["authCookie"] as? String + return true + } catch { + log.error("Fail to initialize ClickstreamAnalytics: \(error)") + return false + } + } + + func recordEvent(_ arguments: [String: Any]) { + let eventName = arguments["eventName"] as! String + let attributes = arguments["attributes"] as! [String: Any] + if attributes.count > 0 { + ClickstreamAnalytics.recordEvent(eventName, getClickstreamAttributes(attributes)) + } else { + ClickstreamAnalytics.recordEvent(eventName) + } + } + + func setUserId(_ arguments: [String: Any]) { + if arguments["userId"] is NSNull { + ClickstreamAnalytics.setUserId(nil) + } else { + ClickstreamAnalytics.setUserId(arguments["userId"] as? String) + } + } + + func setUserAttributes(_ arguments: [String: Any]) { + ClickstreamAnalytics.addUserAttributes(getClickstreamAttributes(arguments)) + } + + func addGlobalAttributes(_ arguments: [String: Any]) { + ClickstreamAnalytics.addGlobalAttributes(getClickstreamAttributes(arguments)) + } + + func deleteGlobalAttributes(_ arguments: [String: Any]) { + let attributes = arguments["attributes"] as! [String] + for attribute in attributes { + ClickstreamAnalytics.deleteGlobalAttributes(attribute) + } + } + + func updateConfigure(_ arguments: [String: Any]) { + do { + let configure = try ClickstreamAnalytics.getClickstreamConfiguration() + if let appId = arguments["appId"] as? String { + configure.appId = appId + } + if let endpoint = arguments["endpoint"] as? String { + configure.endpoint = endpoint + } + if let isLogEvents = arguments["isLogEvents"] as? Bool { + configure.isLogEvents = isLogEvents + } + if let isTrackScreenViewEvents = arguments["isTrackScreenViewEvents"] as? Bool { + configure.isTrackScreenViewEvents = isTrackScreenViewEvents + } + if let isTrackUserEngagementEvents = arguments["isTrackUserEngagementEvents"] as? Bool { + configure.isTrackUserEngagementEvents = isTrackUserEngagementEvents + } + if let isTrackAppExceptionEvents = arguments["isTrackAppExceptionEvents"] as? Bool { + configure.isTrackAppExceptionEvents = isTrackAppExceptionEvents + } + if let sessionTimeoutDuration = arguments["sessionTimeoutDuration"] as? Int64 { + configure.sessionTimeoutDuration = sessionTimeoutDuration + } + if let isCompressEvents = arguments["isCompressEvents"] as? Bool { + configure.isCompressEvents = isCompressEvents + } + if let authCookie = arguments["authCookie"] as? String { + configure.authCookie = authCookie + } + } catch { + log.error("Failed to config ClickstreamAnalytics: \(error)") + } + } + + func getClickstreamAttributes(_ attrs: [String: Any]) -> ClickstreamAttribute { + var attributes: ClickstreamAttribute = [:] + for (key, value) in attrs { + if value is String { + attributes[key] = value as! String + } else if value is NSNumber { + let value = value as! NSNumber + let objCType = String(cString: value.objCType) + if objCType == "c" { + attributes[key] = value.boolValue + } else if objCType == "d" { + attributes[key] = value.doubleValue + } else if objCType == "i" { + attributes[key] = value.intValue + } else if objCType == "q" { + attributes[key] = value.int64Value + } + } + } + return attributes + } } + +extension ClickstreamFlutterPlugin: DefaultLogger {} diff --git a/ios/Clickstream b/ios/Clickstream new file mode 160000 index 0000000..87f3837 --- /dev/null +++ b/ios/Clickstream @@ -0,0 +1 @@ +Subproject commit 87f38370f35d5df19014a5fbbefbbf4c6d4975c6 diff --git a/ios/amplifyconfiguration.json b/ios/amplifyconfiguration.json new file mode 100644 index 0000000..c1f3658 --- /dev/null +++ b/ios/amplifyconfiguration.json @@ -0,0 +1,15 @@ +{ + "UserAgent": "aws-solution/clickstream", + "Version": "1.0", + "analytics": { + "plugins": { + "awsClickstreamPlugin": { + "appId": "", + "endpoint": "", + "isCompressEvents": true, + "autoFlushEventsInterval": 10000, + "isTrackAppExceptionEvents": false + } + } + } +} \ No newline at end of file diff --git a/ios/clickstream_flutter.podspec b/ios/clickstream_flutter.podspec index bfa8906..fc31316 100644 --- a/ios/clickstream_flutter.podspec +++ b/ios/clickstream_flutter.podspec @@ -15,9 +15,16 @@ clickstream flutter SDK s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.platform = :ios, '11.0' + s.platform = :ios, '13.0' # 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' + s.swift_version = '5.7' + + s.subspec 'Clickstream' do |sc| + sc.source_files = 'Clickstream/Sources/**/*' + sc.dependency 'GzipSwift', '5.1.1' + sc.dependency 'Amplify', '1.30.3' + sc.dependency 'SQLite.swift', '0.13.2' + end end diff --git a/lib/clickstream_flutter.dart b/lib/clickstream_flutter.dart index 3fcb72e..8e25712 100644 --- a/lib/clickstream_flutter.dart +++ b/lib/clickstream_flutter.dart @@ -1,15 +1,85 @@ import 'clickstream_flutter_platform_interface.dart'; -class ClickstreamFlutter { - Future getPlatformVersion() { - return ClickstreamAnalytics.instance.getPlatformVersion(); +class ClickstreamAnalytics { + Future init({ + required String appId, + required String endpoint, + bool isLogEvents = false, + bool isCompressEvents = true, + bool isTrackScreenViewEvents = true, + bool isTrackUserEngagementEvents = true, + bool isTrackAppExceptionEvents = false, + int sendEventsInterval = 10000, + int sessionTimeoutDuration = 1800000, + String authCookie = "", + }) { + Map initConfig = { + 'appId': appId, + 'endpoint': endpoint, + 'isLogEvents': isLogEvents, + 'isCompressEvents': isCompressEvents, + 'isTrackScreenViewEvents': isTrackScreenViewEvents, + 'isTrackUserEngagementEvents': isTrackUserEngagementEvents, + 'isTrackAppExceptionEvents': isTrackAppExceptionEvents, + 'sendEventsInterval': sendEventsInterval, + 'sessionTimeoutDuration': sessionTimeoutDuration, + 'authCookie': authCookie + }; + return ClickstreamInterface.instance.init(initConfig); } - Future init() { - return ClickstreamAnalytics.instance.init(); + Future record( + {required String name, Map? attributes}) { + return ClickstreamInterface.instance + .record({"eventName": name, "attributes": attributes ?? {}}); } - Future record(String eventName) { - return ClickstreamAnalytics.instance.record(eventName); + Future setUserId(String? userId) { + return ClickstreamInterface.instance.setUserId({"userId": userId}); + } + + Future setUserAttributes(Map attributes) { + if (attributes.isEmpty) return Future.value(); + return ClickstreamInterface.instance.setUserAttributes(attributes); + } + + Future addGlobalAttributes(Map attributes) { + if (attributes.isEmpty) return Future.value(); + return ClickstreamInterface.instance.addGlobalAttributes(attributes); + } + + Future deleteGlobalAttributes(List attributes) { + if (attributes.isEmpty) return Future.value(); + return ClickstreamInterface.instance + .deleteGlobalAttributes({"attributes": attributes}); + } + + Future updateConfigure({ + String? appId, + String? endpoint, + bool? isLogEvents, + bool? isCompressEvents, + bool? isTrackScreenViewEvents, + bool? isTrackUserEngagementEvents, + bool? isTrackAppExceptionEvents, + int? sessionTimeoutDuration, + String? authCookie, + }) { + Map configure = { + 'appId': appId, + 'endpoint': endpoint, + 'isLogEvents': isLogEvents, + 'isCompressEvents': isCompressEvents, + 'isTrackScreenViewEvents': isTrackScreenViewEvents, + 'isTrackUserEngagementEvents': isTrackUserEngagementEvents, + 'isTrackAppExceptionEvents': isTrackAppExceptionEvents, + 'sessionTimeoutDuration': sessionTimeoutDuration, + 'authCookie': authCookie + }; + return ClickstreamInterface.instance.updateConfigure(configure); + } + + Future flushEvents() { + return ClickstreamInterface.instance.flushEvents(); } } diff --git a/lib/clickstream_flutter_method_channel.dart b/lib/clickstream_flutter_method_channel.dart index fa650b7..f342067 100644 --- a/lib/clickstream_flutter_method_channel.dart +++ b/lib/clickstream_flutter_method_channel.dart @@ -4,27 +4,49 @@ import 'package:flutter/services.dart'; import 'clickstream_flutter_platform_interface.dart'; /// An implementation of [ClickstreamFlutterPlatform] that uses method channels. -class MethodChannelClickstreamAnalytics extends ClickstreamAnalytics { +class ClickstreamAnalyticsMethodChannel extends ClickstreamInterface { /// The method channel used to interact with the native platform. @visibleForTesting final methodChannel = const MethodChannel('clickstream_flutter'); @override - Future getPlatformVersion() async { - final version = - await methodChannel.invokeMethod('getPlatformVersion'); - return version; + Future init(Map configure) async { + final result = await methodChannel.invokeMethod('init', configure); + return result ?? false; } @override - Future init() async { - final result = await methodChannel.invokeMethod('init'); - return result ?? false; + Future record(Map attributes) async { + await methodChannel.invokeMethod('record', attributes); } @override - Future record(String eventName) async { - final result = await methodChannel.invokeMethod('record', eventName); - return result ?? false; + Future setUserId(Map userId) async { + await methodChannel.invokeMethod('setUserId', userId); + } + + @override + Future setUserAttributes(Map attributes) async { + await methodChannel.invokeMethod('setUserAttributes', attributes); + } + + @override + Future addGlobalAttributes(Map attributes) async { + await methodChannel.invokeMethod('addGlobalAttributes', attributes); + } + + @override + Future deleteGlobalAttributes(Map attributes) async { + await methodChannel.invokeMethod('deleteGlobalAttributes', attributes); + } + + @override + Future updateConfigure(Map configure) async { + await methodChannel.invokeMethod('updateConfigure', configure); + } + + @override + Future flushEvents() async { + await methodChannel.invokeMethod('flushEvents'); } } diff --git a/lib/clickstream_flutter_platform_interface.dart b/lib/clickstream_flutter_platform_interface.dart index 3efaae2..de9ddb5 100644 --- a/lib/clickstream_flutter_platform_interface.dart +++ b/lib/clickstream_flutter_platform_interface.dart @@ -2,36 +2,56 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'clickstream_flutter_method_channel.dart'; -abstract class ClickstreamAnalytics extends PlatformInterface { +abstract class ClickstreamInterface extends PlatformInterface { /// Constructs a ClickstreamAnalytics. - ClickstreamAnalytics() : super(token: _token); + ClickstreamInterface() : super(token: _token); static final Object _token = Object(); - static ClickstreamAnalytics _instance = MethodChannelClickstreamAnalytics(); + static ClickstreamInterface _instance = ClickstreamAnalyticsMethodChannel(); - /// The default instance of [ClickstreamAnalytics] to use. + /// The default instance of [ClickstreamInterface] to use. /// - /// Defaults to [MethodChannelClickstreamAnalytics]. - static ClickstreamAnalytics get instance => _instance; + /// Defaults to [ClickstreamAnalyticsMethodChannel]. + static ClickstreamInterface get instance => _instance; /// Platform-specific implementations should set this with their own - /// platform-specific class that extends [ClickstreamAnalytics] when + /// platform-specific class that extends [ClickstreamInterface] when /// they register themselves. - static set instance(ClickstreamAnalytics instance) { + static set instance(ClickstreamInterface instance) { PlatformInterface.verifyToken(instance, _token); _instance = instance; } - Future getPlatformVersion() { - throw UnimplementedError('platformVersion() has not been implemented.'); + Future init(Map configure) { + throw UnimplementedError('init() has not been implemented.'); } - Future init() { - throw UnimplementedError('init() has not been implemented.'); + Future record(Map attributes) { + throw UnimplementedError('record() has not been implemented.'); + } + + Future setUserId(Map userId) { + throw UnimplementedError('setUserId() has not been implemented.'); + } + + Future setUserAttributes(Map attributes) { + throw UnimplementedError('setUserId() has not been implemented.'); + } + + Future addGlobalAttributes(Map attributes) { + throw UnimplementedError('setUserId() has not been implemented.'); + } + + Future deleteGlobalAttributes(Map attributes) { + throw UnimplementedError('setUserId() has not been implemented.'); + } + + Future updateConfigure(Map configure) { + throw UnimplementedError('setUserId() has not been implemented.'); } - Future record(String eventName) { - throw UnimplementedError('platformVersion() has not been implemented.'); + Future flushEvents() { + throw UnimplementedError('setUserId() has not been implemented.'); } } diff --git a/test/clickstream_flutter_method_channel_test.dart b/test/clickstream_flutter_method_channel_test.dart index a247419..0ecc7e8 100644 --- a/test/clickstream_flutter_method_channel_test.dart +++ b/test/clickstream_flutter_method_channel_test.dart @@ -5,23 +5,115 @@ import 'package:clickstream_flutter/clickstream_flutter_method_channel.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - MethodChannelClickstreamAnalytics platform = MethodChannelClickstreamAnalytics(); + ClickstreamAnalyticsMethodChannel platform = + ClickstreamAnalyticsMethodChannel(); const MethodChannel channel = MethodChannel('clickstream_flutter'); setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( channel, (MethodCall methodCall) async { - return '42'; + switch (methodCall.method) { + case "init": + if (methodCall.arguments['endpoint'] == "") { + return false; + } else { + return true; + } + case "record": + return null; + case "setUserId": + return null; + case "setUserAttributes": + return null; + case "setGlobalAttributes": + return null; + case "deleteGlobalAttributes": + return null; + case "updateConfigure": + return null; + case "flushEvents": + return null; + } }, ); }); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(channel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); }); - test('getPlatformVersion', () async { - expect(await platform.getPlatformVersion(), '42'); + test('init failed', () async { + Map initConfig = { + 'appId': 'testApp', + 'endpoint': "", + }; + expect(await platform.init(initConfig), false); + }); + + test('init success', () async { + Map initConfig = { + 'appId': 'testApp', + 'endpoint': "http://example.com/collect", + }; + expect(await platform.init(initConfig), true); + }); + + test('record', () async { + Map attributes = { + "category": "shoes", + "currency": "CNY", + "value": 279.9 + }; + var result = platform.record(attributes); + expect(result, isNotNull); + }); + + test('setUserId', () async { + Map attributes = { + "userId": "1234", + }; + var result = platform.setUserId(attributes); + expect(result, isNotNull); + }); + + test('setUserAttributes', () async { + Map attributes = {"_user_age": 21, "_user_name": "carl"}; + var result = platform.setUserAttributes(attributes); + expect(result, isNotNull); + }); + + test('setGlobalAttributes', () async { + Map attributes = { + "channel": "Play Store", + "level": 5.1, + "class": 6 + }; + var result = platform.addGlobalAttributes(attributes); + expect(result, isNotNull); + }); + + test('deleteGlobalAttributes', () async { + Map attributes = { + "attributes": ["attr1", "attr2"], + }; + var result = platform.deleteGlobalAttributes(attributes); + expect(result, isNotNull); + }); + + test('updateConfigure', () async { + Map attributes = { + "appId": "newAppId", + "endpoint": "https://example.com/collect", + }; + var result = platform.updateConfigure(attributes); + expect(result, isNotNull); + }); + + test('setGlobalAttributes', () async { + var result = platform.flushEvents(); + expect(result, isNotNull); }); } diff --git a/test/clickstream_flutter_test.dart b/test/clickstream_flutter_test.dart index d958863..b2df4df 100644 --- a/test/clickstream_flutter_test.dart +++ b/test/clickstream_flutter_test.dart @@ -1,35 +1,117 @@ -import 'package:flutter_test/flutter_test.dart'; import 'package:clickstream_flutter/clickstream_flutter.dart'; -import 'package:clickstream_flutter/clickstream_flutter_platform_interface.dart'; import 'package:clickstream_flutter/clickstream_flutter_method_channel.dart'; +import 'package:clickstream_flutter/clickstream_flutter_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; class MockClickstreamFlutterPlatform with MockPlatformInterfaceMixin - implements ClickstreamAnalytics { + implements ClickstreamInterface { @override - Future getPlatformVersion() => Future.value('42'); + Future init(Map configure) => Future.value(true); @override - Future init() => Future.value(true); + Future record(Map params) => Future.value(); @override - Future record(String name) => Future.value(); + Future flushEvents() => Future.value(); + + @override + Future addGlobalAttributes(Map attributes) => + Future.value(); + + @override + Future setUserAttributes(Map attributes) => + Future.value(); + + @override + Future setUserId(Map userId) => Future.value(); + + @override + Future updateConfigure(Map configure) => + Future.value(); + + @override + Future deleteGlobalAttributes(Map attributes) => + Future.value(); } void main() { - final ClickstreamAnalytics initialPlatform = ClickstreamAnalytics.instance; - - test('$MethodChannelClickstreamAnalytics is the default instance', () { - expect(initialPlatform, isInstanceOf()); - }); + final ClickstreamInterface initialPlatform = ClickstreamInterface.instance; + late ClickstreamAnalytics analytics; - test('getPlatformVersion', () async { - ClickstreamFlutter clickstreamFlutterPlugin = ClickstreamFlutter(); + setUp(() { + analytics = ClickstreamAnalytics(); MockClickstreamFlutterPlatform fakePlatform = MockClickstreamFlutterPlatform(); - ClickstreamAnalytics.instance = fakePlatform; + ClickstreamInterface.instance = fakePlatform; + }); + + test('$ClickstreamAnalyticsMethodChannel is the default instance', () { + expect(initialPlatform, isInstanceOf()); + }); + + test('initSDK', () async { + var result = await analytics.init( + appId: 'testApp', endpoint: "https://example.com/collect"); + expect(result, true); + }); + + test('record event', () async { + var result = analytics.record(name: "testEvent"); + expect(result, isNotNull); + }); + + test('record event with attributes', () async { + var result = analytics.record( + name: "testEvent", + attributes: {"category": "shoes", "currency": "CNY", "value": 279.9}); + expect(result, isNotNull); + }); + test('setUserId', () async { + var result = analytics.setUserId("11234"); + expect(result, isNotNull); + }); + + test('setUserAttributes', () async { + var result = + analytics.setUserAttributes({"_user_age": 21, "_user_name": "carl"}); + var result1 = analytics.setUserAttributes({}); + expect(result, isNotNull); + expect(result1, isNotNull); + }); + + test('setGlobalAttributes', () async { + var result = analytics.addGlobalAttributes( + {"channel": "Play Store", "level": 5.1, "class": 6}); + var result1 = analytics.addGlobalAttributes({}); + expect(result, isNotNull); + expect(result1, isNotNull); + }); + + test('deleteGlobalAttributes', () async { + var result = analytics.deleteGlobalAttributes(["attr1", "attr2"]); + var result1 = analytics.deleteGlobalAttributes([]); + expect(result, isNotNull); + expect(result1, isNotNull); + }); + + test('updateConfigure', () async { + var result = analytics.updateConfigure( + appId: "testApp1", + endpoint: "https://example.com/collect", + isLogEvents: true, + isCompressEvents: false, + isTrackScreenViewEvents: false, + isTrackUserEngagementEvents: false, + isTrackAppExceptionEvents: false, + sessionTimeoutDuration: 18000, + authCookie: "your auth cookie"); + expect(result, isNotNull); + }); - expect(await clickstreamFlutterPlugin.getPlatformVersion(), '42'); + test('flushEvents', () async { + var result = analytics.flushEvents(); + expect(result, isNotNull); }); }