diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e4f5d..f44df44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.0.1 +* PianoConsents has been moved to the piano_consents package +* Added feature to specify property type via forceType argument +* Added new features for privacy customization +* Added new features for user customization +* Added feature for set visitorId + ## 1.0.0 * Added plugins for PianoAnalytics and PianoConsents diff --git a/README.md b/README.md index 71e23a8..aa84c04 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## About The Project -The Piano Analytics Apple SDK allows you to collect audience measurement data for the [Piano Analytics](https://piano.io/product/analytics/) solution. +The Piano Analytics Flutter SDK allows you to collect audience measurement data for the [Piano Analytics](https://piano.io/product/analytics/) solution. It can be used with Flutter applications. This SDK makes the implementation of Piano Analytics as simple as possible, while keeping all the flexibility of the solution. By using this plugin in your applications, and using [dedicated and documented methods](https://developers.atinternet-solutions.com/piano-analytics/), you will be able to send powerful events. @@ -17,7 +17,7 @@ This SDK makes the implementation of Piano Analytics as simple as possible, whil 1. Add this to your pubspec.yaml file: ```yaml dependencies: - piano_analytics: ^1.0.0 + piano_analytics: ^1.0.1 ``` 2. Install the plugin using the command @@ -27,102 +27,169 @@ This SDK makes the implementation of Piano Analytics as simple as possible, whil ## Usage -1. Initialize PianoAnalytics with your site and collect domain in your application initialization - ```dart - import 'package:piano_analytics/piano_analytics.dart'; - - ... - - class _MyAppState extends State { - - final _pianoAnalytics = PianoAnalytics( - site: 123456789, - collectDomain: "xxxxxxx.pa-cd.com" - ); - - @override - void initState() { - super.initState(); - initPlugins(); - } +Initialize PianoAnalytics with your site and collect domain in your application initialization + ```dart + import 'package:piano_analytics/piano_analytics.dart'; + import 'package:piano_analytics/enums.dart'; + ... + + class _MyAppState extends State { + + final _pianoAnalytics = PianoAnalytics( + site: 123456789, + collectDomain: "xxxxxxx.pa-cd.com", + visitorIDType: VisitorIDType.uuid, + storageLifetimeVisitor: 395, + visitorStorageMode: VisitorStorageMode.fixed, + ignoreLimitedAdvertisingTracking: true, + visitorId: "WEB-192203AJ" + ); + + @override + void initState() { + super.initState(); + initPlugins(); + } - Future initPlugins() async { - ... - await _pianoAnalytics.init(); - } + Future initPlugins() async { ... + await _pianoAnalytics.init(); } - ``` - -2. Send events - ```dart - await _pianoAnalytics.sendEvents(events: [ - Event(name: "page.display", properties: [ - Property.bool(name: "bool", value: true), - Property.int(name: "int", value: 1), - Property.int(name: "long", value: 9007199254740992), - Property.double(name: "double", value: 1.0), - Property.string(name: "string", value: "value"), - Property.date(name: "date", value: DateTime.now()), - Property.intArray(name: "intArray", value: [1, 2, 3]), - Property.doubleArray(name: "doubleArray", value: [1.0, 2.0, 3.0]), - Property.stringArray(name: "stringArray", value: ["a", "b", "c"]) - ]) - ]); - ``` + ... + } + ``` + +### Send events + ```dart + await _pianoAnalytics.sendEvents(events: [ + Event(name: "page.display", properties: [ + Property.bool(name: "bool", value: true), + Property.int(name: "int", value: 1), + Property.int(name: "long", value: 9007199254740992), + Property.double(name: "double", value: 1.0), + Property.string(name: "string", value: "value"), + Property.date(name: "date", value: DateTime.now()), + Property.intArray(name: "intArray", value: [1, 2, 3]), + Property.doubleArray(name: "doubleArray", value: [1.0, 2.0, 3.0]), + Property.stringArray(name: "stringArray", value: ["a", "b", "c"]) + ]) + ]); + ``` + +## User +You can get/set/delete a user + +### Get user + ```dart + var user = await _pianoAnalytics.getUser(); + ``` + +### Set user + ```dart + await _pianoAnalytics.setUser( + id: "WEB-192203AJ", + category: "premium", + enableStorage: false + ); + ``` + +### Delete user + ```dart + await _pianoAnalytics.deleteUser(); + ``` + +## Visitor ID +You can get/set visitor ID (*only for `VisitorIDType.custom`*) + +### Get visitor ID + ```dart + var user = await _pianoAnalytics.getVisitorId(); + ``` + +### Set visitor ID + ```dart + await _pianoAnalytics.setVisitorId( + visitorId: "WEB-192203AJ" + ); + ``` + +## Privacy +You can change privacy parameters + +### Include storage features + ```dart + await _pianoAnalytics.privacyIncludeStorageFeatures( + features: [ + PrivacyStorageFeature.crash, + PrivacyStorageFeature.visitor + ], + modes: [ + PrivacyMode.custom + ] + ); + ``` + +### Exclude storage features + ```dart + await _pianoAnalytics.privacyExcludeStorageFeatures( + features: [ + PrivacyStorageFeature.crash, + PrivacyStorageFeature.visitor + ], + modes: [ + PrivacyMode.custom + ] + ); + ``` + +### Include properties + ```dart + await _pianoAnalytics.privacyIncludeProperties( + propertyNames: [ + "allowed_property_1", + "allowed_property_2" + ], + modes: [ + PrivacyMode.custom + ], + eventNames: [ + "page.display", + "click.action" + ] + ); + ``` + +### Exclude properties + ```dart + await _pianoAnalytics.privacyExcludeProperties( + propertyNames: [ + "forbidden_property_1", + "forbidden_property_2" + ], + modes: [ + PrivacyMode.custom + ] + ); + ``` + +### Include events + ```dart + await _pianoAnalytics.privacyIncludeEvents( + eventNames: ["page.display"], + modes: [PrivacyMode.custom] + ); + ``` + +### Exclude events + ```dart + await _pianoAnalytics.privacyExcludeEvents( + eventNames: ["click.action"], + modes: [PrivacyMode.custom] + ); + ``` ## Consents > **Important:** Initialize PianoConsents before initializing PianoAnalytics -1. Initialize PianoConsents - ```dart - import 'package:piano_analytics/piano_analytics.dart'; - import 'package:piano_analytics/piano_consents.dart'; - - ... - - class _MyAppState extends State { - final _pianoConsents = PianoConsents( - requireConsents: true, - defaultPurposes: { - PianoConsentProduct.pa: PianoConsentPurpose.audienceMeasurement - } - ); - - final _pianoAnalytics = PianoAnalytics( - site: 1, - collectDomain: "piano.io" - ); - - @override - void initState() { - super.initState(); - initPlugins(); - } - - Future initPlugins() async { - ... - await _pianoConsents.init(); - await _pianoAnalytics.init(); - } - ... - } - ``` -2. Set consents - ```dart - await _pianoConsents.set( - purpose: PianoConsentPurpose.audienceMeasurement, - mode: PianoConsentMode.essential, - products: [PianoConsentProduct.pa]); - ``` - -3. Set all consents - ```dart - await _pianoConsents.setAll(mode: PianoConsentMode.essential); - ``` - -4. Set default consents - ```dart - await _pianoConsents.clear(); - ``` \ No newline at end of file +*Use the **[piano_consents](https://pub.dev/packages/piano_consents)** package* \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 78d752a..96af0ea 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ group = "io.piano.flutter.piano_analytics" -version = "1.0" +version = "1.0.1" buildscript { ext.kotlin_version = "1.9.0" diff --git a/android/src/main/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPlugin.kt b/android/src/main/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPlugin.kt index 1fdfe52..8418e88 100644 --- a/android/src/main/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPlugin.kt +++ b/android/src/main/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPlugin.kt @@ -13,15 +13,19 @@ import io.flutter.plugin.common.StandardMethodCodec import io.piano.android.analytics.Configuration import io.piano.android.analytics.PianoAnalytics import io.piano.android.analytics.model.Event +import io.piano.android.analytics.model.PrivacyMode +import io.piano.android.analytics.model.PrivacyStorageFeature import io.piano.android.analytics.model.Property import io.piano.android.analytics.model.PropertyName +import io.piano.android.analytics.model.User import io.piano.android.analytics.model.VisitorIDType +import io.piano.android.analytics.model.VisitorStorageMode import io.piano.android.consents.PianoConsents import java.lang.ref.WeakReference import java.nio.ByteBuffer import java.util.Date -private class Codec: StandardMessageCodec() { +private class Codec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? = when (type) { DATE -> Date(buffer.getLong()) @@ -36,29 +40,24 @@ private class Codec: StandardMessageCodec() { class PianoAnalyticsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { private lateinit var channel: MethodChannel - - private val consentsPlugin = PianoConsentsPlugin() + private lateinit var visitorIDType: VisitorIDType private var context: WeakReference = WeakReference(null) override fun onAttachedToActivity(binding: ActivityPluginBinding) { context = WeakReference(binding.activity) - consentsPlugin.onAttachedToActivity(binding) } override fun onDetachedFromActivityForConfigChanges() { context = WeakReference(null) - consentsPlugin.onDetachedFromActivityForConfigChanges() } override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { context = WeakReference(binding.activity) - consentsPlugin.onReattachedToActivityForConfigChanges(binding) } override fun onDetachedFromActivity() { context = WeakReference(null) - consentsPlugin.onDetachedFromActivity() } override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { @@ -68,39 +67,62 @@ class PianoAnalyticsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { StandardMethodCodec(Codec()) ) channel.setMethodCallHandler(this) - - consentsPlugin - consentsPlugin.onAttachedToEngine(flutterPluginBinding) } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) - consentsPlugin.onAttachedToEngine(binding) } override fun onMethodCall(call: MethodCall, result: Result) { try { - when (call.method) { + val methodResult: Any? = when (call.method) { + // Main "init" -> handleInit(call) "send" -> handleSend(call) + // User + "getUser" -> handleGetUser() + "setUser" -> handleSetUser(call) + "deleteUser" -> PianoAnalytics.getInstance().userStorage.currentUser = null + // Visitor + "getVisitorId" -> handleGetVisitorId() + "setVisitorId" -> handleSetVisitorId(call) + // Privacy + "privacyIncludeStorageFeatures" -> privacyChangeStorageFeatures(call) + "privacyExcludeStorageFeatures" -> privacyChangeStorageFeatures(call, false) + "privacyIncludeProperties" -> privacyChangeProperties(call) + "privacyExcludeProperties" -> privacyChangeProperties(call, false) + "privacyIncludeEvents" -> privacyChangeEvents(call) + "privacyExcludeEvents" -> privacyChangeEvents(call, false) else -> error("Unknown method") } - result.success(null) + result.success(methodResult.takeUnless { it is Unit }) } catch (e: Throwable) { result.error(call.method, e.message, null) } } private fun handleInit(call: MethodCall) { - PianoAnalytics.init( + visitorIDType = getVisitorIDType(call.arg("visitorIDType")) + val pianoAnalytics = PianoAnalytics.init( context = context.get() ?: error("Activity not attached"), configuration = Configuration.Builder( site = call.arg("site"), collectDomain = call.arg("collectDomain"), - visitorIDType = getVisitorIDType(call.arg("visitorIDType")) + visitorIDType = visitorIDType, + visitorStorageLifetime = call.argument("storageLifetimeVisitor") + ?: Configuration.DEFAULT_VISITOR_STORAGE_LIFETIME, + visitorStorageMode = getVisitorStorageMode(call.argument("visitorStorageMode")), + ignoreLimitedAdTracking = call.argument("ignoreLimitedAdvertisingTracking") + ?: false ).build(), pianoConsents = getPianoConsents() ) + + if (visitorIDType == VisitorIDType.CUSTOM) { + call.argument("visitorId")?.let { + pianoAnalytics.customVisitorId = it + } + } } private fun handleSend(call: MethodCall) { @@ -109,9 +131,19 @@ class PianoAnalyticsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { Event.Builder( name = name, properties = (it["data"] as? Map<*, *>)?.map { property -> + val propertyName = property.key as? String + ?: error("Undefined property name of event \"$name\"") + val value = property.value as? Map<*, *> + ?: error("Undefined property value of event \"$propertyName\"") + val propertyValue = value["value"] + ?: error("Undefined property value of event \"$propertyName\"") + val forceType = (value["forceType"] as? String)?.let { type -> + Property.Type.entries.firstOrNull { t -> t.prefix == type } + } getProperty( - property.key as? String ?: error("Undefined property name of event \"$name\""), - property.value + propertyName, + propertyValue, + forceType ) }?.toMutableSet() ?: mutableSetOf() ).build() @@ -119,6 +151,69 @@ class PianoAnalyticsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { PianoAnalytics.getInstance().sendEvents(*events) } + private fun handleGetUser(): Map? = + PianoAnalytics.getInstance().userStorage.currentUser?.let { + mapOf( + "id" to it.id, + "category" to it.category + ) + } + + private fun handleSetUser(call: MethodCall) { + PianoAnalytics.getInstance().userStorage.currentUser = User( + call.arg("id"), + category = call.argument("category"), + shouldBeStored = call.argument("enableStorage") ?: true + ) + } + + private fun handleGetVisitorId(): String? = + PianoAnalytics.getInstance().let { + if (visitorIDType == VisitorIDType.CUSTOM) + it.customVisitorId + else + it.visitorId + } + + private fun handleSetVisitorId(call: MethodCall) { + PianoAnalytics.getInstance().customVisitorId = call.arg("visitorId") + } + + private fun privacyChangeStorageFeatures(call: MethodCall, include: Boolean = true) { + val features = call.arg>("features").map { feature -> + PrivacyStorageFeature.entries.first { it.name == feature } + } + call.arg>("modes").forEach { + val mode = getPrivacyMode(it) + (if (include) mode.allowedStorageFeatures else mode.forbiddenStorageFeatures) += features + } + } + + private fun privacyChangeProperties(call: MethodCall, include: Boolean = true) { + val propertyNames = call.arg>("propertyNames") + val eventNames = call.argument>("eventNames") ?: listOf(Event.ANY) + + call.arg>("modes").forEach { + val mode = getPrivacyMode(it) + val propertyKeys = if (include) mode.allowedPropertyKeys else mode.forbiddenPropertyKeys + + eventNames.forEach { eventName -> + propertyKeys + .getOrPut(eventName) { mutableSetOf() } + .addAll(propertyNames.map { name -> PropertyName(name) }) + } + } + } + + private fun privacyChangeEvents(call: MethodCall, include: Boolean = true) { + val eventNames = call.arg>("eventNames") + + call.arg>("modes").forEach { + val mode = getPrivacyMode(it) + (if (include) mode.allowedEventNames else mode.forbiddenEventNames).addAll(eventNames) + } + } + companion object { @JvmStatic @@ -131,26 +226,83 @@ class PianoAnalyticsPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { @JvmStatic private fun getVisitorIDType(name: String) = when (name) { "ADID" -> VisitorIDType.ADVERTISING_ID + "CUSTOM" -> VisitorIDType.CUSTOM else -> VisitorIDType.UUID } @JvmStatic - private fun getProperty(name: String, value: Any?) = when (value) { - is Boolean -> Property(PropertyName(name), value) - is Int -> Property(PropertyName(name), value) - is Long -> Property(PropertyName(name), value) - is Double -> Property(PropertyName(name), value) - is String -> Property(PropertyName(name), value) - is Date -> Property(PropertyName(name), value) - is List<*> -> { - when (value.first()) { - is Int -> Property(PropertyName(name), value.filterIsInstance().toTypedArray()) - is Double -> Property(PropertyName(name), value.filterIsInstance().toTypedArray()) - is String -> Property(PropertyName(name), value.filterIsInstance().toTypedArray()) - else -> { error("Invalid array value type of property \"$name\"") } + private fun getVisitorStorageMode(name: String?) = when (name) { + "relative" -> VisitorStorageMode.RELATIVE + else -> VisitorStorageMode.FIXED + } + + @JvmStatic + private fun getProperty(name: String, value: Any?, forceType: Property.Type? = null) = + when (value) { + is Boolean -> Property(PropertyName(name), value, forceType) + is Int -> Property(PropertyName(name), value, forceType) + is Long -> Property(PropertyName(name), value, forceType) + is Double -> Property(PropertyName(name), value, forceType) + is String -> Property(PropertyName(name), value, forceType) + is Date -> Property(PropertyName(name), value) + is List<*> -> { + when (value.first()) { + is Int -> Property( + PropertyName(name), + value.filterIsInstance().toTypedArray(), + forceType + ) + + is Double -> Property( + PropertyName(name), + value.filterIsInstance().toTypedArray(), + forceType + ) + + is String -> Property( + PropertyName(name), + value.filterIsInstance().toTypedArray(), + forceType + ) + + else -> { + error("Invalid array value type of property \"$name\"") + } + } + } + + else -> error("Invalid type of property \"$name\"") + } + + @JvmStatic + private fun getPrivacyMode(name: String) = when (name) { + "opt-in" -> PrivacyMode.OPTIN + "opt-out" -> PrivacyMode.OPTOUT + "exempt" -> PrivacyMode.EXEMPT + "custom" -> PrivacyMode.CUSTOM + "no-consent" -> PrivacyMode.NO_CONSENT + "no-storage" -> PrivacyMode.NO_STORAGE + else -> error("Invalid privacy mode \"$name\"") + } + + @JvmStatic + private fun updatePropertyKeys( + eventNames: List, + propertyKeys: MutableMap>, + propertyName: String + ) { + eventNames.forEach { eventName -> + var propertyNames = propertyKeys[eventName] + if (propertyNames == null) { + propertyNames = mutableSetOf() + propertyKeys[eventName] = propertyNames + } + + val newPropertyName = PropertyName(propertyName) + if (!propertyNames.contains(newPropertyName)) { + propertyNames.add(newPropertyName) } } - else -> error("Invalid type of property \"$name\"") } } } diff --git a/android/src/main/kotlin/io/piano/flutter/piano_analytics/PianoConsentsPlugin.kt b/android/src/main/kotlin/io/piano/flutter/piano_analytics/PianoConsentsPlugin.kt deleted file mode 100644 index 494420f..0000000 --- a/android/src/main/kotlin/io/piano/flutter/piano_analytics/PianoConsentsPlugin.kt +++ /dev/null @@ -1,119 +0,0 @@ -package io.piano.flutter.piano_analytics - -import android.content.Context -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.piano.android.consents.PianoConsents -import io.piano.android.consents.models.ConsentConfiguration -import io.piano.android.consents.models.ConsentMode -import io.piano.android.consents.models.Product -import io.piano.android.consents.models.Purpose -import java.lang.ref.WeakReference - -class PianoConsentsPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { - - private lateinit var channel: MethodChannel - - private var context: WeakReference = WeakReference(null) - - override fun onAttachedToActivity(binding: ActivityPluginBinding) { - context = WeakReference(binding.activity) - } - - override fun onDetachedFromActivityForConfigChanges() { - context = WeakReference(null) - } - - override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { - context = WeakReference(binding.activity) - } - - override fun onDetachedFromActivity() { - context = WeakReference(null) - } - - override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - channel = MethodChannel( - flutterPluginBinding.binaryMessenger, - "piano_consents" - ) - channel.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { - channel.setMethodCallHandler(null) - } - - override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - try { - when (call.method) { - "init" -> handleInit(call) - "set" -> handleSet(call) - "setAll" -> handleSetAll(call) - "clear" -> handleClear() - else -> error("Unknown method") - } - result.success(null) - } catch (e: Throwable) { - result.error(call.method, e.message, null) - } - } - - private fun handleInit(call: MethodCall) { - PianoConsents.init( - context = context.get() ?: error("Activity not attached"), - consentConfiguration = ConsentConfiguration( - requireConsent = call.argument("requireConsents") as? Boolean ?: false, - defaultPurposes = (call.argument("defaultPurposes") as? Map<*, *>)?.map { - getProduct(it.key as? String) to getPurpose(it.value as? String) - }?.toMap() - ) - ) - } - - private fun handleSet(call: MethodCall) { - PianoConsents.getInstance().set( - purpose = getPurpose(call.arg("purpose")), - mode = getMode(call.arg("mode")), - products = (call.arg("products") as? List)?.map { - getProduct(it) - }?.toTypedArray() ?: arrayOf() - ) - } - - private fun handleSetAll(call: MethodCall) { - PianoConsents.getInstance().setAll( - mode = getMode(call.arg("mode")) - ) - } - - private fun handleClear() { - PianoConsents.getInstance().clear() - } - - companion object { - - @JvmStatic - private fun getProduct(name: String?) = - Product.entries.firstOrNull { it.alias == name } - ?: error("Invalid value for consent product \"$name\"") - - @JvmStatic - private fun getMode(name: String?) = - ConsentMode.entries.firstOrNull { it.alias == name } - ?: error("Invalid value for consent mode \"$name\"") - - @JvmStatic - private fun getPurpose(name: String?) = when (name) { - "AM" -> Purpose.AUDIENCE_MEASUREMENT - "CP" -> Purpose.CONTENT_PERSONALISATION - "AD" -> Purpose.ADVERTISING - "PR" -> Purpose.PERSONAL_RELATIONSHIP - else -> error("Invalid value for consent purpose \"$name\"") - } - } -} \ No newline at end of file diff --git a/android/src/test/kotlin/io/piano/flutter/piano_analytics/BasePluginTest.kt b/android/src/test/kotlin/io/piano/flutter/piano_analytics/BasePluginTest.kt index b4ac1f6..7307c29 100644 --- a/android/src/test/kotlin/io/piano/flutter/piano_analytics/BasePluginTest.kt +++ b/android/src/test/kotlin/io/piano/flutter/piano_analytics/BasePluginTest.kt @@ -12,19 +12,19 @@ import io.mockk.mockk internal open class BasePluginTest { - protected fun call(method: String, parameters: Map?, factory: () -> T) + protected fun call(method: String, parameters: Map?, result: MethodChannel.Result?, factory: () -> T) where T: FlutterPlugin, T: MethodCallHandler, T: ActivityAware { val plugin = factory() - val call = MethodCall(method, parameters) - - val result: MethodChannel.Result = mockk() - every { result.success(any()) } returns Unit - val activityBinding: ActivityPluginBinding = mockk() every { activityBinding.activity } returns Activity() plugin.onAttachedToActivity(activityBinding) - plugin.onMethodCall(call, result) + plugin.onMethodCall( + MethodCall(method, parameters), + result ?: mockk().also { r -> + every { r.success(any()) } returns Unit + } + ) } } \ No newline at end of file diff --git a/android/src/test/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPluginTest.kt b/android/src/test/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPluginTest.kt index 2865fed..810bfba 100644 --- a/android/src/test/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPluginTest.kt +++ b/android/src/test/kotlin/io/piano/flutter/piano_analytics/PianoAnalyticsPluginTest.kt @@ -1,5 +1,6 @@ package io.piano.flutter.piano_analytics +import io.flutter.plugin.common.MethodChannel import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -8,15 +9,19 @@ import io.mockk.verify import io.piano.android.analytics.Configuration import io.piano.android.analytics.PianoAnalytics import io.piano.android.analytics.model.Event +import io.piano.android.analytics.model.PrivacyMode +import io.piano.android.analytics.model.PrivacyStorageFeature import io.piano.android.analytics.model.Property import io.piano.android.analytics.model.PropertyName +import io.piano.android.analytics.model.User import io.piano.android.analytics.model.VisitorIDType +import io.piano.android.analytics.model.VisitorStorageMode import java.util.Date import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals -internal class PianoAnalyticsPluginTest: BasePluginTest() { +internal class PianoAnalyticsPluginTest : BasePluginTest() { @BeforeTest fun setUp() { @@ -24,14 +29,24 @@ internal class PianoAnalyticsPluginTest: BasePluginTest() { } @Test - fun `Check PianoAnalytics init`() { - every { PianoAnalytics.Companion.init(any(), any(), any(), any()) } returns mockk() + fun `Check init`() { + val pianoAnalytics: PianoAnalytics = mockk() + every { PianoAnalytics.Companion.init(any(), any(), any(), any()) } returns pianoAnalytics - call("init", mapOf( - "site" to 123456789, - "collectDomain" to "xxxxxxx.pa-cd.com", - "visitorIDType" to "UUID" - )) + val customVisitorIdSlot = slot() + every { pianoAnalytics.customVisitorId = capture(customVisitorIdSlot) } answers {} + + call( + "init", mapOf( + "site" to 123456789, + "collectDomain" to "xxxxxxx.pa-cd.com", + "visitorIDType" to "CUSTOM", + "visitorStorageLifetime" to 395, + "visitorStorageMode" to "fixed", + "ignoreLimitedAdvertisingTracking" to true, + "visitorId" to "WEB-192203AJ" + ) + ) val slot = slot() verify { PianoAnalytics.init(any(), capture(slot), any(), any()) } @@ -39,33 +54,55 @@ internal class PianoAnalyticsPluginTest: BasePluginTest() { val configuration = slot.captured assertEquals(123456789, configuration.site) assertEquals("xxxxxxx.pa-cd.com", configuration.collectDomain) - assertEquals(VisitorIDType.UUID, configuration.visitorIDType) + assertEquals(VisitorIDType.CUSTOM, configuration.visitorIDType) + assertEquals(395, configuration.visitorStorageLifetime) + assertEquals(VisitorStorageMode.FIXED, configuration.visitorStorageMode) + assertEquals(true, configuration.ignoreLimitedAdTracking) + assertEquals("WEB-192203AJ", customVisitorIdSlot.captured) } @Test - fun `Check PianoAnalytics send`() { + fun `Check send`() { val pianoAnalytics: PianoAnalytics = mockk() every { PianoAnalytics.Companion.getInstance() } returns pianoAnalytics every { pianoAnalytics.sendEvents(any()) } returns Unit - call("send", mapOf( - "events" to listOf( - mapOf( - "name" to "page.display", - "data" to mapOf( - "bool" to true, - "int" to 1, - "long" to 1L, - "double" to 1.0, - "string" to "value", - "intArray" to listOf(1, 2, 3), - "doubleArray" to listOf(1.0, 2.0, 3.0), - "stringArray" to listOf("a", "b", "c"), - "date" to Date(0) + call( + "send", mapOf( + "events" to listOf( + mapOf( + "name" to "page.display", + "data" to mapOf( + "bool" to mapOf("value" to true), + "bool_force" to mapOf("value" to "true", "forceType" to "b"), + "int" to mapOf("value" to 1), + "int_force" to mapOf("value" to "1", "forceType" to "n"), + "long" to mapOf("value" to 1L), + "double" to mapOf("value" to 1.0), + "double_force" to mapOf("value" to "1.0", "forceType" to "f"), + "string" to mapOf("value" to "value"), + "string_force" to mapOf("value" to 1, "forceType" to "s"), + "date" to mapOf("value" to Date(0)), + "intArray" to mapOf("value" to listOf(1, 2, 3)), + "intArray_force" to mapOf( + "value" to listOf("1", "2", "3"), + "forceType" to "a:n" + ), + "doubleArray" to mapOf("value" to listOf(1.0, 2.0, 3.0)), + "doubleArray_force" to mapOf( + "value" to listOf("1.0", "2.0", "3.0"), + "forceType" to "a:f" + ), + "stringArray" to mapOf("value" to listOf("a", "b", "c")), + "stringArray_force" to mapOf( + "value" to listOf(1, 2, 3), + "forceType" to "a:s" + ) + ) ) ) ) - )) + ) val slot = slot() verify { pianoAnalytics.sendEvents(capture(slot)) } @@ -86,12 +123,269 @@ internal class PianoAnalyticsPluginTest: BasePluginTest() { assertEquals(setOf(1, 2, 3), event.properties.setOf("intArray")) assertEquals(setOf(1.0, 2.0, 3.0), event.properties.setOf("doubleArray")) assertEquals(setOf("a", "b", "c"), event.properties.setOf("stringArray")) + + assertEquals("true", event.properties.valueOf("bool_force")) + assertEquals(Property.Type.BOOLEAN, event.properties.propertyOf("bool_force")?.forceType) + + assertEquals("1", event.properties.valueOf("int_force")) + assertEquals(Property.Type.INTEGER, event.properties.propertyOf("int_force")?.forceType) + + assertEquals("1.0", event.properties.valueOf("double_force")) + assertEquals(Property.Type.FLOAT, event.properties.propertyOf("double_force")?.forceType) + + assertEquals(1, event.properties.valueOf("string_force")) + assertEquals(Property.Type.STRING, event.properties.propertyOf("string_force")?.forceType) + + assertEquals(setOf("1", "2", "3"), event.properties.setOf("intArray_force")) + assertEquals( + Property.Type.INTEGER_ARRAY, + event.properties.propertyOf("intArray_force")?.forceType + ) + + assertEquals(setOf("1.0", "2.0", "3.0"), event.properties.setOf("doubleArray_force")) + assertEquals( + Property.Type.FLOAT_ARRAY, + event.properties.propertyOf("doubleArray_force")?.forceType + ) + + assertEquals(setOf(1, 2, 3), event.properties.setOf("stringArray_force")) + assertEquals( + Property.Type.STRING_ARRAY, + event.properties.propertyOf("stringArray_force")?.forceType + ) + } + + @Test + fun `Check getUser`() { + val pianoAnalytics: PianoAnalytics = mockk() + every { PianoAnalytics.Companion.getInstance() } returns pianoAnalytics + every { pianoAnalytics.userStorage.currentUser } returns User( + "WEB-192203AJ", + "premium", + false + ) + + val result: MethodChannel.Result = mockk() + val slot = slot>() + every { result.success(capture(slot)) } returns Unit + + call("getUser", null, result) + + val user = slot.captured + assertEquals("WEB-192203AJ", user["id"]) + assertEquals("premium", user["category"]) + } + + @Test + fun `Check setUser`() { + val pianoAnalytics: PianoAnalytics = mockk() + every { PianoAnalytics.Companion.getInstance() } returns pianoAnalytics + + val slot = slot() + every { pianoAnalytics.userStorage.currentUser = capture(slot) } answers {} + + call( + "setUser", mapOf( + "id" to "WEB-192203AJ", + "category" to "premium", + "enableStorage" to false + ) + ) + + val user = slot.captured + assertEquals("WEB-192203AJ", user.id) + assertEquals("premium", user.category) + } + + @Test + fun `Check deleteUser`() { + val pianoAnalytics: PianoAnalytics = mockk() + every { PianoAnalytics.Companion.getInstance() } returns pianoAnalytics + every { pianoAnalytics.userStorage.currentUser = null } answers {} + + call("deleteUser") + + verify { pianoAnalytics.userStorage.currentUser = null } + } + + @Test + fun `Check getVisitorId`() { + every { PianoAnalytics.Companion.init(any(), any(), any(), any()) } returns mockk() + + val pianoAnalytics: PianoAnalytics = mockk() + every { PianoAnalytics.Companion.getInstance() } returns pianoAnalytics + every { pianoAnalytics.customVisitorId } returns "WEB-192203AJ" + every { pianoAnalytics.visitorId } returns "WEB-192203AJ2" + + val result: MethodChannel.Result = mockk() + val slot = slot() + every { result.success(capture(slot)) } returns Unit + + val plugin = PianoAnalyticsPlugin() + + call( + "init", mapOf( + "site" to 123456789, + "collectDomain" to "xxxxxxx.pa-cd.com", + "visitorIDType" to "CUSTOM" + ), null + ) { plugin } + call("getVisitorId", null, result) { plugin } + assertEquals("WEB-192203AJ", slot.captured) + + call( + "init", mapOf( + "site" to 123456789, + "collectDomain" to "xxxxxxx.pa-cd.com", + "visitorIDType" to "UUID" + ), null + ) { plugin } + call("getVisitorId", null, result) { plugin } + assertEquals("WEB-192203AJ2", slot.captured) + } + + @Test + fun `Check setVisitorId`() { + val pianoAnalytics: PianoAnalytics = mockk() + every { PianoAnalytics.Companion.getInstance() } returns pianoAnalytics + + val slot = slot() + every { pianoAnalytics.customVisitorId = capture(slot) } answers {} + + call( + "setVisitorId", mapOf( + "visitorId" to "WEB-192203AJ" + ) + ) + + assertEquals("WEB-192203AJ", slot.captured) + } + + @Test + fun `Check privacyChangeStorageFeatures`() { + mockkObject(PrivacyMode.EXEMPT) + mockkObject(PrivacyMode.CUSTOM) + + val allowedStorageFeatures = mutableSetOf() + val forbiddenStorageFeatures = mutableSetOf() + every { PrivacyMode.EXEMPT.allowedStorageFeatures } returns allowedStorageFeatures + every { PrivacyMode.CUSTOM.forbiddenStorageFeatures } returns forbiddenStorageFeatures + + call( + "privacyIncludeStorageFeatures", mapOf( + "features" to listOf("CRASH"), + "modes" to listOf("exempt") + ) + ) + + assertEquals(setOf(PrivacyStorageFeature.CRASH), allowedStorageFeatures) + + call( + "privacyExcludeStorageFeatures", mapOf( + "features" to listOf("LIFECYCLE"), + "modes" to listOf("custom") + ) + ) + + assertEquals(setOf(PrivacyStorageFeature.LIFECYCLE), forbiddenStorageFeatures) + } + + @Test + fun `Check privacyChangeProperty`() { + mockkObject(PrivacyMode.EXEMPT) + mockkObject(PrivacyMode.CUSTOM) + + val allowedPropertyKeys = mutableMapOf( + Event.PAGE_DISPLAY to mutableSetOf( + PropertyName("allowed_property_1"), + PropertyName("allowed_property_3"), + ) + ) + val forbiddenPropertyKeys = mutableMapOf( + Event.ANY to mutableSetOf( + PropertyName("forbidden_property_1"), + PropertyName("forbidden_property_3"), + ) + ) + every { PrivacyMode.EXEMPT.allowedPropertyKeys } returns allowedPropertyKeys + every { PrivacyMode.CUSTOM.forbiddenPropertyKeys } returns forbiddenPropertyKeys + + call( + "privacyIncludeProperties", mapOf( + "propertyNames" to listOf("allowed_property_1", "allowed_property_2"), + "modes" to listOf("exempt"), + "eventNames" to listOf(Event.PAGE_DISPLAY) + ) + ) + + assertEquals(1, allowedPropertyKeys.count()) + assertEquals( + mutableSetOf( + PropertyName("allowed_property_1"), + PropertyName("allowed_property_2"), + PropertyName("allowed_property_3"), + ), + allowedPropertyKeys[Event.PAGE_DISPLAY] + ) + + call( + "privacyExcludeProperties", mapOf( + "propertyNames" to listOf("forbidden_property_1", "forbidden_property_2"), + "modes" to listOf("custom") + ) + ) + + assertEquals(1, forbiddenPropertyKeys.count()) + assertEquals( + mutableSetOf( + PropertyName("forbidden_property_1"), + PropertyName("forbidden_property_2"), + PropertyName("forbidden_property_3"), + ), + forbiddenPropertyKeys[Event.ANY] + ) + } + + @Test + fun `Check privacyChangeEvents`() { + mockkObject(PrivacyMode.EXEMPT) + mockkObject(PrivacyMode.CUSTOM) + + val allowedEventNames = mutableSetOf(Event.PAGE_DISPLAY, Event.ANY) + val forbiddenEventNames = mutableSetOf(Event.PAGE_DISPLAY, Event.ANY) + every { PrivacyMode.EXEMPT.allowedEventNames } returns allowedEventNames + every { PrivacyMode.CUSTOM.forbiddenEventNames } returns forbiddenEventNames + + call( + "privacyIncludeEvents", mapOf( + "eventNames" to listOf(Event.PAGE_DISPLAY, Event.CLICK_ACTION), + "modes" to listOf("exempt") + ) + ) + + assertEquals(3, allowedEventNames.count()) + assertEquals(allowedEventNames, setOf(Event.PAGE_DISPLAY, Event.ANY, Event.CLICK_ACTION)) + + call( + "privacyExcludeEvents", mapOf( + "eventNames" to listOf(Event.PAGE_DISPLAY, Event.CLICK_ACTION), + "modes" to listOf("custom") + ) + ) + + assertEquals(3, forbiddenEventNames.count()) + assertEquals(forbiddenEventNames, setOf(Event.PAGE_DISPLAY, Event.ANY, Event.CLICK_ACTION)) } - private fun Set.valueOf(name: String) = this.firstOrNull { it.name.key == name }?.value - private fun Set.setOf(name: String) = (this.valueOf(name) as? Array<*>)?.toSet() + private fun Set.propertyOf(name: String) = this.firstOrNull { it.name.key == name } + private fun Set.valueOf(name: String) = propertyOf(name)?.value + private fun Set.setOf(name: String) = (valueOf(name) as? Array<*>)?.toSet() - private fun call(method: String, parameters: Map? = null) { - return call(method, parameters) { PianoAnalyticsPlugin() } + private fun call( + method: String, + parameters: Map? = null, + result: MethodChannel.Result? = null + ) { + call(method, parameters, result) { PianoAnalyticsPlugin() } } } diff --git a/android/src/test/kotlin/io/piano/flutter/piano_analytics/PianoConsentsPluginTest.kt b/android/src/test/kotlin/io/piano/flutter/piano_analytics/PianoConsentsPluginTest.kt deleted file mode 100644 index 580b1c4..0000000 --- a/android/src/test/kotlin/io/piano/flutter/piano_analytics/PianoConsentsPluginTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package io.piano.flutter.piano_analytics - -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkObject -import io.mockk.slot -import io.mockk.verify -import io.piano.android.consents.PianoConsents -import io.piano.android.consents.models.ConsentConfiguration -import io.piano.android.consents.models.ConsentMode -import io.piano.android.consents.models.Product -import io.piano.android.consents.models.Purpose -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -internal class PianoConsentsPluginTest: BasePluginTest() { - - @BeforeTest - fun setUp() { - mockkObject(PianoConsents.Companion) - } - - @Test - fun `Check PianoConsents init`() { - every { PianoConsents.Companion.init(any(), any()) } returns mockk() - - call("init", mapOf( - "requireConsents" to true, - "defaultPurposes" to mapOf( - "PA" to "AM" - ) - )) - - val slot = slot() - verify { PianoConsents.init(any(), capture(slot)) } - - val configuration = slot.captured - assertEquals(true, configuration.requireConsent) - assertNotNull(configuration.defaultPurposes) - assertEquals(Purpose.AUDIENCE_MEASUREMENT, configuration.defaultPurposes!![Product.PA]) - } - - @Test - fun `Check PianoConsents set`() { - val pianoConsents = getPianoConsents() - every { pianoConsents.set(any(), any(), any()) } returns Unit - - call("set", mapOf( - "purpose" to "AM", - "mode" to "opt-in", - "products" to listOf("PA") - )) - - val purpose = slot() - val mode = slot() - val product = slot() - verify { pianoConsents.set(capture(purpose), capture(mode), capture(product)) } - - assertEquals(Purpose.AUDIENCE_MEASUREMENT, purpose.captured) - assertEquals(ConsentMode.OPT_IN, mode.captured) - assertEquals(Product.PA, product.captured) - } - - @Test - fun `Check PianoConsents setAll`() { - val pianoConsents = getPianoConsents() - every { pianoConsents.setAll(any()) } returns Unit - - call("setAll", mapOf( - "mode" to "opt-in" - )) - - val mode = slot() - verify { pianoConsents.setAll(capture(mode)) } - - assertEquals(ConsentMode.OPT_IN, mode.captured) - } - - @Test - fun `Check PianoConsents clear`() { - val pianoConsents = getPianoConsents() - every { pianoConsents.clear() } returns Unit - call("clear") - verify { pianoConsents.clear() } - } - - private fun getPianoConsents(): PianoConsents { - val pianoConsents: PianoConsents = mockk() - every { PianoConsents.getInstance() } returns pianoConsents - return pianoConsents - } - - private fun call(method: String, parameters: Map? = null) { - return call(method, parameters) { PianoConsentsPlugin() } - } -} \ No newline at end of file diff --git a/example/README.md b/example/README.md deleted file mode 100644 index 6515377..0000000 --- a/example/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# piano_analytics_example - -Demonstrates how to use the piano_analytics plugin. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/example/android/build.gradle b/example/android/build.gradle index d2ffbff..24e24ae 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -5,14 +5,14 @@ allprojects { } } -rootProject.buildDir = "../build" +rootProject.layout.buildDirectory = "../build" subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" + project.layout.buildDirectory = "${rootProject.buildDir}/${project.name}" } subprojects { project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { - delete rootProject.buildDir + delete rootProject.layout.buildDirectory } diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 3b5b324..39a309e 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx4G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +android.suppressUnsupportedCompileSdk=34 diff --git a/example/android/settings.gradle b/example/android/settings.gradle index f75a38f..c7d430c 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false + id "com.android.application" version '7.4.2' apply false id "org.jetbrains.kotlin.android" version "1.9.0" apply false } diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 4286fd5..0000000 --- a/example/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Flutter -import UIKit -import XCTest - -@testable import piano_analytics - -// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. -// -// See https://developer.apple.com/documentation/xctest for more information about using XCTest. - -class RunnerTests: XCTestCase { - - func testGetPlatformVersion() { - let plugin = PianoAnalyticsPlugin() - - let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) - - let resultExpectation = expectation(description: "result block must be called.") - plugin.handle(call) { result in - XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) - resultExpectation.fulfill() - } - waitForExpectations(timeout: 1) - } - -} diff --git a/example/lib/main.dart b/example/lib/main.dart index d1fd55e..5287207 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:piano_analytics/enums.dart'; -import 'package:piano_analytics/piano_consents.dart'; import 'package:piano_analytics/piano_analytics.dart'; void main() { - runApp(const MyApp()); + runApp(const MaterialApp(home: MyApp())); } class MyApp extends StatefulWidget { @@ -15,17 +15,14 @@ class MyApp extends StatefulWidget { } class _MyAppState extends State { - final _pianoConsents = PianoConsents( - requireConsents: true, - defaultPurposes: { - PianoConsentProduct.pa: PianoConsentPurpose.audienceMeasurement - } - ); - final _pianoAnalytics = PianoAnalytics( - site: 123456789, - collectDomain: "xxxxxxx.pa-cd.com" - ); + site: 123456789, + collectDomain: "xxxxxxx.pa-cd.com", + visitorIDType: VisitorIDType.uuid, + storageLifetimeVisitor: 395, + visitorStorageMode: VisitorStorageMode.fixed, + ignoreLimitedAdvertisingTracking: true, + visitorId: "WEB-192203AJ"); @override void initState() { @@ -34,7 +31,6 @@ class _MyAppState extends State { } Future initPlugins() async { - await _pianoConsents.init(); await _pianoAnalytics.init(); } @@ -48,6 +44,7 @@ class _MyAppState extends State { body: Center( child: Column( children: [ + // Send events FilledButton( onPressed: () async { await _pianoAnalytics.sendEvents(events: [ @@ -66,25 +63,150 @@ class _MyAppState extends State { ]) ]); }, - child: const Text("Send")), + child: const Text("Send events")), + // Get user FilledButton( onPressed: () async { - await _pianoConsents.set( - purpose: PianoConsentPurpose.audienceMeasurement, - mode: PianoConsentMode.optIn, - products: [PianoConsentProduct.pa]); + var user = await _pianoAnalytics.getUser(); + if (context.mounted) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text("User inforamtion"), + content: user != null + ? Text( + "ID: ${user.id}, Category: ${user.category ?? "-"}") + : const Text("-"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'OK'), + child: const Text('OK'), + ), + ], + ); + }); + } }, - child: const Text("Set consents")), + child: const Text("Get user")), + // Set user FilledButton( onPressed: () async { - await _pianoConsents.setAll(mode: PianoConsentMode.optOut); + await _pianoAnalytics.setUser( + id: "WEB-192203AJ", + category: "premium", + enableStorage: false); }, - child: const Text("Set all consents")), + child: const Text("Set user")), + // Delete user FilledButton( onPressed: () async { - await _pianoConsents.clear(); + await _pianoAnalytics.deleteUser(); }, - child: const Text("Clear consents")) + child: const Text("Delete user")), + // Get visitor ID + FilledButton( + onPressed: () async { + var visitorId = await _pianoAnalytics.getVisitorId(); + if (context.mounted) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text("Visitor ID"), + content: visitorId != null + ? Text(visitorId) + : const Text("-"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'OK'), + child: const Text('OK'), + ), + ], + ); + }); + } + }, + child: const Text("Get visitor ID")), + // Set visitor ID + FilledButton( + onPressed: () async { + await _pianoAnalytics.setVisitorId( + visitorId: "WEB-192203AJ"); + }, + child: const Text("Set visitor ID")), + // Include storage features (privcy) + FilledButton( + onPressed: () async { + await _pianoAnalytics.privacyIncludeStorageFeatures( + features: [ + PrivacyStorageFeature.crash, + PrivacyStorageFeature.visitor + ], + modes: [ + PrivacyMode.custom + ]); + }, + child: const Text("Include storage features (privcy)")), + // Exclude storage features (privcy) + FilledButton( + onPressed: () async { + await _pianoAnalytics.privacyExcludeStorageFeatures( + features: [ + PrivacyStorageFeature.crash, + PrivacyStorageFeature.visitor + ], + modes: [ + PrivacyMode.custom + ]); + }, + child: const Text("Exclude storage features (privcy)")), + // Include properties (privcy) + FilledButton( + onPressed: () async { + await _pianoAnalytics.privacyIncludeProperties( + propertyNames: [ + "allowed_property_1", + "allowed_property_2" + ], + modes: [ + PrivacyMode.custom + ], + eventNames: [ + "page.display", + "click.action" + ]); + }, + child: const Text("Include properties (privcy)")), + // Exclude properties (privcy) + FilledButton( + onPressed: () async { + await _pianoAnalytics.privacyExcludeProperties( + propertyNames: [ + "forbidden_property_1", + "forbidden_property_2" + ], + modes: [ + PrivacyMode.custom + ]); + }, + child: const Text("Exclude properties (privcy)")), + // Include events (privcy) + FilledButton( + onPressed: () async { + await _pianoAnalytics.privacyIncludeEvents( + eventNames: ["page.display"], + modes: [PrivacyMode.custom]); + }, + child: const Text("Include events (privcy)")), + // Exclude events (privcy) + FilledButton( + onPressed: () async { + await _pianoAnalytics.privacyExcludeEvents( + eventNames: ["click.action"], + modes: [PrivacyMode.custom]); + }, + child: const Text("Exclude events (privcy)")) ], ), ), diff --git a/ios/Classes/PianoAnalyticsPlugin.swift b/ios/Classes/PianoAnalyticsPlugin.swift index 67fec48..97a8036 100644 --- a/ios/Classes/PianoAnalyticsPlugin.swift +++ b/ios/Classes/PianoAnalyticsPlugin.swift @@ -13,7 +13,7 @@ fileprivate class Reader: FlutterStandardReader { case dateType: var value: Int64 = 0 readBytes(&value, length: 8) - return dateFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(value / 1000 ))) + return Date(timeIntervalSince1970: TimeInterval(value / 1000 )) default: return super.readValue(ofType: type) } @@ -36,21 +36,50 @@ public class PianoAnalyticsPlugin: NSObject, FlutterPlugin { ) let instance = PianoAnalyticsPlugin() registrar.addMethodCallDelegate(instance, channel: channel) - - PianoConsentsPlugin.register(with: registrar) } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { do { + var skipResult = false switch call.method { + // Main case "init": try handleInit(call) case "send": try handleSend(call) + // User + case "getUser": + skipResult = true + try handleGetUser(result) + case "setUser": + try handleSetUser(call) + case "deleteUser": + PianoAnalytics.shared.deleteUser() + // Visitor + case "getVisitorId": + skipResult = true + handleGetVisitorId(result) + case "setVisitorId": + try handleSetVisitorId(call) + // Privacy + case "privacyIncludeStorageFeatures": + try privacyChangeStorageFeatures(call) + case "privacyExcludeStorageFeatures": + try privacyChangeStorageFeatures(call, false) + case "privacyIncludeProperties": + try privacyChangeProperties(call) + case "privacyExcludeProperties": + try privacyChangeProperties(call, false) + case "privacyIncludeEvents": + try privacyChangeEvents(call) + case "privacyExcludeEvents": + try privacyChangeEvents(call, false) default: result(FlutterMethodNotImplemented) } - result(nil) + if !skipResult { + result(nil) + } } catch let error as PluginError { switch error { case .message(let message): result(FlutterError.from(call, message)) @@ -63,14 +92,30 @@ public class PianoAnalyticsPlugin: NSObject, FlutterPlugin { private func handleInit(_ call: FlutterMethodCall) throws { let arguments = try getArguments(call) + let visitorIdType = VisitorIdType.from(try getArgument(call, arguments, "visitorIDType")) - PianoAnalytics.shared.setConfiguration( - ConfigurationBuilder() - .withSite(try getArgument(call, arguments, "site")) - .withCollectDomain(try getArgument(call, arguments, "collectDomain")) - .withVisitorIdType(VisitorIdType.from(try getArgument(call, arguments, "visitorIDType")).rawValue) - .build() - ) + var configurationBuilder = ConfigurationBuilder() + .withSite(try getArgument(call, arguments, "site")) + .withCollectDomain(try getArgument(call, arguments, "collectDomain")) + .withVisitorIdType(visitorIdType.rawValue) + + if let storageLifetimeVisitor = arguments["storageLifetimeVisitor"] as? Int { + _ = configurationBuilder.withStorageLifetimeVisitor(storageLifetimeVisitor) + } + + if let visitorStorageMode = arguments["visitorStorageMode"] as? String { + _ = configurationBuilder.withVisitorStorageMode(try PianoAnalyticsPlugin.getVisitorStorageMode(visitorStorageMode)) + } + + if let ignoreLimitedAdvertisingTracking = arguments["ignoreLimitedAdvertisingTracking"] as? Bool { + _ = configurationBuilder.enableIgnoreLimitedAdTracking(ignoreLimitedAdvertisingTracking) + } + + PianoAnalytics.shared.setConfiguration(configurationBuilder.build()) + + if let visitorId = arguments["visitorId"] as? String, visitorIdType == .Custom { + PianoAnalytics.shared.setVisitorId(visitorId) + } } private func handleSend(_ call: FlutterMethodCall) throws { @@ -82,10 +127,155 @@ public class PianoAnalyticsPlugin: NSObject, FlutterPlugin { guard let name = event["name"] as? String else { throw PluginError.message("Undefined event name") } - return Event(name, data: event["data"] as? [String:Any] ?? [:]) + let properties = try (event["data"] as? [String:Any])?.map { property in + guard let value = property.value as? [String:Any] else { + throw PluginError.message("Undefined property value of event \"\(property.key)\"") + } + guard let propertyValue = value["value"] else { + throw PluginError.message("Undefined property value of event \"\(property.key)\"") + } + return try Property.from(property.key, propertyValue, forceType: Property.forceType(value["forceType"] as? String)) + } + return Event(name, properties: Set(properties ?? [])) + } + ) + } + + private func handleGetUser(_ result: @escaping FlutterResult) throws { + PianoAnalytics.shared.getUser { user in + guard let user else { + result(nil) + return } + + result([ + "id": user.id, + "category": user.category + ]) + } + } + + private func handleSetUser(_ call: FlutterMethodCall) throws { + let arguments = try getArguments(call) + + PianoAnalytics.shared.setUser( + try getArgument(call, arguments, "id"), + category: arguments["category"] as? String, + enableStorage: arguments["enableStorage"] as? Bool ?? true ) } + + private func handleGetVisitorId(_ result: @escaping FlutterResult) { + PianoAnalytics.shared.getVisitorId { visitorId in + result(visitorId) + } + } + + private func handleSetVisitorId(_ call: FlutterMethodCall) throws { + let arguments = try getArguments(call) + let visitorId: String = try getArgument(call, arguments, "visitorId") + + PianoAnalytics.shared.getConfiguration(.VisitorIdType) { type in + if type == VisitorIdType.Custom.rawValue { + PianoAnalytics.shared.setVisitorId(visitorId) + } + } + } + + private func privacyChangeStorageFeatures(_ call: FlutterMethodCall, _ include: Bool = true) throws { + let arguments = try getArguments(call) + + let features: [String] = try getArgument(call, arguments, "features") + let keys = try features.map { try PianoAnalyticsPlugin.getStorageKeyByFeature($0) } + + let modes: [String] = try getArgument(call, arguments, "modes") + let privacyModes = try modes.map { try PianoAnalyticsPlugin.getPrivacyMode($0) } + + if include { + PianoAnalytics.shared.privacyIncludeStorageKeys(keys, privacyModes: privacyModes) + } else { + PianoAnalytics.shared.privacyExcludeStorageKeys(keys, privacyModes: privacyModes) + } + } + + private func privacyChangeProperties(_ call: FlutterMethodCall, _ include: Bool = true) throws { + let arguments = try getArguments(call) + + let propertyNames: [String] = try getArgument(call, arguments, "propertyNames") + + let modes: [String] = try getArgument(call, arguments, "modes") + let privacyModes = try modes.map { try PianoAnalyticsPlugin.getPrivacyMode($0) } + + let eventNames = arguments["eventNames"] as? [String] + + if include { + PianoAnalytics.shared.privacyIncludeProperties(propertyNames, privacyModes: privacyModes, eventNames: eventNames) + } else { + PianoAnalytics.shared.privacyExcludeProperties(propertyNames, privacyModes: privacyModes, eventNames: eventNames) + } + } + + private func privacyChangeEvents(_ call: FlutterMethodCall, _ include: Bool = true) throws { + let arguments = try getArguments(call) + + let eventNames: [String] = try getArgument(call, arguments, "eventNames") + + let modes: [String] = try getArgument(call, arguments, "modes") + let privacyModes = try modes.map { try PianoAnalyticsPlugin.getPrivacyMode($0) } + + if include { + PianoAnalytics.shared.privacyIncludeEvents(eventNames, privacyModes: privacyModes) + } else { + PianoAnalytics.shared.privacyExcludeEvents(eventNames, privacyModes: privacyModes) + } + } + + private static func getStorageKeyByFeature(_ feature: String) throws -> String { + switch feature { + case "VISITOR": + return PA.Privacy.Storage.VisitorId + case "CRASH": + return PA.Privacy.Storage.Crash + case "LIFECYCLE": + return PA.Privacy.Storage.Lifecycle + case "PRIVACY": + return PA.Privacy.Storage.Privacy + case "USER": + return PA.Privacy.Storage.User + default: + throw PluginError.message("Invalid feature \"\(feature)\"") + } + } + + private static func getPrivacyMode(_ name: String) throws -> String { + switch name { + case "opt-in": + return "optin" + case "opt-out": + return "optout" + case "exempt": + return "exempt" + case "custom": + return "custom" + case "no-consent": + return "no-consent" + case "no-storage": + return "no-storage" + default: + throw PluginError.message("Invalid privacy mode \"\(name)\"") + } + } + + private static func getVisitorStorageMode(_ name: String) throws -> String { + switch name { + case "fixed": + return "fixed" + case "relative": + return "relative" + default: + throw PluginError.message("Invalid visitor storage mode \"\(name)\"") + } + } } fileprivate extension VisitorIdType { @@ -93,7 +283,63 @@ fileprivate extension VisitorIdType { static func from(_ name: String) -> Self { switch name { case "ADID": return VisitorIdType.IDFA + case "CUSTOM": return VisitorIdType.Custom default: return VisitorIdType.UUID } } } + +fileprivate extension Property { + + static func forceType(_ name: String?) throws -> PA.PropertyType? { + guard let name else { + return nil + } + + switch name { + case "b": + return .bool + case "n": + return .int + case "f": + return .float + case "s": + return .string + case "d": + return .date + case "a:n": + return .intArray + case "a:f": + return .floatArray + case "a:s": + return .stringArray + default: + throw PluginError.message("Undefined force type \"\(name)\"") + } + } + + static func from(_ name: String, _ value: Any, forceType: PA.PropertyType? = nil) throws -> Property { + switch value { + case let v as Bool: + return try Property(name, v, forceType: forceType) + case let v as Int: + return try Property(name, v, forceType: forceType) + case let v as Int64: + return try Property(name, v, forceType: forceType) + case let v as Double: + return try Property(name, v, forceType: forceType) + case let v as String: + return try Property(name, v, forceType: forceType) + case let v as Date: + return try Property(name, v) + case let v as [Int]: + return try Property(name, v, forceType: forceType) + case let v as [Double]: + return try Property(name, v, forceType: forceType) + case let v as [String]: + return try Property(name, v, forceType: forceType) + default: + throw PluginError.message("Undefined type for event property \"\(name)\"") + } + } +} diff --git a/ios/Classes/PianoConsentsPlugin.swift b/ios/Classes/PianoConsentsPlugin.swift deleted file mode 100644 index 181429f..0000000 --- a/ios/Classes/PianoConsentsPlugin.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Flutter -import UIKit - -import PianoConsents - -public class PianoConsentsPlugin: NSObject, FlutterPlugin { - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel( - name: "piano_consents", binaryMessenger: registrar.messenger() - ) - let instance = PianoConsentsPlugin() - registrar.addMethodCallDelegate(instance, channel: channel) - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - do { - switch call.method { - case "init": - try handleInit(call) - case "set": - try handleSet(call) - case "setAll": - try handleSetAll(call) - case "clear": - handleClear() - default: - result(FlutterMethodNotImplemented) - } - result(nil) - } catch let error as PluginError { - switch error { - case .message(let message): result(FlutterError.from(call, message)) - case .inner(let inner): result(inner) - } - } catch { - result(FlutterError.from(call, error.localizedDescription)) - } - } - - private func handleInit(_ call: FlutterMethodCall) throws { - let arguments = try getArguments(call) - - let requireConsents = arguments["requireConsents"] as? Bool ?? false - - var defaultPurposes: [PianoConsentProduct:PianoConsentPurpose]? - if let purposes = arguments["defaultPurposes"] as? [String:String], !purposes.isEmpty { - defaultPurposes = Dictionary( - try purposes.map { (try PianoConsentProduct.from($0), try PianoConsentPurpose.from($1)) }, - uniquingKeysWith: { key, _ in key } - ) - } - - PianoConsents.initialize( - configuration: PianoConsentsConfiguration( - requireConsents: requireConsents, - defaultPurposes: defaultPurposes - ) - ) - } - - private func handleSet(_ call: FlutterMethodCall) throws { - let arguments = try getArguments(call) - - try PianoConsents.shared.set( - purpose: try PianoConsentPurpose.from(try getArgument(call, arguments, "purpose")), - mode: try PianoConsentMode.from(try getArgument(call, arguments, "mode")), - products: try (arguments["products"] as? [String])?.map { try PianoConsentProduct.from($0) } ?? [] - ) - } - - private func handleSetAll(_ call: FlutterMethodCall) throws { - try PianoConsents.shared.setAll( - mode: try PianoConsentMode.from(try getArgument(call, try getArguments(call), "mode")) - ) - } - - private func handleClear() { - PianoConsents.shared.clear() - } -} - -fileprivate extension PianoConsentProduct { - - static func from(_ name: String) throws -> Self { - switch name { - case "PA": return PianoConsentProduct.PA - case "DMP": return PianoConsentProduct.DMP - case "COMPOSER": return PianoConsentProduct.COMPOSER - case "ID": return PianoConsentProduct.ID - case "VX": return PianoConsentProduct.VX - case "ESP": return PianoConsentProduct.ESP - case "SOCIAL_FLOW": return PianoConsentProduct.SOCIAL_FLOW - default: throw PluginError.message("Invalid value for consent product \"\(name)\"") - } - } -} - -fileprivate extension PianoConsentPurpose { - - static func from(_ name: String) throws -> Self { - switch name { - case "AM": return PianoConsentPurpose.AUDIENCE_MEASUREMENT - case "CP": return PianoConsentPurpose.CONTENT_PERSONALISATION - case "AD": return PianoConsentPurpose.ADVERTISING - case "PR": return PianoConsentPurpose.PERSONAL_RELATIONSHIP - default: throw PluginError.message("Invalid value for consent purpose \"\(name)\"") - } - } -} - -fileprivate extension PianoConsentMode { - - static func from(_ name: String) throws -> Self { - switch name { - case "opt-in": return PianoConsentMode.OPT_IN - case "essential": return PianoConsentMode.ESSENTIAL - case "opt-out": return PianoConsentMode.OPT_OUT - case "custom": return PianoConsentMode.CUSTOM - case "not-acquired": return PianoConsentMode.NOT_ACQUIRED - default: throw PluginError.message("Invalid value for consent mode \"\(name)\"") - } - } -} diff --git a/ios/piano_analytics.podspec b/ios/piano_analytics.podspec index d12e422..a97d2b9 100644 --- a/ios/piano_analytics.podspec +++ b/ios/piano_analytics.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'piano_analytics' - s.version = '1.0.0' + s.version = '1.0.1' s.summary = 'Piano Analytics SDK Flutter' s.homepage = 'https://piano.io/product/analytics/' s.license = { :file => '../LICENSE' } diff --git a/lib/enums.dart b/lib/enums.dart new file mode 100644 index 0000000..4f290d7 --- /dev/null +++ b/lib/enums.dart @@ -0,0 +1,58 @@ +enum VisitorIDType { + uuid("UUID"), + adid("ADID"), + custom("CUSTOM"); + + final String value; + + const VisitorIDType(this.value); +} + +enum VisitorStorageMode { + fixed("fixed"), + relative("relative"); + + final String value; + + const VisitorStorageMode(this.value); +} + +enum PropertyType { + bool("b"), + int("n"), + float("f"), + string("s"), + date("d"), + intArray("a:n"), + floatArray("a:f"), + stringArray("a:s"); + + final String value; + + const PropertyType(this.value); +} + +enum PrivacyMode { + optIn("opt-in"), + optOut("opt-out"), + exempt("exempt"), + custom("custom"), + noConsent("no-consent"), + noStorage("no-storage"); + + final String value; + + const PrivacyMode(this.value); +} + +enum PrivacyStorageFeature { + visitor("VISITOR"), + crash("CRASH"), + lifecycle("LIFECYCLE"), + privacy("PRIVACY"), + user("USER"); + + final String value; + + const PrivacyStorageFeature(this.value); +} diff --git a/lib/piano_analytics.dart b/lib/piano_analytics.dart index ad8ed38..c71d0d9 100644 --- a/lib/piano_analytics.dart +++ b/lib/piano_analytics.dart @@ -1,50 +1,62 @@ import 'package:flutter/services.dart'; -import 'package:piano_analytics/piano_consents.dart'; - -enum PianoAnalyticsVisitorIDType { - uuid("UUID"), - adid("ADID"); - - final String value; - - const PianoAnalyticsVisitorIDType(this.value); -} +import 'package:piano_analytics/enums.dart'; class Property { final String _name; final dynamic _value; + final PropertyType? _forceType; - Property.bool({required String name, required bool value}) + Property.bool( + {required String name, required bool value, PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; - Property.int({required String name, required int value}) + Property.int( + {required String name, required int value, PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; - Property.double({required String name, required double value}) + Property.double( + {required String name, required double value, PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; - Property.string({required String name, required String value}) + Property.string( + {required String name, required String value, PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; - Property.date({required String name, required DateTime value}) + Property.date( + {required String name, required DateTime value, PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; - Property.intArray({required String name, required List value}) + Property.intArray( + {required String name, required List value, PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; - Property.doubleArray({required String name, required List value}) + Property.doubleArray( + {required String name, + required List value, + PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; - Property.stringArray({required String name, required List value}) + Property.stringArray( + {required String name, + required List value, + PropertyType? forceType}) : _name = name, - _value = value; + _value = value, + _forceType = forceType; } class Event { @@ -59,12 +71,25 @@ class Event { return { "name": _name, "data": _properties != null - ? {for (var property in _properties) property._name: property._value} + ? { + for (var property in _properties) + property._name: { + "value": property._value, + "forceType": property._forceType?.value + } + } : null }; } } +class User { + final String id; + final String? category; + + User({required this.id, this.category}); +} + class PianoAnalytics { static final _pianoAnalyticsChannel = MethodChannel( "piano_analytics", StandardMethodCodec(PianoAnalyticsMessageCodec())); @@ -77,14 +102,20 @@ class PianoAnalytics { PianoAnalytics( {required int site, required String collectDomain, - PianoAnalyticsVisitorIDType visitorIDType = - PianoAnalyticsVisitorIDType.uuid, - PianoConsents? consents, + VisitorIDType visitorIDType = VisitorIDType.uuid, + int? storageLifetimeVisitor, + VisitorStorageMode? visitorStorageMode, + bool? ignoreLimitedAdvertisingTracking, + String? visitorId, MethodChannel? channel}) : _parameters = { "site": site, "collectDomain": collectDomain, - "visitorIDType": visitorIDType.value + "visitorIDType": visitorIDType.value, + "storageLifetimeVisitor": storageLifetimeVisitor, + "visitorStorageMode": visitorStorageMode?.value, + "ignoreLimitedAdvertisingTracking": ignoreLimitedAdvertisingTracking, + "visitorId": visitorId }, _channel = channel ?? _pianoAnalyticsChannel; @@ -99,6 +130,102 @@ class PianoAnalytics { "send", {"events": events.map((event) => event.toMap()).toList()}); } + Future getUser() async { + _checkInit(); + var user = await _channel.invokeMethod("getUser") as Map?; + if (user == null) { + return null; + } + return User( + id: user["id"] as String, category: user["category"] as String?); + } + + Future setUser( + {required String id, String? category, bool enableStorage = true}) async { + _checkInit(); + await _channel.invokeMethod("setUser", + {"id": id, "category": category, "enableStorage": enableStorage}); + } + + Future deleteUser() async { + _checkInit(); + await _channel.invokeMethod("deleteUser"); + } + + Future getVisitorId() async { + _checkInit(); + return await _channel.invokeMethod("getVisitorId") as String?; + } + + Future setVisitorId({required String visitorId}) async { + _checkInit(); + await _channel.invokeMethod("setVisitorId", {"visitorId": visitorId}); + } + + Future privacyIncludeStorageFeatures( + {required List features, + required List modes}) async { + _checkInit(); + await _channel.invokeMethod("privacyIncludeStorageFeatures", { + "features": features.map((feature) => feature.value).toList(), + "modes": modes.map((mode) => mode.value).toList() + }); + } + + Future privacyExcludeStorageFeatures( + {required List features, + required List modes}) async { + _checkInit(); + await _channel.invokeMethod("privacyExcludeStorageFeatures", { + "features": features.map((feature) => feature.value).toList(), + "modes": modes.map((mode) => mode.value).toList() + }); + } + + Future privacyIncludeProperties( + {required List propertyNames, + required List modes, + List? eventNames}) async { + _checkInit(); + await _channel.invokeMethod("privacyIncludeProperties", { + "propertyNames": propertyNames, + "modes": modes.map((mode) => mode.value).toList(), + "eventNames": eventNames + }); + } + + Future privacyExcludeProperties( + {required List propertyNames, + required List modes, + List? eventNames}) async { + _checkInit(); + await _channel.invokeMethod("privacyExcludeProperties", { + "propertyNames": propertyNames, + "modes": modes.map((mode) => mode.value).toList(), + "eventNames": eventNames + }); + } + + Future privacyIncludeEvents( + {required List eventNames, + required List modes}) async { + _checkInit(); + await _channel.invokeMethod("privacyIncludeEvents", { + "eventNames": eventNames, + "modes": modes.map((mode) => mode.value).toList() + }); + } + + Future privacyExcludeEvents( + {required List eventNames, + required List modes}) async { + _checkInit(); + await _channel.invokeMethod("privacyExcludeEvents", { + "eventNames": eventNames, + "modes": modes.map((mode) => mode.value).toList() + }); + } + void _checkInit() { if (!_initialized) { throw Error.safeToString("PianoAnalytics not initialized"); diff --git a/lib/piano_consents.dart b/lib/piano_consents.dart deleted file mode 100644 index c39ccde..0000000 --- a/lib/piano_consents.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/services.dart'; - -enum PianoConsentProduct { - pa("PA"), - dmp("DMP"), - composer("COMPOSER"), - id("ID"), - vx("VX"), - esp("ESP"), - socialFlow("SOCIAL_FLOW"); - - final String value; - - const PianoConsentProduct(this.value); -} - -enum PianoConsentPurpose { - audienceMeasurement("AM"), - contentPersonalisation("CP"), - advertising("AD"), - personalRelationship("PR"); - - final String value; - - const PianoConsentPurpose(this.value); -} - -enum PianoConsentMode { - optIn("opt-in"), - essential("essential"), - optOut("opt-out"), - custom("custom"), - notAcquired("not-acquired"); - - final String value; - - const PianoConsentMode(this.value); -} - -class PianoConsents { - static const _pianoConsentsChannel = MethodChannel("piano_consents"); - - final Map _parameters; - final MethodChannel _channel; - - bool _initialized = false; - - PianoConsents( - {bool requireConsents = false, - Map? defaultPurposes, - MethodChannel? channel}) - : _parameters = { - "requireConsents": requireConsents, - "defaultPurposes": defaultPurposes?.map( - (product, purpose) => MapEntry(product.value, purpose.value)) - }, - _channel = channel ?? _pianoConsentsChannel; - - Future init() async { - await _channel.invokeMethod("init", _parameters); - _initialized = true; - } - - Future set( - {required PianoConsentPurpose purpose, - required PianoConsentMode mode, - List? products}) async { - _checkInit(); - await _channel.invokeMethod("set", { - "purpose": purpose.value, - "mode": mode.value, - "products": products?.map((product) => product.value).toList() - }); - } - - Future setAll({required PianoConsentMode mode}) async { - _checkInit(); - await _channel.invokeMethod("setAll", {"mode": mode.value}); - } - - Future clear() async { - _checkInit(); - await _channel.invokeMethod("clear"); - } - - void _checkInit() { - if (!_initialized) { - throw Error.safeToString("PianoConsents not initialized"); - } - } -} diff --git a/pubspec.yaml b/pubspec.yaml index e3b9fc9..f2242e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: piano_analytics description: "Piano Analytics SDK Flutter" -version: 1.0.0 +version: 1.0.1 homepage: https://piano.io/product/analytics/ repository: https://github.com/at-internet/piano-analytics-flutter diff --git a/test/piano_analytics_test.dart b/test/piano_analytics_test.dart index 801962a..7f3ae85 100644 --- a/test/piano_analytics_test.dart +++ b/test/piano_analytics_test.dart @@ -1,12 +1,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:piano_analytics/enums.dart'; import 'package:piano_analytics/piano_analytics.dart'; -PianoAnalytics getPianoAnalytics(MethodChannel channel) { - return PianoAnalytics( - site: 123456789, collectDomain: "xxxxxxx.pa-cd.com", channel: channel); -} - main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -15,12 +11,119 @@ main() { MethodCall? call; - setUp(() { + var testUserId = "WEB-192203AJ"; + var testUserCategory = "premium"; + + var testFeatures = [ + PrivacyStorageFeature.crash, + PrivacyStorageFeature.lifecycle, + PrivacyStorageFeature.privacy, + PrivacyStorageFeature.user, + PrivacyStorageFeature.visitor + ]; + + var testModes = [ + PrivacyMode.custom, + PrivacyMode.exempt, + PrivacyMode.noConsent, + PrivacyMode.noStorage, + PrivacyMode.optIn, + PrivacyMode.optOut + ]; + + var testPropertyNames = ["property_1", "property_2"]; + + var testEventNames = ["page.display", "click.action"]; + + void setHandler({dynamic result}) { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall methodCall) async { call = methodCall; - return null; + return result; }); + } + + Future getPianoAnalytics() async { + var pianoAnalytics = PianoAnalytics( + site: 123456789, collectDomain: "xxxxxxx.pa-cd.com", channel: channel); + await pianoAnalytics.init(); + return pianoAnalytics; + } + + void checkItemsWithExpand( + dynamic actual, List expected, Object? Function(T) expand) { + var actualList = actual as List; + expect(actualList.length, expected.length); + for (var item in expected) { + expect(actualList.contains(expand(item)), true); + } + } + + void checkItems(dynamic actual, List expected) { + checkItemsWithExpand(actual, expected, (item) => item); + } + + void testProperty(dynamic actual, dynamic matcher, + {PropertyType? forceType}) { + var property = actual as Map; + expect(property["value"], matcher); + if (forceType != null) { + expect(property["forceType"], forceType.value); + } + } + + Future testChangeStorageFeatures( + String method, + Function(PianoAnalytics pa, List features, + List modes) + invoke) async { + var pianoAnalytics = await getPianoAnalytics(); + await invoke(pianoAnalytics, testFeatures, testModes); + + expect(call?.method, method); + + var arguments = call?.arguments as Map; + + checkItemsWithExpand( + arguments["features"], testFeatures, (feature) => feature.value); + checkItemsWithExpand(arguments["modes"], testModes, (mode) => mode.value); + } + + Future testChangeProperties( + String method, + Function(PianoAnalytics pa, List propertyNames, + List modes, List? eventNames) + invoke) async { + var pianoAnalytics = await getPianoAnalytics(); + await invoke(pianoAnalytics, testPropertyNames, testModes, testEventNames); + + expect(call?.method, method); + + var arguments = call?.arguments as Map; + + checkItems(arguments["propertyNames"], testPropertyNames); + checkItemsWithExpand(arguments["modes"], testModes, (mode) => mode.value); + checkItems(arguments["eventNames"], testEventNames); + } + + Future testChangeEvents( + String method, + Function(PianoAnalytics pa, List eventNames, + List modes) + invoke) async { + var pianoAnalytics = await getPianoAnalytics(); + await invoke(pianoAnalytics, testEventNames, testModes); + + expect(call?.method, method); + + var arguments = call?.arguments as Map; + + checkItems(arguments["eventNames"], testEventNames); + checkItemsWithExpand(arguments["modes"], testModes, (mode) => mode.value); + } + + setUp(() { + setHandler(result: null); }); tearDown(() { @@ -30,7 +133,15 @@ main() { }); test("init", () async { - await getPianoAnalytics(channel).init(); + var pianoAnalytics = PianoAnalytics( + site: 123456789, + collectDomain: "xxxxxxx.pa-cd.com", + channel: channel, + storageLifetimeVisitor: 395, + visitorStorageMode: VisitorStorageMode.fixed, + ignoreLimitedAdvertisingTracking: true, + visitorId: testUserId); + await pianoAnalytics.init(); expect(call?.method, "init"); @@ -38,22 +149,46 @@ main() { expect(arguments["site"], 123456789); expect(arguments["collectDomain"], "xxxxxxx.pa-cd.com"); - expect(arguments["visitorIDType"], PianoAnalyticsVisitorIDType.uuid.value); + expect(arguments["visitorIDType"], VisitorIDType.uuid.value); + expect(arguments["storageLifetimeVisitor"], 395); + expect(arguments["visitorStorageMode"], "fixed"); + expect(arguments["ignoreLimitedAdvertisingTracking"], true); + expect(arguments["visitorId"], testUserId); }); test("send", () async { - var pianoAnalytics = getPianoAnalytics(channel); - await pianoAnalytics.init(); + var pianoAnalytics = await getPianoAnalytics(); await pianoAnalytics.sendEvents(events: [ Event(name: "page.display", properties: [ Property.bool(name: "bool", value: true), + Property.bool( + name: "bool_force", value: true, forceType: PropertyType.bool), Property.int(name: "int", value: 1), + Property.int(name: "int_force", value: 1, forceType: PropertyType.int), Property.double(name: "double", value: 1.0), + Property.double( + name: "double_force", value: 1.0, forceType: PropertyType.float), Property.string(name: "string", value: "value"), + Property.string( + name: "string_force", + value: "value", + forceType: PropertyType.string), Property.date(name: "date", value: DateTime(2000)), Property.intArray(name: "intArray", value: [1, 2, 3]), + Property.intArray( + name: "intArray_force", + value: [1, 2, 3], + forceType: PropertyType.intArray), Property.doubleArray(name: "doubleArray", value: [1.0, 2.0, 3.0]), - Property.stringArray(name: "stringArray", value: ["a", "b", "c"]) + Property.doubleArray( + name: "doubleArray_force", + value: [1.0, 2.0, 3.0], + forceType: PropertyType.floatArray), + Property.stringArray(name: "stringArray", value: ["a", "b", "c"]), + Property.stringArray( + name: "stringArray_force", + value: ["a", "b", "c"], + forceType: PropertyType.stringArray) ]) ]); @@ -68,13 +203,122 @@ main() { expect(event["name"], "page.display"); var properties = event["data"] as Map; - expect(properties["bool"], true); - expect(properties["int"], 1); - expect(properties["double"], 1.0); - expect(properties["string"], "value"); - expect(properties["date"], DateTime(2000)); - expect(properties["intArray"], [1, 2, 3]); - expect(properties["doubleArray"], [1.0, 2.0, 3.0]); - expect(properties["stringArray"], ["a", "b", "c"]); + testProperty(properties["bool"], true); + testProperty(properties["bool_force"], true, forceType: PropertyType.bool); + testProperty(properties["int"], 1); + testProperty(properties["int_force"], 1, forceType: PropertyType.int); + testProperty(properties["double"], 1.0); + testProperty(properties["double_force"], 1.0, + forceType: PropertyType.float); + testProperty(properties["string"], "value"); + testProperty(properties["string_force"], "value", + forceType: PropertyType.string); + testProperty(properties["date"], DateTime(2000)); + testProperty(properties["intArray"], [1, 2, 3]); + testProperty(properties["intArray_force"], [1, 2, 3], + forceType: PropertyType.intArray); + testProperty(properties["doubleArray"], [1.0, 2.0, 3.0]); + testProperty(properties["doubleArray_force"], [1.0, 2.0, 3.0], + forceType: PropertyType.floatArray); + testProperty(properties["stringArray"], ["a", "b", "c"]); + testProperty(properties["stringArray_force"], ["a", "b", "c"], + forceType: PropertyType.stringArray); + }); + + test("getUser", () async { + setHandler(result: {"id": testUserId, "category": testUserCategory}); + + var pianoAnalytics = await getPianoAnalytics(); + var user = await pianoAnalytics.getUser(); + + expect(call?.method, "getUser"); + expect(user?.id, testUserId); + expect(user?.category, testUserCategory); + }); + + test("setUser", () async { + var pianoAnalytics = await getPianoAnalytics(); + await pianoAnalytics.setUser( + id: testUserId, category: testUserCategory, enableStorage: false); + + expect(call?.method, "setUser"); + + var arguments = call?.arguments as Map; + expect(arguments["id"], testUserId); + expect(arguments["category"], testUserCategory); + expect(arguments["enableStorage"], false); + }); + + test("getVisitorId", () async { + setHandler(result: testUserId); + + var pianoAnalytics = await getPianoAnalytics(); + var visitorId = await pianoAnalytics.getVisitorId(); + + expect(call?.method, "getVisitorId"); + expect(visitorId, testUserId); + }); + + test("setVisitorId", () async { + var pianoAnalytics = await getPianoAnalytics(); + await pianoAnalytics.setVisitorId(visitorId: testUserId); + + expect(call?.method, "setVisitorId"); + + var arguments = call?.arguments as Map; + expect(arguments["visitorId"], testUserId); + }); + + test("deleteUser", () async { + var pianoAnalytics = await getPianoAnalytics(); + await pianoAnalytics.deleteUser(); + + expect(call?.method, "deleteUser"); + }); + + test("privacyIncludeStorageFeatures", () async { + await testChangeStorageFeatures( + "privacyIncludeStorageFeatures", + (pa, features, modes) => + pa.privacyIncludeStorageFeatures(features: features, modes: modes)); + }); + + test("privacyExcludeStorageFeatures", () async { + await testChangeStorageFeatures( + "privacyExcludeStorageFeatures", + (pa, features, modes) => + pa.privacyExcludeStorageFeatures(features: features, modes: modes)); + }); + + test("privacyIncludeProperties", () async { + await testChangeProperties( + "privacyIncludeProperties", + (pa, propertyNames, modes, eventNames) => pa.privacyIncludeProperties( + propertyNames: propertyNames, + modes: modes, + eventNames: eventNames)); + }); + + test("privacyExcludeProperties", () async { + await testChangeProperties( + "privacyExcludeProperties", + (pa, propertyNames, modes, eventNames) => pa.privacyExcludeProperties( + propertyNames: propertyNames, + modes: modes, + eventNames: eventNames)); + }); + + test("privacyIncludeEvents", () async { + await testChangeEvents( + "privacyIncludeEvents", + (pa, eventNames, modes) => + pa.privacyIncludeEvents(eventNames: eventNames, modes: modes)); + }); + + test("privacyExcludeEvents", () async { + await testChangeEvents( + "privacyExcludeEvents", + (pa, eventNames, modes) => + pa.privacyExcludeEvents(eventNames: eventNames, modes: modes)); }); } diff --git a/test/piano_consents_test.dart b/test/piano_consents_test.dart deleted file mode 100644 index 8d11a88..0000000 --- a/test/piano_consents_test.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:piano_analytics/piano_consents.dart'; - -PianoConsents getPianoConsents(MethodChannel channel) { - return PianoConsents( - requireConsents: true, - defaultPurposes: { - PianoConsentProduct.pa: PianoConsentPurpose.audienceMeasurement - }, - channel: channel); -} - -main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - const MethodChannel channel = MethodChannel("piano_consents"); - - MethodCall? call; - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, (MethodCall methodCall) async { - call = methodCall; - return null; - }); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(channel, null); - call = null; - }); - - test("init", () async { - await getPianoConsents(channel).init(); - - expect(call?.method, "init"); - - var arguments = call?.arguments as Map; - expect(arguments["requireConsents"], true); - - var purposes = arguments["defaultPurposes"] as Map; - expect(purposes[PianoConsentProduct.pa.value], - PianoConsentPurpose.audienceMeasurement.value); - }); - - test("set", () async { - var pianoConsents = getPianoConsents(channel); - await pianoConsents.init(); - await pianoConsents.set( - purpose: PianoConsentPurpose.audienceMeasurement, - mode: PianoConsentMode.optIn, - products: [PianoConsentProduct.pa]); - - expect(call?.method, "set"); - - var arguments = call?.arguments as Map; - - expect(arguments["purpose"], PianoConsentPurpose.audienceMeasurement.value); - expect(arguments["mode"], PianoConsentMode.optIn.value); - expect((arguments["products"] as List).first, - PianoConsentProduct.pa.value); - }); - - test("setAll", () async { - var pianoConsents = getPianoConsents(channel); - await pianoConsents.init(); - await pianoConsents.setAll(mode: PianoConsentMode.optIn); - - expect(call?.method, "setAll"); - - var arguments = call?.arguments as Map; - - expect(arguments["mode"], PianoConsentMode.optIn.value); - }); - - test("clear", () async { - var pianoConsents = getPianoConsents(channel); - await pianoConsents.init(); - await pianoConsents.clear(); - - expect(call?.method, "clear"); - }); -}