From b939091fe46362e72e0a8a680d5421128a85451c Mon Sep 17 00:00:00 2001 From: Ankmara <118185796+Ankmara@users.noreply.github.com> Date: Fri, 3 Mar 2023 15:18:27 +0100 Subject: [PATCH] AppInbox feature support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michal Severín --- android/build.gradle | 14 +- .../main/kotlin/com/exponea/ExponeaPlugin.kt | 148 +++++++ .../src/main/kotlin/com/exponea/Extensions.kt | 401 ++++++++++++++++++ .../com/exponea/FlutterAppInboxProvider.kt | 86 ++++ .../kotlin/com/exponea/data/AppInboxCoder.kt | 29 ++ .../data/ExponeaConfigurationParser.kt | 3 + .../com/exponea/exception/ExponeaException.kt | 4 + .../exponea/style/AppInboxListItemStyle.kt | 37 ++ .../exponea/style/AppInboxListViewStyle.kt | 16 + .../kotlin/com/exponea/style/AppInboxStyle.kt | 7 + .../com/exponea/style/AppInboxStyleParser.kt | 114 +++++ .../kotlin/com/exponea/style/ButtonStyle.kt | 218 ++++++++++ .../com/exponea/style/DetailViewStyle.kt | 40 ++ .../com/exponea/style/ImageViewStyle.kt | 19 + .../com/exponea/style/ListScreenStyle.kt | 21 + .../com/exponea/style/ProgressBarStyle.kt | 24 ++ .../kotlin/com/exponea/style/TextViewStyle.kt | 31 ++ .../exponea/widget/FlutterAppInboxButton.kt | 40 ++ documentation/APP_INBOX.md | 287 +++++++++++++ documentation/AUTHORIZATION.md | 204 +++++++++ documentation/CONFIGURATION.md | 2 + documentation/TRACKING_CONSENT.md | 33 ++ example/android/app/build.gradle | 13 +- .../android/app/src/main/AndroidManifest.xml | 4 + .../com/exponea/example/MainActivity.kt | 8 + .../example/managers/CustomerTokenStorage.kt | 168 ++++++++ .../example/managers/NetworkManager.kt | 77 ++++ .../example/services/ExampleAuthProvider.kt | 13 + example/ios/Podfile.lock | 14 +- example/ios/Runner.xcodeproj/project.pbxproj | 8 + example/ios/Runner/AppDelegate.swift | 6 + example/ios/Runner/Base.lproj/Main.storyboard | 13 +- example/ios/Runner/CustomerTokenStorage.swift | 159 +++++++ example/ios/Runner/ExampleAuthProvider.swift | 19 + example/lib/page/config.dart | 51 +++ example/lib/page/home.dart | 53 ++- example/node_modules/.yarn-integrity | 10 + example/pubspec.lock | 40 +- example/yarn.lock | 4 + ios/Classes/Data/AppInboxCoder.swift | 30 ++ ios/Classes/Data/ExponeaConfiguration.swift | 10 +- ios/Classes/FlutterAppInboxProvider.swift | 71 ++++ ios/Classes/NSDictionary+getSafely.swift | 30 ++ ios/Classes/SwiftExponeaPlugin.swift | 347 +++++++++++++-- ios/Classes/style/AppInboxListItemStyle.swift | 56 +++ ios/Classes/style/AppInboxListViewStyle.swift | 26 ++ ios/Classes/style/AppInboxStyle.swift | 20 + ios/Classes/style/AppInboxStyleParser.swift | 129 ++++++ ios/Classes/style/ButtonStyle.swift | 75 ++++ ios/Classes/style/DetailViewStyle.swift | 53 +++ ios/Classes/style/ImageViewStyle.swift | 37 ++ ios/Classes/style/ListScreenStyle.swift | 55 +++ ios/Classes/style/ProgressBarStyle.swift | 33 ++ ios/Classes/style/StyleExtensions.swift | 388 +++++++++++++++++ ios/Classes/style/TextViewStyle.swift | 50 +++ ios/exponea.podspec | 2 +- lib/exponea.dart | 13 +- lib/src/data/encoder/app_inbox.dart | 41 ++ lib/src/data/encoder/configuration.dart | 2 + lib/src/data/encoder/main.dart | 1 + lib/src/data/model/app_inbox_action.dart | 15 + .../data/model/app_inbox_list_item_style.dart | 33 ++ .../data/model/app_inbox_list_view_style.dart | 25 ++ lib/src/data/model/app_inbox_message.dart | 23 + lib/src/data/model/app_inbox_style.dart | 27 ++ lib/src/data/model/button_style.dart | 44 ++ lib/src/data/model/configuration.dart | 4 + lib/src/data/model/detail_view_style.dart | 32 ++ lib/src/data/model/image_view_style.dart | 24 ++ lib/src/data/model/list_screen_style.dart | 33 ++ lib/src/data/model/progress_bar_style.dart | 26 ++ lib/src/data/model/text_view_style.dart | 41 ++ lib/src/data/util/Codable.dart | 8 + lib/src/interface.dart | 39 +- lib/src/platform/method_channel.dart | 73 +++- lib/src/platform/platform_interface.dart | 51 ++- lib/src/plugin/exponea.dart | 52 ++- lib/src/widget/app_inbox_button.dart | 19 + node_modules/.yarn-integrity | 10 + pubspec.lock | 11 +- pubspec.yaml | 1 + yarn.lock | 4 + 82 files changed, 4355 insertions(+), 147 deletions(-) create mode 100644 android/src/main/kotlin/com/exponea/Extensions.kt create mode 100644 android/src/main/kotlin/com/exponea/FlutterAppInboxProvider.kt create mode 100644 android/src/main/kotlin/com/exponea/data/AppInboxCoder.kt create mode 100644 android/src/main/kotlin/com/exponea/style/AppInboxListItemStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/AppInboxListViewStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/AppInboxStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/AppInboxStyleParser.kt create mode 100644 android/src/main/kotlin/com/exponea/style/ButtonStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/DetailViewStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/ImageViewStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/ListScreenStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/ProgressBarStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/style/TextViewStyle.kt create mode 100644 android/src/main/kotlin/com/exponea/widget/FlutterAppInboxButton.kt create mode 100644 documentation/APP_INBOX.md create mode 100644 documentation/AUTHORIZATION.md create mode 100644 documentation/TRACKING_CONSENT.md create mode 100644 example/android/app/src/main/kotlin/com/exponea/example/managers/CustomerTokenStorage.kt create mode 100644 example/android/app/src/main/kotlin/com/exponea/example/managers/NetworkManager.kt create mode 100644 example/android/app/src/main/kotlin/com/exponea/example/services/ExampleAuthProvider.kt create mode 100644 example/ios/Runner/CustomerTokenStorage.swift create mode 100644 example/ios/Runner/ExampleAuthProvider.swift create mode 100644 example/node_modules/.yarn-integrity create mode 100644 example/yarn.lock create mode 100644 ios/Classes/Data/AppInboxCoder.swift create mode 100644 ios/Classes/FlutterAppInboxProvider.swift create mode 100644 ios/Classes/NSDictionary+getSafely.swift create mode 100644 ios/Classes/style/AppInboxListItemStyle.swift create mode 100644 ios/Classes/style/AppInboxListViewStyle.swift create mode 100644 ios/Classes/style/AppInboxStyle.swift create mode 100644 ios/Classes/style/AppInboxStyleParser.swift create mode 100644 ios/Classes/style/ButtonStyle.swift create mode 100644 ios/Classes/style/DetailViewStyle.swift create mode 100644 ios/Classes/style/ImageViewStyle.swift create mode 100644 ios/Classes/style/ListScreenStyle.swift create mode 100644 ios/Classes/style/ProgressBarStyle.swift create mode 100644 ios/Classes/style/StyleExtensions.swift create mode 100644 ios/Classes/style/TextViewStyle.swift create mode 100644 lib/src/data/encoder/app_inbox.dart create mode 100644 lib/src/data/model/app_inbox_action.dart create mode 100644 lib/src/data/model/app_inbox_list_item_style.dart create mode 100644 lib/src/data/model/app_inbox_list_view_style.dart create mode 100644 lib/src/data/model/app_inbox_message.dart create mode 100644 lib/src/data/model/app_inbox_style.dart create mode 100644 lib/src/data/model/button_style.dart create mode 100644 lib/src/data/model/detail_view_style.dart create mode 100644 lib/src/data/model/image_view_style.dart create mode 100644 lib/src/data/model/list_screen_style.dart create mode 100644 lib/src/data/model/progress_bar_style.dart create mode 100644 lib/src/data/model/text_view_style.dart create mode 100644 lib/src/data/util/Codable.dart create mode 100644 lib/src/widget/app_inbox_button.dart create mode 100644 node_modules/.yarn-integrity create mode 100644 yarn.lock diff --git a/android/build.gradle b/android/build.gradle index 76c876d..d1eb17d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,7 +6,7 @@ buildscript { androidGradlePluginVersion = '4.1.0' kotlinVersion = '1.5.30' - exponeaSdkVersion = '3.2.1' + exponeaSdkVersion = '3.4.0' gsonVersion = '2.8.6' } @@ -33,7 +33,13 @@ apply plugin: 'kotlin-android' android { compileSdkVersion 31 - + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } sourceSets { main.java.srcDirs += 'src/main/kotlin' } @@ -47,4 +53,8 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" implementation "com.exponea.sdk:sdk:$exponeaSdkVersion" implementation "com.google.code.gson:gson:$gsonVersion" + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + + implementation 'androidx.appcompat:appcompat:1.2.0' } diff --git a/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt b/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt index bdcdb20..03d8e72 100644 --- a/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt +++ b/android/src/main/kotlin/com/exponea/ExponeaPlugin.kt @@ -7,6 +7,7 @@ import android.os.Handler import android.os.Looper import android.util.Log import androidx.annotation.NonNull +import com.exponea.data.AppInboxCoder import com.exponea.data.ConsentEncoder import com.exponea.data.Customer import com.exponea.data.Event @@ -23,6 +24,8 @@ import com.exponea.sdk.models.FlushMode import com.exponea.sdk.models.FlushPeriod import com.exponea.sdk.models.PropertiesList import com.exponea.sdk.util.Logger +import com.exponea.style.AppInboxStyleParser +import com.exponea.widget.FlutterAppInboxButton import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -105,6 +108,9 @@ class ExponeaPlugin : FlutterPlugin, ActivityAware { val handler = ReceivedPushStreamHandler() setStreamHandler(handler) } + binding + .platformViewRegistry + .registerViewFactory("FluffView", FlutterAppInboxButton.Factory()) } override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { @@ -158,6 +164,14 @@ private class ExponeaMethodHandler(private val context: Context) : MethodCallHan private const val METHOD_GET_LOG_LEVEL = "getLogLevel" private const val METHOD_SET_LOG_LEVEL = "setLogLevel" private const val METHOD_CHECK_PUSH_SETUP = "checkPushSetup" + private const val METHOD_SET_APP_INBOX_PROVIDER = "setAppInboxProvider" + private const val METHOD_TRACK_APP_INBOX_OPENED = "trackAppInboxOpened" + private const val METHOD_TRACK_APP_INBOX_OPENED_WITHOUT_TRACKING_CONSENT = "trackAppInboxOpenedWithoutTrackingConsent" + private const val METHOD_TRACK_APP_INBOX_CLICK = "trackAppInboxClick" + private const val METHOD_TRACK_APP_INBOX_CLICK_WITHOUT_TRACKING_CONSENT = "trackAppInboxClickWithoutTrackingConsent" + private const val METHOD_MARK_APP_INBOX_AS_READ = "markAppInboxAsRead" + private const val METHOD_FETCH_APP_INBOX = "fetchAppInbox" + private const val METHOD_FETCH_APP_INBOX_ITEM = "fetchAppInboxItem" } var activity: Context? = null @@ -228,12 +242,140 @@ private class ExponeaMethodHandler(private val context: Context) : MethodCallHan METHOD_CHECK_PUSH_SETUP -> { checkPushSetup(result) } + METHOD_SET_APP_INBOX_PROVIDER -> { + setAppInboxProvider(call.arguments, result) + } + METHOD_TRACK_APP_INBOX_OPENED -> { + trackAppInboxOpened(call.arguments, result) + } + METHOD_TRACK_APP_INBOX_OPENED_WITHOUT_TRACKING_CONSENT -> { + trackAppInboxOpenedWithoutTrackingConsent(call.arguments, result) + } + METHOD_TRACK_APP_INBOX_CLICK -> { + trackAppInboxClick(call.arguments, result) + } + METHOD_TRACK_APP_INBOX_CLICK_WITHOUT_TRACKING_CONSENT -> { + trackAppInboxClickWithoutTrackingConsent(call.arguments, result) + } + METHOD_MARK_APP_INBOX_AS_READ -> { + markAppInboxAsRead(call.arguments, result) + } + METHOD_FETCH_APP_INBOX -> { + fetchAppInbox(result) + } + METHOD_FETCH_APP_INBOX_ITEM -> { + fetchAppInboxItem(call.arguments, result) + } else -> { result.notImplemented() } } } + private fun trackAppInboxOpened(args: Any?, result: Result) = runAsync(result) { + requireNotConfigured() + val messageData = args as Map + Exponea.fetchAppInboxItem(messageId = messageData.getRequired("id")) { nativeMessage -> + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + if (nativeMessage == null) { + result.error(TAG, "AppInbox message data are invalid. See logs", null) + return@fetchAppInboxItem + } + Exponea.trackAppInboxOpened(nativeMessage) + result.success(null) + } + } + + private fun trackAppInboxOpenedWithoutTrackingConsent(args: Any?, result: Result) = runAsync(result) { + requireNotConfigured() + val messageData = args as Map + Exponea.fetchAppInboxItem(messageId = messageData.getRequired("id")) { nativeMessage -> + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + if (nativeMessage == null) { + result.error(TAG, "AppInbox message data are invalid. See logs", null) + return@fetchAppInboxItem + } + Exponea.trackAppInboxOpenedWithoutTrackingConsent(nativeMessage) + result.success(null) + } + } + + private fun trackAppInboxClick(args: Any?, result: Result) = runAsync(result) { + requireNotConfigured() + val inputData = args as Map + val messageData = inputData.getRequired>("message") + val action = AppInboxCoder.decodeAction(inputData.getRequired("action")) + ?: throw ExponeaException.common("AppInbox message action data are invalid. See logs") + Exponea.fetchAppInboxItem(messageId = messageData.getRequired("id")) { nativeMessage -> + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + if (nativeMessage == null) { + result.error(TAG, "AppInbox message data are invalid. See logs", null) + return@fetchAppInboxItem + } + Exponea.trackAppInboxClick(action, nativeMessage) + result.success(null) + } + } + + private fun trackAppInboxClickWithoutTrackingConsent(args: Any?, result: Result) = runAsync(result) { + requireNotConfigured() + val inputData = args as Map + val messageData = inputData.getRequired>("message") + val action = AppInboxCoder.decodeAction(inputData.getRequired("action")) + ?: throw ExponeaException.common("AppInbox message action data are invalid. See logs") + Exponea.fetchAppInboxItem(messageId = messageData.getRequired("id")) { nativeMessage -> + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + if (nativeMessage == null) { + result.error(TAG, "AppInbox message data are invalid. See logs", null) + return@fetchAppInboxItem + } + Exponea.trackAppInboxClickWithoutTrackingConsent(action, nativeMessage) + result.success(null) + } + } + + private fun markAppInboxAsRead(args: Any?, result: Result) = runAsync(result) { + requireNotConfigured() + val messageData = args as Map + Exponea.fetchAppInboxItem(messageId = messageData.getRequired("id")) { nativeMessage -> + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + if (nativeMessage == null) { + result.error(TAG, "AppInbox message data are invalid. See logs", null) + return@fetchAppInboxItem + } + Exponea.markAppInboxAsRead(nativeMessage) { markedAsRead -> + result.success(markedAsRead) + } + } + } + + private fun fetchAppInbox(result: Result) = runAsync(result) { + requireConfigured() + Exponea.fetchAppInbox { response -> + if (response == null) { + handler.post { + result.error(TAG, "AppInbox load failed. See logs", null) + } + } else { + result.success(response.map { AppInboxCoder.encode(it) }) + } + } + } + + private fun fetchAppInboxItem(args: Any?, result: Result) = runAsync(result) { + requireNotConfigured() + val messageId = args as String + Exponea.fetchAppInboxItem(messageId = messageId) { nativeMessage -> + if (nativeMessage == null) { + handler.post { + result.error(TAG, "AppInbox message not found. See logs", null) + } + } else { + result.success(AppInboxCoder.encode(nativeMessage)) + } + } + } + private fun requireConfigured() { if (!Exponea.isInitialized) { throw ExponeaException.notConfigured() @@ -456,6 +598,12 @@ private class ExponeaMethodHandler(private val context: Context) : MethodCallHan requireNotConfigured() Exponea.checkPushSetup = true } + + private fun setAppInboxProvider(args: Any, result: Result) = runWithResult(result) { + val configMap = args as Map + val appInboxStyle = AppInboxStyleParser(configMap).parse() + Exponea.appInboxProvider = FlutterAppInboxProvider(appInboxStyle) + } } /** diff --git a/android/src/main/kotlin/com/exponea/Extensions.kt b/android/src/main/kotlin/com/exponea/Extensions.kt new file mode 100644 index 0000000..6865922 --- /dev/null +++ b/android/src/main/kotlin/com/exponea/Extensions.kt @@ -0,0 +1,401 @@ +package com.exponea + +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.Drawable +import android.util.TypedValue +import androidx.core.graphics.drawable.DrawableCompat +import com.exponea.exception.ExponeaException +import com.exponea.sdk.Exponea +import com.exponea.sdk.util.Logger +import java.util.Date +import kotlin.reflect.KClass +import kotlin.text.RegexOption.IGNORE_CASE + +internal inline fun Map.getRequired(key: String): T { + return getSafely(key, T::class) +} + +internal fun Map.getSafely(key: String, type: KClass): T { + val value = this[key] ?: throw ExponeaException.common("Property '$key' cannot be null.") + if (value::class == type) { + @Suppress("UNCHECKED_CAST") + return value as T + } else { + throw ExponeaException.common( + "Incorrect type for key '$key'. Expected ${type.simpleName} got ${value::class.simpleName}" + ) + } +} + +internal inline fun Map.getNullSafelyMap(key: String, defaultValue: Map? = null): Map? { + return getNullSafelyMap(key, T::class, defaultValue) +} + +internal inline fun Map.getNullSafelyMap(key: String, type: KClass, defaultValue: Map? = null): Map? { + val value = this[key] ?: return defaultValue + @Suppress("UNCHECKED_CAST") + val mapOfAny = value as? Map ?: throw ExponeaException.common( + "Non-map type for key '$key'. Got ${value::class.simpleName}" + ) + return mapOfAny.filterValueIsInstance(type.java) +} + +/** + * Returns a map containing all key-value pairs with values are instances of specified class. + * + * The returned map preserves the entry iteration order of the original map. + */ +internal fun Map.filterValueIsInstance(klass: Class): Map { + val result = LinkedHashMap() + for (entry in this) { + if (klass.isInstance(entry.value)) { + @Suppress("UNCHECKED_CAST") + ((entry.value as R).also { result[entry.key] = it }) + } + } + return result +} + +internal inline fun Map.getNullSafelyArray(key: String, defaultValue: List? = null): List? { + return getNullSafelyArray(key, T::class, defaultValue) +} + +internal inline fun Map.getNullSafelyArray(key: String, type: KClass, defaultValue: List? = null): List? { + val value = this[key] ?: return defaultValue + val arrayOfAny = value as? List ?: throw ExponeaException.common( + "Non-array type for key '$key'. Got ${value::class.simpleName}" + ) + return arrayOfAny + .filterIsInstance(type.java) +} + +internal inline fun Map.getNullSafely(key: String, defaultValue: T? = null): T? { + return getNullSafely(key, T::class, defaultValue) +} + +internal fun Map.getNullSafely(key: String, type: KClass, defaultValue: T? = null): T? { + val value = this[key] ?: return defaultValue + @Suppress("UNCHECKED_CAST") + return (value as? T) ?: throw ExponeaException.common( + "Incorrect type for key '$key'. Expected ${type.simpleName} got ${value::class.simpleName}" + ) +} + +private fun sizeValue(source: String): Float { + val parsed = source.filter { it.isDigit() || it == '.' }.toFloatOrNull() + if (parsed == null) { + Logger.e(source, "Unable to read float value from $source") + } + return parsed ?: 0F +} + +internal data class PlatformSize(val unit: Int, val size: Float) { + companion object { + fun parse(from: String?): PlatformSize? { + if (from.isNullOrBlank()) return null + return PlatformSize( + sizeType(from), + sizeValue(from) + ) + } + } + fun asString(): String { + return "$size${sizeTypeString(unit)}" + } +} + +private fun sizeTypeString(unit: Int): String { + return when (unit) { + TypedValue.COMPLEX_UNIT_PX -> "px" + TypedValue.COMPLEX_UNIT_IN -> "in" + TypedValue.COMPLEX_UNIT_MM -> "mm" + TypedValue.COMPLEX_UNIT_PT -> "pt" + TypedValue.COMPLEX_UNIT_DIP -> "dp" + TypedValue.COMPLEX_UNIT_SP -> "sp" + else -> "px" + } +} + +private fun sizeType(source: String): Int { + return when { + source.endsWith("px", true) -> TypedValue.COMPLEX_UNIT_PX + source.endsWith("in", true) -> TypedValue.COMPLEX_UNIT_IN + source.endsWith("mm", true) -> TypedValue.COMPLEX_UNIT_MM + source.endsWith("pt", true) -> TypedValue.COMPLEX_UNIT_PT + source.endsWith("dp", true) -> TypedValue.COMPLEX_UNIT_DIP + source.endsWith("dip", true) -> TypedValue.COMPLEX_UNIT_DIP + source.endsWith("sp", true) -> TypedValue.COMPLEX_UNIT_SP + else -> TypedValue.COMPLEX_UNIT_PX + } +} + +fun toTypeface(source: String?): Int { + when (source) { + "normal" -> return Typeface.NORMAL + "bold" -> return Typeface.BOLD + "100" -> return Typeface.NORMAL + "200" -> return Typeface.NORMAL + "300" -> return Typeface.NORMAL + "400" -> return Typeface.NORMAL + "500" -> return Typeface.NORMAL + "600" -> return Typeface.NORMAL + "700" -> return Typeface.BOLD + "800" -> return Typeface.BOLD + "900" -> return Typeface.BOLD + else -> return Typeface.NORMAL + } +} + +internal fun Drawable?.applyTint(color: Int): Drawable? { + if (this == null) return null + val wrappedIcon: Drawable = DrawableCompat.wrap(this) + DrawableCompat.setTint(wrappedIcon, color) + return wrappedIcon +} + +private val HEX_VALUE = Regex("[0-9A-F]", IGNORE_CASE) +private val SHORT_HEX_FORMAT = Regex("^#([0-9A-F]){3}\$", IGNORE_CASE) +private val SHORT_HEXA_FORMAT = Regex("^#[0-9A-F]{4}\$", IGNORE_CASE) +private val HEX_FORMAT = Regex("^#[0-9A-F]{6}\$", IGNORE_CASE) +private val HEXA_FORMAT = Regex("^#[0-9A-F]{8}\$", IGNORE_CASE) +private val RGBA_FORMAT = Regex( + "^rgba\\([ ]*([0-9]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]{1,3})[,/ ]+([0-9.]+)[ ]*\\)\$", + IGNORE_CASE +) +private val ARGB_FORMAT = Regex( + "^argb\\([ ]*([0-9.]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]+)[ ]*\\)\$", + IGNORE_CASE +) +private val RGB_FORMAT = Regex("^rgb\\([ ]*([0-9]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]{1,3})[ ]*\\)\$", IGNORE_CASE) +private val NAMED_COLORS = mapOf( + // any color name from here: https://www.w3.org/wiki/CSS/Properties/color/keywords + "aliceblue" to Color.parseColor("#f0f8ff"), + "antiquewhite" to Color.parseColor("#faebd7"), + "aqua" to Color.parseColor("#00ffff"), + "aquamarine" to Color.parseColor("#7fffd4"), + "azure" to Color.parseColor("#f0ffff"), + "beige" to Color.parseColor("#f5f5dc"), + "bisque" to Color.parseColor("#ffe4c4"), + "black" to Color.parseColor("#000000"), + "blanchedalmond" to Color.parseColor("#ffebcd"), + "blue" to Color.parseColor("#0000ff"), + "blueviolet" to Color.parseColor("#8a2be2"), + "brown" to Color.parseColor("#a52a2a"), + "burlywood" to Color.parseColor("#deb887"), + "cadetblue" to Color.parseColor("#5f9ea0"), + "chartreuse" to Color.parseColor("#7fff00"), + "chocolate" to Color.parseColor("#d2691e"), + "coral" to Color.parseColor("#ff7f50"), + "cornflowerblue" to Color.parseColor("#6495ed"), + "cornsilk" to Color.parseColor("#fff8dc"), + "crimson" to Color.parseColor("#dc143c"), + "cyan" to Color.parseColor("#00ffff"), + "darkblue" to Color.parseColor("#00008b"), + "darkcyan" to Color.parseColor("#008b8b"), + "darkgoldenrod" to Color.parseColor("#b8860b"), + "darkgray" to Color.parseColor("#a9a9a9"), + "darkgreen" to Color.parseColor("#006400"), + "darkgrey" to Color.parseColor("#a9a9a9"), + "darkkhaki" to Color.parseColor("#bdb76b"), + "darkmagenta" to Color.parseColor("#8b008b"), + "darkolivegreen" to Color.parseColor("#556b2f"), + "darkorange" to Color.parseColor("#ff8c00"), + "darkorchid" to Color.parseColor("#9932cc"), + "darkred" to Color.parseColor("#8b0000"), + "darksalmon" to Color.parseColor("#e9967a"), + "darkseagreen" to Color.parseColor("#8fbc8f"), + "darkslateblue" to Color.parseColor("#483d8b"), + "darkslategray" to Color.parseColor("#2f4f4f"), + "darkslategrey" to Color.parseColor("#2f4f4f"), + "darkturquoise" to Color.parseColor("#00ced1"), + "darkviolet" to Color.parseColor("#9400d3"), + "deeppink" to Color.parseColor("#ff1493"), + "deepskyblue" to Color.parseColor("#00bfff"), + "dimgray" to Color.parseColor("#696969"), + "dimgrey" to Color.parseColor("#696969"), + "dodgerblue" to Color.parseColor("#1e90ff"), + "firebrick" to Color.parseColor("#b22222"), + "floralwhite" to Color.parseColor("#fffaf0"), + "forestgreen" to Color.parseColor("#228b22"), + "fuchsia" to Color.parseColor("#ff00ff"), + "gainsboro" to Color.parseColor("#dcdcdc"), + "ghostwhite" to Color.parseColor("#f8f8ff"), + "gold" to Color.parseColor("#ffd700"), + "goldenrod" to Color.parseColor("#daa520"), + "gray" to Color.parseColor("#808080"), + "green" to Color.parseColor("#008000"), + "greenyellow" to Color.parseColor("#adff2f"), + "grey" to Color.parseColor("#808080"), + "honeydew" to Color.parseColor("#f0fff0"), + "hotpink" to Color.parseColor("#ff69b4"), + "indianred" to Color.parseColor("#cd5c5c"), + "indigo" to Color.parseColor("#4b0082"), + "ivory" to Color.parseColor("#fffff0"), + "khaki" to Color.parseColor("#f0e68c"), + "lavender" to Color.parseColor("#e6e6fa"), + "lavenderblush" to Color.parseColor("#fff0f5"), + "lawngreen" to Color.parseColor("#7cfc00"), + "lemonchiffon" to Color.parseColor("#fffacd"), + "lightblue" to Color.parseColor("#add8e6"), + "lightcoral" to Color.parseColor("#f08080"), + "lightcyan" to Color.parseColor("#e0ffff"), + "lightgoldenrodyellow" to Color.parseColor("#fafad2"), + "lightgray" to Color.parseColor("#d3d3d3"), + "lightgreen" to Color.parseColor("#90ee90"), + "lightgrey" to Color.parseColor("#d3d3d3"), + "lightpink" to Color.parseColor("#ffb6c1"), + "lightsalmon" to Color.parseColor("#ffa07a"), + "lightseagreen" to Color.parseColor("#20b2aa"), + "lightskyblue" to Color.parseColor("#87cefa"), + "lightslategray" to Color.parseColor("#778899"), + "lightslategrey" to Color.parseColor("#778899"), + "lightsteelblue" to Color.parseColor("#b0c4de"), + "lightyellow" to Color.parseColor("#ffffe0"), + "lime" to Color.parseColor("#00ff00"), + "limegreen" to Color.parseColor("#32cd32"), + "linen" to Color.parseColor("#faf0e6"), + "magenta" to Color.parseColor("#ff00ff"), + "maroon" to Color.parseColor("#800000"), + "mediumaquamarine" to Color.parseColor("#66cdaa"), + "mediumblue" to Color.parseColor("#0000cd"), + "mediumorchid" to Color.parseColor("#ba55d3"), + "mediumpurple" to Color.parseColor("#9370db"), + "mediumseagreen" to Color.parseColor("#3cb371"), + "mediumslateblue" to Color.parseColor("#7b68ee"), + "mediumspringgreen" to Color.parseColor("#00fa9a"), + "mediumturquoise" to Color.parseColor("#48d1cc"), + "mediumvioletred" to Color.parseColor("#c71585"), + "midnightblue" to Color.parseColor("#191970"), + "mintcream" to Color.parseColor("#f5fffa"), + "mistyrose" to Color.parseColor("#ffe4e1"), + "moccasin" to Color.parseColor("#ffe4b5"), + "navajowhite" to Color.parseColor("#ffdead"), + "navy" to Color.parseColor("#000080"), + "oldlace" to Color.parseColor("#fdf5e6"), + "olive" to Color.parseColor("#808000"), + "olivedrab" to Color.parseColor("#6b8e23"), + "orange" to Color.parseColor("#ffa500"), + "orangered" to Color.parseColor("#ff4500"), + "orchid" to Color.parseColor("#da70d6"), + "palegoldenrod" to Color.parseColor("#eee8aa"), + "palegreen" to Color.parseColor("#98fb98"), + "paleturquoise" to Color.parseColor("#afeeee"), + "palevioletred" to Color.parseColor("#db7093"), + "papayawhip" to Color.parseColor("#ffefd5"), + "peachpuff" to Color.parseColor("#ffdab9"), + "peru" to Color.parseColor("#cd853f"), + "pink" to Color.parseColor("#ffc0cb"), + "plum" to Color.parseColor("#dda0dd"), + "powderblue" to Color.parseColor("#b0e0e6"), + "purple" to Color.parseColor("#800080"), + "red" to Color.parseColor("#ff0000"), + "rosybrown" to Color.parseColor("#bc8f8f"), + "royalblue" to Color.parseColor("#4169e1"), + "saddlebrown" to Color.parseColor("#8b4513"), + "salmon" to Color.parseColor("#fa8072"), + "sandybrown" to Color.parseColor("#f4a460"), + "seagreen" to Color.parseColor("#2e8b57"), + "seashell" to Color.parseColor("#fff5ee"), + "sienna" to Color.parseColor("#a0522d"), + "silver" to Color.parseColor("#c0c0c0"), + "skyblue" to Color.parseColor("#87ceeb"), + "slateblue" to Color.parseColor("#6a5acd"), + "slategray" to Color.parseColor("#708090"), + "slategrey" to Color.parseColor("#708090"), + "snow" to Color.parseColor("#fffafa"), + "springgreen" to Color.parseColor("#00ff7f"), + "steelblue" to Color.parseColor("#4682b4"), + "tan" to Color.parseColor("#d2b48c"), + "teal" to Color.parseColor("#008080"), + "thistle" to Color.parseColor("#d8bfd8"), + "tomato" to Color.parseColor("#ff6347"), + "turquoise" to Color.parseColor("#40e0d0"), + "violet" to Color.parseColor("#ee82ee"), + "wheat" to Color.parseColor("#f5deb3"), + "white" to Color.parseColor("#ffffff"), + "whitesmoke" to Color.parseColor("#f5f5f5"), + "yellow" to Color.parseColor("#ffff00"), + "yellowgreen" to Color.parseColor("#9acd32") +) + +internal fun String?.asColor(): Int? { + val colorString = this + try { + if (colorString.isNullOrBlank()) { + return null + } + if (SHORT_HEX_FORMAT.matches(colorString)) { + val hexFormat = HEX_VALUE.replace(colorString) { + // doubles HEX value + it.value + it.value + } + return Color.parseColor(hexFormat) + } + if (HEX_FORMAT.matches(colorString)) { + return Color.parseColor(colorString) + } + if (SHORT_HEXA_FORMAT.matches(colorString)) { + val hexaFormat = HEX_VALUE.replace(colorString) { + // doubles HEX value + it.value + it.value + } + // #RRGGBBAA (css) -> #AARRGGBB (android) + val ahexFormat = "#${hexaFormat.substring(7, 9)}${hexaFormat.substring(1, 7)}" + return Color.parseColor(ahexFormat) + } + if (HEXA_FORMAT.matches(colorString)) { + // #RRGGBBAA (css) -> #AARRGGBB (android) + val ahexFormat = "#${colorString.substring(7, 9)}${colorString.substring(1, 7)}" + return Color.parseColor(ahexFormat) + } + if (RGBA_FORMAT.matches(colorString)) { + // rgba(255, 255, 255, 1.0) + // rgba(255 255 255 / 1.0) + val parts = RGBA_FORMAT.findAll(colorString).flatMap { it.groupValues }.toList() + return Color.argb( + // parts[0] skip! + (parts[4].toFloat() * 255).toInt(), + parts[1].toInt(), + parts[2].toInt(), + parts[3].toInt() + ) + } + if (ARGB_FORMAT.matches(colorString)) { + // argb(1.0, 255, 0, 0) + val parts = ARGB_FORMAT.findAll(colorString).flatMap { it.groupValues }.toList() + return Color.argb( + // parts[0] skip! + (parts[1].toFloat() * 255).toInt(), + parts[2].toInt(), + parts[3].toInt(), + parts[4].toInt() + ) + } + if (RGB_FORMAT.matches(colorString)) { + // rgb(255, 255, 255) + val parts = RGB_FORMAT.findAll(colorString).flatMap { it.groupValues }.toList() + return Color.rgb( + // parts[0] skip! + parts[1].toInt(), + parts[2].toInt(), + parts[3].toInt() + ) + } + return NAMED_COLORS[colorString.lowercase()] + } catch (e: IllegalArgumentException) { + Logger.e(ExponeaPlugin(), "Unable to parse color from $colorString") + return null + } +} + +internal fun Int.asColorString(): String { + // HEXa format = #RRGGBBAA + val red = Color.red(this) + val green = Color.green(this) + val blue = Color.blue(this) + val alpha = Color.alpha(this) + return String.format("#%02x%02x%02x%02x", red, green, blue, alpha) +} + +fun currentTimeSeconds() = Date().time / 1000.0 diff --git a/android/src/main/kotlin/com/exponea/FlutterAppInboxProvider.kt b/android/src/main/kotlin/com/exponea/FlutterAppInboxProvider.kt new file mode 100644 index 0000000..745c2ff --- /dev/null +++ b/android/src/main/kotlin/com/exponea/FlutterAppInboxProvider.kt @@ -0,0 +1,86 @@ +package com.exponea + +import android.content.Context +import android.view.View +import android.view.ViewGroup.OnHierarchyChangeListener +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView.OnChildAttachStateChangeListener +import com.exponea.sdk.models.MessageItem +import com.exponea.sdk.services.DefaultAppInboxProvider +import com.exponea.sdk.view.AppInboxDetailView +import com.exponea.sdk.view.AppInboxListView +import com.exponea.style.AppInboxStyle + +class FlutterAppInboxProvider(private val appInboxStyle: AppInboxStyle) : DefaultAppInboxProvider() { + override fun getAppInboxButton(context: Context): Button { + val button = super.getAppInboxButton(context) + // Default Button style is set to capitalize text + button.isAllCaps = false + appInboxStyle.appInboxButton?.applyTo(button) + return button + } + + override fun getAppInboxDetailView(context: Context, messageId: String): View { + val view = super.getAppInboxDetailView(context, messageId) as AppInboxDetailView + appInboxStyle.detailView?.let { detailViewStyle -> + detailViewStyle.applyTo(view) + detailViewStyle.button?.let { button -> + // actions may be populated later + // (performance) register listener only if button style is set + view.actionsContainerView.setOnHierarchyChangeListener(object : OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View?, child: View?) { + if (parent != view.actionsContainerView || child == null || child !is Button) { + return + } + if (child.layoutParams is LinearLayout.LayoutParams) { + val layoutParams = child.layoutParams as LinearLayout.LayoutParams + layoutParams.topMargin = 8 + child.layoutParams = layoutParams + } + button.applyTo(child) + } + + override fun onChildViewRemoved(p0: View?, p1: View?) { + // no action + } + }) + } + } + return view + } + + override fun getAppInboxListView(context: Context, onItemClicked: (MessageItem, Int) -> Unit): View { + val view = super.getAppInboxListView(context, onItemClicked) as AppInboxListView + appInboxStyle.listView?.applyTo(view) + appInboxStyle.listView?.list?.item?.let { itemStyle -> + // items are populated and shown later + // (performance) register listener only if item style is set + view.listView.addOnChildAttachStateChangeListener(object : OnChildAttachStateChangeListener { + override fun onChildViewAttachedToWindow(view: View) { + itemStyle.applyTo(FlutterMessageItemViewHolder(view)) + } + + override fun onChildViewDetachedFromWindow(view: View) { + // nothing to do + } + }) + } + return view + } + + /** + * As MessageItemViewHolder from SDK is internal, we need to create mirror. + */ + class FlutterMessageItemViewHolder(target: View) { + val itemContainer: RelativeLayout? = target.findViewById(R.id.message_item_container) + val readFlag: ImageView? = target.findViewById(R.id.message_item_read_flag) + val receivedTime: TextView? = target.findViewById(R.id.message_item_received_time) + val title: TextView? = target.findViewById(R.id.message_item_title) + val content: TextView? = target.findViewById(R.id.message_item_content) + val image: ImageView? = target.findViewById(R.id.message_item_image) + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/exponea/data/AppInboxCoder.kt b/android/src/main/kotlin/com/exponea/data/AppInboxCoder.kt new file mode 100644 index 0000000..4a2703b --- /dev/null +++ b/android/src/main/kotlin/com/exponea/data/AppInboxCoder.kt @@ -0,0 +1,29 @@ +package com.exponea.data + +import com.exponea.getNullSafely +import com.exponea.sdk.models.MessageItem +import com.exponea.sdk.models.MessageItemAction +import com.exponea.sdk.models.MessageItemAction.Type + +class AppInboxCoder { + companion object { + fun encode(source: MessageItem): Map { + return mapOf( + "id" to source.id, + "type" to source.rawType, + "is_read" to source.read, + "create_time" to source.receivedTime, + "content" to source.rawContent + ) + } + + fun decodeAction(source: Map): MessageItemAction? { + val sourceType = Type.find(source.getNullSafely("type")) ?: return null + return MessageItemAction().apply { + type = sourceType + title = source.getNullSafely("title") + url = source.getNullSafely("url") + } + } + } +} \ No newline at end of file diff --git a/android/src/main/kotlin/com/exponea/data/ExponeaConfigurationParser.kt b/android/src/main/kotlin/com/exponea/data/ExponeaConfigurationParser.kt index 8594300..69f0afb 100644 --- a/android/src/main/kotlin/com/exponea/data/ExponeaConfigurationParser.kt +++ b/android/src/main/kotlin/com/exponea/data/ExponeaConfigurationParser.kt @@ -45,6 +45,9 @@ class ExponeaConfigurationParser { map.getOptional>("android")?.let { parseAndroidConfig(it, this) } + map.getOptional("advancedAuthEnabled")?.let { + advancedAuthEnabled = it + } } } diff --git a/android/src/main/kotlin/com/exponea/exception/ExponeaException.kt b/android/src/main/kotlin/com/exponea/exception/ExponeaException.kt index 9141509..da85156 100644 --- a/android/src/main/kotlin/com/exponea/exception/ExponeaException.kt +++ b/android/src/main/kotlin/com/exponea/exception/ExponeaException.kt @@ -25,6 +25,10 @@ class ExponeaException : Exception { fun fetchError(description: String): ExponeaException { return ExponeaException("Data fetching failed: $description.") } + + fun common(description: String): ExponeaException { + return ExponeaException("Error occurred: $description.") + } } constructor(message: String) : super(message) diff --git a/android/src/main/kotlin/com/exponea/style/AppInboxListItemStyle.kt b/android/src/main/kotlin/com/exponea/style/AppInboxListItemStyle.kt new file mode 100644 index 0000000..4009fcf --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/AppInboxListItemStyle.kt @@ -0,0 +1,37 @@ +package com.exponea.style + +import com.exponea.FlutterAppInboxProvider.FlutterMessageItemViewHolder +import com.exponea.asColor + +data class AppInboxListItemStyle( + var backgroundColor: String? = null, + var readFlag: ImageViewStyle? = null, + var receivedTime: TextViewStyle? = null, + var title: TextViewStyle? = null, + var content: TextViewStyle? = null, + var image: ImageViewStyle? = null +) { + fun applyTo(target: FlutterMessageItemViewHolder) { + backgroundColor?.asColor()?.let { + target.itemContainer?.setBackgroundColor(it) + } + nonNull(readFlag, target.readFlag) { style, view -> + style.applyTo(view) + } + nonNull(receivedTime, target.receivedTime) { style, view -> + style.applyTo(view) + } + nonNull(title, target.title) { style, view -> + style.applyTo(view) + } + nonNull(content, target.content) { style, view -> + style.applyTo(view) + } + nonNull(image, target.image) { style, view -> + style.applyTo(view) + } + } + private inline fun nonNull(p1: T1?, p2: T2?, block: (T1, T2) -> R?): R? { + return if (p1 != null && p2 != null) block(p1, p2) else null + } +} diff --git a/android/src/main/kotlin/com/exponea/style/AppInboxListViewStyle.kt b/android/src/main/kotlin/com/exponea/style/AppInboxListViewStyle.kt new file mode 100644 index 0000000..c53d27b --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/AppInboxListViewStyle.kt @@ -0,0 +1,16 @@ +package com.exponea.style + +import android.view.View +import com.exponea.asColor + +data class AppInboxListViewStyle( + var backgroundColor: String? = null, + var item: AppInboxListItemStyle? = null +) { + fun applyTo(target: View) { + backgroundColor?.asColor()?.let { + target.setBackgroundColor(it) + } + // note: 'item' style is used elsewhere due to performance reasons + } +} diff --git a/android/src/main/kotlin/com/exponea/style/AppInboxStyle.kt b/android/src/main/kotlin/com/exponea/style/AppInboxStyle.kt new file mode 100644 index 0000000..cde076e --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/AppInboxStyle.kt @@ -0,0 +1,7 @@ +package com.exponea.style + +data class AppInboxStyle( + var appInboxButton: ButtonStyle? = null, + var detailView: DetailViewStyle? = null, + var listView: ListScreenStyle? = null +) diff --git a/android/src/main/kotlin/com/exponea/style/AppInboxStyleParser.kt b/android/src/main/kotlin/com/exponea/style/AppInboxStyleParser.kt new file mode 100644 index 0000000..644b4eb --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/AppInboxStyleParser.kt @@ -0,0 +1,114 @@ +package com.exponea.style + +import com.exponea.getNullSafely + +class AppInboxStyleParser(private val configMap: Map) { + fun parse(): AppInboxStyle { + return AppInboxStyle( + appInboxButton = parseButtonStyle(configMap.getNullSafely("appInboxButton")), + detailView = parseDetailViewStyle(configMap.getNullSafely("detailView")), + listView = parseListScreenStyle(configMap.getNullSafely("listView")) + ) + } + + private fun parseListScreenStyle(source: Map?): ListScreenStyle? { + if (source == null) { + return null + } + return ListScreenStyle( + emptyMessage = parseTextViewStyle(source.getNullSafely("emptyMessage")), + emptyTitle = parseTextViewStyle(source.getNullSafely("emptyTitle")), + errorMessage = parseTextViewStyle(source.getNullSafely("errorMessage")), + errorTitle = parseTextViewStyle(source.getNullSafely("errorTitle")), + list = parseListViewStyle(source.getNullSafely("list")), + progress = parseProgressStyle(source.getNullSafely("progress")) + ) + } + + private fun parseProgressStyle(source: Map?): ProgressBarStyle? { + if (source == null) { + return null + } + return ProgressBarStyle( + visible = source.getNullSafely("visible"), + backgroundColor = source.getNullSafely("backgroundColor"), + progressColor = source.getNullSafely("progressColor") + ) + } + + private fun parseListViewStyle(source: Map?): AppInboxListViewStyle? { + if (source == null) { + return null + } + return AppInboxListViewStyle( + backgroundColor = source.getNullSafely("backgroundColor"), + item = parseListItemStyle(source.getNullSafely("item")) + ) + } + + private fun parseListItemStyle(source: Map?): AppInboxListItemStyle? { + if (source == null) { + return null + } + return AppInboxListItemStyle( + backgroundColor = source.getNullSafely("backgroundColor"), + receivedTime = parseTextViewStyle(source.getNullSafely("receivedTime")), + title = parseTextViewStyle(source.getNullSafely("title")), + content = parseTextViewStyle(source.getNullSafely("content")), + image = parseImageViewStyle(source.getNullSafely("image")), + readFlag = parseImageViewStyle(source.getNullSafely("readFlag")) + ) + } + + private fun parseImageViewStyle(source: Map?): ImageViewStyle? { + if (source == null) { + return null + } + return ImageViewStyle( + backgroundColor = source.getNullSafely("backgroundColor"), + visible = source.getNullSafely("visible") + ) + } + + private fun parseTextViewStyle(source: Map?): TextViewStyle? { + if (source == null) { + return null + } + return TextViewStyle( + textSize = source.getNullSafely("textSize"), + textColor = source.getNullSafely("textColor"), + textOverride = source.getNullSafely("textOverride"), + textWeight = source.getNullSafely("textWeight"), + visible = source.getNullSafely("visible") + ) + } + + private fun parseDetailViewStyle(source: Map?): DetailViewStyle? { + if (source == null) { + return null + } + return DetailViewStyle( + button = parseButtonStyle(source.getNullSafely("button")), + content = parseTextViewStyle(source.getNullSafely("content")), + image = parseImageViewStyle(source.getNullSafely("image")), + receivedTime = parseTextViewStyle(source.getNullSafely("receivedTime")), + title = parseTextViewStyle(source.getNullSafely("title")) + ) + } + + private fun parseButtonStyle(source: Map?): ButtonStyle? { + if (source == null) { + return null + } + return ButtonStyle( + textOverride = source.getNullSafely("textOverride"), + textColor = source.getNullSafely("textColor"), + backgroundColor = source.getNullSafely("backgroundColor"), + showIcon = source.getNullSafely("showIcon"), + textSize = source.getNullSafely("textSize"), + enabled = source.getNullSafely("enabled"), + borderRadius = source.getNullSafely("borderRadius"), + textWeight = source.getNullSafely("textWeight") + ) + } +} diff --git a/android/src/main/kotlin/com/exponea/style/ButtonStyle.kt b/android/src/main/kotlin/com/exponea/style/ButtonStyle.kt new file mode 100644 index 0000000..16eca8d --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/ButtonStyle.kt @@ -0,0 +1,218 @@ +package com.exponea.style + +import android.content.Context +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.DrawableWrapper +import android.graphics.drawable.GradientDrawable +import android.graphics.drawable.InsetDrawable +import android.graphics.drawable.LayerDrawable +import android.graphics.drawable.RippleDrawable +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import android.util.Base64 +import android.widget.Button +import androidx.core.content.res.ResourcesCompat +import com.exponea.PlatformSize +import com.exponea.applyTint +import com.exponea.asColor +import com.exponea.sdk.util.Logger +import com.exponea.toTypeface + +data class ButtonStyle( + var textOverride: String? = null, + var textColor: String? = null, + var backgroundColor: String? = null, + var showIcon: String? = null, + var textSize: String? = null, + var enabled: Boolean? = null, + var borderRadius: String? = null, + var textWeight: String? = null +) { + private fun applyColorTo(target: Drawable?, color: Int): Boolean { + if (target == null) { + return false + } + val colorizedDrawable = findColorizedDrawable(target) + when (colorizedDrawable) { + is GradientDrawable -> { + colorizedDrawable.setColor(color) + return true + } + is ColorDrawable -> { + colorizedDrawable.color = color + return true + } + } + Logger.e(this, "Unable to find colored background") + return false + } + private fun findColorizedDrawable(root: Drawable?): Drawable? { + if (root == null) { + return null + } + if (VERSION.SDK_INT >= VERSION_CODES.M && root is DrawableWrapper) { + return findColorizedDrawable(root.drawable) + } + when (root) { + is GradientDrawable -> return root + is ColorDrawable -> return root + is LayerDrawable -> { + for (i in 0 until root.numberOfLayers) { + val drawableLayer = root.getDrawable(i) + val colorizedDrawable = findColorizedDrawable(drawableLayer) + if (colorizedDrawable != null) { + return colorizedDrawable + } + // continue + } + Logger.d(this, "No colorizable Drawable found in LayerDrawable") + return null + } + else -> { + Logger.d(this, "Not implemented Drawable type to search colorized drawable") + return null + } + } + } + fun applyTo(button: Button) { + showIcon?.let { + when (it.lowercase()) { + "none", "false", "0", "no" -> { + button.setCompoundDrawablesRelative(null, null, null, null) + } + isNamedDrawable(it, button.context) -> { + button.setCompoundDrawablesRelative( + getDrawable(it, button.context), null, null, null + ) + } + else -> { + try { + val iconDrawable = fromBase64(it, button.resources) + button.setCompoundDrawablesRelative( + iconDrawable, null, null, null + ) + } catch (e: Exception) { + Logger.e(this, "Unable to parse Base64 (error: ${e.localizedMessage}): $it") + } + } + } + } + textOverride?.let { + button.text = it + } + textColor?.asColor()?.let { + button.setTextColor(it) + val origIcons = button.compoundDrawablesRelative + button.setCompoundDrawablesRelative( + origIcons[0].applyTint(it), + origIcons[1].applyTint(it), + origIcons[2].applyTint(it), + origIcons[3].applyTint(it) + ) + } + backgroundColor?.asColor()?.let { + // force color drawable + button.setBackgroundColor(Color.WHITE) + val currentBackground = button.background + if (!applyColorTo(currentBackground, it)) { + Logger.d(this, "Overriding color as new background") + button.setBackgroundColor(it) + } + } + PlatformSize.parse(textSize)?.let { + button.setTextSize(it.unit, it.size) + } + button.setTypeface(button.typeface, toTypeface(textWeight)) + enabled?.let { + button.isEnabled = it + } + PlatformSize.parse(borderRadius)?.let { + when (val currentBackground = button.background) { + is GradientDrawable -> { + currentBackground.cornerRadius = it.size + button.background = currentBackground + } + is ColorDrawable -> { + val newBackground = GradientDrawable() + newBackground.cornerRadius = it.size + newBackground.setColor(currentBackground.color) + button.background = newBackground + } + is RippleDrawable -> { + for (i in 0 until currentBackground.numberOfLayers) { + try { + val drawable = currentBackground.getDrawable(i) + if (drawable is InsetDrawable) { + val subdrawable = drawable.drawable + Logger.e(this, "SubDrawable $i is ${subdrawable?.javaClass}") + if (subdrawable is GradientDrawable) { + subdrawable.cornerRadius = it.size + } + } + Logger.e(this, "Drawable $i is ${drawable.javaClass}") + } catch (e: Exception) { + Logger.e(this, "No Drawable for $i") + } + } + Logger.e(this, "Background is ${currentBackground.current.javaClass}") + button.background = currentBackground + } + else -> { + Logger.e(this, "BorderRadius for Button can be used only with colored background") + } + } + } + } + + private fun fromBase64(imageB64: String, resources: Resources): Drawable { + val imageDataB64 = if (imageB64.contains(",")) { + // probably: 'data:image/jpg;base64,BASE_64_DATA' + imageB64.split(",").last() + } else { + imageB64 + } + val decodedString: ByteArray = Base64.decode(imageDataB64, Base64.DEFAULT) + val decodedByte: Bitmap = BitmapFactory.decodeByteArray(decodedString, 0, decodedString.size) + val drawable = BitmapDrawable(resources, decodedByte) + drawable.setBounds(0, 0, decodedByte.width, decodedByte.height); + return drawable + } + + private fun getDrawable(name: String, context: Context): Drawable? { + return ResourcesCompat.getDrawable( + context.resources, + getDrawableIdentifier(context, name), + context.theme + ) + } + + /** + * Please accept Non-null String as TRUE. See usage in when :-) + */ + private fun isNamedDrawable(name: String, context: Context): String? { + if (getDrawableIdentifier(context, name) == 0) { + return name + } + return null + } + + private fun getDrawableIdentifier(context: Context, name: String) = + context.resources.getIdentifier(name, "drawable", context.packageName) + + fun merge(source: ButtonStyle): ButtonStyle { + this.textOverride = source.textOverride + this.textColor = source.textColor ?: this.textColor + this.backgroundColor = source.backgroundColor ?: this.backgroundColor + this.showIcon = source.showIcon ?: this.showIcon + this.textSize = source.textSize ?: this.textSize + this.enabled = source.enabled ?: this.enabled + this.borderRadius = source.borderRadius ?: this.borderRadius + return this + } +} diff --git a/android/src/main/kotlin/com/exponea/style/DetailViewStyle.kt b/android/src/main/kotlin/com/exponea/style/DetailViewStyle.kt new file mode 100644 index 0000000..ac06478 --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/DetailViewStyle.kt @@ -0,0 +1,40 @@ +package com.exponea.style + +import android.view.View +import android.view.ViewGroup.OnHierarchyChangeListener +import android.widget.Button +import android.widget.ImageView +import com.exponea.sdk.view.AppInboxDetailView + +data class DetailViewStyle( + var title: TextViewStyle? = null, + var content: TextViewStyle? = null, + var receivedTime: TextViewStyle? = null, + var image: ImageViewStyle? = null, + var button: ButtonStyle? = null +) { + fun applyTo(view: AppInboxDetailView) { + title?.applyTo(view.titleView) + content?.applyTo(view.contentView) + receivedTime?.applyTo(view.receivedTimeView) + image?.applyTo(view.imageView as ImageView) + button?.let { buttonStyle -> + for (i in 0 until view.actionsContainerView.childCount) { + (view.actionsContainerView.getChildAt(i) as? Button)?.let { button -> + buttonStyle.applyTo(button) + } + } + view.actionsContainerView.setOnHierarchyChangeListener(object : OnHierarchyChangeListener { + override fun onChildViewAdded(parent: View?, child: View?) { + if (child == null) { + return + } + if (child is Button) { + buttonStyle.applyTo(child) + } + } + override fun onChildViewRemoved(p0: View?, p1: View?) { } + }) + } + } +} diff --git a/android/src/main/kotlin/com/exponea/style/ImageViewStyle.kt b/android/src/main/kotlin/com/exponea/style/ImageViewStyle.kt new file mode 100644 index 0000000..f737a26 --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/ImageViewStyle.kt @@ -0,0 +1,19 @@ +package com.exponea.style + +import android.view.View +import android.widget.ImageView +import com.exponea.asColor + +data class ImageViewStyle( + var visible: Boolean? = null, + var backgroundColor: String? = null +) { + fun applyTo(target: ImageView) { + visible?.let { + target.visibility = if (it) View.VISIBLE else View.GONE + } + backgroundColor?.asColor()?.let { + target.setBackgroundColor(it) + } + } +} diff --git a/android/src/main/kotlin/com/exponea/style/ListScreenStyle.kt b/android/src/main/kotlin/com/exponea/style/ListScreenStyle.kt new file mode 100644 index 0000000..28b7b9d --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/ListScreenStyle.kt @@ -0,0 +1,21 @@ +package com.exponea.style + +import com.exponea.sdk.view.AppInboxListView + +data class ListScreenStyle( + var emptyTitle: TextViewStyle? = null, + var emptyMessage: TextViewStyle? = null, + var errorTitle: TextViewStyle? = null, + var errorMessage: TextViewStyle? = null, + var progress: ProgressBarStyle? = null, + var list: AppInboxListViewStyle? = null +) { + fun applyTo(target: AppInboxListView) { + emptyTitle?.applyTo(target.statusEmptyTitleView) + emptyMessage?.applyTo(target.statusEmptyMessageView) + errorTitle?.applyTo(target.statusErrorTitleView) + errorMessage?.applyTo(target.statusErrorMessageView) + progress?.applyTo(target.statusProgressView) + list?.applyTo(target.listView) + } +} diff --git a/android/src/main/kotlin/com/exponea/style/ProgressBarStyle.kt b/android/src/main/kotlin/com/exponea/style/ProgressBarStyle.kt new file mode 100644 index 0000000..81d8820 --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/ProgressBarStyle.kt @@ -0,0 +1,24 @@ +package com.exponea.style + +import android.content.res.ColorStateList +import android.view.View +import android.widget.ProgressBar +import com.exponea.asColor + +data class ProgressBarStyle( + var visible: Boolean? = null, + var progressColor: String? = null, + var backgroundColor: String? = null +) { + fun applyTo(target: ProgressBar) { + visible?.let { + target.visibility = if (it) View.VISIBLE else View.GONE + } + progressColor?.asColor()?.let { + target.progressTintList = ColorStateList.valueOf(it) + } + backgroundColor?.asColor()?.let { + target.setBackgroundColor(it) + } + } +} diff --git a/android/src/main/kotlin/com/exponea/style/TextViewStyle.kt b/android/src/main/kotlin/com/exponea/style/TextViewStyle.kt new file mode 100644 index 0000000..ed4a33a --- /dev/null +++ b/android/src/main/kotlin/com/exponea/style/TextViewStyle.kt @@ -0,0 +1,31 @@ +package com.exponea.style + +import android.view.View +import android.widget.TextView +import com.exponea.PlatformSize +import com.exponea.asColor +import com.exponea.toTypeface + +data class TextViewStyle( + var visible: Boolean? = null, + var textColor: String? = null, + var textSize: String? = null, + var textWeight: String? = null, + var textOverride: String? = null +) { + fun applyTo(target: TextView) { + visible?.let { + target.visibility = if (it) View.VISIBLE else View.GONE + } + textColor?.asColor()?.let { + target.setTextColor(it) + } + PlatformSize.parse(textSize)?.let { + target.setTextSize(it.unit, it.size) + } + textWeight?.let { + target.setTypeface(target.typeface, toTypeface(it)) + } + textOverride?.let { target.text = it } + } +} diff --git a/android/src/main/kotlin/com/exponea/widget/FlutterAppInboxButton.kt b/android/src/main/kotlin/com/exponea/widget/FlutterAppInboxButton.kt new file mode 100644 index 0000000..9f89b1b --- /dev/null +++ b/android/src/main/kotlin/com/exponea/widget/FlutterAppInboxButton.kt @@ -0,0 +1,40 @@ +package com.exponea.widget + +import android.content.Context +import android.view.View +import android.widget.Button +import com.exponea.sdk.Exponea +import io.flutter.plugin.common.StandardMessageCodec +import io.flutter.plugin.platform.PlatformView +import io.flutter.plugin.platform.PlatformViewFactory + +class FlutterAppInboxButton( + private val context: Context, + id: Int, + creationParams: Map? +) : PlatformView { + + private val appInboxButton: Button? + + class Factory : PlatformViewFactory(StandardMessageCodec.INSTANCE) { + override fun create(context: Context?, viewId: Int, args: Any?): PlatformView { + @Suppress("UNCHECKED_CAST") + val creationParams = args as Map? + return FlutterAppInboxButton(context!!, viewId, creationParams) + } + } + + init { + appInboxButton = Exponea.getAppInboxButton(context = context) + } + + override fun getView(): Button? { + return appInboxButton + } + + override fun dispose() { + + } + +} + diff --git a/documentation/APP_INBOX.md b/documentation/APP_INBOX.md new file mode 100644 index 0000000..7ff14a1 --- /dev/null +++ b/documentation/APP_INBOX.md @@ -0,0 +1,287 @@ +## App Inbox + +Exponea SDK feature App Inbox allows you to use message list in your app. You can find information on creating your messages in [Exponea documentation](https://documentation.bloomreach.com/engagement/docs/app-inbox). + +### Using App Inbox + +Only required step to use App Inbox in your application is to add a button into your screen. Messages are then displayed by clicking on a button: + +```dart +ListTile( + title: const Text('App Inbox'), + subtitle: Row( + children: const [ + SizedBox(width: 150, height: 50, child: AppInboxProvider()), + ] + ) +), +``` + +App Inbox button has registered a click action to show an App Inbox list screen. + +No more work is required for showing App Inbox but may be customized in multiple ways. + +## Default App Inbox behavior + +Exponea SDK is fetching and showing an App Inbox for you automatically in default steps: + +1. Shows a button to access App Inbox list (need to be done by developer) +2. Shows a screen for App Inbox list. Each item is shown with: + 1. Flag if message is read or unread + 2. Delivery time in human-readable form (i.e. `2 hours ago`) + 3. Single-lined title of message ended by '...' for longer value + 4. Two-lined content of message ended by '...' for longer value + 5. Squared image if message contains any + 6. Shows a loading state of list (indeterminate progress) + 7. Shows an empty state of list with title and message + 8. Shows an error state of list with title and description +3. Screen for App Inbox list calls a `ExponeaPlugin().trackAppInboxOpened` on item click and marks message as read automatically +4. Shows a screen for App Inbox message detail that contains: + 1. Large squared image. A gray placeholder is shown if message has no image + 2. Delivery time in human-readable form (i.e. `2 hours ago`) + 3. Full title of message + 4. Full content of message + 5. Buttons for each reasonable action (actions to open browser link or invoking of universal link). Action that just opens current app is meaningless so is not listed +5. Screen for message detail calls `ExponeaPlugin().trackAppInboxClick` on action button click automatically + +> The behavior of `trackAppInboxOpened` and `trackAppInboxClick` may be affected by the tracking consent feature, which in enabled mode considers the requirement of explicit consent for tracking. Read more in [tracking consent documentation](./TRACKING_CONSENT.md). + +### UI components styling + +App Inbox screens are designed with love and to fulfill customers needs but may not fit design of your application. You are able to customize multiple colors and text appearances with simple configuration. + +```dart +_plugin.setAppInboxProvider(AppInboxStyle( + appInboxButton: SimpleButtonStyle( + // ButtonStyle + textOverride: 'text value', + textColor: 'color', + backgroundColor: 'color', + showIcon: 'none|named_icon|base64', + textSize: '12px', + enabled: true, + borderRadius: '5px', + textWeight: 'bold|normal|100..900' + ), + detailView: DetailViewStyle( + title: TextViewStyle( + // TextViewStyle + visible: true, + textColor: 'color', + textSize: '12px', + textWeight: 'bold|normal|100..900', + textOverride: 'text' + ), + content: TextViewStyle(...), + receivedTime: TextViewStyle(...), + image: ImageViewStyle( + // ImageViewStyle + visible: true, + backgroundColor: 'color' + ), + button: SimpleButtonStyle(), + }, + listView: ListScreenStyle( + emptyTitle: TextViewStyle(...), + emptyMessage: TextViewStyle(...), + errorTitle: TextViewStyle(...), + errorMessage: TextViewStyle(...), + progress: ProgressBarStyle( + // ProgressBarStyle + visible: true, + progressColor: 'color', + backgroundColor: 'color' + ), + list: AppInboxListViewStyle( + backgroundColor: 'color', + item: AppInboxListItemStyle( + backgroundColor: 'color', + readFlag: ImageViewStyle(), + receivedTime: TextViewStyle(...), + title: TextViewStyle(...), + content: TextViewStyle(...), + image: ImageViewStyle(), + ), + ), + ) + }) +``` + +Supported colors formats are: +* Short hex `#rgb` or with alpha `#rgba` +* Hex format `#rrggbb` or `#rrggbbaa` +* RGB format `rgb(255, 255, 255)` +* RGBA format `rgba(255, 255, 255, 1.0)` or `rgba(255 255 255 / 1.0)` +* ARGB format `argb(1.0, 255, 255, 255)` +* name format `yellow` (names has to be supported by Android/iOS platform) + +Supported size formats are: +* Pixels `12px` or `12` +* Scaleable Pixels `12sp` +* Density-independent Pixels `12dp` or `12dip` +* Points `12pt` +* Inches `12in` +* Millimeters `12mm` + +> Platform iOS does not support DPI conversions so only number value from Size is accepted. + +Supported text weight formats are: +* 'normal' - normal/regular style on both platforms +* 'bold' - bold style on both platforms +* Number from `100` to `900` - mainly usable on iOS platform. Can be used also on Android but with limitation (100-600 means 'normal'; 700-900 means 'bold') + +> You may register your own styling at any time - before Exponea SDK init or later in some of your screens. Every action in scope of App Inbox is using currently registered styles. Nevertheless, we recommend to set your configuration right before Exponea SDK initialization. + +### Localization + +Exponea SDK contains only texts in EN translation. To modify this or add a localization, you are able to define customized strings. + +For Android (i.e. in your `strings.xml` files) add: +```xml +Inbox +Inbox +Inbox message +Empty Inbox +You have no messages yet. +Something went wrong :( +We could not retrieve your messages. +See more +``` + +For iOS (i.e. in your `Localizable.string` files) add: +```text +"exponea.inbox.button" = "Inbox"; +"exponea.inbox.title" = "AppInbox"; +"exponea.inbox.loading" = "Loading messages..."; +"exponea.inbox.emptyTitle" = "Empty Inbox"; +"exponea.inbox.emptyMessage" = "You have no messages yet."; +"exponea.inbox.errorTitle" = "Something went wrong :("; +"exponea.inbox.errorMessage" = "We could not retrieve your messages."; +"exponea.inbox.defaultTitle" = "Message"; +"exponea.inbox.mainActionTitle" = "See more"; +``` + +### UI components styling (only Android) + +Please check these style-rules that are applied to UI components and override them (i.e. in your `styles.xml` files) + +```xml + + + + + + + + + + + + + + + + + +``` + +## App Inbox data API + +Exponea SDK provides methods to access App Inbox data directly without accessing UI layer at all. + +### App Inbox load + +App Inbox is assigned to existing customer account (defined by hardIds) so App Inbox is cleared in case of: + +- calling any `ExponeaPlugin().identifyCustomer` method +- calling any `ExponeaPlugin().anonymize` method + +To prevent a large data transferring on each fetch, App Inbox are stored locally and next loading is incremental. It means that first fetch contains whole App Inbox but next requests contain only new messages. You are freed by handling such a behavior, result data contains whole App Inbox but HTTP request in your logs may be empty for that call. +List of assigned App Inbox is done by + +``` dart +var messages = await ExponeaPlugin().fetchAppInbox(); +``` + +Exponea SDK provides API to get single message from App Inbox. To load it you need to pass a message ID: + +``` dart +var message = await ExponeaPlugin().fetchAppInboxItem(messageId); +``` + +Fetching of single message is still requesting for fetch of all messages (including incremental loading). But message data are returned from local repository in normal case (due to previous fetch of messages). + +### App Inbox message read state + +To set an App Inbox message read flag you need to pass a message: +```dart +var markedAsRead = ExponeaPlugin().markAppInboxAsRead(message); +``` +> Marking a message as read by `markAppInboxAsRead` method is not invoking a tracking event for opening a message. To track an opened message, you need to call `Exponea.trackAppInboxOpened` method. + +## Tracking events for App Inbox + +Exponea SDK default behavior is tracking the events for you automatically. In case of your custom implementation, please use tracking methods in right places. + +### Tracking opened App Inbox message + +To track an opening of message detail, you should use method `ExponeaPlugin().trackAppInboxOpened(MessageItem)` with opened message data. +The behaviour of `trackAppInboxOpened` may be affected by the tracking consent feature, which in enabled mode considers the requirement of explicit consent for tracking. Read more in [tracking consent documentation](./TRACKING_CONSENT.md). +If you want to avoid to consider tracking, you may use `ExponeaPlugin().trackAppInboxOpenedWithoutTrackingConsent` instead. This method will do track event ignoring tracking consent state. + +### Tracking clicked App Inbox message action + +To track an invoking of action, you should use method `ExponeaPlugin().trackAppInboxClick(MessageItemAction, MessageItem)` with clicked message action and data. +The behaviour of `trackAppInboxClick` may be affected by the tracking consent feature, which in enabled mode considers the requirement of explicit consent for tracking. Read more in [tracking consent documentation](./TRACKING_CONSENT.md). +If you want to avoid to consider tracking, you may use `ExponeaPlugin().trackAppInboxClickWithoutTrackingConsent` instead. This method will do track event ignoring tracking consent state. diff --git a/documentation/AUTHORIZATION.md b/documentation/AUTHORIZATION.md new file mode 100644 index 0000000..84d857c --- /dev/null +++ b/documentation/AUTHORIZATION.md @@ -0,0 +1,204 @@ +## Authorization + +Data between SDK and BE are delivered trough authorized HTTP/HTTPS communication. Level of security has to be defined by developer. +All authorization modes are used to set `Authorization` HTTP/HTTPS header. + +### Token authorization + +This mode is required and has to be set by `authorization` parameter in `ExponeaConfiguration`. See [configuration](./CONFIGURATION.md). + +Token authorization mode has to be given in `Token ` format. It is used for all public API access by default: + +* `POST /track/v2/projects//customers` as tracking of customer data +* `POST /track/v2/projects//customers/events` as tracking of event data +* `POST /track/v2/projects//campaigns/clicks` as tracking campaign events +* `POST /data/v2/projects//customers/attributes` as fetching customer attributes +* `POST /data/v2/projects//consent/categories` as fetching consents +* `POST /webxp/s//inappmessages?v=1` as fetching InApp messages +* `POST /webxp/projects//appinbox/fetch` as fetching of AppInbox data +* `POST /webxp/projects//appinbox/markasread` as marking of AppInbox message as read +* `POST /campaigns/send-self-check-notification?project_id=` as part of SelfCheck push notification flow + +Please check more details about [Public API](https://documentation.bloomreach.com/engagement/reference/authentication#public-api-access). + +``` dart +final _plugin = ExponeaPlugin(); +... +final config = ExponeaConfiguration( + ... + authorizationToken: "Token yourPublicApiTokenAbc123", + ... +); +final configured = await _plugin.configure(config); +``` + +> There is no change nor migration needed in case of using of `authorization` parameter if you are already using SDK in your app. + +### Customer Token authorization + +JSON Web Tokens are an open, industry standard [RFC 7519](https://tools.ietf.org/html/rfc7519) method for representing claims securely between SDK and BE. We recommend this method to be used according to higher security. + +This mode is optional and may be set by `advancedAuthEnabled` parameter in `ExponeaConfiguration`. See [configuration](./CONFIGURATION.md). + +Authorization value is used in `Bearer ` format. Currently, it is supported for listed API (for others Token authorization is used when `advancedAuthEnabled = true`): + +* `POST /webxp/projects//appinbox/fetch` as fetching of AppInbox data +* `POST /webxp/projects//appinbox/markasread` as marking of AppInbox message as read + +To activate this mode you need to set `advancedAuthEnabled` parameter `true` in `ExponeaConfiguration` as in given example: + +``` typescript +final _plugin = ExponeaPlugin(); +... +final config = ExponeaConfiguration( + ... + advancedAuthEnabled: true, + ... +); +final configured = await _plugin.configure(config); +``` + +Then an implementation of Authorization provider is needed. Provider has to be written in native code and differently for each platform. + +### Android authorization provider + +First step is to implement an interface of `com.exponea.sdk.services.AuthorizationProvider` as in given example: + +```kotlin +class ExampleAuthProvider : AuthorizationProvider { + + override fun getAuthorizationToken(): String? { + return "eyJ0eXAiOiJKV1Q..." + } +} +``` + +Final step is to register your AuthorizationProvider to AndroidManifest.xml file as: + +```xml + + +``` + +If you define AuthorizationProvider but is not working correctly, SDK initialization will fail. Please check for logs. +1. If you enable Customer Token authorization by configuration flag `advancedAuthEnabled` and implementation has not been found, you'll see log +`Advanced auth has been enabled but provider has not been found` +2. If you register class in AndroidManifest.xml but it cannot been found, you'll see log +`Registered class has not been found` with detailed info. +3. If you register class in AndroidManifest.xml but it is not implementing auth interface, you will see log +`Registered class has to implement com.exponea.sdk.services.AuthorizationProvider` + +AuthorizationProvider is loaded while SDK is initializing or after `ExponeaPlugin().anonymize()` is called; so you're able to see these logs in that time in case of any problem. + +### iOS authorization provider + +Single step is to implement a protocol of `AuthorizationProviderType` with @objc attribute as in given example: + +```swift +@objc(ExponeaAuthProvider) +public class ExampleAuthProvider: NSObject, AuthorizationProviderType { + required public override init() { } + public func getAuthorizationToken() -> String? { + "YOUR JWT TOKEN" + } +} +``` + +#### Asynchronous implementation of AuthorizationProvider + +Token value is requested for every HTTP call in runtime. Method `getAuthorizationToken()` is written for synchronously usage but is invoked in background thread. Therefore, you are able to block any asynchronous token retrieval (i.e. other HTTP call) and waits for result by blocking this thread. In case of error result of your token retrieval you may return NULL value but request will automatically fail. + +As example for Android: + +```kotlin +class ExampleAuthProvider : AuthorizationProvider { + override fun getAuthorizationToken(): String? = runBlocking { + return@runBlocking suspendCoroutine { done -> + retrieveTokenAsync( + success = {token -> done.resume(token)}, + error = {error -> done.resume(null)} + ) + } + } +} +``` + +As example for iOS: + +```swift +@objc(ExponeaAuthProvider) +public class ExampleAuthProvider: NSObject, AuthorizationProviderType { + required public override init() { } + public func getAuthorizationToken() -> String? { + let semaphore = DispatchSemaphore(value: 0) + var token: String? + let task = yourAuthTokenReqUrl.dataTask(with: request) { + token = $0 + semaphore.signal() + } + task.resume() + semaphore.wait() + return token + } +} +``` + +> Multiple network libraries are supporting a different approaches (coroutines, Promises, Rx, etc...) but principle stays same - feel free to block invocation of `getAuthorizationToken` method. + +#### Customer Token retrieval policy + +Token value is requested for every HTTP call (listed previously in this doc) that requires it. +As it is common thing that JWT tokens have own expiration lifetime so may be used multiple times. Thus information cannot be read from JWT token value directly so SDK is not storing token in any cache. As developer, you may implement any type of token cache behavior you want. + +As example for Android: + +```kotlin +class ExampleAuthProvider : AuthorizationProvider { + + private var tokenCache: String? = null + + override fun getAuthorizationToken(): String? = runBlocking { + if (tokenCache.isNullOrEmpty()) { + tokenCache = suspendCoroutine { done -> + retrieveTokenAsync( + success = {token -> done.resume(token)}, + error = {error -> done.resume(null)} + ) + } + } + return@runBlocking tokenCache + } +} +``` + +As example or iOS: + +```swift +@objc(ExponeaAuthProvider) +public class ExampleAuthProvider: NSObject, AuthorizationProviderType { + required public override init() { } + + private var tokenCache: String? + private var lifetime: Double? + + public func getAuthorizationToken() -> String? { + if tokenCache == nil || hasExpired(lifetime) { + (tokenCache, lifetime) = loadJwtToken() + } + return tokenCache + } + + private func loadJwtToken() -> String? { + ... + } +} +``` + +> Please consider to store your cached token in more secured way. Android offers you multiple options such as using [KeyStore](https://developer.android.com/training/articles/keystore) or use [Encrypted Shared Preferences](https://developer.android.com/reference/androidx/security/crypto/EncryptedSharedPreferences), for iOS try to use [Keychain](https://developer.apple.com/documentation/security/certificate_key_and_trust_services/keys/storing_keys_in_the_keychain) or use [CryptoKit](https://developer.apple.com/documentation/cryptokit/) + +> :warning: Customer Token is valid until its expiration and is assigned to current customer IDs. Bear in mind that if customer IDs have changed (during `identifyCustomer` or `anonymize` method) your Customer token is invalid for future HTTP requests invoked for new customer IDs. diff --git a/documentation/CONFIGURATION.md b/documentation/CONFIGURATION.md index 3f10b1a..6d62167 100644 --- a/documentation/CONFIGURATION.md +++ b/documentation/CONFIGURATION.md @@ -68,6 +68,8 @@ You can see the dart definition for Configuration object at [lib/src/data/model/ * **pushTokenTrackingFrequency** You can define your policy for tracking push notification token. Default value `TokenFrequency.onTokenChange` is recommended. +* **advancedAuthEnabled** If true, Customer Token authentication is used for communication with BE for API listed in [Authorization](./AUTHORIZATION.md) document. + * **android** Specific configuration for Android * **ios** Specific configuration for iOS diff --git a/documentation/TRACKING_CONSENT.md b/documentation/TRACKING_CONSENT.md new file mode 100644 index 0000000..09a788e --- /dev/null +++ b/documentation/TRACKING_CONSENT.md @@ -0,0 +1,33 @@ +## Tracking consent + +Based on the recent judgment (May 2022) made by the Federal Court of Justice in Germany (Bundesgerichtshof – BGH) +regarding the EU Datenschutz Grundverordnung (EU-GDPR), all access to data on the affected person’s device would +require explicit consent. For more info see [Configuration of the tracking consent categories](https://documentation.bloomreach.com/engagement/docs/configuration-of-tracking-consent). + +The SDK is adapted to the rules and is controlled according to the data received from the Push Notifications or InApp Messages. +If the tracking consent feature is disabled, the Push Notifications and InApp Messages data do not contain 'hasTrackingConsent' and their tracking behaviour has not been changed, so if the attribute 'hasTrackingConsent' is not present in data, SDK considers it as 'true'. +If the tracking consent feature is enabled, Push Notifications and InApp Messages data contain 'hasTrackingConsent' and the SDK tracks events according to the boolean value of this field. + +Disallowed tracking consent ('hasTrackingConsent' provided with 'false' value) can be overridden with URL query param 'xnpe_force_track' with 'true' value. + +### Event for opened AppInbox Message + +Event is normally tracked by calling `ExponeaPlugin().trackAppInboxOpened`. This method is tracking a delivered event only if: + +* Tracking consent feature is disabled +* Tracking consent feature is enabled and 'hasTrackingConsent' has 'true' value +* AppInbox has been loaded and given MessageItem is listed in AppInbox + +If you are using `ExponeaPlugin().trackAppInboxOpened` method manually and you want to avoid to consider tracking, you may use `ExponeaPlugin().trackAppInboxOpenedWithoutTrackingConsent` instead. This method will do track event ignoring tracking consent state. + +### Event for clicked AppInbox Message action + +Event is normally tracked by calling `ExponeaPlugin().trackAppInboxClick`. This method is tracking a clicked event only if: + +* Tracking consent feature is disabled +* Tracking consent feature is enabled and 'hasTrackingConsent' has 'true' value +* Action URL contains 'xnpe_force_track' with 'true' value independently from 'hasTrackingConsent' value + +> Event that is tracked because of `xnpe_force_track` (forced tracking) will contains an additional property `tracking_forced` with value `true` + +If you are using `ExponeaPlugin().trackAppInboxClick` method manually and you want to avoid to consider tracking, you may use `ExponeaPlugin().trackAppInboxClickWithoutTrackingConsent` instead. This method will do track event ignoring tracking consent state. diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index f0a3d85..1dd9c61 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -27,7 +27,13 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { compileSdkVersion rootProject.ext.compileSdkVersion - + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } sourceSets { main.java.srcDirs += 'src/main/kotlin' } @@ -79,6 +85,9 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" implementation "com.android.support:multidex:$multidexVersion" + implementation "com.exponea.sdk:sdk:3.4.0" + implementation 'com.squareup.okhttp3:okhttp:4.9.3' + implementation 'com.google.code.gson:gson:2.8.9' gmsImplementation "com.google.firebase:firebase-messaging:$firebaseVersion" hmsImplementation "com.huawei.agconnect:agconnect-core:$agconnectVersion" @@ -86,7 +95,7 @@ dependencies { // tests : testImplementation 'junit:junit:4.12' - testImplementation 'com.exponea.sdk:sdk:3.1.0' + testImplementation 'com.exponea.sdk:sdk:3.4.0' testImplementation 'com.google.code.gson:gson:2.8.6' } diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 41352e3..a0ed56b 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -50,5 +50,9 @@ + diff --git a/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt index d9a0e5a..0cae062 100644 --- a/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt +++ b/example/android/app/src/main/kotlin/com/exponea/example/MainActivity.kt @@ -1,12 +1,20 @@ package com.exponea.example +import android.content.Context import android.content.Intent import android.os.Bundle import com.exponea.ExponeaPlugin import io.flutter.embedding.android.FlutterActivity class MainActivity : FlutterActivity() { + + companion object { + var APP_CONTEXT: Context? = null + } + override fun onCreate(savedInstanceState: Bundle?) { + // Potential MemoryLeak but still fine + APP_CONTEXT = applicationContext ExponeaPlugin.handleCampaignIntent(intent, applicationContext) super.onCreate(savedInstanceState) } diff --git a/example/android/app/src/main/kotlin/com/exponea/example/managers/CustomerTokenStorage.kt b/example/android/app/src/main/kotlin/com/exponea/example/managers/CustomerTokenStorage.kt new file mode 100644 index 0000000..e842d76 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/exponea/example/managers/CustomerTokenStorage.kt @@ -0,0 +1,168 @@ +package com.exponea.example.managers + +import android.content.Context +import android.content.SharedPreferences +import android.preference.PreferenceManager +import com.exponea.example.MainActivity +import com.exponea.sdk.util.Logger +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import com.google.gson.reflect.TypeToken +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +/** + * !!! WARN for developers. + * This implementation is just proof of concept. Do not rely on any part of it as possible solution for Customer Token + * handling. + * It is in your own interest to provide proper token generating and handling of its cache, expiration and secured storing. + */ +class CustomerTokenStorage( + private var networkManager: NetworkManager = NetworkManager(), + private val gson: Gson = Gson(), + private val prefs: SharedPreferences = MainActivity.APP_CONTEXT!!.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) +) { + + companion object { + private const val CUSTOMER_TOKEN_CONF = "CustomerTokenConf" + val INSTANCE = CustomerTokenStorage() + } + + private var currentConf: Config? = null + + private var tokenCache: String? = null + + private var lastTokenRequestTime: Long = 0 + + fun retrieveJwtToken(): String? { + validateChangedConf() + val now = System.currentTimeMillis() + if (TimeUnit.MILLISECONDS.toMinutes(abs(now - lastTokenRequestTime)) < 5) { + // allows request for token once per 5 minutes, doesn't care if cache is NULL + Logger.d(this, "[CTS] Token retrieved within 5min, using cache $tokenCache") + return tokenCache + } + lastTokenRequestTime = now + if (tokenCache != null) { + // return cached value + Logger.d(this, "[CTS] Token cache returned $tokenCache") + return tokenCache + } + synchronized(this) { + // recheck nullity just in case + if (tokenCache == null) { + tokenCache = loadJwtToken() + } + } + return tokenCache + } + + private fun validateChangedConf() { + val storedConf = loadConfiguration() + if (currentConf != storedConf) { + // reset token + tokenCache = null + lastTokenRequestTime = 0 + } + currentConf = storedConf + } + + private fun loadJwtToken(): String? { + currentConf = loadConfiguration() + val host = currentConf?.host + val projectToken = currentConf?.projectToken + val publicKey = currentConf?.publicKey + val customerIds = currentConf?.customerIds + if ( + host == null || projectToken == null || + publicKey == null || customerIds == null || customerIds.size == 0 + ) { + Logger.d(this, "[CTS] Not configured yet") + return null + } + val reqBody = hashMapOf( + "project_id" to projectToken, + "kid" to publicKey, + "sub" to customerIds + ) + val jsonRequest = gson.toJson(reqBody) + val response = networkManager.post( + "$host/webxp/exampleapp/customertokens", + null, + jsonRequest + ).execute() + Logger.d(this, "[CTS] Requested for token with $jsonRequest") + if (!response.isSuccessful) { + if (response.code == 404) { + // that is fine, only some BE has this endpoint + Logger.d(this, "[CTS] Token request returns 404") + return null + } + Logger.e(this, "[CTS] Token request returns ${response.code}") + return null + } + val jsonResponse = response.body?.string() + val responseData = try { + gson.fromJson(jsonResponse, Response::class.java) + } catch (e: Exception) { + Logger.e(this, "[CTS] Token cannot be parsed from $jsonResponse") + return null + } + if (responseData?.token == null) { + Logger.e(this, "[CTS] Token received NULL") + } + Logger.d(this, "[CTS] Token received ${responseData?.token}") + return responseData?.token + } + + private fun loadConfiguration(): Config { + return Config( + // See config.dart how are stored fields + host = prefs.getString("flutter.base_url", null), + projectToken = prefs.getString("flutter.project_token", null), + publicKey = prefs.getString("flutter.advanced_auth_token", null), + // See home.dart how are stored customerIds in _identifyCustomer method + customerIds = prefs.getString("flutter.customer_ids", null).let { + gson.fromJson(it ?: "{}", object : TypeToken>() {}.type) + } + ) + } + + data class Response( + @SerializedName("customer_token") + val token: String?, + + @SerializedName("expire_time") + val expiration: Int? + ) + + data class Config( + var host: String? = null, + var projectToken: String? = null, + var publicKey: String? = null, + var customerIds: HashMap? = null + + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Config + + if (host != other.host) return false + if (projectToken != other.projectToken) return false + if (publicKey != other.publicKey) return false + if (customerIds != other.customerIds) return false + + return true + } + + override fun hashCode(): Int { + var result = host?.hashCode() ?: 0 + result = 31 * result + (projectToken?.hashCode() ?: 0) + result = 31 * result + (publicKey?.hashCode() ?: 0) + result = 31 * result + (customerIds?.hashCode() ?: 0) + return result + } + } +} diff --git a/example/android/app/src/main/kotlin/com/exponea/example/managers/NetworkManager.kt b/example/android/app/src/main/kotlin/com/exponea/example/managers/NetworkManager.kt new file mode 100644 index 0000000..3d3a069 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/exponea/example/managers/NetworkManager.kt @@ -0,0 +1,77 @@ +package com.exponea.example.managers + +import okhttp3.Call +import okhttp3.Interceptor +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody + +class NetworkManager { + private val mediaTypeJson: MediaType = "application/json".toMediaTypeOrNull()!! + private lateinit var networkClient: OkHttpClient + + init { + setupNetworkClient() + } + + private fun getNetworkInterceptor(): Interceptor { + return Interceptor { + val request = it.request() + return@Interceptor try { + it.proceed(request) + } catch (e: Exception) { + // Sometimes the request can fail due to SSL problems crashing the app. When that + // happens, we return a dummy failed request + val message = "Error: request canceled by $e" + Response.Builder() + .code(400) + .protocol(Protocol.HTTP_2) + .message(message) + .request(it.request()) + .body(ResponseBody.create("text/plain".toMediaTypeOrNull(), message)) + .build() + } + } + } + + private fun setupNetworkClient() { + val networkInterceptor = getNetworkInterceptor() + + networkClient = OkHttpClient.Builder() + .addInterceptor(networkInterceptor) + .build() + } + + private fun request(method: String, url: String, authorization: String?, body: String?): Call { + val requestBuilder = Request.Builder().url(url) + + requestBuilder.addHeader("Content-Type", "application/json") + if (authorization != null) { + requestBuilder.addHeader("Authorization", authorization) + } + + if (body != null) { + when (method) { + "GET" -> requestBuilder.get() + "POST" -> requestBuilder.post(RequestBody.create(mediaTypeJson, body)) + else -> throw RuntimeException("Http method $method not supported.") + } + requestBuilder.post(RequestBody.create(mediaTypeJson, body)) + } + + return networkClient.newCall(requestBuilder.build()) + } + + fun post(url: String, authorization: String?, body: String?): Call { + return request("POST", url, authorization, body) + } + + fun get(url: String, authorization: String?): Call { + return request("GET", url, authorization, null) + } +} diff --git a/example/android/app/src/main/kotlin/com/exponea/example/services/ExampleAuthProvider.kt b/example/android/app/src/main/kotlin/com/exponea/example/services/ExampleAuthProvider.kt new file mode 100644 index 0000000..8e863b0 --- /dev/null +++ b/example/android/app/src/main/kotlin/com/exponea/example/services/ExampleAuthProvider.kt @@ -0,0 +1,13 @@ +package com.exponea.example.services + +import com.exponea.example.managers.CustomerTokenStorage +import com.exponea.sdk.services.AuthorizationProvider + +class ExampleAuthProvider : AuthorizationProvider { + + override fun getAuthorizationToken(): String? { + // Receive and return JWT token here. + return CustomerTokenStorage.INSTANCE.retrieveJwtToken() + // NULL as returned value will be handled by SDK as 'no value' and leads to Error + } +} \ No newline at end of file diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index beb16a4..a95ae70 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,12 +1,12 @@ PODS: - AnyCodable-FlightSchool (0.6.3) - - exponea (0.0.1): + - exponea (1.2.0): - AnyCodable-FlightSchool (= 0.6.3) - - ExponeaSDK (= 2.13.1) + - ExponeaSDK (= 2.15.1) - Flutter - - ExponeaSDK (2.13.1): + - ExponeaSDK (2.15.1): - SwiftSoup (= 2.4.3) - - ExponeaSDK-Notifications (2.13.1) + - ExponeaSDK-Notifications (2.15.1) - Flutter (1.0.0) - Nimble (9.2.1) - Quick (4.0.0) @@ -46,9 +46,9 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: AnyCodable-FlightSchool: 1c9890be5884140c60da5fde1b66380dec4c244a - exponea: 4eb412fccf4a68cf8509566b158d6e72be578f26 - ExponeaSDK: 4e2567f56fda8a1cb20560ceb33cd03836fbb9f3 - ExponeaSDK-Notifications: 8c776f24b10de56ba09dc50d2542d49888b6e894 + exponea: aa4ff0aa17c3a39d4d1a24e9e67088916e9857a3 + ExponeaSDK: 0c4d548e5a425b528d54b40400bf7004ec06377b + ExponeaSDK-Notifications: e2392b5b0ce902beac20c65b884b604e6de7db40 Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a Nimble: e7e615c0335ee4bf5b0d786685451e62746117d5 Quick: 6473349e43b9271a8d43839d9ba1c442ed1b7ac4 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 86b1a0b..d4fa6e9 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -31,6 +31,8 @@ 31C2AE91268B8E4C00C00CC5 /* RecommendationOptionsSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31C2AE90268B8E4C00C00CC5 /* RecommendationOptionsSpec.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 4C260E0D59EF40A9D6B37888 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 362BFA2D4F75F0F70FA2FD20 /* Pods_Runner.framework */; }; + 5531B70929B1514F00BF354F /* ExampleAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5531B70829B1514F00BF354F /* ExampleAuthProvider.swift */; }; + 5531B70B29B1519300BF354F /* CustomerTokenStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5531B70A29B1519300BF354F /* CustomerTokenStorage.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -124,6 +126,8 @@ 3253A0E9A7047ED9F79FD763 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 362BFA2D4F75F0F70FA2FD20 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 5531B70829B1514F00BF354F /* ExampleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleAuthProvider.swift; sourceTree = ""; }; + 5531B70A29B1519300BF354F /* CustomerTokenStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerTokenStorage.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -304,6 +308,8 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 5531B70829B1514F00BF354F /* ExampleAuthProvider.swift */, + 5531B70A29B1519300BF354F /* CustomerTokenStorage.swift */, ); path = Runner; sourceTree = ""; @@ -667,6 +673,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5531B70929B1514F00BF354F /* ExampleAuthProvider.swift in Sources */, + 5531B70B29B1519300BF354F /* CustomerTokenStorage.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 209679e..100a08c 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -10,6 +10,12 @@ import exponea didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) + + + registrar(forPlugin: "AppInbox")?.register(FluffViewFactory(), withId: "FluffView") + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard index f3c2851..a9cba04 100644 --- a/example/ios/Runner/Base.lproj/Main.storyboard +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/example/ios/Runner/CustomerTokenStorage.swift b/example/ios/Runner/CustomerTokenStorage.swift new file mode 100644 index 0000000..db2e31b --- /dev/null +++ b/example/ios/Runner/CustomerTokenStorage.swift @@ -0,0 +1,159 @@ +// +// CustomerTokenStorage.swift +// Runner +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import ExponeaSDK + +class CustomerTokenStorage { + + public static var shared = CustomerTokenStorage() + + private let semaphore: DispatchQueue = DispatchQueue( + label: "CustomerTokenStorageLockingQueue", + attributes: .concurrent + ) + + private var currentConf: Config? + + private var tokenCache: String? + + private var lastTokenRequestTime: Double = 0 + + func retrieveJwtToken() -> String? { + validateChangedConf() + let now = Date().timeIntervalSince1970 + let timeDiffMinutes = abs(now - lastTokenRequestTime) / 60.0 + if timeDiffMinutes < 5 { + // allows request for token once per 5 minutes, doesn't care if cache is NULL + return tokenCache + } + lastTokenRequestTime = now + if tokenCache != nil { + // return cached value + return tokenCache + } + semaphore.sync(flags: .barrier) { + // recheck nullity just in case + if tokenCache == nil { + tokenCache = loadJwtToken() + } + } + return tokenCache + } + + private func validateChangedConf() { + let storedConf = loadConfiguration() + if currentConf != storedConf { + // reset token + tokenCache = nil + lastTokenRequestTime = 0 + } + currentConf = storedConf + } + + private func loadJwtToken() -> String? { + currentConf = loadConfiguration() + guard let host = currentConf?.host, + let projectToken = currentConf?.projectToken, + let publicKey = currentConf?.publicKey, + let customerIds = currentConf?.customerIds, + customerIds.count > 0 else { + Exponea.logger.log(.verbose, message: "CustomerTokenStorage not configured yet") + return nil + } + guard let url = URL(string: "\(host)/webxp/exampleapp/customertokens") else { + Exponea.logger.log(.error, message: "Invalid URL host \(host) for CustomerTokenStorage") + return nil + } + var requestBody: [String: Codable] = [ + "project_id": projectToken, + "kid": publicKey, + "sub": customerIds + ] + do { + let postData = try JSONSerialization.data(withJSONObject: requestBody, options: []) + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.httpBody = postData + request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + let httpSemaphore = DispatchSemaphore(value: 0) + var httpResponseData: Data? + var httpResponse: URLResponse? + let httpTask = URLSession.shared.dataTask(with: request) { + httpResponseData = $0 + httpResponse = $1 + _ = $2 + httpSemaphore.signal() + } + httpTask.resume() + _ = httpSemaphore.wait(timeout: .distantFuture) + if let httpResponse = httpResponse as? HTTPURLResponse { + // this is optional check - we looking for 404 posibility + switch httpResponse.statusCode { + case 404: + // that is fine, only some BE has this endpoint + return nil + case 300..<599: + Exponea.logger.log( + .error, + message: "Example token receiver returns \(httpResponse.statusCode)" + ) + return nil + default: + break + } + } + guard let httpResponseData = httpResponseData else { + Exponea.logger.log(.error, message: "Example token response is empty") + return nil + } + let responseData = try JSONDecoder().decode(TokenResponse.self, from: httpResponseData) + if responseData.token == nil { + Exponea.logger.log(.error, message: "Example token received NULL") + } + return responseData.token + } catch let error { + Exponea.logger.log(.error, message: "Example token cannot be parsed due error \(error.localizedDescription)") + return nil + } + } + + private func loadConfiguration() -> Config { + let prefs = UserDefaults.standard + var customerIds: [String: String]? + if let customerIdsString = prefs.string(forKey: "flutter.customer_ids"), + let customerIdsData = customerIdsString.data(using: .utf8), + let loadedIds = try? JSONDecoder().decode([String: String].self, from: customerIdsData) { + customerIds = loadedIds + } + return Config( + // See config.dart how are stored fields + host: prefs.string(forKey: "flutter.base_url"), + projectToken: prefs.string(forKey: "flutter.project_token"), + publicKey: prefs.string(forKey: "flutter.advanced_auth_token"), + // See home.dart how are stored customerIds in _identifyCustomer method + customerIds: customerIds + ) + } +} + +struct TokenResponse: Codable { + var token: String? + var expiration: Int? + + enum CodingKeys: String, CodingKey { + case token = "customer_token" + case expiration = "expire_time" + } +} + +struct Config: Equatable { + var host: String? + var projectToken: String? + var publicKey: String? + var customerIds: [String: String]? +} diff --git a/example/ios/Runner/ExampleAuthProvider.swift b/example/ios/Runner/ExampleAuthProvider.swift new file mode 100644 index 0000000..5d8b9a3 --- /dev/null +++ b/example/ios/Runner/ExampleAuthProvider.swift @@ -0,0 +1,19 @@ +// +// ExampleAuthProvider.swift +// Runner +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import ExponeaSDK + +@objc(ExponeaAuthProvider) +public class ExampleAuthProvider: NSObject, AuthorizationProviderType { + required public override init() { } + public func getAuthorizationToken() -> String? { + // Receive and return JWT token here. + return CustomerTokenStorage.shared.retrieveJwtToken() + // NULL as returned value will be handled by SDK as 'no value' + } +} diff --git a/example/lib/page/config.dart b/example/lib/page/config.dart index 5465c07..688c928 100644 --- a/example/lib/page/config.dart +++ b/example/lib/page/config.dart @@ -22,12 +22,14 @@ class ConfigPage extends StatefulWidget { class _ConfigPageState extends State { static const _spKeyProject = 'project_token'; static const _spKeyAuth = 'auth_token'; + static const _spKeyAdvancedAuth = 'advanced_auth_token'; static const _spKeyBaseUrl = 'base_url'; static const _spKeySessionTracking = 'session_tracking'; final _loading = ValueNotifier(false); late final TextEditingController _projectTokenController; late final TextEditingController _authTokenController; + late final TextEditingController _advancedAuthTokenController; late final TextEditingController _baseUrlController; late final ValueNotifier _sessionTrackingController; @@ -35,11 +37,13 @@ class _ConfigPageState extends State { void initState() { _projectTokenController = TextEditingController(text: ''); _authTokenController = TextEditingController(text: ''); + _advancedAuthTokenController = TextEditingController(text: ''); _baseUrlController = TextEditingController(text: ''); _sessionTrackingController = ValueNotifier(true); SharedPreferences.getInstance().then((sp) async { _projectTokenController.text = sp.getString(_spKeyProject) ?? ''; _authTokenController.text = sp.getString(_spKeyAuth) ?? ''; + _advancedAuthTokenController.text = sp.getString(_spKeyAdvancedAuth) ?? ''; _baseUrlController.text = sp.getString(_spKeyBaseUrl) ?? ''; _sessionTrackingController.value = sp.getBool(_spKeySessionTracking) ?? true; @@ -73,6 +77,12 @@ class _ConfigPageState extends State { decoration: const InputDecoration(labelText: 'Auth Token'), ), ), + ListTile( + title: TextField( + controller: _advancedAuthTokenController, + decoration: const InputDecoration(labelText: 'Advanced Auth Token'), + ), + ), ListTile( title: TextField( controller: _baseUrlController, @@ -110,6 +120,7 @@ class _ConfigPageState extends State { _loading.value = true; final projectToken = _projectTokenController.text.trim(); final authToken = _authTokenController.text.trim(); + final advancedAuthToken = _advancedAuthTokenController.text.trim(); final rawBaseUrl = _baseUrlController.text.trim(); final baseUrl = rawBaseUrl.isNotEmpty ? rawBaseUrl : null; final sessionTracking = _sessionTrackingController.value; @@ -117,6 +128,7 @@ class _ConfigPageState extends State { final sp = await SharedPreferences.getInstance(); await sp.setString(_spKeyProject, projectToken); await sp.setString(_spKeyAuth, authToken); + await sp.setString(_spKeyAdvancedAuth, advancedAuthToken); await sp.setString(_spKeyBaseUrl, rawBaseUrl); await sp.setBool(_spKeySessionTracking, sessionTracking); @@ -133,6 +145,7 @@ class _ConfigPageState extends State { 'double': 1.2, 'int': 10, 'bool': true, + 'fontWeight': "normal" }, allowDefaultCustomerProperties: false, // projectMapping: { @@ -143,6 +156,7 @@ class _ConfigPageState extends State { // ExponeaProject(projectToken: '2', authorizationToken: '22'), // ], // }, + advancedAuthEnabled: advancedAuthToken.isNotEmpty, android: const AndroidExponeaConfiguration( automaticPushNotifications: true, httpLoggingLevel: HttpLoggingLevel.body, @@ -159,6 +173,43 @@ class _ConfigPageState extends State { ), ); try { + _plugin.setAppInboxProvider(AppInboxStyle( + appInboxButton: SimpleButtonStyle( + backgroundColor: 'rgb(245, 195, 68)', + borderRadius: '10dp', + showIcon: 'iVBORw0KGgoAAAANSUhEUgAAAEUAAABICAMAAACXzcjFAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAJcEhZcwAAITgAACE4AUWWMWAAAAA/UExURUdwTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALoT4l0AAAAUdFJOUwDvMHAgvxDfn0Bgz4BQr5B/j7CgEZfI5wAAAcJJREFUWMPtl9mWhCAMRBtFNlF74f+/dZBwRoNBQftpxnrpPqjXUFRQH49bt74o0ejrEC6dk+IqRTkve5XSzRR1U/4oRRirlHphih9RduDFcQ1J85HFFBh0qozTMLdHcZLXQHKUEszcfQeUAgxAWMu9MMUPNC0U2h1AnunNkpWOpT53IQNUAhCR1AK2waT0sSltdLkXC4V3scIWqhUHQY319/6fx0RK4L9XJ41lpnRg42f+mUS/mMrZUjAhjaYMuXnZcMW4siua55o9UyyOX+iGUJf8vWzasWZcopamOFl9Afd7ZU1hnM4xzmtc7q01nDqwYJLQt9tbrpKvMl216ZyO7ASTaTPAEOOMijBYa+iV64gehjlNeDCkyvUKK1B1WGGTHOqpKTlaGrfpRrKIYvAETlIm9OpwlsIESlMRha3tg0T0YhX5cX08S0FjIt7NaN1K4pIySuzclewZipDHHhSMNTKzM1RR0M6w6YJiis99FxnbJ0cFxbujB9NQe2MVJat/RHEVXw2corCad7Z56eLmSO3pHl6oePobU6w7pWS7F+wMRNJPhkoNG7/q58QMtXYfWTUbK/Lfq4Xilz9Ib926qB8ZxV6DpmAIowAAAABJRU5ErkJggg==', + textColor: 'white', + ), + detailView: DetailViewStyle( + button: SimpleButtonStyle( + backgroundColor: 'red' + ), + title: TextViewStyle( + textSize: '20sp', + textOverride: 'TEST', + textWeight: 'bold', + textColor: 'rgba(100, 100, 100, 1.0)' + ) + ), + listView: ListScreenStyle( + errorTitle: TextViewStyle( + textColor: 'red' + ), + errorMessage: TextViewStyle( + textColor: 'red' + ), + list: AppInboxListViewStyle( + backgroundColor: 'blue', + item: AppInboxListItemStyle( + backgroundColor: 'yellow', + content: TextViewStyle( + textColor: '#FFF', + textWeight: '700' + ) + ) + ) + ) + )); final configured = await _plugin.configure(config); if (!configured) { const snackBar = SnackBar( diff --git a/example/lib/page/home.dart b/example/lib/page/home.dart index d5aa53d..6e6adbf 100644 --- a/example/lib/page/home.dart +++ b/example/lib/page/home.dart @@ -1,9 +1,11 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:exponea/exponea.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; final _plugin = ExponeaPlugin(); @@ -175,10 +177,6 @@ class _HomePageState extends State { title: const Text('Flush Period'), subtitle: Row( children: [ - ElevatedButton( - onPressed: () => _getFlushPeriod(context), - child: const Text('Get'), - ), const SizedBox(width: 8), ElevatedButton( onPressed: () => _setFlushPeriod(context), @@ -274,11 +272,6 @@ class _HomePageState extends State { child: const Text('Get'), ), const SizedBox(width: 8), - ElevatedButton( - onPressed: () => _setLogLevel(context), - child: const Text('Set'), - ), - const SizedBox(width: 8), Expanded( child: ValueListenableBuilder( valueListenable: _logLevelController, @@ -304,6 +297,26 @@ class _HomePageState extends State { ], ), ), + ListTile( + title: const Text('App Inbox'), + subtitle: Row( + children: const [ + SizedBox(width: 150, height: 50, child: AppInboxProvider()), + ] + ) + ), + ListTile( + title: ElevatedButton( + onPressed: () => _fetchAppInbox(context), + child: const Text('Fetch all'), + ), + ), + ListTile( + title: ElevatedButton( + onPressed: () => _fetchAppInboxItem(context), + child: const Text('Fetch first'), + ), + ), ], ), ), @@ -312,6 +325,18 @@ class _HomePageState extends State { ); } + Future _fetchAppInbox(BuildContext context) => + _runAndShowResult(context, () async { + return await _plugin.fetchAppInbox(); + }); + + Future _fetchAppInboxItem(BuildContext context) => + _runAndShowResult(context, () async { + var messages = await _plugin.fetchAppInbox(); + if (messages.isEmpty) return "EMPTY APPINBOX"; + return messages[0]; + }); + Future _checkIsConfigured(BuildContext context) => _runAndShowResult(context, () async { return await _plugin.isConfigured(); @@ -325,11 +350,11 @@ class _HomePageState extends State { Future _identifyCustomer(BuildContext context) => _runAndShowResult(context, () async { const email = 'test-user-1@test.com'; - const customer = Customer( - ids: { - 'registered': email, - }, - ); + const customerIds = {'registered': email}; + const customer = Customer(ids: customerIds); + final sp = await SharedPreferences.getInstance(); + var customerIdsString = json.encode(customerIds); + await sp.setString("customer_ids", customerIdsString); await _plugin.identifyCustomer(customer); return email; }); diff --git a/example/node_modules/.yarn-integrity b/example/node_modules/.yarn-integrity new file mode 100644 index 0000000..9937b9e --- /dev/null +++ b/example/node_modules/.yarn-integrity @@ -0,0 +1,10 @@ +{ + "systemParams": "darwin-arm64-93", + "modulesFolders": [], + "flags": [], + "linkedModules": [], + "topLevelPatterns": [], + "lockfileEntries": {}, + "files": [], + "artifacts": {} +} \ No newline at end of file diff --git a/example/pubspec.lock b/example/pubspec.lock index 9d956d5..96b832c 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -21,21 +21,21 @@ packages: path: ".." relative: true source: path - version: "1.1.0" + version: "1.2.0" ffi: dependency: transitive description: name: ffi url: "https://pub.dartlang.org" source: hosted - version: "1.1.2" + version: "2.0.1" file: dependency: transitive description: name: file url: "https://pub.dartlang.org" source: hosted - version: "6.1.2" + version: "6.1.4" flutter: dependency: "direct main" description: flutter @@ -87,28 +87,28 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.8.0" + version: "1.8.3" path_provider_linux: dependency: transitive description: name: path_provider_linux url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.9" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.0.6" path_provider_windows: dependency: transitive description: name: path_provider_windows url: "https://pub.dartlang.org" source: hosted - version: "2.0.1" + version: "2.1.4" platform: dependency: transitive description: @@ -122,14 +122,14 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.4" process: dependency: transitive description: name: process url: "https://pub.dartlang.org" source: hosted - version: "4.2.1" + version: "4.2.4" shared_preferences: dependency: "direct main" description: @@ -143,49 +143,49 @@ packages: name: shared_preferences_android url: "https://pub.dartlang.org" source: hosted - version: "2.0.11" + version: "2.0.16" shared_preferences_ios: dependency: transitive description: name: shared_preferences_ios url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.1" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.4" shared_preferences_macos: dependency: transitive description: name: shared_preferences_macos url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.5" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.0.5" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.1.4" sky_engine: dependency: transitive description: flutter @@ -225,14 +225,14 @@ packages: name: win32 url: "https://pub.dartlang.org" source: hosted - version: "2.2.5" + version: "3.1.3" xdg_directories: dependency: transitive description: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.2.0" + version: "1.0.0" sdks: - dart: ">=2.17.0-0 <3.0.0" - flutter: ">=2.10.0" + dart: ">=2.17.0 <3.0.0" + flutter: ">=3.0.0" diff --git a/example/yarn.lock b/example/yarn.lock new file mode 100644 index 0000000..fb57ccd --- /dev/null +++ b/example/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/ios/Classes/Data/AppInboxCoder.swift b/ios/Classes/Data/AppInboxCoder.swift new file mode 100644 index 0000000..fbc60f1 --- /dev/null +++ b/ios/Classes/Data/AppInboxCoder.swift @@ -0,0 +1,30 @@ +// +// AppInboxCoder.swift +// exponea +// +// Created by Adam Mihalik on 03/03/2023. +// + +import Foundation +import ExponeaSDK + +class AppInboxCoder { + static func encode(_ source: MessageItem) throws -> [String:Any?] { + // keep in sync with app_inbox.dart + return [ + "id": source.id, + "type": source.type, + "isRead": source.read, + "createTime": source.rawReceivedTime, + "content": try self.normalizeData(source.rawContent) + ] + } + + private static func normalizeData(_ source: [String: JSONValue]?) throws -> [String: Any?] { + let rawContent = try JSONEncoder().encode(source) + let normalized = try JSONSerialization.jsonObject( + with: rawContent, options: [] + ) as? [String: Any?] + return normalized ?? [:] + } +} diff --git a/ios/Classes/Data/ExponeaConfiguration.swift b/ios/Classes/Data/ExponeaConfiguration.swift index 523c4bd..38e5726 100644 --- a/ios/Classes/Data/ExponeaConfiguration.swift +++ b/ios/Classes/Data/ExponeaConfiguration.swift @@ -14,9 +14,10 @@ class ExponeaConfiguration { let automaticSessionTracking: ExponeaSDK.Exponea.AutomaticSessionTracking let flushingSetup: ExponeaSDK.Exponea.FlushingSetup let defaultProperties: [String: JSONConvertible]? - let allowDefaultCustomerProperties: Bool? + var allowDefaultCustomerProperties: Bool? = nil + var advancedAuthEnabled: Bool? = nil - init(_ data: [String:Any?], parser: ConfigurationParser) throws { + init(_ data: [String: Any?], parser: ConfigurationParser) throws { self.projectSettings = try parser.parseProjectSettings(data) self.pushNotificationTracking = try parser.parsePushNotificationTracking(data) self.automaticSessionTracking = try parser.parseSessionTracking(data) @@ -24,8 +25,9 @@ class ExponeaConfiguration { self.defaultProperties = try parser.parseDefaultProperties(data) if let allowDefaultCustomerProperties = data["allowDefaultCustomerProperties"] as? Bool { self.allowDefaultCustomerProperties = allowDefaultCustomerProperties - } else { - self.allowDefaultCustomerProperties = nil + } + if let advancedAuthEnabled = data["advancedAuthEnabled"] as? Bool { + self.advancedAuthEnabled = advancedAuthEnabled } } } diff --git a/ios/Classes/FlutterAppInboxProvider.swift b/ios/Classes/FlutterAppInboxProvider.swift new file mode 100644 index 0000000..1e363e6 --- /dev/null +++ b/ios/Classes/FlutterAppInboxProvider.swift @@ -0,0 +1,71 @@ +// +// FlutterAppInboxProvider.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import ExponeaSDK + +class FlutterAppInboxProvider: DefaultAppInboxProvider { + + private let appInboxStyle: AppInboxStyle + + override init() { + appInboxStyle = AppInboxStyle() + } + + init(_ style: AppInboxStyle) { + appInboxStyle = style + } + + override func getAppInboxButton() -> UIButton { + let button = super.getAppInboxButton() + if let buttonStyle = appInboxStyle.appInboxButton { + buttonStyle.applyTo(button) + } + return button + } + + override func getAppInboxListViewController() -> UIViewController { + let listView = super.getAppInboxListViewController() + guard let listView = listView as? AppInboxListViewController else { + ExponeaSDK.Exponea.logger.log(.warning, message: "AppInbox list view impl mismatch - unable to customize") + return listView + } + listView.loadViewIfNeeded() + if let listViewStyle = appInboxStyle.listView { + listViewStyle.applyTo(listView) + } + return listView + } + + override func getAppInboxListTableViewCell(_ cell: UITableViewCell) -> UITableViewCell { + guard let listViewItem = cell as? MessageItemCell else { + ExponeaSDK.Exponea.logger.log(.warning, message: "AppInbox list item impl mismatch - unable to customize") + return cell + } + if let listItemViewStyle = appInboxStyle.listView?.list?.item { + listItemViewStyle.applyTo(listViewItem) + } + return listViewItem + } + + override func getAppInboxDetailViewController(_ messageId: String) -> UIViewController { + let detailView = super.getAppInboxDetailViewController(messageId) + guard let detailView = detailView as? AppInboxDetailViewController else { + ExponeaSDK.Exponea.logger.log(.warning, message: "AppInbox detail view impl mismatch - unable to customize") + return detailView + } + guard let detailStyle = appInboxStyle.detailView else { + return detailView + } + detailView.loadViewIfNeeded() + detailView.actionsContainer.axis = .vertical + detailView.actionsContainer.spacing = 8 + detailStyle.applyTo(detailView) + return detailView + } + +} diff --git a/ios/Classes/NSDictionary+getSafely.swift b/ios/Classes/NSDictionary+getSafely.swift new file mode 100644 index 0000000..c478ac9 --- /dev/null +++ b/ios/Classes/NSDictionary+getSafely.swift @@ -0,0 +1,30 @@ +// +// NSDictionary+getSafely.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation + +extension NSDictionary { + func getOptionalSafely(property: String) throws -> T? { + if let value = self[property] { + guard let value = value as? T else { + throw ExponeaDataError.invalidType(for: property) + } + return value + } + return nil + } + + func getRequiredSafely(property: String) throws -> T { + guard let anyValue = self[property] else { + throw ExponeaDataError.missingProperty(property: property) + } + guard let value = anyValue as? T else { + throw ExponeaDataError.invalidType(for: property) + } + return value + } +} diff --git a/ios/Classes/SwiftExponeaPlugin.swift b/ios/Classes/SwiftExponeaPlugin.swift index 338154d..c6cf6b5 100644 --- a/ios/Classes/SwiftExponeaPlugin.swift +++ b/ios/Classes/SwiftExponeaPlugin.swift @@ -7,27 +7,38 @@ import Foundation private let channelName = "com.exponea" private let openedPushStreamName = "\(channelName)/opened_push" private let receivedPushStreamName = "\(channelName)/received_push" -private let methodConfigure = "configure" -private let methodIsConfigured = "isConfigured" -private let methodGetCustomerCookie = "getCustomerCookie" -private let methodIdentifyCustomer = "identifyCustomer" -private let methodAnonymize = "anonymize" -private let methodGetDefaultProperties = "getDefaultProperties" -private let methodSetDefaultProperties = "setDefaultProperties" -private let methodFlush = "flush" -private let methodGetFlushMode = "getFlushMode" -private let methodSetFlushMode = "setFlushMode" -private let methodGetFlushPeriod = "getFlushPeriod" -private let methodSetFlushPeriod = "setFlushPeriod" -private let methodTrackEvent = "trackEvent" -private let methodTrackSessionStart = "trackSessionStart" -private let methodTrackSessionEnd = "trackSessionEnd" -private let methodFetchConsents = "fetchConsents" -private let methodFetchRecommendations = "fetchRecommendations" -private let methodGetLogLevel = "getLogLevel" -private let methodSetLogLevel = "setLogLevel" -private let methodCheckPushSetup = "checkPushSetup" -private let methodRequestPushAuthorization = "requestPushAuthorization" + +enum METHOD_NAME: String { + case methodConfigure = "configure" + case methodIsConfigured = "isConfigured" + case methodGetCustomerCookie = "getCustomerCookie" + case methodIdentifyCustomer = "identifyCustomer" + case methodAnonymize = "anonymize" + case methodGetDefaultProperties = "getDefaultProperties" + case methodSetDefaultProperties = "setDefaultProperties" + case methodFlush = "flush" + case methodGetFlushMode = "getFlushMode" + case methodSetFlushMode = "setFlushMode" + case methodGetFlushPeriod = "getFlushPeriod" + case methodSetFlushPeriod = "setFlushPeriod" + case methodTrackEvent = "trackEvent" + case methodTrackSessionStart = "trackSessionStart" + case methodTrackSessionEnd = "trackSessionEnd" + case methodFetchConsents = "fetchConsents" + case methodFetchRecommendations = "fetchRecommendations" + case methodGetLogLevel = "getLogLevel" + case methodSetLogLevel = "setLogLevel" + case methodCheckPushSetup = "checkPushSetup" + case methodRequestPushAuthorization = "requestPushAuthorization" + case setAppInboxProvider = "setAppInboxProvider" + case trackAppInboxOpened = "trackAppInboxOpened" + case trackAppInboxOpenedWithoutTrackingConsent = "trackAppInboxOpenedWithoutTrackingConsent" + case trackAppInboxClick = "trackAppInboxClick" + case trackAppInboxClickWithoutTrackingConsent = "trackAppInboxClickWithoutTrackingConsent" + case markAppInboxAsRead = "markAppInboxAsRead" + case fetchAppInbox = "fetchAppInbox" + case fetchAppInboxItem = "fetchAppInboxItem" +} private let defaultFlushPeriod = 5 * 60 // 5 minutes @@ -47,6 +58,25 @@ public class ExponeaFlutterVersion: NSObject, ExponeaVersionProvider { } } +public class FluffViewFactory: NSObject, FlutterPlatformViewFactory { + public func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView { + FluffView(frame: frame, viewId: viewId) + } +} + +public class FluffView: NSObject, FlutterPlatformView { + public func view() -> UIView {ExponeaSDK.Exponea.shared.getAppInboxButton() + } + + let frame: CGRect + let viewId: Int64 + + init(frame: CGRect, viewId: Int64) { + self.frame = frame + self.viewId = viewId + } +} + public class SwiftExponeaPlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { @@ -64,54 +94,277 @@ public class SwiftExponeaPlugin: NSObject, FlutterPlugin { var exponeaInstance: ExponeaType = ExponeaSDK.Exponea.shared public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case methodConfigure: + guard let method = METHOD_NAME(rawValue: call.method) else { + let error = FlutterError(code: errorCode, message: "\(call.method) is not supported by iOS", details: nil) + result(error) + return + } + switch method { + case .methodConfigure: configure(call.arguments, with: result) - case methodIsConfigured: + case .methodIsConfigured: isConfigured(with: result) - case methodGetCustomerCookie: + case .methodGetCustomerCookie: getCustomerCookie(with: result) - case methodIdentifyCustomer: + case .methodIdentifyCustomer: identifyCustomer(call.arguments, with: result) - case methodAnonymize: + case .methodAnonymize: anonymize(call.arguments, with: result) - case methodGetDefaultProperties: + case .methodGetDefaultProperties: getDefaultProperties(with: result) - case methodSetDefaultProperties: + case .methodSetDefaultProperties: setDefaultProperties(call.arguments, with: result) - case methodFlush: + case .methodFlush: flush(with: result) - case methodGetFlushMode: + case .methodGetFlushMode: getFlushMode(with: result) - case methodSetFlushMode: + case .methodSetFlushMode: setFlushMode(call.arguments, with: result) - case methodGetFlushPeriod: + case .methodGetFlushPeriod: getFlushPeriod(with: result) - case methodSetFlushPeriod: + case .methodSetFlushPeriod: setFlushPeriod(call.arguments, with: result) - case methodTrackEvent: + case .methodTrackEvent: trackEvent(call.arguments, with: result) - case methodTrackSessionStart: + case .methodTrackSessionStart: trackSessionStart(call.arguments, with: result) - case methodTrackSessionEnd: + case .methodTrackSessionEnd: trackSessionEnd(call.arguments, with: result) - case methodFetchConsents: + case .methodFetchConsents: fetchConsents(with: result) - case methodFetchRecommendations: + case .methodFetchRecommendations: fetchRecommendations(call.arguments, with: result) - case methodGetLogLevel: + case .methodGetLogLevel: getLogLevel(with: result) - case methodSetLogLevel: + case .methodSetLogLevel: setLogLevel(call.arguments, with: result) - case methodCheckPushSetup: + case .methodCheckPushSetup: checkPushSetup(with: result) - case methodRequestPushAuthorization: + case .methodRequestPushAuthorization: requestPushAuthorization(with: result) - default: - let error = FlutterError(code: errorCode, message: "\(call.method) is not supported by iOS", details: nil) + case .setAppInboxProvider: + setupAppInbox(call.arguments, with: result) + case .trackAppInboxOpened: + trackAppInboxOpened(call.arguments, with: result) + case .trackAppInboxOpenedWithoutTrackingConsent: + trackAppInboxOpenedWithoutTrackingConsent(call.arguments, with: result) + case .trackAppInboxClick: + trackAppInboxClick(call.arguments, with: result) + case .trackAppInboxClickWithoutTrackingConsent: + trackAppInboxClickWithoutTrackingConsent(call.arguments, with: result) + case .markAppInboxAsRead: + markAppInboxAsRead(call.arguments, with: result) + case .fetchAppInbox: + fetchAppInbox(with: result) + case .fetchAppInboxItem: + fetchAppInboxItem(call.arguments, with: result) + } + } + + private func trackAppInboxOpened(_ args: Any?, with result: @escaping FlutterResult) { + guard requireConfigured(with: result) else { return } + do { + guard let data = args as? NSDictionary, + let messageId: String = try data.getRequiredSafely(property: "id") else { + result(FlutterError( + code: errorCode, + message: "AppInbox message data are invalid. See logs", details: "no ID" + )) + return + } + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + exponeaInstance.fetchAppInboxItem(messageId) { nativeMessageResult in + switch nativeMessageResult { + case .success(let nativeMessage): + self.exponeaInstance.trackAppInboxOpened(message: nativeMessage) + result(nil) + case .failure(let error): + result(error) + } + } + } catch { + result(error) + } + } + + private func trackAppInboxOpenedWithoutTrackingConsent(_ args: Any?, with result: @escaping FlutterResult) { + guard requireConfigured(with: result) else { return } + do { + guard let data = args as? NSDictionary, + let messageId: String = try data.getRequiredSafely(property: "id") else { + result(FlutterError( + code: errorCode, + message: "AppInbox message data are invalid. See logs", details: "no ID" + )) + return + } + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + exponeaInstance.fetchAppInboxItem(messageId) { nativeMessageResult in + switch nativeMessageResult { + case .success(let nativeMessage): + self.exponeaInstance.trackAppInboxOpenedWithoutTrackingConsent(message: nativeMessage) + result(nil) + case .failure(let error): + result(error) + } + } + } catch { + result(error) + } + } + + private func trackAppInboxClick(_ args: Any?, with result: @escaping FlutterResult) { + guard requireConfigured(with: result) else { return } + do { + guard let data = args as? NSDictionary, + let messageData: NSDictionary = try? data.getRequiredSafely(property: "message"), + let messageId: String = try messageData.getRequiredSafely(property: "id"), + let actionData: NSDictionary = try? data.getRequiredSafely(property: "action") else { + result(FlutterError( + code: errorCode, + message: "AppInbox message data are invalid. See logs", details: "no message or action" + )) + return + } + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + exponeaInstance.fetchAppInboxItem(messageId) { nativeMessageResult in + switch nativeMessageResult { + case .success(let nativeMessage): + do { + let action = MessageItemAction( + action: try actionData.getOptionalSafely(property: "action"), + title: try actionData.getOptionalSafely(property: "title"), + url: try actionData.getOptionalSafely(property: "url") + ) + self.exponeaInstance.trackAppInboxClick(action: action, message: nativeMessage) + result(nil) + } catch { + result(error) + } + case .failure(let error): + result(error) + } + } + } catch { + result(error) + } + } + + private func trackAppInboxClickWithoutTrackingConsent(_ args: Any?, with result: @escaping FlutterResult) { + guard requireConfigured(with: result) else { return } + do { + guard let data = args as? NSDictionary, + let messageData: NSDictionary = try? data.getRequiredSafely(property: "message"), + let messageId: String = try messageData.getRequiredSafely(property: "id"), + let actionData: NSDictionary = try? data.getRequiredSafely(property: "action") else { + result(FlutterError( + code: errorCode, + message: "AppInbox message data are invalid. See logs", details: "no message or action" + )) + return + } + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + exponeaInstance.fetchAppInboxItem(messageId) { nativeMessageResult in + switch nativeMessageResult { + case .success(let nativeMessage): + do { + let action = MessageItemAction( + action: try actionData.getOptionalSafely(property: "action"), + title: try actionData.getOptionalSafely(property: "title"), + url: try actionData.getOptionalSafely(property: "url") + ) + self.exponeaInstance.trackAppInboxClickWithoutTrackingConsent(action: action, message: nativeMessage) + result(nil) + } catch { + result(error) + } + case .failure(let error): + result(error) + } + } + } catch { + result(error) + } + } + + private func markAppInboxAsRead(_ args: Any?, with result: @escaping FlutterResult) { + guard requireConfigured(with: result) else { return } + do { + guard let data = args as? NSDictionary, + let messageId: String = try data.getRequiredSafely(property: "id") else { + result(FlutterError( + code: errorCode, + message: "AppInbox message data are invalid. See logs", details: "no ID" + )) + return + } + exponeaInstance.fetchAppInboxItem(messageId) { nativeMessageResult in + switch nativeMessageResult { + case .success(let nativeMessage): + // we need to fetch native MessageItem; method needs syncToken and customerIds to be fetched + self.exponeaInstance.markAppInboxAsRead(nativeMessage) { marked in + result(marked) + } + case .failure(let error): + result(error) + } + } + } catch { result(error) } } + + private func fetchAppInbox(with result: @escaping FlutterResult) { + guard requireConfigured(with: result) else { return } + exponeaInstance.fetchAppInbox { fetchResult in + switch fetchResult { + case .success(let response): + do { + let outData: [[String: Any?]] = try response.map({ item in + try AppInboxCoder.encode(item) + }) + result(outData) + } catch { + result(error) + } + case .failure(let error): + result(error) + } + } + } + + private func fetchAppInboxItem(_ args: Any?, with result: @escaping FlutterResult) { + guard requireConfigured(with: result) else { return } + guard let messageId = args as? String else { + result(FlutterError( + code: errorCode, + message: "AppInbox message ID is invalid. See logs", details: "no ID" + )) + return + } + exponeaInstance.fetchAppInboxItem(messageId) { fetchResult in + switch fetchResult { + case .success(let message): + do { + result(try AppInboxCoder.encode(message)) + } catch { + result(error) + } + case .failure(let error): + result(error) + } + } + } + + private func setupAppInbox(_ args: Any?, with result: FlutterResult) { + guard let configMap = args as? NSDictionary, + let appInboxStyle = try? AppInboxStyleParser(configMap).parse() else { + result(FlutterError(code: errorCode, message: "Unable to parse AppInboxStyle data", details: nil)) + return + } + exponeaInstance.appInboxProvider = FlutterAppInboxProvider(appInboxStyle) + result(nil) + } private func configure(_ args: Any?, with result: FlutterResult) { guard !exponeaInstance.isConfigured else { @@ -120,7 +373,6 @@ public class SwiftExponeaPlugin: NSObject, FlutterPlugin { } do { let data = args as! [String:Any?] - let parser = ConfigurationParser() let config = try parser.parseConfig(data) @@ -131,7 +383,8 @@ public class SwiftExponeaPlugin: NSObject, FlutterPlugin { automaticSessionTracking: config.automaticSessionTracking, defaultProperties: config.defaultProperties, flushingSetup: config.flushingSetup, - allowDefaultCustomerProperties: config.allowDefaultCustomerProperties + allowDefaultCustomerProperties: config.allowDefaultCustomerProperties, + advancedAuthEnabled: config.advancedAuthEnabled ) exponeaInstance.pushNotificationsDelegate = self result(true) diff --git a/ios/Classes/style/AppInboxListItemStyle.swift b/ios/Classes/style/AppInboxListItemStyle.swift new file mode 100644 index 0000000..fadf0c8 --- /dev/null +++ b/ios/Classes/style/AppInboxListItemStyle.swift @@ -0,0 +1,56 @@ +// +// AppInboxListItemStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import ExponeaSDK +import UIKit + +class AppInboxListItemStyle { + var backgroundColor: String? + var readFlag: ImageViewStyle? + var receivedTime: TextViewStyle? + var title: TextViewStyle? + var content: TextViewStyle? + var image: ImageViewStyle? + + init( + backgroundColor: String? = nil, + readFlag: ImageViewStyle? = nil, + receivedTime: TextViewStyle? = nil, + title: TextViewStyle? = nil, + content: TextViewStyle? = nil, + image: ImageViewStyle? = nil + ) { + self.backgroundColor = backgroundColor + self.readFlag = readFlag + self.receivedTime = receivedTime + self.title = title + self.content = content + self.image = image + } + + func applyTo(_ target: MessageItemCell) { + if let backgroundColor = UIColor.parse(backgroundColor) { + target.backgroundColor = backgroundColor + } + if let readFlagStyle = readFlag { + readFlagStyle.applyTo(target.readFlag) + } + if let receivedTime = receivedTime { + receivedTime.applyTo(target.receivedTime) + } + if let title = title { + title.applyTo(target.titleLabel) + } + if let content = content { + content.applyTo(target.messageLabel) + } + if let image = image { + image.applyTo(target.messageImage) + } + } +} diff --git a/ios/Classes/style/AppInboxListViewStyle.swift b/ios/Classes/style/AppInboxListViewStyle.swift new file mode 100644 index 0000000..c438622 --- /dev/null +++ b/ios/Classes/style/AppInboxListViewStyle.swift @@ -0,0 +1,26 @@ +// +// AppInboxListViewStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import UIKit + +class AppInboxListViewStyle { + var backgroundColor: String? + var item: AppInboxListItemStyle? + + init(backgroundColor: String? = nil, item: AppInboxListItemStyle? = nil) { + self.backgroundColor = backgroundColor + self.item = item + } + + func applyTo(_ target: UITableView) { + if let backgroundColor = UIColor.parse(backgroundColor) { + target.backgroundColor = backgroundColor + } + // note: 'item' style is used elsewhere due to performance reasons + } +} diff --git a/ios/Classes/style/AppInboxStyle.swift b/ios/Classes/style/AppInboxStyle.swift new file mode 100644 index 0000000..f7e878d --- /dev/null +++ b/ios/Classes/style/AppInboxStyle.swift @@ -0,0 +1,20 @@ +// +// AppInboxStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation + +class AppInboxStyle { + var appInboxButton: ButtonStyle? + var detailView: DetailViewStyle? + var listView: ListScreenStyle? + + init(appInboxButton: ButtonStyle? = nil, detailView: DetailViewStyle? = nil, listView: ListScreenStyle? = nil) { + self.appInboxButton = appInboxButton + self.detailView = detailView + self.listView = listView + } +} diff --git a/ios/Classes/style/AppInboxStyleParser.swift b/ios/Classes/style/AppInboxStyleParser.swift new file mode 100644 index 0000000..d50e54f --- /dev/null +++ b/ios/Classes/style/AppInboxStyleParser.swift @@ -0,0 +1,129 @@ +// +// AppInboxStyleParser.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation + +class AppInboxStyleParser { + + private let source: NSDictionary + + init( + _ configMap: NSDictionary + ) { + self.source = configMap + } + + func parse() throws -> AppInboxStyle { + return AppInboxStyle( + appInboxButton: try parseButtonStyle(self.source.getOptionalSafely(property: "appInboxButton")), + detailView: try parseDetailViewStyle(self.source.getOptionalSafely(property: "detailView")), + listView: try parseListScreenStyle(self.source.getOptionalSafely(property: "listView")) + ) + } + + private func parseButtonStyle(_ source: NSDictionary?) throws -> ButtonStyle? { + guard let source = source else { + return nil + } + return ButtonStyle( + textOverride: try source.getOptionalSafely(property: "textOverride"), + textColor: try source.getOptionalSafely(property: "textColor"), + backgroundColor: try source.getOptionalSafely(property: "backgroundColor"), + showIcon: try source.getOptionalSafely(property: "showIcon"), + textSize: try source.getOptionalSafely(property: "textSize"), + enabled: try source.getOptionalSafely(property: "enabled"), + borderRadius: try source.getOptionalSafely(property: "borderRadius"), + textWeight: try source.getOptionalSafely(property: "textWeight") + ) + } + + private func parseProgressStyle(_ source: NSDictionary?) throws -> ProgressBarStyle? { + guard let source = source else { + return nil + } + return ProgressBarStyle( + visible: try source.getOptionalSafely(property: "visible"), + progressColor: try source.getOptionalSafely(property: "progressColor"), + backgroundColor: try source.getOptionalSafely(property: "backgroundColor") + ) + } + + private func parseListViewStyle(_ source: NSDictionary?) throws -> AppInboxListViewStyle? { + guard let source = source else { + return nil + } + return AppInboxListViewStyle( + backgroundColor: try source.getOptionalSafely(property: "backgroundColor"), + item: try parseListItemStyle(source.getOptionalSafely(property: "item")) + ) + } + + private func parseListItemStyle(_ source: NSDictionary?) throws -> AppInboxListItemStyle? { + guard let source = source else { + return nil + } + return AppInboxListItemStyle( + backgroundColor: try source.getOptionalSafely(property: "backgroundColor"), + readFlag: try parseImageViewStyle(source.getOptionalSafely(property: "readFlag")), + receivedTime: try parseTextViewStyle(source.getOptionalSafely(property: "receivedTime")), + title: try parseTextViewStyle(source.getOptionalSafely(property: "title")), + content: try parseTextViewStyle(source.getOptionalSafely(property: "content")), + image: try parseImageViewStyle(source.getOptionalSafely(property: "image")) + ) + } + + private func parseImageViewStyle(_ source: NSDictionary?) throws -> ImageViewStyle? { + guard let source = source else { + return nil + } + return ImageViewStyle( + visible: try source.getOptionalSafely(property: "visible"), + backgroundColor: try source.getOptionalSafely(property: "backgroundColor") + ) + } + + private func parseTextViewStyle(_ source: NSDictionary?) throws -> TextViewStyle? { + guard let source = source else { + return nil + } + return TextViewStyle( + visible: try source.getOptionalSafely(property: "visible"), + textColor: try source.getOptionalSafely(property: "textColor"), + textSize: try source.getOptionalSafely(property: "textSize"), + textWeight: try source.getOptionalSafely(property: "textWeight"), + textOverride: try source.getOptionalSafely(property: "textOverride") + ) + } + + private func parseDetailViewStyle(_ source: NSDictionary?) throws -> DetailViewStyle? { + guard let source = source else { + return nil + } + return DetailViewStyle( + title: try parseTextViewStyle(source.getOptionalSafely(property: "title")), + content: try parseTextViewStyle(source.getOptionalSafely(property: "content")), + receivedTime: try parseTextViewStyle(source.getOptionalSafely(property: "receivedTime")), + image: try parseImageViewStyle(source.getOptionalSafely(property: "readFlag")), + button: try parseButtonStyle(source.getOptionalSafely(property: "button")) + ) + } + + private func parseListScreenStyle(_ source: NSDictionary?) throws -> ListScreenStyle? { + guard let source = source else { + return nil + } + return ListScreenStyle( + emptyTitle: try parseTextViewStyle(source.getOptionalSafely(property: "emptyTitle")), + emptyMessage: try parseTextViewStyle(source.getOptionalSafely(property: "emptyMessage")), + errorTitle: try parseTextViewStyle(source.getOptionalSafely(property: "errorTitle")), + errorMessage: try parseTextViewStyle(source.getOptionalSafely(property: "errorMessage")), + progress: try parseProgressStyle(source.getOptionalSafely(property: "progress")), + list: try parseListViewStyle(source.getOptionalSafely(property: "list")) + ) + } + +} diff --git a/ios/Classes/style/ButtonStyle.swift b/ios/Classes/style/ButtonStyle.swift new file mode 100644 index 0000000..50c0fa0 --- /dev/null +++ b/ios/Classes/style/ButtonStyle.swift @@ -0,0 +1,75 @@ +// +// ButtonStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import UIKit + +class ButtonStyle { + var textOverride: String? + var textColor: String? + var backgroundColor: String? + var showIcon: String? + var textSize: String? + var enabled: Bool? + var borderRadius: String? + var textWeight: String? + + init( + textOverride: String? = nil, + textColor: String? = nil, + backgroundColor: String? = nil, + showIcon: String? = nil, + textSize: String? = nil, + enabled: Bool? = nil, + borderRadius: String? = nil, + textWeight: String? = nil + ) { + self.textOverride = textOverride + self.textColor = textColor + self.backgroundColor = backgroundColor + self.showIcon = showIcon + self.textSize = textSize + self.enabled = enabled + self.borderRadius = borderRadius + self.textWeight = textWeight + } + + func applyTo(_ target: UIButton) { + if let showIcon = showIcon { + target.setImage( + UIImage.parse(showIcon)?.withRenderingMode(.alwaysTemplate), + for: .normal + ) + target.imageView?.contentMode = .scaleAspectFit + target.imageEdgeInsets = UIEdgeInsets(top: 4, left: 0, bottom: 4, right: 0) + } + if let textOverride = textOverride { + target.setTitle(textOverride, for: .normal) + } + if let textColor = UIColor.parse(textColor) { + target.setTitleColor(textColor, for: .normal) + target.tintColor = textColor + } + if let backgroundColor = UIColor.parse(backgroundColor) { + target.backgroundColor = backgroundColor + } + if let textSize = CGFloat.parse(textSize), + let titleLabel = target.titleLabel { + titleLabel.font = titleLabel.font.withSize(textSize) + } + if let textWeight = UIFont.Weight.parse(textWeight), + let titleLabel = target.titleLabel { + titleLabel.font = UIFont.systemFont(ofSize: titleLabel.font.pointSize, weight: textWeight) + } + if let enabled = enabled { + target.isEnabled = enabled + } + if let borderRadius = CGFloat.parse(borderRadius) { + target.layer.cornerRadius = borderRadius + } + } +} diff --git a/ios/Classes/style/DetailViewStyle.swift b/ios/Classes/style/DetailViewStyle.swift new file mode 100644 index 0000000..1f97f52 --- /dev/null +++ b/ios/Classes/style/DetailViewStyle.swift @@ -0,0 +1,53 @@ +// +// DetailViewStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import ExponeaSDK + +class DetailViewStyle { + var title: TextViewStyle? + var content: TextViewStyle? + var receivedTime: TextViewStyle? + var image: ImageViewStyle? + var button: ButtonStyle? + + init( + title: TextViewStyle? = nil, + content: TextViewStyle? = nil, + receivedTime: TextViewStyle? = nil, + image: ImageViewStyle? = nil, + button: ButtonStyle? = nil + ) { + self.title = title + self.content = content + self.receivedTime = receivedTime + self.image = image + self.button = button + } + + func applyTo(_ target: AppInboxDetailViewController) { + if let title = title { + title.applyTo(target.messageTitle) + } + if let content = content { + content.applyTo(target.message) + } + if let receivedTime = receivedTime { + receivedTime.applyTo(target.receivedTime) + } + if let image = image { + image.applyTo(target.messageImage) + } + if let button = button { + button.applyTo(target.actionMain) + button.applyTo(target.action1) + button.applyTo(target.action2) + button.applyTo(target.action3) + button.applyTo(target.action4) + } + } +} diff --git a/ios/Classes/style/ImageViewStyle.swift b/ios/Classes/style/ImageViewStyle.swift new file mode 100644 index 0000000..dfeba73 --- /dev/null +++ b/ios/Classes/style/ImageViewStyle.swift @@ -0,0 +1,37 @@ +// +// ImageViewStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import UIKit + +class ImageViewStyle { + var visible: Bool? + var backgroundColor: String? + + init(visible: Bool? = nil, backgroundColor: String? = nil) { + self.visible = visible + self.backgroundColor = backgroundColor + } + + func applyTo(_ target: UIView) { + if let visible = visible { + target.isHidden = !visible + } + if let backgroundColor = UIColor.parse(backgroundColor) { + target.tintColor = backgroundColor + } + } + + func applyTo(_ target: UIImageView) { + if let visible = visible { + target.isHidden = !visible + } + if let backgroundColor = UIColor.parse(backgroundColor) { + target.backgroundColor = backgroundColor + } + } +} diff --git a/ios/Classes/style/ListScreenStyle.swift b/ios/Classes/style/ListScreenStyle.swift new file mode 100644 index 0000000..ee90f9e --- /dev/null +++ b/ios/Classes/style/ListScreenStyle.swift @@ -0,0 +1,55 @@ +// +// ListScreenStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import ExponeaSDK + +class ListScreenStyle { + var emptyTitle: TextViewStyle? + var emptyMessage: TextViewStyle? + var errorTitle: TextViewStyle? + var errorMessage: TextViewStyle? + var progress: ProgressBarStyle? + var list: AppInboxListViewStyle? + + init( + emptyTitle: TextViewStyle? = nil, + emptyMessage: TextViewStyle? = nil, + errorTitle: TextViewStyle? = nil, + errorMessage: TextViewStyle? = nil, + progress: ProgressBarStyle? = nil, + list: AppInboxListViewStyle? = nil + ) { + self.emptyTitle = emptyTitle + self.emptyMessage = emptyMessage + self.errorTitle = errorTitle + self.errorMessage = errorMessage + self.progress = progress + self.list = list + } + + func applyTo(_ target: AppInboxListViewController) { + if let emptyTitleStyle = emptyTitle { + emptyTitleStyle.applyTo(target.statusEmptyTitle) + } + if let emptyMessageStyle = emptyMessage { + emptyMessageStyle.applyTo(target.statusEmptyMessage) + } + if let errorTitleStyle = errorTitle { + errorTitleStyle.applyTo(target.statusErrorTitle) + } + if let errorMessageStyle = errorMessage { + errorMessageStyle.applyTo(target.statusEmptyMessage) + } + if let progressStyle = progress { + progressStyle.applyTo(target.statusProgress) + } + if let listStyle = list { + listStyle.applyTo(target.tableView) + } + } +} diff --git a/ios/Classes/style/ProgressBarStyle.swift b/ios/Classes/style/ProgressBarStyle.swift new file mode 100644 index 0000000..e97f2e8 --- /dev/null +++ b/ios/Classes/style/ProgressBarStyle.swift @@ -0,0 +1,33 @@ +// +// ProgressBarStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import UIKit + +class ProgressBarStyle { + var visible: Bool? + var progressColor: String? + var backgroundColor: String? + + init(visible: Bool? = nil, progressColor: String? = nil, backgroundColor: String? = nil) { + self.visible = visible + self.progressColor = progressColor + self.backgroundColor = backgroundColor + } + + func applyTo(_ target: UIActivityIndicatorView) { + if let visible = visible { + target.isHidden = !visible + } + if let progressColor = UIColor.parse(progressColor) { + target.tintColor = progressColor + } + if let backgroundColor = UIColor.parse(backgroundColor) { + target.backgroundColor = backgroundColor + } + } +} diff --git a/ios/Classes/style/StyleExtensions.swift b/ios/Classes/style/StyleExtensions.swift new file mode 100644 index 0000000..3cfce93 --- /dev/null +++ b/ios/Classes/style/StyleExtensions.swift @@ -0,0 +1,388 @@ +// +// StyleExtensions.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import ExponeaSDK + +extension UIColor { + convenience init(rgb: Int) { + self.init( + red: (rgb >> 16) & 0xFF, + green: (rgb >> 8) & 0xFF, + blue: rgb & 0xFF + ) + } + convenience init(red: Int, green: Int, blue: Int) { + self.init(red: red, green: green, blue: blue, alpha: 1.0) + } + convenience init(red: Int, green: Int, blue: Int, alpha: CGFloat) { + assert(red >= 0 && red <= 255, "Invalid red component") + assert(green >= 0 && green <= 255, "Invalid green component") + assert(blue >= 0 && blue <= 255, "Invalid blue component") + self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: alpha) + } + convenience init?(hexString: String) { + let hex = hexString.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int = UInt64() + Scanner(string: hex).scanHexInt64(&int) + let red, green, blue, alpha: UInt64 + switch hex.count { + case 3: // #rgb (12-bit) + (red, green, blue, alpha) = ((int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17, 255) + case 4: // #rgba (16bit) + (red, green, blue, alpha) = ((int >> 16) * 17, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // #rrggbb (24-bit) + (red, green, blue, alpha) = (int >> 16, int >> 8 & 0xFF, int & 0xFF, 255) + case 8: // #rrggbbaa (32-bit) + (red, green, blue, alpha) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + return nil + } + self.init( + red: CGFloat(red) / 255, + green: CGFloat(green) / 255, + blue: CGFloat(blue) / 255, + alpha: CGFloat(alpha) / 255 + ) + } + convenience init?(rgbaString: String) { + // rgba(255, 255, 255, 1.0) + // rgba(255 255 255 / 1.0) + do { + let rgbaFormat = try NSRegularExpression( + pattern: "^rgba\\([ ]*([0-9]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]{1,3})[,/ ]+([0-9.]+)[ ]*\\)$", + options: .caseInsensitive + ) + let rgbaValues = rgbaFormat.matches( + in: rgbaString, + range: NSRange(location: 0, length: rgbaString.utf16.count) + ) + guard let rgbaResult = rgbaValues.first, + let rRange = Range(rgbaResult.range(at: 1), in: rgbaString), + let gRange = Range(rgbaResult.range(at: 2), in: rgbaString), + let bRange = Range(rgbaResult.range(at: 3), in: rgbaString), + let aRange = Range(rgbaResult.range(at: 4), in: rgbaString), + let red = CGFloat.parse(String(rgbaString[rRange])), + let green = CGFloat.parse(String(rgbaString[gRange])), + let blue = CGFloat.parse(String(rgbaString[bRange])), + let alpha = CGFloat.parse(String(rgbaString[aRange])) else { + ExponeaSDK.Exponea.logger.log(.warning, message: "Unable to parse RGBA color \(rgbaString)") + return nil + } + self.init(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) + } catch let error { + ExponeaSDK.Exponea.logger.log( + .warning, + message: "Unable to parse RGBA color \(rgbaString): \(error.localizedDescription)" + ) + return nil + } + } + convenience init?(rgbString: String) { + // rgb(255, 255, 255) + do { + let rgbFormat = try NSRegularExpression( + pattern: "^rgb\\([ ]*([0-9]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]{1,3})[ ]*\\)$", + options: .caseInsensitive + ) + let rgbValues = rgbFormat.matches( + in: rgbString, + range: NSRange(location: 0, length: rgbString.utf16.count) + ) + guard let rgbResult = rgbValues.first, + let rRange = Range(rgbResult.range(at: 1), in: rgbString), + let gRange = Range(rgbResult.range(at: 2), in: rgbString), + let bRange = Range(rgbResult.range(at: 3), in: rgbString), + let red = CGFloat.parse(String(rgbString[rRange])), + let green = CGFloat.parse(String(rgbString[gRange])), + let blue = CGFloat.parse(String(rgbString[bRange])) else { + ExponeaSDK.Exponea.logger.log(.warning, message: "Unable to parse RGB color \(rgbString)") + return nil + } + self.init(red: red / 255, green: green / 255, blue: blue / 255, alpha: 1.0) + } catch let error { + ExponeaSDK.Exponea.logger.log( + .warning, + message: "Unable to parse RGB color \(rgbString): \(error.localizedDescription)" + ) + return nil + } + } + convenience init?(argbString: String) { + // argb(1.0, 255, 0, 0) + do { + let argbFormat = try NSRegularExpression( + pattern: "^argb\\([ ]*([0-9.]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]{1,3})[, ]+([0-9]+)[ ]*\\)$", + options: .caseInsensitive + ) + let argbValues = argbFormat.matches( + in: argbString, + range: NSRange(location: 0, length: argbString.utf16.count) + ) + guard let argbResult = argbValues.first, + let aRange = Range(argbResult.range(at: 1), in: argbString), + let rRange = Range(argbResult.range(at: 2), in: argbString), + let gRange = Range(argbResult.range(at: 3), in: argbString), + let bRange = Range(argbResult.range(at: 4), in: argbString), + let alpha = CGFloat.parse(String(argbString[aRange])), + let red = CGFloat.parse(String(argbString[rRange])), + let green = CGFloat.parse(String(argbString[gRange])), + let blue = CGFloat.parse(String(argbString[bRange])) else { + ExponeaSDK.Exponea.logger.log(.warning, message: "Unable to parse ARGB color \(argbString)") + return nil + } + self.init(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) + } catch let error { + ExponeaSDK.Exponea.logger.log( + .warning, + message: "Unable to parse ARGB color \(argbString): \(error.localizedDescription)" + ) + return nil + } + } + // swiftlint:disable function_body_length + convenience init?(cssName: String) { + // any color name from here: https://www.w3.org/wiki/CSS/Properties/color/keywords + switch cssName.lowercased() { + case "aliceblue": self.init(hexString: "#f0f8ff") + case "antiquewhite": self.init(hexString: "#faebd7") + case "aqua": self.init(hexString: "#00ffff") + case "aquamarine": self.init(hexString: "#7fffd4") + case "azure": self.init(hexString: "#f0ffff") + case "beige": self.init(hexString: "#f5f5dc") + case "bisque": self.init(hexString: "#ffe4c4") + case "black": self.init(hexString: "#000000") + case "blanchedalmond": self.init(hexString: "#ffebcd") + case "blue": self.init(hexString: "#0000ff") + case "blueviolet": self.init(hexString: "#8a2be2") + case "brown": self.init(hexString: "#a52a2a") + case "burlywood": self.init(hexString: "#deb887") + case "cadetblue": self.init(hexString: "#5f9ea0") + case "chartreuse": self.init(hexString: "#7fff00") + case "chocolate": self.init(hexString: "#d2691e") + case "coral": self.init(hexString: "#ff7f50") + case "cornflowerblue": self.init(hexString: "#6495ed") + case "cornsilk": self.init(hexString: "#fff8dc") + case "crimson": self.init(hexString: "#dc143c") + case "cyan": self.init(hexString: "#00ffff") + case "darkblue": self.init(hexString: "#00008b") + case "darkcyan": self.init(hexString: "#008b8b") + case "darkgoldenrod": self.init(hexString: "#b8860b") + case "darkgray": self.init(hexString: "#a9a9a9") + case "darkgreen": self.init(hexString: "#006400") + case "darkgrey": self.init(hexString: "#a9a9a9") + case "darkkhaki": self.init(hexString: "#bdb76b") + case "darkmagenta": self.init(hexString: "#8b008b") + case "darkolivegreen": self.init(hexString: "#556b2f") + case "darkorange": self.init(hexString: "#ff8c00") + case "darkorchid": self.init(hexString: "#9932cc") + case "darkred": self.init(hexString: "#8b0000") + case "darksalmon": self.init(hexString: "#e9967a") + case "darkseagreen": self.init(hexString: "#8fbc8f") + case "darkslateblue": self.init(hexString: "#483d8b") + case "darkslategray": self.init(hexString: "#2f4f4f") + case "darkslategrey": self.init(hexString: "#2f4f4f") + case "darkturquoise": self.init(hexString: "#00ced1") + case "darkviolet": self.init(hexString: "#9400d3") + case "deeppink": self.init(hexString: "#ff1493") + case "deepskyblue": self.init(hexString: "#00bfff") + case "dimgray": self.init(hexString: "#696969") + case "dimgrey": self.init(hexString: "#696969") + case "dodgerblue": self.init(hexString: "#1e90ff") + case "firebrick": self.init(hexString: "#b22222") + case "floralwhite": self.init(hexString: "#fffaf0") + case "forestgreen": self.init(hexString: "#228b22") + case "fuchsia": self.init(hexString: "#ff00ff") + case "gainsboro": self.init(hexString: "#dcdcdc") + case "ghostwhite": self.init(hexString: "#f8f8ff") + case "gold": self.init(hexString: "#ffd700") + case "goldenrod": self.init(hexString: "#daa520") + case "gray": self.init(hexString: "#808080") + case "green": self.init(hexString: "#008000") + case "greenyellow": self.init(hexString: "#adff2f") + case "grey": self.init(hexString: "#808080") + case "honeydew": self.init(hexString: "#f0fff0") + case "hotpink": self.init(hexString: "#ff69b4") + case "indianred": self.init(hexString: "#cd5c5c") + case "indigo": self.init(hexString: "#4b0082") + case "ivory": self.init(hexString: "#fffff0") + case "khaki": self.init(hexString: "#f0e68c") + case "lavender": self.init(hexString: "#e6e6fa") + case "lavenderblush": self.init(hexString: "#fff0f5") + case "lawngreen": self.init(hexString: "#7cfc00") + case "lemonchiffon": self.init(hexString: "#fffacd") + case "lightblue": self.init(hexString: "#add8e6") + case "lightcoral": self.init(hexString: "#f08080") + case "lightcyan": self.init(hexString: "#e0ffff") + case "lightgoldenrodyellow": self.init(hexString: "#fafad2") + case "lightgray": self.init(hexString: "#d3d3d3") + case "lightgreen": self.init(hexString: "#90ee90") + case "lightgrey": self.init(hexString: "#d3d3d3") + case "lightpink": self.init(hexString: "#ffb6c1") + case "lightsalmon": self.init(hexString: "#ffa07a") + case "lightseagreen": self.init(hexString: "#20b2aa") + case "lightskyblue": self.init(hexString: "#87cefa") + case "lightslategray": self.init(hexString: "#778899") + case "lightslategrey": self.init(hexString: "#778899") + case "lightsteelblue": self.init(hexString: "#b0c4de") + case "lightyellow": self.init(hexString: "#ffffe0") + case "lime": self.init(hexString: "#00ff00") + case "limegreen": self.init(hexString: "#32cd32") + case "linen": self.init(hexString: "#faf0e6") + case "magenta": self.init(hexString: "#ff00ff") + case "maroon": self.init(hexString: "#800000") + case "mediumaquamarine": self.init(hexString: "#66cdaa") + case "mediumblue": self.init(hexString: "#0000cd") + case "mediumorchid": self.init(hexString: "#ba55d3") + case "mediumpurple": self.init(hexString: "#9370db") + case "mediumseagreen": self.init(hexString: "#3cb371") + case "mediumslateblue": self.init(hexString: "#7b68ee") + case "mediumspringgreen": self.init(hexString: "#00fa9a") + case "mediumturquoise": self.init(hexString: "#48d1cc") + case "mediumvioletred": self.init(hexString: "#c71585") + case "midnightblue": self.init(hexString: "#191970") + case "mintcream": self.init(hexString: "#f5fffa") + case "mistyrose": self.init(hexString: "#ffe4e1") + case "moccasin": self.init(hexString: "#ffe4b5") + case "navajowhite": self.init(hexString: "#ffdead") + case "navy": self.init(hexString: "#000080") + case "oldlace": self.init(hexString: "#fdf5e6") + case "olive": self.init(hexString: "#808000") + case "olivedrab": self.init(hexString: "#6b8e23") + case "orange": self.init(hexString: "#ffa500") + case "orangered": self.init(hexString: "#ff4500") + case "orchid": self.init(hexString: "#da70d6") + case "palegoldenrod": self.init(hexString: "#eee8aa") + case "palegreen": self.init(hexString: "#98fb98") + case "paleturquoise": self.init(hexString: "#afeeee") + case "palevioletred": self.init(hexString: "#db7093") + case "papayawhip": self.init(hexString: "#ffefd5") + case "peachpuff": self.init(hexString: "#ffdab9") + case "peru": self.init(hexString: "#cd853f") + case "pink": self.init(hexString: "#ffc0cb") + case "plum": self.init(hexString: "#dda0dd") + case "powderblue": self.init(hexString: "#b0e0e6") + case "purple": self.init(hexString: "#800080") + case "red": self.init(hexString: "#ff0000") + case "rosybrown": self.init(hexString: "#bc8f8f") + case "royalblue": self.init(hexString: "#4169e1") + case "saddlebrown": self.init(hexString: "#8b4513") + case "salmon": self.init(hexString: "#fa8072") + case "sandybrown": self.init(hexString: "#f4a460") + case "seagreen": self.init(hexString: "#2e8b57") + case "seashell": self.init(hexString: "#fff5ee") + case "sienna": self.init(hexString: "#a0522d") + case "silver": self.init(hexString: "#c0c0c0") + case "skyblue": self.init(hexString: "#87ceeb") + case "slateblue": self.init(hexString: "#6a5acd") + case "slategray": self.init(hexString: "#708090") + case "slategrey": self.init(hexString: "#708090") + case "snow": self.init(hexString: "#fffafa") + case "springgreen": self.init(hexString: "#00ff7f") + case "steelblue": self.init(hexString: "#4682b4") + case "tan": self.init(hexString: "#d2b48c") + case "teal": self.init(hexString: "#008080") + case "thistle": self.init(hexString: "#d8bfd8") + case "tomato": self.init(hexString: "#ff6347") + case "turquoise": self.init(hexString: "#40e0d0") + case "violet": self.init(hexString: "#ee82ee") + case "wheat": self.init(hexString: "#f5deb3") + case "white": self.init(hexString: "#ffffff") + case "whitesmoke": self.init(hexString: "#f5f5f5") + case "yellow": self.init(hexString: "#ffff00") + case "yellowgreen": self.init(hexString: "#9acd32") + default: + ExponeaSDK.Exponea.logger.log(.warning, message: "Unable to parse CSS color \(cssName)") + return nil + } + } + static func parse(_ source: Any?) -> UIColor? { + guard let source = source else { + return nil + } + if let source = source as? Int { + return UIColor(rgb: source) + } + guard let source = source as? String else { + ExponeaSDK.Exponea.logger.log(.warning, message: "Unable to parse color \(source)") + return nil + } + if source.starts(with: "#") { + return UIColor(hexString: source) + } + if source.lowercased().starts(with: "rgba(") { + return UIColor(rgbaString: source) + } + if source.lowercased().starts(with: "argb(") { + return UIColor(argbString: source) + } + if source.lowercased().starts(with: "rgb(") { + return UIColor(rgbString: source) + } + return UIColor(cssName: source) + } +} + +extension CGFloat { + static func parse(_ source: String?) -> CGFloat? { + guard let source = source else { + return nil + } + let numberString = source.trimmingCharacters(in: CharacterSet.decimalDigits.inverted) + guard let number = NumberFormatter().number(from: numberString) else { + return nil + } + return CGFloat(truncating: number) + } +} + +extension UIFont.Weight { + static func parse(_ source: String?) -> UIFont.Weight? { + guard let source = source else { + return nil + } + switch source.lowercased() { + case "normal": return UIFont.Weight.regular + case "bold": return UIFont.Weight.bold + case "100": return UIFont.Weight.ultraLight + case "200": return UIFont.Weight.thin + case "300": return UIFont.Weight.light + case "400": return UIFont.Weight.regular + case "500": return UIFont.Weight.medium + case "600": return UIFont.Weight.semibold + case "700": return UIFont.Weight.bold + case "800": return UIFont.Weight.heavy + case "900": return UIFont.Weight.black + default: return UIFont.Weight.regular + } + } +} + +extension UIImage { + convenience init?(base64: String) { + guard let base64Data = base64.data(using: .utf8), + let imageData = Data(base64Encoded: base64Data) else { + return nil + } + self.init(data: imageData) + } + static func parse(_ source: String?) -> UIImage? { + guard let source = source else { + return nil + } + if ["none", "false", "0", "no"].contains(where: { item in + item == source.lowercased() + }) { + return nil + } + if source.count > 59, // performance, 1x1 rgb pixel GIF has 60 chars + let image = UIImage(base64: source) { + return image + } + return UIImage(named: source) + } +} diff --git a/ios/Classes/style/TextViewStyle.swift b/ios/Classes/style/TextViewStyle.swift new file mode 100644 index 0000000..71aebc8 --- /dev/null +++ b/ios/Classes/style/TextViewStyle.swift @@ -0,0 +1,50 @@ +// +// TextViewStyle.swift +// exponea +// +// Created by Adam Mihalik on 02/03/2023. +// + +import Foundation +import UIKit + +class TextViewStyle { + var visible: Bool? + var textColor: String? + var textSize: String? + var textWeight: String? + var textOverride: String? + + init( + visible: Bool? = nil, + textColor: String? = nil, + textSize: String? = nil, + textWeight: String? = nil, + textOverride: String? = nil + ) { + self.visible = visible + self.textColor = textColor + self.textSize = textSize + self.textWeight = textWeight + self.textOverride = textOverride + } + + func applyTo(_ target: UILabel) { + if let visible = visible { + target.isHidden = !visible + } + if let textColor = UIColor.parse(textColor) { + target.textColor = textColor + } + if let textSize = CGFloat.parse(textSize) { + target.font = target.font?.withSize(textSize) + } + if let textWeight = UIFont.Weight.parse(textWeight), + let currentFont = target.font { + target.font = UIFont.systemFont(ofSize: currentFont.pointSize, weight: textWeight) + } + if let textOverride = textOverride { + target.text = textOverride + } + } +} diff --git a/ios/exponea.podspec b/ios/exponea.podspec index 2314b71..ef6d04a 100644 --- a/ios/exponea.podspec +++ b/ios/exponea.podspec @@ -15,7 +15,7 @@ A new flutter plugin project. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.dependency 'ExponeaSDK', '2.13.1' + s.dependency 'ExponeaSDK', '2.15.1' s.dependency 'AnyCodable-FlightSchool', '0.6.3' s.platform = :ios, '11.0' diff --git a/lib/exponea.dart b/lib/exponea.dart index 3f2cd04..9c5863c 100644 --- a/lib/exponea.dart +++ b/lib/exponea.dart @@ -14,7 +14,18 @@ export 'src/data/model/push_opened.dart'; export 'src/data/model/push_received.dart'; export 'src/data/model/push_type.dart'; export 'src/data/model/recommendation.dart'; -export 'src/data/model/recommendation.dart'; +export 'src/data/model/app_inbox_list_item_style.dart'; +export 'src/data/model/app_inbox_list_view_style.dart'; +export 'src/data/model/app_inbox_style.dart'; +export 'src/data/model/button_style.dart'; +export 'src/data/model/detail_view_style.dart'; +export 'src/data/model/image_view_style.dart'; +export 'src/data/model/list_screen_style.dart'; +export 'src/data/model/progress_bar_style.dart'; +export 'src/data/model/text_view_style.dart'; export 'src/data/model/token_frequency.dart'; +export 'src/data/model/app_inbox_action.dart'; +export 'src/data/model/app_inbox_message.dart'; export 'src/platform/platform_interface.dart'; +export 'src/widget/app_inbox_button.dart'; export 'src/plugin/exponea.dart'; diff --git a/lib/src/data/encoder/app_inbox.dart b/lib/src/data/encoder/app_inbox.dart new file mode 100644 index 0000000..cb9e7c3 --- /dev/null +++ b/lib/src/data/encoder/app_inbox.dart @@ -0,0 +1,41 @@ + +import 'package:exponea/exponea.dart'; +import 'package:exponea/src/data/util/object.dart'; + +abstract class AppInboxCoder { + static Map encodeMessage(AppInboxMessage source) { + return { + 'id': source.id, + 'type': source.type, + 'isRead': source.isRead, + 'createTime': source.createTime, + 'content': source.content, + }..removeWhere((key, value) => value == null); + } + + static Map encodeAction(AppInboxAction source) { + return { + 'action': source.action, + 'title': source.title, + 'url': source.url + }..removeWhere((key, value) => value == null); + } + + static Map encodeActionMessage(AppInboxAction sourceAction, AppInboxMessage sourceMessage) { + return { + 'action': encodeAction(sourceAction), + 'message': encodeMessage(sourceMessage) + }..removeWhere((key, value) => value == null); + } + + static AppInboxMessage decodeMessage(Map source) { + return AppInboxMessage( + id: source.getRequired("id"), + type: source.getRequired("type"), + isRead: source.getOptional("isRead"), + createTime: source.getOptional("createTime"), + content: source.getOptional("content") + ?.map((key, value) => MapEntry(key?.toString() ?? '', value)), + ); + } +} diff --git a/lib/src/data/encoder/configuration.dart b/lib/src/data/encoder/configuration.dart index f1ccbb1..2e85fc4 100644 --- a/lib/src/data/encoder/configuration.dart +++ b/lib/src/data/encoder/configuration.dart @@ -24,6 +24,7 @@ abstract class ExponeaConfigurationEncoder { .getOptional('pushTokenTrackingFrequency') ?.let(TokenFrequencyEncoder.decode), allowDefaultCustomerProperties: data.getOptional('allowDefaultCustomerProperties'), + advancedAuthEnabled: data.getOptional('advancedAuthEnabled'), android: data .getOptional>('android') ?.let(AndroidExponeaConfigurationEncoder.decode), @@ -45,6 +46,7 @@ abstract class ExponeaConfigurationEncoder { 'sessionTimeout': config.sessionTimeout, 'automaticSessionTracking': config.automaticSessionTracking, 'allowDefaultCustomerProperties': config.allowDefaultCustomerProperties, + 'advancedAuthEnabled': config.advancedAuthEnabled, 'pushTokenTrackingFrequency': config.pushTokenTrackingFrequency?.let(TokenFrequencyEncoder.encode), 'android': config.android?.let(AndroidExponeaConfigurationEncoder.encode), diff --git a/lib/src/data/encoder/main.dart b/lib/src/data/encoder/main.dart index 3fe7d35..bee891b 100644 --- a/lib/src/data/encoder/main.dart +++ b/lib/src/data/encoder/main.dart @@ -16,3 +16,4 @@ export 'push_received.dart'; export 'push_type.dart'; export 'recommendation.dart'; export 'token_frequency.dart'; +export 'app_inbox.dart'; diff --git a/lib/src/data/model/app_inbox_action.dart b/lib/src/data/model/app_inbox_action.dart new file mode 100644 index 0000000..cbde379 --- /dev/null +++ b/lib/src/data/model/app_inbox_action.dart @@ -0,0 +1,15 @@ +import 'package:meta/meta.dart'; + +@immutable +class AppInboxAction { + final String? action; + final String? title; + final String? url; + + const AppInboxAction({this.action, this.title, this.url}); + + @override + String toString() { + return 'AppInboxAction{action: $action, title: $title, url: $url}'; + } +} diff --git a/lib/src/data/model/app_inbox_list_item_style.dart b/lib/src/data/model/app_inbox_list_item_style.dart new file mode 100644 index 0000000..37813e5 --- /dev/null +++ b/lib/src/data/model/app_inbox_list_item_style.dart @@ -0,0 +1,33 @@ + +import 'package:exponea/exponea.dart'; + +import '../util/Codable.dart'; + +class AppInboxListItemStyle extends Encodable { + final String? backgroundColor; + final ImageViewStyle? readFlag; + final TextViewStyle? receivedTime; + final TextViewStyle? title; + final TextViewStyle? content; + final ImageViewStyle? image; + + AppInboxListItemStyle({this.backgroundColor, this.readFlag, this.receivedTime, + this.title, this.content, this.image}); + + @override + String toString() { + return 'AppInboxListItemStyle{backgroundColor: $backgroundColor, readFlag: $readFlag, receivedTime: $receivedTime, title: $title, content: $content, image: $image}'; + } + + @override + Map encode() { + return { + 'backgroundColor': backgroundColor, + 'readFlag': readFlag?.encodeClean(), + 'receivedTime': receivedTime?.encodeClean(), + 'title': title?.encodeClean(), + 'content': content?.encodeClean(), + 'image': image?.encodeClean() + }; + } +} diff --git a/lib/src/data/model/app_inbox_list_view_style.dart b/lib/src/data/model/app_inbox_list_view_style.dart new file mode 100644 index 0000000..18bb99c --- /dev/null +++ b/lib/src/data/model/app_inbox_list_view_style.dart @@ -0,0 +1,25 @@ + +import 'package:exponea/exponea.dart'; + +import '../util/Codable.dart'; +import 'app_inbox_list_item_style.dart'; + +class AppInboxListViewStyle extends Encodable { + final String? backgroundColor; + final AppInboxListItemStyle? item; + + AppInboxListViewStyle({this.backgroundColor, this.item}); + + @override + String toString() { + return 'AppInboxListViewStyle{backgroundColor: $backgroundColor, item: $item}'; + } + + @override + Map encode() { + return { + 'backgroundColor': backgroundColor, + 'item': item?.encodeClean() + }; + } +} diff --git a/lib/src/data/model/app_inbox_message.dart b/lib/src/data/model/app_inbox_message.dart new file mode 100644 index 0000000..a86502d --- /dev/null +++ b/lib/src/data/model/app_inbox_message.dart @@ -0,0 +1,23 @@ +import 'package:meta/meta.dart'; + +@immutable +class AppInboxMessage { + final String id; + final String type; + final bool? isRead; + final double? createTime; + final Map? content; + + const AppInboxMessage({ + required this.id, + required this.type, + this.isRead, + this.createTime, + this.content + }); + + @override + String toString() { + return 'AppInboxMessage{id: $id, type: $type, isRead: $isRead, createTime: $createTime, content: $content}'; + } +} diff --git a/lib/src/data/model/app_inbox_style.dart b/lib/src/data/model/app_inbox_style.dart new file mode 100644 index 0000000..3e5e3f1 --- /dev/null +++ b/lib/src/data/model/app_inbox_style.dart @@ -0,0 +1,27 @@ + +import 'package:exponea/exponea.dart'; + +import '../util/Codable.dart'; + +class AppInboxStyle extends Encodable { + final SimpleButtonStyle? appInboxButton; + final DetailViewStyle? detailView; + final ListScreenStyle? listView; + + AppInboxStyle({this.appInboxButton, this.detailView, this.listView}); + + @override + String toString() { + return 'AppInboxStyle{appInboxButton: $appInboxButton, detailView: $detailView, listView: $listView}'; + } + + @override + Map encode() { + return { + 'appInboxButton': appInboxButton?.encodeClean(), + 'detailView': detailView?.encodeClean(), + 'listView': listView?.encodeClean() + }; + } + +} diff --git a/lib/src/data/model/button_style.dart b/lib/src/data/model/button_style.dart new file mode 100644 index 0000000..f5dc8e5 --- /dev/null +++ b/lib/src/data/model/button_style.dart @@ -0,0 +1,44 @@ + +import 'dart:ffi'; + +import '../util/Codable.dart'; + +class SimpleButtonStyle extends Encodable { + final String? textOverride; + final String? textColor; + final String? backgroundColor; + final String? showIcon; + final String? textSize; + final Bool? enabled; + final String? borderRadius; + final String? textWeight; + + SimpleButtonStyle({ + this.textOverride, + this.textColor, + this.backgroundColor, + this.showIcon, + this.textSize, + this.enabled, + this.borderRadius, + this.textWeight}); + + @override + String toString() { + return 'ButtonStyle{textOverride: $textOverride, textColor: $textColor, backgroundColor: $backgroundColor, showIcon: $showIcon, textSize: $textSize, enabled: $enabled, borderRadius: $borderRadius, textWeight: $textWeight}'; + } + + @override + Map encode() { + return { + 'textOverride': textOverride, + 'textColor': textColor, + 'backgroundColor': backgroundColor, + 'showIcon': showIcon, + 'textSize': textSize, + 'enabled': enabled, + 'borderRadius': borderRadius, + 'textWeight': textWeight + }; + } +} diff --git a/lib/src/data/model/configuration.dart b/lib/src/data/model/configuration.dart index ba5e2c5..8ff9bdb 100644 --- a/lib/src/data/model/configuration.dart +++ b/lib/src/data/model/configuration.dart @@ -40,6 +40,9 @@ class ExponeaConfiguration { /// If true, default properties are applied also for 'identifyCustomer' event. final bool? allowDefaultCustomerProperties; + /// If true, Customer Token authentication is used + final bool? advancedAuthEnabled; + /// Platform specific settings for Android final AndroidExponeaConfiguration? android; @@ -57,6 +60,7 @@ class ExponeaConfiguration { this.automaticSessionTracking, this.pushTokenTrackingFrequency, this.allowDefaultCustomerProperties, + this.advancedAuthEnabled, this.android, this.ios, }); diff --git a/lib/src/data/model/detail_view_style.dart b/lib/src/data/model/detail_view_style.dart new file mode 100644 index 0000000..b6008e4 --- /dev/null +++ b/lib/src/data/model/detail_view_style.dart @@ -0,0 +1,32 @@ + +import 'package:exponea/exponea.dart'; + +import '../util/Codable.dart'; + +class DetailViewStyle extends Encodable { + final TextViewStyle? title; + final TextViewStyle? content; + final TextViewStyle? receivedTime; + final TextViewStyle? image; + final SimpleButtonStyle? button; + + DetailViewStyle({ + this.title, this.content, this.receivedTime, this.image, this.button}); + + @override + String toString() { + return 'DetailViewStyle{title: $title, content: $content, receivedTime: $receivedTime, image: $image, button: $button}'; + } + + @override + Map encode() { + return { + 'title': title?.encodeClean(), + 'content': content?.encodeClean(), + 'receivedTime': receivedTime?.encodeClean(), + 'image': image?.encodeClean(), + 'button': button?.encodeClean() + }; + } + +} diff --git a/lib/src/data/model/image_view_style.dart b/lib/src/data/model/image_view_style.dart new file mode 100644 index 0000000..85840f1 --- /dev/null +++ b/lib/src/data/model/image_view_style.dart @@ -0,0 +1,24 @@ + +import 'dart:ffi'; + +import '../util/Codable.dart'; + +class ImageViewStyle extends Encodable { + final Bool? visible; + final String? backgroundColor; + + ImageViewStyle({this.visible, this.backgroundColor}); + + @override + String toString() { + return 'ImageViewStyle{visible: $visible, backgroundColor: $backgroundColor}'; + } + + @override + Map encode() { + return { + 'visible': visible, + 'backgroundColor': backgroundColor + }; + } +} diff --git a/lib/src/data/model/list_screen_style.dart b/lib/src/data/model/list_screen_style.dart new file mode 100644 index 0000000..5ab0b32 --- /dev/null +++ b/lib/src/data/model/list_screen_style.dart @@ -0,0 +1,33 @@ + +import 'package:exponea/exponea.dart'; + +import '../util/Codable.dart'; + +class ListScreenStyle extends Encodable { + final TextViewStyle? emptyTitle; + final TextViewStyle? emptyMessage; + final TextViewStyle? errorTitle; + final TextViewStyle? errorMessage; + final ProgressBarStyle? progress; + final AppInboxListViewStyle? list; + + ListScreenStyle({this.emptyTitle, this.emptyMessage, this.errorTitle, + this.errorMessage, this.progress, this.list}); + + @override + String toString() { + return 'ListScreenStyle{emptyTitle: $emptyTitle, emptyMessage: $emptyMessage, errorTitle: $errorTitle, errorMessage: $errorMessage, progress: $progress, list: $list}'; + } + + @override + Map encode() { + return { + 'emptyTitle': emptyTitle?.encodeClean(), + 'emptyMessage': emptyMessage?.encodeClean(), + 'errorTitle': errorTitle?.encodeClean(), + 'errorMessage': errorMessage?.encodeClean(), + 'progress': progress?.encodeClean(), + 'list': list?.encodeClean() + }; + } +} diff --git a/lib/src/data/model/progress_bar_style.dart b/lib/src/data/model/progress_bar_style.dart new file mode 100644 index 0000000..a4cfa34 --- /dev/null +++ b/lib/src/data/model/progress_bar_style.dart @@ -0,0 +1,26 @@ + +import 'dart:ffi'; + +import '../util/Codable.dart'; + +class ProgressBarStyle extends Encodable { + final Bool? visible; + final String? progressColor; + final String? backgroundColor; + + ProgressBarStyle({this.visible, this.progressColor, this.backgroundColor}); + + @override + String toString() { + return 'ProgressBarStyle{visible: $visible, progressColor: $progressColor, backgroundColor: $backgroundColor}'; + } + + @override + Map encode() { + return { + 'visible': visible, + 'progressColor': progressColor, + 'backgroundColor': backgroundColor, + }; + } +} diff --git a/lib/src/data/model/text_view_style.dart b/lib/src/data/model/text_view_style.dart new file mode 100644 index 0000000..2f27fae --- /dev/null +++ b/lib/src/data/model/text_view_style.dart @@ -0,0 +1,41 @@ + +import 'dart:ffi'; + +import 'package:exponea/exponea.dart'; +import 'package:flutter/material.dart'; + +import '../util/Codable.dart'; + +class TextViewStyle extends Encodable { + final Bool? visible; + final String? textColor; + final String? textSize; + final String? textWeight; + final String? textOverride; + + TextViewStyle({ + this.visible, + this.textColor, + this.textSize, + this.textWeight, + this.textOverride}); + + @override + String toString() { + return 'TextViewStyle{visible: $visible, textColor: $textColor,' + 'textSize: $textSize, textWeight: $textWeight,' + 'textOverride: $textOverride}'; + } + + @override + Map encode() { + return { + 'visible': visible, + 'textColor': textColor, + 'textSize': textSize, + 'textWeight': textWeight, + 'textOverride': textOverride + }; + } + +} diff --git a/lib/src/data/util/Codable.dart b/lib/src/data/util/Codable.dart new file mode 100644 index 0000000..dc10155 --- /dev/null +++ b/lib/src/data/util/Codable.dart @@ -0,0 +1,8 @@ + +abstract class Encodable { + /// Returns encoded Map without NULL values + Map encodeClean() { + return encode()..removeWhere((key, value) => value == null); + } + Map encode(); +} diff --git a/lib/src/interface.dart b/lib/src/interface.dart index 50c29d4..654189d 100644 --- a/lib/src/interface.dart +++ b/lib/src/interface.dart @@ -1,13 +1,4 @@ -import 'data/model/configuration.dart'; -import 'data/model/configuration_change.dart'; -import 'data/model/consent.dart'; -import 'data/model/customer.dart'; -import 'data/model/event.dart'; -import 'data/model/flush_mode.dart'; -import 'data/model/log_level.dart'; -import 'data/model/push_opened.dart'; -import 'data/model/push_received.dart'; -import 'data/model/recommendation.dart'; +import '../exponea.dart'; /// /// Base interface implemented by platforms and plugin. @@ -104,4 +95,32 @@ abstract class BaseInterface { /// Request push authorization on iOS. Future requestIosPushAuthorization(); + + /// Set AppInboxProvider + Future setAppInboxProvider(AppInboxStyle style); + + /// Track AppInbox message detail opened event + /// Event is tracked if parameter 'message' has TRUE value of 'hasTrackingConsent' property + Future trackAppInboxOpened(AppInboxMessage message); + + /// Track AppInbox message detail opened event + Future trackAppInboxOpenedWithoutTrackingConsent(AppInboxMessage message); + + /// Track AppInbox message click event + /// Event is tracked if one or both conditions met: + /// - parameter 'message' has TRUE value of 'hasTrackingConsent' property + /// - parameter 'buttonLink' has TRUE value of query parameter 'xnpe_force_track' + Future trackAppInboxClick(AppInboxAction action, AppInboxMessage message); + + /// Track AppInbox message click event + Future trackAppInboxClickWithoutTrackingConsent(AppInboxAction action, AppInboxMessage message); + + /// Marks AppInbox message as read + Future markAppInboxAsRead(AppInboxMessage message); + + /// Fetches AppInbox for the current customer + Future> fetchAppInbox(); + + /// Fetches AppInbox message by ID for the current customer + Future fetchAppInboxItem(String messageId); } diff --git a/lib/src/platform/method_channel.dart b/lib/src/platform/method_channel.dart index e8f3fc4..6d44070 100644 --- a/lib/src/platform/method_channel.dart +++ b/lib/src/platform/method_channel.dart @@ -1,18 +1,8 @@ +import 'package:exponea/exponea.dart'; import 'package:flutter/services.dart'; import '../data/encoder/main.dart'; -import '../data/model/configuration.dart'; -import '../data/model/configuration_change.dart'; -import '../data/model/consent.dart'; -import '../data/model/customer.dart'; -import '../data/model/event.dart'; -import '../data/model/flush_mode.dart'; -import '../data/model/log_level.dart'; -import '../data/model/push_opened.dart'; -import '../data/model/push_received.dart'; -import '../data/model/recommendation.dart'; import '../data/util/object.dart'; -import 'platform_interface.dart'; /// An implementation of [ExponeaPlatform] that uses method channels. class MethodChannelExponeaPlatform extends ExponeaPlatform { @@ -47,6 +37,14 @@ class MethodChannelExponeaPlatform extends ExponeaPlatform { static const _methodSetLogLevel = 'setLogLevel'; static const _methodCheckPushSetup = 'checkPushSetup'; static const _methodRequestPushAuthorization = 'requestPushAuthorization'; + static const _setAppInboxProvider = 'setAppInboxProvider'; + static const _trackAppInboxOpened = 'trackAppInboxOpened'; + static const _trackAppInboxOpenedWithoutTrackingConsent = 'trackAppInboxOpenedWithoutTrackingConsent'; + static const _trackAppInboxClick = 'trackAppInboxClick'; + static const _trackAppInboxClickWithoutTrackingConsent = 'trackAppInboxClickWithoutTrackingConsent'; + static const _markAppInboxAsRead = 'markAppInboxAsRead'; + static const _fetchAppInbox = 'fetchAppInbox'; + static const _fetchAppInboxItem = 'fetchAppInboxItem'; Stream? _openedPushStream; Stream? _receivedPushStream; @@ -202,4 +200,57 @@ class MethodChannelExponeaPlatform extends ExponeaPlatform { final data = LogLevelEncoder.encode(level); await _channel.invokeMethod(_methodSetLogLevel, data); } + + @override + Future setAppInboxProvider(AppInboxStyle style) async { + final data = style.encodeClean(); + await _channel.invokeMethod(_setAppInboxProvider, data); + } + + @override + Future trackAppInboxOpened(AppInboxMessage message) async { + final data = AppInboxCoder.encodeMessage(message); + await _channel.invokeMethod(_trackAppInboxOpened, data); + } + + @override + Future trackAppInboxOpenedWithoutTrackingConsent(AppInboxMessage message) async { + final data = AppInboxCoder.encodeMessage(message); + await _channel.invokeMethod(_trackAppInboxOpenedWithoutTrackingConsent, data); + } + + @override + Future trackAppInboxClick(AppInboxAction action, AppInboxMessage message) async { + final data = AppInboxCoder.encodeActionMessage(action, message); + await _channel.invokeMethod(_trackAppInboxClick, data); + } + + @override + Future trackAppInboxClickWithoutTrackingConsent(AppInboxAction action, AppInboxMessage message) async { + final data = AppInboxCoder.encodeActionMessage(action, message); + await _channel.invokeMethod(_trackAppInboxClickWithoutTrackingConsent, data); + } + + @override + Future markAppInboxAsRead(AppInboxMessage message) async { + final data = AppInboxCoder.encodeMessage(message); + return (await _channel.invokeMethod(_markAppInboxAsRead, data))!; + } + + @override + Future> fetchAppInbox() async { + final outData = + (await _channel.invokeListMethod(_fetchAppInbox))!; + final res = + outData.map((it) => AppInboxCoder.decodeMessage(it)).toList(growable: false); + return res; + } + + @override + Future fetchAppInboxItem(String messageId) async { + const method = _fetchAppInboxItem; + final inData = messageId; + final outData = (await _channel.invokeMapMethod(method, inData))!; + return AppInboxCoder.decodeMessage(outData); + } } diff --git a/lib/src/platform/platform_interface.dart b/lib/src/platform/platform_interface.dart index b2a1d52..03f801c 100644 --- a/lib/src/platform/platform_interface.dart +++ b/lib/src/platform/platform_interface.dart @@ -1,17 +1,8 @@ import 'dart:async'; +import 'package:exponea/exponea.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; -import '../data/model/configuration.dart'; -import '../data/model/configuration_change.dart'; -import '../data/model/consent.dart'; -import '../data/model/customer.dart'; -import '../data/model/event.dart'; -import '../data/model/flush_mode.dart'; -import '../data/model/log_level.dart'; -import '../data/model/push_opened.dart'; -import '../data/model/push_received.dart'; -import '../data/model/recommendation.dart'; import '../interface.dart'; import 'method_channel.dart'; @@ -148,6 +139,46 @@ abstract class ExponeaPlatform extends PlatformInterface throw UnimplementedError(); } + @override + Future setAppInboxProvider(AppInboxStyle style) async { + throw UnimplementedError(); + } + + @override + Future trackAppInboxOpened(AppInboxMessage message) async { + throw UnimplementedError(); + } + + @override + Future trackAppInboxOpenedWithoutTrackingConsent(AppInboxMessage message) async { + throw UnimplementedError(); + } + + @override + Future trackAppInboxClick(AppInboxAction action, AppInboxMessage message) async { + throw UnimplementedError(); + } + + @override + Future trackAppInboxClickWithoutTrackingConsent(AppInboxAction action, AppInboxMessage message) async { + throw UnimplementedError(); + } + + @override + Future markAppInboxAsRead(AppInboxMessage message) async { + throw UnimplementedError(); + } + + @override + Future> fetchAppInbox() async { + throw UnimplementedError(); + } + + @override + Future fetchAppInboxItem(String messageId) async { + throw UnimplementedError(); + } + @override Stream get openedPushStream => throw UnimplementedError(); diff --git a/lib/src/plugin/exponea.dart b/lib/src/plugin/exponea.dart index 7d53dcf..26b93f1 100644 --- a/lib/src/plugin/exponea.dart +++ b/lib/src/plugin/exponea.dart @@ -1,15 +1,6 @@ -import '../data/model/configuration.dart'; -import '../data/model/configuration_change.dart'; -import '../data/model/consent.dart'; -import '../data/model/customer.dart'; -import '../data/model/event.dart'; -import '../data/model/flush_mode.dart'; -import '../data/model/log_level.dart'; -import '../data/model/push_opened.dart'; -import '../data/model/push_received.dart'; -import '../data/model/recommendation.dart'; +import 'package:exponea/exponea.dart'; + import '../interface.dart'; -import '../platform/platform_interface.dart'; class ExponeaPlugin implements BaseInterface { ExponeaPlatform get _platform => ExponeaPlatform.instance; @@ -91,6 +82,45 @@ class ExponeaPlugin implements BaseInterface { Future trackSessionStart({DateTime? timestamp}) => _platform.trackSessionStart(timestamp: timestamp); + @override + Future setAppInboxProvider(AppInboxStyle style) => + _platform.setAppInboxProvider(style); + + @override + Future trackAppInboxOpened(AppInboxMessage message) => + _platform.trackAppInboxOpened(message); + + @override + Future trackAppInboxOpenedWithoutTrackingConsent( + AppInboxMessage message + ) => + _platform.trackAppInboxOpenedWithoutTrackingConsent(message); + + @override + Future trackAppInboxClick( + AppInboxAction action, + AppInboxMessage message + ) => + _platform.trackAppInboxClick(action, message); + + @override + Future trackAppInboxClickWithoutTrackingConsent( + AppInboxAction action, + AppInboxMessage message + ) => + _platform.trackAppInboxClickWithoutTrackingConsent(action, message); + + @override + Future markAppInboxAsRead(AppInboxMessage message) => + _platform.markAppInboxAsRead(message); + + @override + Future> fetchAppInbox() => _platform.fetchAppInbox(); + + @override + Future fetchAppInboxItem(String messageId) => + _platform.fetchAppInboxItem(messageId); + @override Stream get openedPushStream => _platform.openedPushStream; diff --git a/lib/src/widget/app_inbox_button.dart b/lib/src/widget/app_inbox_button.dart new file mode 100644 index 0000000..5366115 --- /dev/null +++ b/lib/src/widget/app_inbox_button.dart @@ -0,0 +1,19 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class AppInboxProvider extends StatelessWidget { + const AppInboxProvider({Key? key}) : super(key: key); + + static const String viewType = 'FluffView'; + + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform == TargetPlatform.android){ + return const AndroidView(viewType: viewType); + } else if (defaultTargetPlatform == TargetPlatform.iOS){ + return const UiKitView(viewType: viewType); + } else { + return const SizedBox.shrink(); + } + } +} diff --git a/node_modules/.yarn-integrity b/node_modules/.yarn-integrity new file mode 100644 index 0000000..9937b9e --- /dev/null +++ b/node_modules/.yarn-integrity @@ -0,0 +1,10 @@ +{ + "systemParams": "darwin-arm64-93", + "modulesFolders": [], + "flags": [], + "linkedModules": [], + "topLevelPatterns": [], + "lockfileEntries": {}, + "files": [], + "artifacts": {} +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 7f3839b..cc4ded7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,6 +50,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.0" + file: + dependency: "direct main" + description: + name: file + url: "https://pub.dartlang.org" + source: hosted + version: "6.1.4" flutter: dependency: "direct main" description: flutter @@ -120,7 +127,7 @@ packages: name: plugin_platform_interface url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.4" sky_engine: dependency: transitive description: flutter @@ -176,5 +183,5 @@ packages: source: hosted version: "2.1.2" sdks: - dart: ">=2.17.0-0 <3.0.0" + dart: ">=2.17.0 <3.0.0" flutter: ">=2.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index e7a0cf1..2e9d896 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: sdk: flutter meta: ^1.7.0 plugin_platform_interface: ^2.1.2 + file: ^6.1.4 dev_dependencies: flutter_lints: ^1.0.4 diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..fb57ccd --- /dev/null +++ b/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + +