diff --git a/android/build.gradle.kts b/android/build.gradle.kts index a922ab9..6a9cde6 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -61,6 +61,11 @@ android { defaultConfig { minSdk = 24 + // The REES46 Android library is flavored on a `default` dimension + // (rees46 / personaclick); this plugin has no such dimension. Tell Gradle + // which flavor to consume. A no-op for the single-variant JitPack artifact; + // required when the SDK is consumed from source (local `includeBuild`). + missingDimensionStrategy("default", "rees46") } testOptions { @@ -82,11 +87,9 @@ android { dependencies { // REES46 Android SDK (JitPack). - // // Published from github.com/rees46/android-sdk as `com.github.rees46:android-sdk:`. - // v2.34.0 adds the catalog read managers (profile, product counters, - // category, collection) on top of the loyalty manager (v2.33.0). - val rees46AndroidSdkVersion = "v2.34.0" + // v2.36.0 is the first tag that ships the multi-instance `Rees46` facade. + val rees46AndroidSdkVersion = "v2.36.0" add( "rees46Implementation", "com.github.rees46:android-sdk:$rees46AndroidSdkVersion", diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt index 893831c..24a7614 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/FlutterTrackingBridge.kt @@ -102,6 +102,7 @@ internal object FlutterTrackingBridge { ) fun postTrackEvent( + sdk: SDK, event: String, time: Long?, category: String?, @@ -153,7 +154,7 @@ internal object FlutterTrackingBridge { } @Suppress("DEPRECATION") - SDK.instance.sendAsync( + sdk.sendAsync( CUSTOM_PUSH_PATH, body, object : OnApiCallbackListener() { @@ -170,6 +171,7 @@ internal object FlutterTrackingBridge { } fun postTrackPurchase( + sdk: SDK, orderId: String, orderPrice: Double, items: List, @@ -223,7 +225,7 @@ internal object FlutterTrackingBridge { val body = buildResult.getOrNull()!! @Suppress("DEPRECATION") - SDK.instance.sendAsync( + sdk.sendAsync( PURCHASE_PUSH_PATH, body, object : OnApiCallbackListener() { diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt index 5f231ad..6935ca9 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/Rees46FlutterSdkPlugin.kt @@ -2,7 +2,6 @@ package com.rees46.rees46_flutter_sdk import android.content.Context import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle import android.os.SystemClock import com.rees46.rees46_flutter_sdk.pigeon.FlutterError @@ -13,12 +12,17 @@ import com.rees46.rees46_flutter_sdk.pigeon.ProfileParamsWire import com.rees46.rees46_flutter_sdk.pigeon.PurchaseLineItemWire import com.google.gson.Gson import com.personalization.Params +import com.personalization.PushEventType +import com.personalization.PushProvider +import com.personalization.Rees46 +import com.personalization.Rees46Config import com.personalization.SDK import com.personalization.api.OnApiCallbackListener import com.personalization.api.params.ProfileParams import com.personalization.api.params.SearchParams as NativeSearchParams import com.personalization.sdk.data.models.dto.notification.NotificationData import com.rees46.rees46_flutter_sdk.push.Rees46PushNotifier +import com.rees46.rees46_flutter_sdk.push.Rees46ShopStore import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -92,12 +96,29 @@ class Rees46FlutterSdkPlugin : override fun getPlatformVersion(): String = "Android ${android.os.Build.VERSION.RELEASE}" - override fun getStoredPushToken(): String? { - val prefs: SharedPreferences = - applicationContext.getSharedPreferences(DEFAULT_STORAGE_KEY, Context.MODE_PRIVATE) - return prefs.getString(TOKEN_KEY, null) - ?.takeIf { it.isNotBlank() } - } + /** + * Resolves the SDK instance a call targets via the multi-instance [Rees46] + * facade. A null [shopId] resolves the single default instance; an unknown or + * (with no id) ambiguous shop throws [com.personalization.UnknownShopIdException] + * / [com.personalization.AmbiguousShopException], which the calling method turns + * into a Flutter error. This is the F3 wiring; requires the native `Rees46` + * facade (local `android-sdk` via `includeBuild`, or a version that ships it). + */ + private fun sdk(shopId: String?): SDK = Rees46.getInstance(shopId) + + override fun getStoredPushToken(shopId: String?): String? = + try { + // Read the token from the resolved instance's own storage via the SDK's + // public getter — NOT the legacy `DEFAULT_STORAGE_KEY` prefs. Multi-instance + // partitions storage per shop_id (`personalization_sdk_`), so the + // legacy shared file is empty on a fresh install and the token would never + // be found there. + val sdk = sdk(shopId) + sdk.getPushToken(PushProvider.FCM) ?: sdk.getPushToken(PushProvider.HMS) + } catch (t: Throwable) { + // Unknown/ambiguous shop, or the instance is not initialized yet. + null + } override fun initialize(config: InitConfig, callback: (Result) -> Unit) { val shopId = config.shopId @@ -106,26 +127,32 @@ class Rees46FlutterSdkPlugin : return } try { - val sdk = SDK.instance - sdk.initialize( - context = applicationContext, + val shopConfig = Rees46Config( shopId = shopId, apiDomain = config.apiDomain, stream = config.stream, autoSendPushToken = config.autoSendPushToken, needReInitialization = config.needReInitialization, ) + // F3: initialize (and register) the instance through the multi-instance + // `Rees46` facade so it is reachable by shopId via Rees46.getInstance. + Rees46.initialize(context = applicationContext, config = shopConfig) + + // Persist so the cold-start push provider (Rees46PushInitProvider) can re-register this + // shop on a process FCM spins up before Dart runs — otherwise the registry is empty and + // Rees46.handlePush drops the push (killed-app "push never arrives" on Android). + Rees46ShopStore.save(applicationContext, shopConfig) Rees46PushNotifier.ensureChannel(applicationContext) - // Show a heads-up BigPicture notification on message (pop-up, image, tap opens the app) - // — the native equivalent of the REES46 React Native demo. This replaces the SDK's - // built-in collapsed/low-importance custom-view notification. The push is also forwarded - // to Dart (onPushReceived / onPushDelivered) so the host app can react. - sdk.setOnMessageListener { data -> + // FL-5: one process-global, shop-aware listener (not per-instance) so a push for any + // shop routes here with its shopId. Shows a heads-up BigPicture notification (pop-up, + // image, tap opens the app) and forwards to Dart (onPushReceived / onPushDelivered) + // tagged with the shop it routed to, so the Dart dispatcher delivers it to that shop. + Rees46.setOnMessageListener { messageShopId, data -> android.util.Log.d( Rees46PushNotifier.TAG, - "onMessage (plugin listener) id=${data.id}", + "onMessage (plugin listener) shop=$messageShopId id=${data.id}", ) val payload = data.toPayload() // The listener fires on an FCM background thread, but flutterApi is a Pigeon @@ -134,11 +161,11 @@ class Rees46FlutterSdkPlugin : // is Dispatchers.Main) for every flutterApi call, download+post the notification // off the main thread, and never let a flutterApi failure block the display. coroutineScope.launch { - runCatching { flutterApi?.onPushReceived(payload) { _ -> } } + runCatching { flutterApi?.onPushReceived(messageShopId, payload) { _ -> } } withContext(Dispatchers.IO) { Rees46PushNotifier.show(applicationContext, data) } - runCatching { flutterApi?.onPushDelivered(payload) { _ -> } } + runCatching { flutterApi?.onPushDelivered(messageShopId, payload) { _ -> } } } } @@ -151,6 +178,7 @@ class Rees46FlutterSdkPlugin : override fun getRecommendation( code: String, paramsJson: String?, + shopId: String?, callback: (Result) -> Unit, ) { if (code.isBlank()) { @@ -159,7 +187,7 @@ class Rees46FlutterSdkPlugin : } try { val params = buildRecommendationParams(paramsJson) - SDK.instance.recommendationManager.getExtendedRecommendation( + sdk(shopId).recommendationManager.getExtendedRecommendation( recommenderCode = code, params = params, onGetExtendedRecommendation = { response -> @@ -174,13 +202,13 @@ class Rees46FlutterSdkPlugin : } } - override fun getProductInfo(itemId: String, callback: (Result) -> Unit) { + override fun getProductInfo(itemId: String, shopId: String?, callback: (Result) -> Unit) { if (itemId.isBlank()) { callback(Result.failure(FlutterError("bad_args", "itemId is required", null))) return } try { - SDK.instance.productsManager.getProductInfo( + sdk(shopId).productsManager.getProductInfo( itemId = itemId, listener = object : OnApiCallbackListener() { override fun onSuccess(response: org.json.JSONObject?) { @@ -197,7 +225,7 @@ class Rees46FlutterSdkPlugin : } } - override fun getProductsList(paramsJson: String?, callback: (Result) -> Unit) { + override fun getProductsList(paramsJson: String?, shopId: String?, callback: (Result) -> Unit) { try { val p = if (!paramsJson.isNullOrBlank()) JSONObject(paramsJson) else null val brands = p?.optString("brands")?.takeIf { it.isNotEmpty() } @@ -209,7 +237,7 @@ class Rees46FlutterSdkPlugin : val filters: Map? = p?.optJSONObject("filters")?.let { obj -> obj.keys().asSequence().associateWith { key -> obj.get(key) } } - SDK.instance.productsManager.getProductsList( + sdk(shopId).productsManager.getProductsList( brands = brands, merchants = merchants, categories = categories, @@ -232,9 +260,9 @@ class Rees46FlutterSdkPlugin : } } - override fun searchBlank(callback: (Result) -> Unit) { + override fun searchBlank(shopId: String?, callback: (Result) -> Unit) { try { - SDK.instance.searchManager.searchBlank( + sdk(shopId).searchManager.searchBlank( onSearchBlank = { response -> callback(Result.success(Gson().toJson(response))) }, @@ -250,6 +278,7 @@ class Rees46FlutterSdkPlugin : override fun searchInstant( query: String, paramsJson: String?, + shopId: String?, callback: (Result) -> Unit, ) { if (query.isBlank()) { @@ -260,7 +289,7 @@ class Rees46FlutterSdkPlugin : val json = if (!paramsJson.isNullOrBlank()) JSONObject(paramsJson) else null val locations = json?.optString("locations")?.takeIf { it.isNotEmpty() } val excludedBrands = jsonArrayToStringList(json?.optJSONArray("excluded_brands")) - SDK.instance.searchManager.searchInstant( + sdk(shopId).searchManager.searchInstant( query = query, locations = locations, excludedMerchants = null, @@ -280,6 +309,7 @@ class Rees46FlutterSdkPlugin : override fun searchFull( query: String, paramsJson: String?, + shopId: String?, callback: (Result) -> Unit, ) { if (query.isBlank()) { @@ -288,7 +318,7 @@ class Rees46FlutterSdkPlugin : } try { val params = buildSearchParams(paramsJson) - SDK.instance.searchManager.searchFull( + sdk(shopId).searchManager.searchFull( query = query, searchParams = params, onSearchFull = { response -> @@ -308,6 +338,7 @@ class Rees46FlutterSdkPlugin : email: String?, firstName: String?, lastName: String?, + shopId: String?, callback: (Result) -> Unit, ) { if (phone.isBlank()) { @@ -315,7 +346,7 @@ class Rees46FlutterSdkPlugin : return } try { - SDK.instance.loyaltyManager.join( + sdk(shopId).loyaltyManager.join( phone = phone, email = email, firstName = firstName, @@ -332,13 +363,13 @@ class Rees46FlutterSdkPlugin : } } - override fun getLoyaltyStatus(identifier: String, callback: (Result) -> Unit) { + override fun getLoyaltyStatus(identifier: String, shopId: String?, callback: (Result) -> Unit) { if (identifier.isBlank()) { callback(Result.failure(FlutterError("bad_args", "identifier is required", null))) return } try { - SDK.instance.loyaltyManager.getStatus( + sdk(shopId).loyaltyManager.getStatus( identifier = identifier, onSuccess = { response -> callback(Result.success(Gson().toJson(response))) @@ -352,9 +383,9 @@ class Rees46FlutterSdkPlugin : } } - override fun getProfile(callback: (Result) -> Unit) { + override fun getProfile(shopId: String?, callback: (Result) -> Unit) { try { - SDK.instance.profileManager.getProfile( + sdk(shopId).profileManager.getProfile( onSuccess = { response -> callback(Result.success(Gson().toJson(response))) }, @@ -367,13 +398,13 @@ class Rees46FlutterSdkPlugin : } } - override fun getProductCounters(item: String, callback: (Result) -> Unit) { + override fun getProductCounters(item: String, shopId: String?, callback: (Result) -> Unit) { if (item.isBlank()) { callback(Result.failure(FlutterError("bad_args", "item is required", null))) return } try { - SDK.instance.productsManager.getProductCounters( + sdk(shopId).productsManager.getProductCounters( item = item, onSuccess = { response -> callback(Result.success(Gson().toJson(response))) @@ -391,6 +422,7 @@ class Rees46FlutterSdkPlugin : category: String, limit: Long?, page: Long?, + shopId: String?, callback: (Result) -> Unit, ) { if (category.isBlank()) { @@ -398,7 +430,7 @@ class Rees46FlutterSdkPlugin : return } try { - SDK.instance.categoryManager.getCategory( + sdk(shopId).categoryManager.getCategory( category = category, limit = limit?.toInt(), page = page?.toInt(), @@ -414,13 +446,13 @@ class Rees46FlutterSdkPlugin : } } - override fun getCollection(collectionId: String, callback: (Result) -> Unit) { + override fun getCollection(collectionId: String, shopId: String?, callback: (Result) -> Unit) { if (collectionId.isBlank()) { callback(Result.failure(FlutterError("bad_args", "collectionId is required", null))) return } try { - SDK.instance.collectionManager.getCollection( + sdk(shopId).collectionManager.getCollection( collectionId = collectionId, onSuccess = { response -> callback(Result.success(Gson().toJson(response))) @@ -434,11 +466,11 @@ class Rees46FlutterSdkPlugin : } } - override fun getSid(): String = SDK.instance.getSid() + override fun getSid(shopId: String?): String = sdk(shopId).getSid() - override fun getDid(): String? = SDK.instance.getDid() + override fun getDid(shopId: String?): String? = sdk(shopId).getDid() - override fun setProfile(params: ProfileParamsWire, callback: (Result) -> Unit) { + override fun setProfile(params: ProfileParamsWire, shopId: String?, callback: (Result) -> Unit) { try { val builder = ProfileParams.Builder() params.email?.let { builder.put("email", it) } @@ -464,7 +496,7 @@ class Rees46FlutterSdkPlugin : val obj = JSONObject(json) obj.keys().forEach { key -> builder.put(key, obj.getString(key)) } } - SDK.instance.profile(builder.build(), object : OnApiCallbackListener() { + sdk(shopId).profile(builder.build(), object : OnApiCallbackListener() { override fun onSuccess(response: JSONObject?) { callback(Result.success(Unit)) } @@ -484,6 +516,7 @@ class Rees46FlutterSdkPlugin : label: String?, value: Long?, customFieldsJson: String?, + shopId: String?, callback: (Result) -> Unit, ) { if (event.isBlank()) { @@ -493,6 +526,7 @@ class Rees46FlutterSdkPlugin : try { val customFields = jsonObjectStringToMap(customFieldsJson) FlutterTrackingBridge.postTrackEvent( + sdk = sdk(shopId), event = event, time = time, category = category, @@ -524,6 +558,7 @@ class Rees46FlutterSdkPlugin : recommendedSourceJson: String?, stream: String?, segment: String?, + shopId: String?, callback: (Result) -> Unit, ) { if (orderId.isBlank()) { @@ -542,6 +577,7 @@ class Rees46FlutterSdkPlugin : JSONObject(recommendedSourceJson) } FlutterTrackingBridge.postTrackPurchase( + sdk = sdk(shopId), orderId = orderId, orderPrice = orderPrice, items = items, @@ -566,6 +602,22 @@ class Rees46FlutterSdkPlugin : } } + override fun handlePush( + payload: Map, + event: Long, + callback: (Result) -> Unit, + ) { + try { + // Flutter PushEvent index: 0=received, 1=delivered, 2=clicked. Android's + // PushEventType has no `delivered`, so received & delivered both track received. + val type = if (event.toInt() == 2) PushEventType.CLICKED else PushEventType.RECEIVED + Rees46.handlePush(payload, type) + callback(Result.success(Unit)) + } catch (t: Throwable) { + callback(Result.failure(FlutterError("handle_push_failed", t.message, null))) + } + } + private fun handleNotificationLaunchIntent(intent: Intent?) { val extras = intent?.extras ?: return if (!extras.isPersonalizationNotificationClick()) { @@ -579,8 +631,13 @@ class Rees46FlutterSdkPlugin : return } try { - SDK.instance.notificationClicked(extras) - flutterApi?.onPushClicked(payload) { _ -> } + // FL-5: route the click through the multi-instance facade — it resolves the shop from + // the payload's shop_id and tracks the click on that instance. Forward to Dart tagged + // with the shop so the dispatcher delivers the click to it. + val shopId = payload[SHOP_ID_KEY] + val stringPayload = payload.filterValues { it != null }.mapValues { it.value!! } + Rees46.handlePush(stringPayload, PushEventType.CLICKED) + flutterApi?.onPushClicked(shopId, payload) { _ -> } } catch (_: Throwable) { // SDK may not be initialized yet; ignore. } @@ -588,8 +645,7 @@ class Rees46FlutterSdkPlugin : companion object { private const val NOTIFICATION_CLICK_DEBOUNCE_MS = 800L - private const val DEFAULT_STORAGE_KEY = "DEFAULT_STORAGE_KEY" - private const val TOKEN_KEY = "token" + private const val SHOP_ID_KEY = "shop_id" private var lastClickSignature: String? = null private var lastClickAtElapsedMs: Long = 0L diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt index e485e0f..c1eb6d9 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/pigeon/PersonalizationApi.g.kt @@ -315,86 +315,93 @@ interface PersonalizationHostApi { fun initialize(config: InitConfig, callback: (Result) -> Unit) fun getPlatformVersion(): String /** Returns the push token stored by the native SDK (if any). */ - fun getStoredPushToken(): String? + fun getStoredPushToken(shopId: String?): String? /** [customFieldsJson] is JSON object string or null (maps to native custom fields map). */ - fun trackEvent(event: String, time: Long?, category: String?, label: String?, value: Long?, customFieldsJson: String?, callback: (Result) -> Unit) - fun setProfile(params: ProfileParamsWire, callback: (Result) -> Unit) + fun trackEvent(event: String, time: Long?, category: String?, label: String?, value: Long?, customFieldsJson: String?, shopId: String?, callback: (Result) -> Unit) + fun setProfile(params: ProfileParamsWire, shopId: String?, callback: (Result) -> Unit) /** * Returns the recommendation block as a JSON string. * [paramsJson] is a JSON object string with optional filter parameters. * Dart layer parses the result into [RecommendationResponse]. */ - fun getRecommendation(code: String, paramsJson: String?, callback: (Result) -> Unit) + fun getRecommendation(code: String, paramsJson: String?, shopId: String?, callback: (Result) -> Unit) /** Returns the current session ID from the native SDK. */ - fun getSid(): String + fun getSid(shopId: String?): String /** Returns the device ID assigned by the native SDK, or null before first sync. */ - fun getDid(): String? + fun getDid(shopId: String?): String? /** * Returns a single product's details as a JSON string. * Dart layer parses the result into [Product]. */ - fun getProductInfo(itemId: String, callback: (Result) -> Unit) + fun getProductInfo(itemId: String, shopId: String?, callback: (Result) -> Unit) /** * Returns a paginated product catalog list as a JSON string. * [paramsJson] is a JSON object with optional filter fields. * Dart layer parses the result into [ProductsListResponse]. */ - fun getProductsList(paramsJson: String?, callback: (Result) -> Unit) + fun getProductsList(paramsJson: String?, shopId: String?, callback: (Result) -> Unit) /** * Returns blank search results (trending/popular) as a JSON string. * No parameters — the native SDK decides what to return based on shop config. * Dart layer parses the result into [SearchBlankResponse]. */ - fun searchBlank(callback: (Result) -> Unit) + fun searchBlank(shopId: String?, callback: (Result) -> Unit) /** * Returns instant (typeahead) search results as a JSON string. * [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). * Dart layer parses the result into [SearchInstantResponse]. */ - fun searchInstant(query: String, paramsJson: String?, callback: (Result) -> Unit) + fun searchInstant(query: String, paramsJson: String?, shopId: String?, callback: (Result) -> Unit) /** * Returns full search results as a JSON string. * [paramsJson] is a JSON object string with optional search parameters. * Dart layer parses the result into [SearchFullResponse]. */ - fun searchFull(query: String, paramsJson: String?, callback: (Result) -> Unit) + fun searchFull(query: String, paramsJson: String?, shopId: String?, callback: (Result) -> Unit) /** * Joins the loyalty program (`loyalty/members/join`) and returns the * response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. * The shop is identified by the SDK's configured `shop_id`; [phone] is required. * Dart layer parses the result into [LoyaltyJoinResponse]. */ - fun joinLoyalty(phone: String, email: String?, firstName: String?, lastName: String?, callback: (Result) -> Unit) + fun joinLoyalty(phone: String, email: String?, firstName: String?, lastName: String?, shopId: String?, callback: (Result) -> Unit) /** * Returns the loyalty membership status (`loyalty/members/status`) as a JSON * string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. * [identifier] is the member identifier (phone). * Dart layer parses the result into [LoyaltyStatusResponse]. */ - fun getLoyaltyStatus(identifier: String, callback: (Result) -> Unit) + fun getLoyaltyStatus(identifier: String, shopId: String?, callback: (Result) -> Unit) /** * Returns the current user's profile as a JSON string. * Dart layer parses the result into [ProfileResponse]. */ - fun getProfile(callback: (Result) -> Unit) + fun getProfile(shopId: String?, callback: (Result) -> Unit) /** * Returns view / cart / purchase counters for [item] as a JSON string. * Dart layer parses the result into [ProductCountersResponse]. */ - fun getProductCounters(item: String, callback: (Result) -> Unit) + fun getProductCounters(item: String, shopId: String?, callback: (Result) -> Unit) /** * Returns a category product listing as a JSON string. * [limit] and [page] paginate the result; both are optional. * Dart layer parses the result into [CategoryResponse]. */ - fun getCategory(category: String, limit: Long?, page: Long?, callback: (Result) -> Unit) + fun getCategory(category: String, limit: Long?, page: Long?, shopId: String?, callback: (Result) -> Unit) /** * Returns a merchandised collection's products as a JSON string. * Dart layer parses the result into [CollectionResponse]. */ - fun getCollection(collectionId: String, callback: (Result) -> Unit) + fun getCollection(collectionId: String, shopId: String?, callback: (Result) -> Unit) + /** + * Routes a push to the shop it belongs to (payload `shop_id`) and tracks it + * via the native `Rees46.handlePush`. [event] is the index of the Dart + * `PushEvent` enum: 0 = received, 1 = delivered, 2 = clicked. The native side + * maps it to its own vocabulary (Android `PushEventType`, iOS `PushEvent`). + */ + fun handlePush(payload: Map, event: Long, callback: (Result) -> Unit) /** [customJson] and [recommendedSourceJson] are JSON object strings or null. */ - fun trackPurchase(orderId: String, orderPrice: Double, items: List, deliveryType: String?, deliveryAddress: String?, paymentType: String?, isTaxFree: Boolean, promocode: String?, orderCash: Double?, orderBonuses: Double?, orderDelivery: Double?, orderDiscount: Double?, channel: String?, customJson: String?, recommendedSourceJson: String?, stream: String?, segment: String?, callback: (Result) -> Unit) + fun trackPurchase(orderId: String, orderPrice: Double, items: List, deliveryType: String?, deliveryAddress: String?, paymentType: String?, isTaxFree: Boolean, promocode: String?, orderCash: Double?, orderBonuses: Double?, orderDelivery: Double?, orderDiscount: Double?, channel: String?, customJson: String?, recommendedSourceJson: String?, stream: String?, segment: String?, shopId: String?, callback: (Result) -> Unit) companion object { /** The codec used by PersonalizationHostApi. */ @@ -442,9 +449,11 @@ interface PersonalizationHostApi { run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getStoredPushToken$separatedMessageChannelSuffix", codec) if (api != null) { - channel.setMessageHandler { _, reply -> + channel.setMessageHandler { message, reply -> + val args = message as List + val shopIdArg = args[0] as String? val wrapped: List = try { - listOf(api.getStoredPushToken()) + listOf(api.getStoredPushToken(shopIdArg)) } catch (exception: Throwable) { PersonalizationApiPigeonUtils.wrapError(exception) } @@ -465,7 +474,8 @@ interface PersonalizationHostApi { val labelArg = args[3] as String? val valueArg = args[4] as Long? val customFieldsJsonArg = args[5] as String? - api.trackEvent(eventArg, timeArg, categoryArg, labelArg, valueArg, customFieldsJsonArg) { result: Result -> + val shopIdArg = args[6] as String? + api.trackEvent(eventArg, timeArg, categoryArg, labelArg, valueArg, customFieldsJsonArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -484,7 +494,8 @@ interface PersonalizationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val paramsArg = args[0] as ProfileParamsWire - api.setProfile(paramsArg) { result: Result -> + val shopIdArg = args[1] as String? + api.setProfile(paramsArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -504,7 +515,8 @@ interface PersonalizationHostApi { val args = message as List val codeArg = args[0] as String val paramsJsonArg = args[1] as String? - api.getRecommendation(codeArg, paramsJsonArg) { result: Result -> + val shopIdArg = args[2] as String? + api.getRecommendation(codeArg, paramsJsonArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -521,9 +533,11 @@ interface PersonalizationHostApi { run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid$separatedMessageChannelSuffix", codec) if (api != null) { - channel.setMessageHandler { _, reply -> + channel.setMessageHandler { message, reply -> + val args = message as List + val shopIdArg = args[0] as String? val wrapped: List = try { - listOf(api.getSid()) + listOf(api.getSid(shopIdArg)) } catch (exception: Throwable) { PersonalizationApiPigeonUtils.wrapError(exception) } @@ -536,9 +550,11 @@ interface PersonalizationHostApi { run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid$separatedMessageChannelSuffix", codec) if (api != null) { - channel.setMessageHandler { _, reply -> + channel.setMessageHandler { message, reply -> + val args = message as List + val shopIdArg = args[0] as String? val wrapped: List = try { - listOf(api.getDid()) + listOf(api.getDid(shopIdArg)) } catch (exception: Throwable) { PersonalizationApiPigeonUtils.wrapError(exception) } @@ -554,7 +570,8 @@ interface PersonalizationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val itemIdArg = args[0] as String - api.getProductInfo(itemIdArg) { result: Result -> + val shopIdArg = args[1] as String? + api.getProductInfo(itemIdArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -574,7 +591,8 @@ interface PersonalizationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val paramsJsonArg = args[0] as String? - api.getProductsList(paramsJsonArg) { result: Result -> + val shopIdArg = args[1] as String? + api.getProductsList(paramsJsonArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -591,8 +609,10 @@ interface PersonalizationHostApi { run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchBlank$separatedMessageChannelSuffix", codec) if (api != null) { - channel.setMessageHandler { _, reply -> - api.searchBlank{ result: Result -> + channel.setMessageHandler { message, reply -> + val args = message as List + val shopIdArg = args[0] as String? + api.searchBlank(shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -613,7 +633,8 @@ interface PersonalizationHostApi { val args = message as List val queryArg = args[0] as String val paramsJsonArg = args[1] as String? - api.searchInstant(queryArg, paramsJsonArg) { result: Result -> + val shopIdArg = args[2] as String? + api.searchInstant(queryArg, paramsJsonArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -634,7 +655,8 @@ interface PersonalizationHostApi { val args = message as List val queryArg = args[0] as String val paramsJsonArg = args[1] as String? - api.searchFull(queryArg, paramsJsonArg) { result: Result -> + val shopIdArg = args[2] as String? + api.searchFull(queryArg, paramsJsonArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -657,7 +679,8 @@ interface PersonalizationHostApi { val emailArg = args[1] as String? val firstNameArg = args[2] as String? val lastNameArg = args[3] as String? - api.joinLoyalty(phoneArg, emailArg, firstNameArg, lastNameArg) { result: Result -> + val shopIdArg = args[4] as String? + api.joinLoyalty(phoneArg, emailArg, firstNameArg, lastNameArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -677,7 +700,8 @@ interface PersonalizationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val identifierArg = args[0] as String - api.getLoyaltyStatus(identifierArg) { result: Result -> + val shopIdArg = args[1] as String? + api.getLoyaltyStatus(identifierArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -694,8 +718,10 @@ interface PersonalizationHostApi { run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile$separatedMessageChannelSuffix", codec) if (api != null) { - channel.setMessageHandler { _, reply -> - api.getProfile{ result: Result -> + channel.setMessageHandler { message, reply -> + val args = message as List + val shopIdArg = args[0] as String? + api.getProfile(shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -715,7 +741,8 @@ interface PersonalizationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val itemArg = args[0] as String - api.getProductCounters(itemArg) { result: Result -> + val shopIdArg = args[1] as String? + api.getProductCounters(itemArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -737,7 +764,8 @@ interface PersonalizationHostApi { val categoryArg = args[0] as String val limitArg = args[1] as Long? val pageArg = args[2] as Long? - api.getCategory(categoryArg, limitArg, pageArg) { result: Result -> + val shopIdArg = args[3] as String? + api.getCategory(categoryArg, limitArg, pageArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -757,7 +785,8 @@ interface PersonalizationHostApi { channel.setMessageHandler { message, reply -> val args = message as List val collectionIdArg = args[0] as String - api.getCollection(collectionIdArg) { result: Result -> + val shopIdArg = args[1] as String? + api.getCollection(collectionIdArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -771,6 +800,26 @@ interface PersonalizationHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.handlePush$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val payloadArg = args[0] as Map + val eventArg = args[1] as Long + api.handlePush(payloadArg, eventArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) + } else { + reply.reply(PersonalizationApiPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase$separatedMessageChannelSuffix", codec) if (api != null) { @@ -793,7 +842,8 @@ interface PersonalizationHostApi { val recommendedSourceJsonArg = args[14] as String? val streamArg = args[15] as String? val segmentArg = args[16] as String? - api.trackPurchase(orderIdArg, orderPriceArg, itemsArg, deliveryTypeArg, deliveryAddressArg, paymentTypeArg, isTaxFreeArg, promocodeArg, orderCashArg, orderBonusesArg, orderDeliveryArg, orderDiscountArg, channelArg, customJsonArg, recommendedSourceJsonArg, streamArg, segmentArg) { result: Result -> + val shopIdArg = args[17] as String? + api.trackPurchase(orderIdArg, orderPriceArg, itemsArg, deliveryTypeArg, deliveryAddressArg, paymentTypeArg, isTaxFreeArg, promocodeArg, orderCashArg, orderBonusesArg, orderDeliveryArg, orderDiscountArg, channelArg, customJsonArg, recommendedSourceJsonArg, streamArg, segmentArg, shopIdArg) { result: Result -> val error = result.exceptionOrNull() if (error != null) { reply.reply(PersonalizationApiPigeonUtils.wrapError(error)) @@ -817,12 +867,12 @@ class PersonalizationFlutterApi(private val binaryMessenger: BinaryMessenger, pr PersonalizationApiPigeonCodec() } } - fun onPushReceived(payloadArg: Map, callback: (Result) -> Unit) + fun onPushReceived(shopIdArg: String?, payloadArg: Map, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(payloadArg)) { + channel.send(listOf(shopIdArg, payloadArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -834,12 +884,12 @@ class PersonalizationFlutterApi(private val binaryMessenger: BinaryMessenger, pr } } } - fun onPushDelivered(payloadArg: Map, callback: (Result) -> Unit) + fun onPushDelivered(shopIdArg: String?, payloadArg: Map, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(payloadArg)) { + channel.send(listOf(shopIdArg, payloadArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) @@ -851,12 +901,12 @@ class PersonalizationFlutterApi(private val binaryMessenger: BinaryMessenger, pr } } } - fun onPushClicked(payloadArg: Map, callback: (Result) -> Unit) + fun onPushClicked(shopIdArg: String?, payloadArg: Map, callback: (Result) -> Unit) { val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" val channelName = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked$separatedMessageChannelSuffix" val channel = BasicMessageChannel(binaryMessenger, channelName, codec) - channel.send(listOf(payloadArg)) { + channel.send(listOf(shopIdArg, payloadArg)) { if (it is List<*>) { if (it.size > 1) { callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt index 411d0c2..1dd595a 100644 --- a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46PushInitProvider.kt @@ -5,22 +5,23 @@ import android.content.ContentValues import android.database.Cursor import android.net.Uri import android.util.Log -import com.personalization.SDK +import com.personalization.Rees46 /** - * Installs the push display listener at process start — including the cold process FCM spins up - * just to deliver a push when the app has been swiped away and no Flutter engine is running. + * Bootstraps push handling at process start — including the cold process FCM spins up just to + * deliver a push when the app has been swiped away and no Flutter engine is running. * - * A [ContentProvider.onCreate] runs before `Application.onCreate` and before the SDK's - * messaging services, the same auto-initialization trick `FirebaseInitProvider` uses. We only - * attach an [com.personalization.OnMessageListener] to the SDK singleton (no full - * [SDK.initialize] — display needs none): when the message arrives, the SDK routes it to this - * listener and the heads-up BigPicture is posted. Tracking the "received" event needs an - * initialized SDK and is skipped in this cold path; the click is still tracked once the user taps - * and the app starts and initializes. + * A [ContentProvider.onCreate] runs before `Application.onCreate` and before the SDK's messaging + * services, the same auto-initialization trick `FirebaseInitProvider` uses. On a cold start Dart + * never runs, so nothing has registered the shops — and `Rees46.handlePush` (called by the SDK's + * `MessagingService`) would resolve no shop and drop the push. So here we: + * 1. re-register every shop initialized in a previous run (persisted in [Rees46ShopStore]), + * lazily — [Rees46.handlePush] brings a pending shop up just enough to display and track; + * 2. attach the shop-aware [com.personalization.OnShopMessageListener] via the facade, so a + * routed push posts the heads-up BigPicture. * * On a normal launch this listener is replaced by the plugin's full listener (which also forwards - * the push to Dart) when Dart calls `initialize()` on the same singleton. + * the push to Dart) when Dart calls `initialize()`. */ class Rees46PushInitProvider : ContentProvider() { @@ -28,12 +29,25 @@ class Rees46PushInitProvider : ContentProvider() { val context = context?.applicationContext ?: return false try { Rees46PushNotifier.ensureChannel(context) - SDK.instance.setOnMessageListener { data -> - Log.d(Rees46PushNotifier.TAG, "onMessage (provider listener) id=${data.id}") + + // Re-register shops from a previous run so the cold-start registry is non-empty and + // Rees46.handlePush can resolve the push instead of dropping it. + val shops = Rees46ShopStore.read(context) + if (shops.isNotEmpty()) { + Rees46.registerShops(context = context, configs = shops, eagerInit = false) + } + + // Shop-aware display listener via the facade, matching the running-app path: the SDK + // routes each push to its shop and fires this to post the notification. + Rees46.setOnMessageListener { shopId, data -> + Log.d(Rees46PushNotifier.TAG, "onMessage (provider listener) shop=$shopId id=${data.id}") // Off the main thread: show() downloads the image synchronously. Thread { Rees46PushNotifier.show(context, data) }.start() } - Log.d(Rees46PushNotifier.TAG, "provider installed cold-start push listener") + Log.d( + Rees46PushNotifier.TAG, + "provider installed cold-start push listener (${shops.size} shop(s) re-registered)", + ) } catch (t: Throwable) { // Never let push bootstrap crash the host process at startup. Log.e(Rees46PushNotifier.TAG, "provider failed to install push listener", t) diff --git a/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46ShopStore.kt b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46ShopStore.kt new file mode 100644 index 0000000..72e31ac --- /dev/null +++ b/android/src/main/kotlin/com/rees46/rees46_flutter_sdk/push/Rees46ShopStore.kt @@ -0,0 +1,68 @@ +package com.rees46.rees46_flutter_sdk.push + +import android.content.Context +import com.personalization.Rees46Config +import org.json.JSONArray +import org.json.JSONObject + +/** + * Persists the configs of shops initialized from Dart, so the cold-start + * [Rees46PushInitProvider] can re-register them in a process the Flutter engine never ran in — + * the cold process FCM spins up just to deliver a push after the app was swiped away. + * + * Without this the registry is empty on a cold start, so `Rees46.handlePush` (called by the SDK's + * `MessagingService`) resolves no shop and drops the push — no notification appears. On Android + * the app draws the notification itself (REES46 pushes are data messages), so it must have the + * shop registered. iOS needs none of this: its pushes are system-drawn APNs alert payloads, so a + * registered token delivers regardless of whether the app runs. + * + * Upsert-only by shopId; not cleared (a shop that stops receiving simply stops being pushed to). + */ +internal object Rees46ShopStore { + + private const val PREFS = "rees46_flutter_push_shops" + private const val KEY_CONFIGS = "configs" + + /** Records [config] (upsert by shopId) so it survives to the next cold process start. */ + fun save(context: Context, config: Rees46Config) { + val byId = read(context).associateByTo(LinkedHashMap()) { it.shopId } + byId[config.shopId] = config + val array = JSONArray() + for (c in byId.values) { + array.put( + JSONObject() + .put("shopId", c.shopId) + .put("apiDomain", c.apiDomain) + .put("stream", c.stream) + .put("autoSendPushToken", c.autoSendPushToken) + .put("needReInitialization", c.needReInitialization), + ) + } + prefs(context).edit().putString(KEY_CONFIGS, array.toString()).apply() + } + + /** All persisted shop configs, or empty if none / unreadable. */ + fun read(context: Context): List { + val raw = prefs(context).getString(KEY_CONFIGS, null) ?: return emptyList() + return try { + val array = JSONArray(raw) + (0 until array.length()).mapNotNull { i -> + val obj = array.optJSONObject(i) ?: return@mapNotNull null + val shopId = obj.optString("shopId").takeIf { it.isNotBlank() } + ?: return@mapNotNull null + Rees46Config( + shopId = shopId, + apiDomain = obj.optString("apiDomain", "api.rees46.ru"), + stream = obj.optString("stream", "android"), + autoSendPushToken = obj.optBoolean("autoSendPushToken", true), + needReInitialization = obj.optBoolean("needReInitialization", false), + ) + } + } catch (t: Throwable) { + emptyList() + } + } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/example/integration_test/multi_instance_test.dart b/example/integration_test/multi_instance_test.dart new file mode 100644 index 0000000..e329a0e --- /dev/null +++ b/example/integration_test/multi_instance_test.dart @@ -0,0 +1,106 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:patrol/patrol.dart'; + +import 'package:rees46_sdk_example/main.dart' as app; + +import 'patrol_setup.dart'; + +/// On-device E2E for the multi-instance screen — mirror of the native +/// `MultiInstanceE2ETest` (Android) / `multi-instance.e2e.js` (RN). Opening the +/// screen makes shop A (eager) and shop B (lazy → materialized) both live, so the +/// fail-fast contracts and `Rees46.handlePush` routing run with two real shops in +/// one process. Runs on an emulator/simulator like the other `*_sdk_test.dart`. +/// +/// Reads the deterministic result labels the screen exposes (`mi-contract-result` +/// / `mi-push-result`) rather than parsing the scrolling log. +void main() { + String? textByKey(String key) { + final elements = find.byKey(Key(key)).evaluate(); + if (elements.isEmpty) return null; + return (elements.single.widget as Text).data; + } + + Future openMultiInstance(PatrolIntegrationTester $) async { + await $.pumpWidgetAndSettle(const app.App()); + await dismissStartupPermissionDialog($); + await $('REES46 SDK init demo').waitUntilVisible(); + await $(const Key('open-multi-instance')).tap(); + await $('Two shops, one app').waitUntilVisible(); + } + + patrolTest('multi-instance screen opens without crashing', ($) async { + await openMultiInstance($); + await $('Shop A').waitUntilVisible(); + await $('Shop B').waitUntilVisible(); + }); + + patrolTest('getInstance() with two live shops is ambiguous', ($) async { + await openMultiInstance($); + + await $('getInstance() → Ambiguous').scrollTo(); + await $('getInstance() → Ambiguous').tap(); + + await pumpUntil( + $, + () => + textByKey('mi-contract-result')?.contains('AmbiguousShopException') ?? + false, + ); + expect(textByKey('mi-contract-result'), contains('AmbiguousShopException')); + }); + + patrolTest('getInstance("nope") is an unknown shop', ($) async { + await openMultiInstance($); + + await $('getInstance("nope") → Unknown').scrollTo(); + await $('getInstance("nope") → Unknown').tap(); + + await pumpUntil( + $, + () => + textByKey('mi-contract-result')?.contains('UnknownShopIdException') ?? + false, + ); + expect(textByKey('mi-contract-result'), contains('UnknownShopIdException')); + }); + + patrolTest('push shop_id=A routes to a shop', ($) async { + await openMultiInstance($); + + await $('push shop_id=A').scrollTo(); + await $('push shop_id=A').tap(); + + await pumpUntil( + $, + () => textByKey('mi-push-result')?.contains('routed:') ?? false, + ); + expect(textByKey('mi-push-result'), contains('routed:')); + }); + + patrolTest('push shop_id=unknown is dropped', ($) async { + await openMultiInstance($); + + await $('push shop_id=unknown').scrollTo(); + await $('push shop_id=unknown').tap(); + + await pumpUntil( + $, + () => textByKey('mi-push-result')?.contains('dropped') ?? false, + ); + expect(textByKey('mi-push-result'), contains('dropped')); + }); + + patrolTest('push with no shop_id is dropped (two shops live)', ($) async { + await openMultiInstance($); + + await $('push (no shop_id)').scrollTo(); + await $('push (no shop_id)').tap(); + + await pumpUntil( + $, + () => textByKey('mi-push-result')?.contains('dropped') ?? false, + ); + expect(textByKey('mi-push-result'), contains('dropped')); + }); +} diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 8d3fb30..c6eca3c 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -5,10 +5,10 @@ PODS: - CocoaAsyncSocket (~> 7.6) - Flutter - FlutterMacOS - - REES46 (3.28.0) + - REES46 (3.30.0) - rees46_sdk (0.0.1): - Flutter - - REES46 (= 3.28.0) + - REES46 (= 3.30.0) DEPENDENCIES: - Flutter (from `Flutter`) @@ -32,8 +32,8 @@ SPEC CHECKSUMS: CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 patrol: cea8074f183a2a4232d0ebd10569ae05149ada42 - REES46: ecba4cbbfd060636541beedff390564561a1a562 - rees46_sdk: 58ed3f7511bca74a71702a41f7d37f757869837b + REES46: e99e94c2ae031e62b6dd62c81a0d0db37659a48c + rees46_sdk: 5677cdb063f6ed6f1fd7cfafe3cd209a47313c7c PODFILE CHECKSUM: b6e248ac4c1eff5807c8b044b39b8bc326af3f5f diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 72e3478..246e0e7 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ AA11BB22CC33DD4400EEFF01 /* RunnerUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD4400EEFF00 /* RunnerUITests.m */; }; B632BB76B3DE033E33C3AF7C /* Pods_Runner_RunnerUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 96DA490D0CB17C99E25655CC /* Pods_Runner_RunnerUITests.framework */; }; D1AEE439B64D48FFC231B7FD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 101FDAA560BF8F521BAB8014 /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -78,6 +79,7 @@ AA11BB22CC33DD4400EEFF07 /* Pods-RunnerUITests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerUITests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerUITests/Pods-RunnerUITests.profile.xcconfig"; sourceTree = ""; }; C8A20CF582848CCD4E47E3D6 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; CF01641D7F1716455460D103 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +87,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, A7B83A61A5E2A2634D4B68DD /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -119,6 +122,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -227,6 +231,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -270,6 +277,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -884,6 +894,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 4df9df3..041c1ba 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + runApp(const App()); class App extends StatelessWidget { @@ -30,7 +32,13 @@ class InitPage extends StatefulWidget { enum InitState { idle, initializing, initialized, failed } class _InitPageState extends State { - final _sdk = PersonalizationSdk(); + // Initialized through the multi-instance [Rees46] facade — the same entry + // point the iOS/Android demos use — so shop A is registered in the facade and + // the Multi-instance screen reaches the very same instance via + // Rees46.getInstance(shopId). Bound to shop A explicitly, so every call stays + // unambiguous even after that screen brings a second shop to life. Assigned in + // [_initialize] (rebuilt on re-initialize). + late PersonalizationSdk _sdk; // Platform channel used to ask the host for the Android 13+ notification // permission. See [_requestNotificationPermission]. @@ -142,15 +150,10 @@ class _InitPageState extends State { @override void initState() { super.initState(); - _sdk.setPushNotificationCallbacks( - onReceived: (payload) { - // In the demo we only surface init/token state; push payloads can be added later. - }, - onDelivered: (payload) {}, - onClicked: (payload) {}, - ); // Auto-initialize on startup — the demo uses hardcoded config, so no manual - // step is needed. The button below only re-initializes (e.g. after toggling flags). + // step is needed. `_initialize` assigns `_sdk` (synchronously, before its + // first await) and wires the push callbacks. The button below only + // re-initializes (e.g. after toggling flags). _initialize(); // Ask for the notification permission after the first frame. Triggering it from // Dart (rather than MainActivity.onCreate) guarantees it runs after Patrol's @@ -178,8 +181,13 @@ class _InitPageState extends State { }); try { - await _sdk.initialize( - SdkInitConfig( + // Initialize shop A through the multi-instance facade — the unified entry + // point, same as iOS/Android. Returns the handle and registers the shop, + // so the Multi-instance screen resolves the very same instance via + // Rees46.getInstance(shopId). Re-initializing rebuilds the handle with the + // current toggles. + _sdk = Rees46.initialize( + Rees46Config( shopId: _shopId, apiDomain: _apiDomain, stream: _stream, @@ -190,6 +198,14 @@ class _InitPageState extends State { needReInitialization: _needReInitialization, ), ); + _sdk.setPushNotificationCallbacks( + onReceived: (payload) { + // In the demo we only surface init/token state; push payloads can be + // added later. + }, + onDelivered: (payload) {}, + onClicked: (payload) {}, + ); setState(() { _initState = InitState.initialized; _lastInitAt = DateTime.now(); @@ -583,7 +599,21 @@ class _InitPageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('REES46 SDK init demo')), + appBar: AppBar( + title: const Text('REES46 SDK init demo'), + actions: [ + IconButton( + key: const Key('open-multi-instance'), + tooltip: 'Multi-instance (two shops)', + icon: const Icon(Icons.storefront), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const MultiInstancePane(), + ), + ), + ), + ], + ), // A SingleChildScrollView + Column (rather than a lazy ListView) so every // card and input field is always built and findable by integration tests, // even when off-screen. A ListView only builds children near the viewport, @@ -599,6 +629,17 @@ class _InitPageState extends State { lastInitAt: _lastInitAt, ), const SizedBox(height: 12), + FilledButton.tonalIcon( + key: const Key('open-multi-instance-button'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const MultiInstancePane(), + ), + ), + icon: const Icon(Icons.storefront), + label: const Text('Open multi-instance demo (two shops)'), + ), + const SizedBox(height: 12), _PushTokenCard( token: _storedPushToken, updatedAt: _tokenUpdatedAt, diff --git a/example/lib/multi_instance_pane.dart b/example/lib/multi_instance_pane.dart new file mode 100644 index 0000000..f5e9663 --- /dev/null +++ b/example/lib/multi_instance_pane.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:rees46_sdk/rees46_sdk.dart'; + +/// "Multi-instance" screen — two shops living in one app at the same time. +/// +/// Mirror of the native demos (Android `MultiInstancePane`, iOS +/// `MultiInstanceViewController`, RN `MultiInstancePane`). Shop A is the eager +/// default; shop B is registered lazily and comes to life the moment this screen +/// resolves it. Everything each instance sends carries its own `shop_id`/`did`, +/// so the two session cards (did/sid) are the in-app proof of isolation. Also +/// exercises the fail-fast resolution contract and `Rees46.handlePush` routing. +/// +/// (The native demos also show per-shop Stories; the Flutter SDK exposes no +/// stories widget, so that part is omitted here.) +class MultiInstancePane extends StatefulWidget { + const MultiInstancePane({super.key}); + + static const shopIdA = 'c1140c8254976de297c3caf971701a'; + static const shopIdB = '4b464e7c386120d4b621bf7cb79293'; + + @override + State createState() => _MultiInstancePaneState(); +} + +class _MultiInstancePaneState extends State { + static const _maxLog = 20; + + final List _log = []; + late final PersonalizationSdk _shopA; + late final PersonalizationSdk _shopB; + + _Session _sessionA = const _Session.notReady(); + _Session _sessionB = const _Session.notReady(); + + // Deterministic last-result labels for integration tests (mirror of the native + // demos' `mi-contract-result` / `mi-push-result`). + String? _lastContractResult; + String? _lastPushResult; + + @override + void initState() { + super.initState(); + + // Shop A — eager default. + _shopA = Rees46.isInitialized(MultiInstancePane.shopIdA) + ? Rees46.getInstance(MultiInstancePane.shopIdA) + : Rees46.initialize( + const Rees46Config(shopId: MultiInstancePane.shopIdA), + ); + + // Shop B — registered lazily, then materialized right here by resolving it. + if (!Rees46.isInitialized(MultiInstancePane.shopIdB) && + !Rees46.pendingShopIds.contains(MultiInstancePane.shopIdB)) { + Rees46.registerShops(const [ + Rees46Config(shopId: MultiInstancePane.shopIdB), + ]); + } + _shopB = Rees46.getInstance(MultiInstancePane.shopIdB); // B is born here + + _shopA.setPushNotificationCallbacks( + onReceived: (p) => _addLog('✓ shop A onReceived: ${_pushLabel(p)}'), + ); + _shopB.setPushNotificationCallbacks( + onReceived: (p) => _addLog('✓ shop B onReceived: ${_pushLabel(p)}'), + ); + + _refresh(); + } + + void _addLog(String message) { + setState(() { + _log.insert(0, message); + if (_log.length > _maxLog) _log.removeLast(); + }); + } + + Future _tryString(Future Function() call) async { + try { + return await call(); + } catch (_) { + return null; + } + } + + Future _refresh() async { + final a = _Session( + did: await _tryString(_shopA.getDid), + sid: await _tryString(() => _shopA.getSid()), + ); + final b = _Session( + did: await _tryString(_shopB.getDid), + sid: await _tryString(() => _shopB.getSid()), + ); + if (!mounted) return; + setState(() { + _sessionA = a; + _sessionB = b; + }); + _addLog('refreshed sessions'); + } + + /// Runs a `getInstance` call and turns the outcome — a value, or the fail-fast + /// exception — into a log line. + void _runContract(String label, void Function() call) { + String result; + try { + call(); + result = 'unexpected: returned without throwing'; + } on AmbiguousShopException catch (e) { + result = 'AmbiguousShopException ${e.registeredShopIds}'; + } on UnknownShopIdException catch (e) { + result = 'UnknownShopIdException (${e.shopId})'; + } catch (e) { + result = '${e.runtimeType}'; + } + setState(() => _lastContractResult = result); + _addLog('$label → $result'); + } + + Future _injectPush(String? shopId, String note) async { + final payload = { + 'shop_id': ?shopId, + 'type': 'bulk', + 'id': 'mi-demo', + 'title': note, + 'body': note, + }; + final routed = await Rees46.handlePush(payload, PushEvent.received); + final result = routed != null ? 'routed:$routed' : 'dropped'; + setState(() => _lastPushResult = result); + _addLog('injected shop_id=${shopId ?? '—'} → $result'); + } + + static String _pushLabel(Map p) => + p['title'] ?? p['type'] ?? '—'; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Multi-instance')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'Two shops, one app', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + const SizedBox(height: 4), + const Text( + 'Shop A is the eager default; shop B is lazy and materialized on ' + 'open. Each session below carries its own shop_id/did/sid.', + style: TextStyle(fontSize: 13), + ), + const SizedBox(height: 12), + + _SessionCard( + title: 'Shop A', + shopId: MultiInstancePane.shopIdA, + session: _sessionA, + ), + const SizedBox(height: 8), + _SessionCard( + title: 'Shop B', + shopId: MultiInstancePane.shopIdB, + session: _sessionB, + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: _refresh, + child: const Text('Refresh sessions'), + ), + + const SizedBox(height: 16), + const Text( + 'Fail-fast contracts', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: () => + _runContract('getInstance()', () => Rees46.getInstance()), + child: const Text('getInstance() → Ambiguous'), + ), + ElevatedButton( + onPressed: () => _runContract( + 'getInstance("nope")', + () => Rees46.getInstance('nope'), + ), + child: const Text('getInstance("nope") → Unknown'), + ), + ], + ), + Text( + 'result: ${_lastContractResult ?? '—'}', + key: const Key('mi-contract-result'), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + + const SizedBox(height: 16), + const Text( + 'Push routing (Rees46.handlePush)', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Wrap( + spacing: 8, + children: [ + ElevatedButton( + onPressed: () => + _injectPush(MultiInstancePane.shopIdA, 'Shop A push'), + child: const Text('push shop_id=A'), + ), + ElevatedButton( + onPressed: () => + _injectPush(MultiInstancePane.shopIdB, 'Shop B push'), + child: const Text('push shop_id=B'), + ), + ElevatedButton( + onPressed: () => + _injectPush('zzz-unknown-shop', 'Unknown shop'), + child: const Text('push shop_id=unknown'), + ), + ElevatedButton( + onPressed: () => _injectPush(null, 'No shop'), + child: const Text('push (no shop_id)'), + ), + ], + ), + Text( + 'result: ${_lastPushResult ?? '—'}', + key: const Key('mi-push-result'), + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + + const SizedBox(height: 16), + const Text('Log', style: TextStyle(fontWeight: FontWeight.bold)), + if (_log.isEmpty) + const Text( + 'Interact above — routed/dropped decisions and callbacks appear here.', + style: TextStyle(fontSize: 12), + ) + else + ..._log.map( + (e) => Text( + e, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ), + ], + ), + ); + } +} + +class _Session { + const _Session({this.did, this.sid}); + const _Session.notReady() : did = null, sid = null; + + final String? did; + final String? sid; + + bool get ready => (did?.isNotEmpty ?? false) || (sid?.isNotEmpty ?? false); +} + +class _SessionCard extends StatelessWidget { + const _SessionCard({ + required this.title, + required this.shopId, + required this.session, + }); + + final String title; + final String shopId; + final _Session session; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text( + 'shop_id=$shopId', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + const SizedBox(height: 4), + Text( + session.ready + ? 'did=${session.did?.isNotEmpty == true ? session.did : '—'} ' + 'sid=${session.sid?.isNotEmpty == true ? session.sid : '—'}' + : 'not initialized yet — tap Refresh after init settles', + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + ], + ), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 6906544..2449395 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -256,7 +256,7 @@ packages: path: ".." relative: true source: path - version: "0.0.3" + version: "0.1.1" shelf: dependency: transitive description: diff --git a/ios/Classes/Rees46FlutterSdkPlugin.swift b/ios/Classes/Rees46FlutterSdkPlugin.swift index 8cfcbbd..9682ff5 100644 --- a/ios/Classes/Rees46FlutterSdkPlugin.swift +++ b/ios/Classes/Rees46FlutterSdkPlugin.swift @@ -42,7 +42,10 @@ public class Rees46FlutterSdkPlugin: NSObject, FlutterPlugin, FlutterApplication didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) -> Bool { - flutterApi?.onPushReceived(payload: Self._stringPayload(userInfo)) { _ in } + flutterApi?.onPushReceived( + shopId: Self._shopId(userInfo), + payload: Self._stringPayload(userInfo) + ) { _ in } Rees46FlutterSdkPlugin.notificationService? .didReceiveRemoteNotifications(application, didReceiveRemoteNotification: userInfo) { result, _ in @@ -59,9 +62,10 @@ extension Rees46FlutterSdkPlugin: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { - var payload = Self._stringPayload(response.notification.request.content.userInfo) + let userInfo = response.notification.request.content.userInfo + var payload = Self._stringPayload(userInfo) payload["actionIdentifier"] = response.actionIdentifier - flutterApi?.onPushClicked(payload: payload) { _ in } + flutterApi?.onPushClicked(shopId: Self._shopId(userInfo), payload: payload) { _ in } completionHandler() } @@ -70,7 +74,10 @@ extension Rees46FlutterSdkPlugin: UNUserNotificationCenterDelegate { willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - flutterApi?.onPushDelivered(payload: Self._stringPayload(notification.request.content.userInfo)) { _ in } + flutterApi?.onPushDelivered( + shopId: Self._shopId(notification.request.content.userInfo), + payload: Self._stringPayload(notification.request.content.userInfo) + ) { _ in } if #available(iOS 14.0, *) { completionHandler([.badge, .sound, .banner, .list]) } else { @@ -80,6 +87,13 @@ extension Rees46FlutterSdkPlugin: UNUserNotificationCenterDelegate { } extension Rees46FlutterSdkPlugin { + /// The shop the push is addressed to — its `shop_id`, resolved by the Dart + /// dispatcher to the matching handle's callbacks (nil falls back to the single + /// default). + fileprivate static func _shopId(_ userInfo: [AnyHashable: Any]) -> String? { + return userInfo["shop_id"] as? String + } + fileprivate static func _stringPayload(_ userInfo: [AnyHashable: Any]) -> [String: String?] { var result: [String: String?] = [:] for (keyAny, value) in userInfo { @@ -99,7 +113,17 @@ extension Rees46FlutterSdkPlugin { } final class PersonalizationHostApiImpl: PersonalizationHostApi { - func getStoredPushToken() throws -> String? { + /// Resolves the SDK instance a call targets via the multi-instance `Rees46` + /// facade. `shopId == nil` resolves the single default instance; an unknown or + /// (with no id) ambiguous shop throws, which `try?` turns into `nil` — the + /// caller then reports `not_initialized`. This is the F3 wiring; requires the + /// native `Rees46` facade (local `ios-sdk` via Podfile `:path`, or a pod + /// version that ships it). + private func sdk(_ shopId: String?) -> PersonalizationSDK? { + return try? Rees46.instance(for: shopId) + } + + func getStoredPushToken(shopId: String?) throws -> String? { guard let deviceToken = UserDefaults.standard.data(forKey: Rees46FlutterSdkPlugin.pushTokenKey) else { return nil } @@ -113,16 +137,19 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { return } - let sdk = createPersonalizationSDK( - shopId: config.shopId, - apiDomain: config.apiDomain, - stream: config.stream, - enableLogs: config.enableLogs, - autoSendPushToken: config.autoSendPushToken, - sendAdvertisingId: config.sendAdvertisingId, - parentViewController: nil, - enableAutoPopupPresentation: config.enableAutoPopupPresentation, - needReInitialization: config.needReInitialization + // F3: initialize (and register) the instance through the multi-instance + // `Rees46` facade so it is reachable by `shopId` via `Rees46.instance(for:)`. + let sdk = Rees46.initialize( + Rees46Config( + shopId: config.shopId, + apiDomain: config.apiDomain, + stream: config.stream, + enableLogs: config.enableLogs, + autoSendPushToken: config.autoSendPushToken, + sendAdvertisingId: config.sendAdvertisingId, + enableAutoPopupPresentation: config.enableAutoPopupPresentation, + needReInitialization: config.needReInitialization + ) ) { error in if let error = error { completion(.failure(PigeonError(code: "init_failed", message: String(describing: error), details: nil))) @@ -131,6 +158,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } + // Kept for the AppDelegate push path (device token / remote notification), + // which still uses the last-initialized instance until F4 routes by shop. Rees46FlutterSdkPlugin.sdk = sdk // Create notification service to receive AppDelegate callbacks (device token, remote notification). @@ -145,8 +174,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { return "iOS " + UIDevice.current.systemVersion } - func getRecommendation(code: String, paramsJson: String?, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getRecommendation(code: String, paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -222,8 +251,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { return dict } - func getProductInfo(itemId: String, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getProductInfo(itemId: String, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -247,8 +276,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } - func getProductsList(paramsJson: String?, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getProductsList(paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -323,8 +352,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { ] } - func searchBlank(completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func searchBlank(shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -351,8 +380,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { ] } - func searchInstant(query: String, paramsJson: String?, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func searchInstant(query: String, paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -403,8 +432,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } - func searchFull(query: String, paramsJson: String?, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func searchFull(query: String, paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -521,9 +550,10 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { email: String?, firstName: String?, lastName: String?, + shopId: String?, completion: @escaping (Result) -> Void ) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -543,8 +573,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } - func getLoyaltyStatus(identifier: String, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getLoyaltyStatus(identifier: String, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -573,8 +603,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } - func getProfile(completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getProfile(shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -588,8 +618,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } - func getProductCounters(item: String, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getProductCounters(item: String, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -607,8 +637,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } - func getCategory(category: String, limit: Int64?, page: Int64?, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getCategory(category: String, limit: Int64?, page: Int64?, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -633,8 +663,8 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } - func getCollection(collectionId: String, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getCollection(collectionId: String, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -749,19 +779,19 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { completion(.success(json)) } - func getSid() throws -> String { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func getSid(shopId: String?) throws -> String { + guard let sdk = sdk(shopId) else { throw PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil) } return sdk.userSeance } - func getDid() throws -> String? { - return Rees46FlutterSdkPlugin.sdk?.deviceId + func getDid(shopId: String?) throws -> String? { + return sdk(shopId)?.deviceId } - func setProfile(params: ProfileParamsWire, completion: @escaping (Result) -> Void) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + func setProfile(params: ProfileParamsWire, shopId: String?, completion: @escaping (Result) -> Void) { + guard let sdk = sdk(shopId) else { completion(.failure(PigeonError(code: "not_initialized", message: "SDK is not initialized", details: nil))) return } @@ -821,9 +851,10 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { label: String?, value: Int64?, customFieldsJson: String?, + shopId: String?, completion: @escaping (Result) -> Void ) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + guard let sdk = sdk(shopId) else { completion( .failure( PigeonError( @@ -880,9 +911,10 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { recommendedSourceJson: String?, stream: String?, segment: String?, + shopId: String?, completion: @escaping (Result) -> Void ) { - guard let sdk = Rees46FlutterSdkPlugin.sdk else { + guard let sdk = sdk(shopId) else { completion( .failure( PigeonError( @@ -945,6 +977,18 @@ final class PersonalizationHostApiImpl: PersonalizationHostApi { } } + func handlePush(payload: [String: String], event: Int64, completion: @escaping (Result) -> Void) { + // Flutter PushEvent index: 0=received, 1=delivered, 2=clicked. + let pushEvent: PushEvent + switch event { + case 1: pushEvent = .delivered + case 2: pushEvent = .clicked + default: pushEvent = .received + } + Rees46.handlePush(payload as [AnyHashable: Any], event: pushEvent) + completion(.success(())) + } + private func parseJsonObject(_ json: String?) -> [String: Any]? { guard let json, !json.isEmpty, let data = json.data(using: .utf8) else { return nil } let obj = try? JSONSerialization.jsonObject(with: data) diff --git a/ios/Classes/pigeon/PersonalizationApi.g.swift b/ios/Classes/pigeon/PersonalizationApi.g.swift index c5119e8..34c43b3 100644 --- a/ios/Classes/pigeon/PersonalizationApi.g.swift +++ b/ios/Classes/pigeon/PersonalizationApi.g.swift @@ -390,62 +390,67 @@ protocol PersonalizationHostApi { func initialize(config: InitConfig, completion: @escaping (Result) -> Void) func getPlatformVersion() throws -> String /// Returns the push token stored by the native SDK (if any). - func getStoredPushToken() throws -> String? + func getStoredPushToken(shopId: String?) throws -> String? /// [customFieldsJson] is JSON object string or null (maps to native custom fields map). - func trackEvent(event: String, time: Int64?, category: String?, label: String?, value: Int64?, customFieldsJson: String?, completion: @escaping (Result) -> Void) - func setProfile(params: ProfileParamsWire, completion: @escaping (Result) -> Void) + func trackEvent(event: String, time: Int64?, category: String?, label: String?, value: Int64?, customFieldsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) + func setProfile(params: ProfileParamsWire, shopId: String?, completion: @escaping (Result) -> Void) /// Returns the recommendation block as a JSON string. /// [paramsJson] is a JSON object string with optional filter parameters. /// Dart layer parses the result into [RecommendationResponse]. - func getRecommendation(code: String, paramsJson: String?, completion: @escaping (Result) -> Void) + func getRecommendation(code: String, paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) /// Returns the current session ID from the native SDK. - func getSid() throws -> String + func getSid(shopId: String?) throws -> String /// Returns the device ID assigned by the native SDK, or null before first sync. - func getDid() throws -> String? + func getDid(shopId: String?) throws -> String? /// Returns a single product's details as a JSON string. /// Dart layer parses the result into [Product]. - func getProductInfo(itemId: String, completion: @escaping (Result) -> Void) + func getProductInfo(itemId: String, shopId: String?, completion: @escaping (Result) -> Void) /// Returns a paginated product catalog list as a JSON string. /// [paramsJson] is a JSON object with optional filter fields. /// Dart layer parses the result into [ProductsListResponse]. - func getProductsList(paramsJson: String?, completion: @escaping (Result) -> Void) + func getProductsList(paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) /// Returns blank search results (trending/popular) as a JSON string. /// No parameters — the native SDK decides what to return based on shop config. /// Dart layer parses the result into [SearchBlankResponse]. - func searchBlank(completion: @escaping (Result) -> Void) + func searchBlank(shopId: String?, completion: @escaping (Result) -> Void) /// Returns instant (typeahead) search results as a JSON string. /// [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). /// Dart layer parses the result into [SearchInstantResponse]. - func searchInstant(query: String, paramsJson: String?, completion: @escaping (Result) -> Void) + func searchInstant(query: String, paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) /// Returns full search results as a JSON string. /// [paramsJson] is a JSON object string with optional search parameters. /// Dart layer parses the result into [SearchFullResponse]. - func searchFull(query: String, paramsJson: String?, completion: @escaping (Result) -> Void) + func searchFull(query: String, paramsJson: String?, shopId: String?, completion: @escaping (Result) -> Void) /// Joins the loyalty program (`loyalty/members/join`) and returns the /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. /// Dart layer parses the result into [LoyaltyJoinResponse]. - func joinLoyalty(phone: String, email: String?, firstName: String?, lastName: String?, completion: @escaping (Result) -> Void) + func joinLoyalty(phone: String, email: String?, firstName: String?, lastName: String?, shopId: String?, completion: @escaping (Result) -> Void) /// Returns the loyalty membership status (`loyalty/members/status`) as a JSON /// string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. /// [identifier] is the member identifier (phone). /// Dart layer parses the result into [LoyaltyStatusResponse]. - func getLoyaltyStatus(identifier: String, completion: @escaping (Result) -> Void) + func getLoyaltyStatus(identifier: String, shopId: String?, completion: @escaping (Result) -> Void) /// Returns the current user's profile as a JSON string. /// Dart layer parses the result into [ProfileResponse]. - func getProfile(completion: @escaping (Result) -> Void) + func getProfile(shopId: String?, completion: @escaping (Result) -> Void) /// Returns view / cart / purchase counters for [item] as a JSON string. /// Dart layer parses the result into [ProductCountersResponse]. - func getProductCounters(item: String, completion: @escaping (Result) -> Void) + func getProductCounters(item: String, shopId: String?, completion: @escaping (Result) -> Void) /// Returns a category product listing as a JSON string. /// [limit] and [page] paginate the result; both are optional. /// Dart layer parses the result into [CategoryResponse]. - func getCategory(category: String, limit: Int64?, page: Int64?, completion: @escaping (Result) -> Void) + func getCategory(category: String, limit: Int64?, page: Int64?, shopId: String?, completion: @escaping (Result) -> Void) /// Returns a merchandised collection's products as a JSON string. /// Dart layer parses the result into [CollectionResponse]. - func getCollection(collectionId: String, completion: @escaping (Result) -> Void) + func getCollection(collectionId: String, shopId: String?, completion: @escaping (Result) -> Void) + /// Routes a push to the shop it belongs to (payload `shop_id`) and tracks it + /// via the native `Rees46.handlePush`. [event] is the index of the Dart + /// `PushEvent` enum: 0 = received, 1 = delivered, 2 = clicked. The native side + /// maps it to its own vocabulary (Android `PushEventType`, iOS `PushEvent`). + func handlePush(payload: [String: String], event: Int64, completion: @escaping (Result) -> Void) /// [customJson] and [recommendedSourceJson] are JSON object strings or null. - func trackPurchase(orderId: String, orderPrice: Double, items: [PurchaseLineItemWire], deliveryType: String?, deliveryAddress: String?, paymentType: String?, isTaxFree: Bool, promocode: String?, orderCash: Double?, orderBonuses: Double?, orderDelivery: Double?, orderDiscount: Double?, channel: String?, customJson: String?, recommendedSourceJson: String?, stream: String?, segment: String?, completion: @escaping (Result) -> Void) + func trackPurchase(orderId: String, orderPrice: Double, items: [PurchaseLineItemWire], deliveryType: String?, deliveryAddress: String?, paymentType: String?, isTaxFree: Bool, promocode: String?, orderCash: Double?, orderBonuses: Double?, orderDelivery: Double?, orderDiscount: Double?, channel: String?, customJson: String?, recommendedSourceJson: String?, stream: String?, segment: String?, shopId: String?, completion: @escaping (Result) -> Void) } /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. @@ -487,9 +492,11 @@ class PersonalizationHostApiSetup { /// Returns the push token stored by the native SDK (if any). let getStoredPushTokenChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getStoredPushToken\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - getStoredPushTokenChannel.setMessageHandler { _, reply in + getStoredPushTokenChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let shopIdArg: String? = nilOrValue(args[0]) do { - let result = try api.getStoredPushToken() + let result = try api.getStoredPushToken(shopId: shopIdArg) reply(wrapResult(result)) } catch { reply(wrapError(error)) @@ -509,7 +516,8 @@ class PersonalizationHostApiSetup { let labelArg: String? = nilOrValue(args[3]) let valueArg: Int64? = nilOrValue(args[4]) let customFieldsJsonArg: String? = nilOrValue(args[5]) - api.trackEvent(event: eventArg, time: timeArg, category: categoryArg, label: labelArg, value: valueArg, customFieldsJson: customFieldsJsonArg) { result in + let shopIdArg: String? = nilOrValue(args[6]) + api.trackEvent(event: eventArg, time: timeArg, category: categoryArg, label: labelArg, value: valueArg, customFieldsJson: customFieldsJsonArg, shopId: shopIdArg) { result in switch result { case .success: reply(wrapResult(nil)) @@ -526,7 +534,8 @@ class PersonalizationHostApiSetup { setProfileChannel.setMessageHandler { message, reply in let args = message as! [Any?] let paramsArg = args[0] as! ProfileParamsWire - api.setProfile(params: paramsArg) { result in + let shopIdArg: String? = nilOrValue(args[1]) + api.setProfile(params: paramsArg, shopId: shopIdArg) { result in switch result { case .success: reply(wrapResult(nil)) @@ -547,7 +556,8 @@ class PersonalizationHostApiSetup { let args = message as! [Any?] let codeArg = args[0] as! String let paramsJsonArg: String? = nilOrValue(args[1]) - api.getRecommendation(code: codeArg, paramsJson: paramsJsonArg) { result in + let shopIdArg: String? = nilOrValue(args[2]) + api.getRecommendation(code: codeArg, paramsJson: paramsJsonArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -562,9 +572,11 @@ class PersonalizationHostApiSetup { /// Returns the current session ID from the native SDK. let getSidChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - getSidChannel.setMessageHandler { _, reply in + getSidChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let shopIdArg: String? = nilOrValue(args[0]) do { - let result = try api.getSid() + let result = try api.getSid(shopId: shopIdArg) reply(wrapResult(result)) } catch { reply(wrapError(error)) @@ -576,9 +588,11 @@ class PersonalizationHostApiSetup { /// Returns the device ID assigned by the native SDK, or null before first sync. let getDidChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - getDidChannel.setMessageHandler { _, reply in + getDidChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let shopIdArg: String? = nilOrValue(args[0]) do { - let result = try api.getDid() + let result = try api.getDid(shopId: shopIdArg) reply(wrapResult(result)) } catch { reply(wrapError(error)) @@ -594,7 +608,8 @@ class PersonalizationHostApiSetup { getProductInfoChannel.setMessageHandler { message, reply in let args = message as! [Any?] let itemIdArg = args[0] as! String - api.getProductInfo(itemId: itemIdArg) { result in + let shopIdArg: String? = nilOrValue(args[1]) + api.getProductInfo(itemId: itemIdArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -614,7 +629,8 @@ class PersonalizationHostApiSetup { getProductsListChannel.setMessageHandler { message, reply in let args = message as! [Any?] let paramsJsonArg: String? = nilOrValue(args[0]) - api.getProductsList(paramsJson: paramsJsonArg) { result in + let shopIdArg: String? = nilOrValue(args[1]) + api.getProductsList(paramsJson: paramsJsonArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -631,8 +647,10 @@ class PersonalizationHostApiSetup { /// Dart layer parses the result into [SearchBlankResponse]. let searchBlankChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchBlank\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - searchBlankChannel.setMessageHandler { _, reply in - api.searchBlank { result in + searchBlankChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let shopIdArg: String? = nilOrValue(args[0]) + api.searchBlank(shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -653,7 +671,8 @@ class PersonalizationHostApiSetup { let args = message as! [Any?] let queryArg = args[0] as! String let paramsJsonArg: String? = nilOrValue(args[1]) - api.searchInstant(query: queryArg, paramsJson: paramsJsonArg) { result in + let shopIdArg: String? = nilOrValue(args[2]) + api.searchInstant(query: queryArg, paramsJson: paramsJsonArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -674,7 +693,8 @@ class PersonalizationHostApiSetup { let args = message as! [Any?] let queryArg = args[0] as! String let paramsJsonArg: String? = nilOrValue(args[1]) - api.searchFull(query: queryArg, paramsJson: paramsJsonArg) { result in + let shopIdArg: String? = nilOrValue(args[2]) + api.searchFull(query: queryArg, paramsJson: paramsJsonArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -698,7 +718,8 @@ class PersonalizationHostApiSetup { let emailArg: String? = nilOrValue(args[1]) let firstNameArg: String? = nilOrValue(args[2]) let lastNameArg: String? = nilOrValue(args[3]) - api.joinLoyalty(phone: phoneArg, email: emailArg, firstName: firstNameArg, lastName: lastNameArg) { result in + let shopIdArg: String? = nilOrValue(args[4]) + api.joinLoyalty(phone: phoneArg, email: emailArg, firstName: firstNameArg, lastName: lastNameArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -719,7 +740,8 @@ class PersonalizationHostApiSetup { getLoyaltyStatusChannel.setMessageHandler { message, reply in let args = message as! [Any?] let identifierArg = args[0] as! String - api.getLoyaltyStatus(identifier: identifierArg) { result in + let shopIdArg: String? = nilOrValue(args[1]) + api.getLoyaltyStatus(identifier: identifierArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -735,8 +757,10 @@ class PersonalizationHostApiSetup { /// Dart layer parses the result into [ProfileResponse]. let getProfileChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { - getProfileChannel.setMessageHandler { _, reply in - api.getProfile { result in + getProfileChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let shopIdArg: String? = nilOrValue(args[0]) + api.getProfile(shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -755,7 +779,8 @@ class PersonalizationHostApiSetup { getProductCountersChannel.setMessageHandler { message, reply in let args = message as! [Any?] let itemArg = args[0] as! String - api.getProductCounters(item: itemArg) { result in + let shopIdArg: String? = nilOrValue(args[1]) + api.getProductCounters(item: itemArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -777,7 +802,8 @@ class PersonalizationHostApiSetup { let categoryArg = args[0] as! String let limitArg: Int64? = nilOrValue(args[1]) let pageArg: Int64? = nilOrValue(args[2]) - api.getCategory(category: categoryArg, limit: limitArg, page: pageArg) { result in + let shopIdArg: String? = nilOrValue(args[3]) + api.getCategory(category: categoryArg, limit: limitArg, page: pageArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -796,7 +822,8 @@ class PersonalizationHostApiSetup { getCollectionChannel.setMessageHandler { message, reply in let args = message as! [Any?] let collectionIdArg = args[0] as! String - api.getCollection(collectionId: collectionIdArg) { result in + let shopIdArg: String? = nilOrValue(args[1]) + api.getCollection(collectionId: collectionIdArg, shopId: shopIdArg) { result in switch result { case .success(let res): reply(wrapResult(res)) @@ -808,6 +835,28 @@ class PersonalizationHostApiSetup { } else { getCollectionChannel.setMessageHandler(nil) } + /// Routes a push to the shop it belongs to (payload `shop_id`) and tracks it + /// via the native `Rees46.handlePush`. [event] is the index of the Dart + /// `PushEvent` enum: 0 = received, 1 = delivered, 2 = clicked. The native side + /// maps it to its own vocabulary (Android `PushEventType`, iOS `PushEvent`). + let handlePushChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.handlePush\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + handlePushChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let payloadArg = args[0] as! [String: String] + let eventArg = args[1] as! Int64 + api.handlePush(payload: payloadArg, event: eventArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + handlePushChannel.setMessageHandler(nil) + } /// [customJson] and [recommendedSourceJson] are JSON object strings or null. let trackPurchaseChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) if let api = api { @@ -830,7 +879,8 @@ class PersonalizationHostApiSetup { let recommendedSourceJsonArg: String? = nilOrValue(args[14]) let streamArg: String? = nilOrValue(args[15]) let segmentArg: String? = nilOrValue(args[16]) - api.trackPurchase(orderId: orderIdArg, orderPrice: orderPriceArg, items: itemsArg, deliveryType: deliveryTypeArg, deliveryAddress: deliveryAddressArg, paymentType: paymentTypeArg, isTaxFree: isTaxFreeArg, promocode: promocodeArg, orderCash: orderCashArg, orderBonuses: orderBonusesArg, orderDelivery: orderDeliveryArg, orderDiscount: orderDiscountArg, channel: channelArg, customJson: customJsonArg, recommendedSourceJson: recommendedSourceJsonArg, stream: streamArg, segment: segmentArg) { result in + let shopIdArg: String? = nilOrValue(args[17]) + api.trackPurchase(orderId: orderIdArg, orderPrice: orderPriceArg, items: itemsArg, deliveryType: deliveryTypeArg, deliveryAddress: deliveryAddressArg, paymentType: paymentTypeArg, isTaxFree: isTaxFreeArg, promocode: promocodeArg, orderCash: orderCashArg, orderBonuses: orderBonusesArg, orderDelivery: orderDeliveryArg, orderDiscount: orderDiscountArg, channel: channelArg, customJson: customJsonArg, recommendedSourceJson: recommendedSourceJsonArg, stream: streamArg, segment: segmentArg, shopId: shopIdArg) { result in switch result { case .success: reply(wrapResult(nil)) @@ -846,9 +896,9 @@ class PersonalizationHostApiSetup { } /// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. protocol PersonalizationFlutterApiProtocol { - func onPushReceived(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) - func onPushDelivered(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) - func onPushClicked(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) + func onPushReceived(shopId shopIdArg: String?, payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) + func onPushDelivered(shopId shopIdArg: String?, payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) + func onPushClicked(shopId shopIdArg: String?, payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) } class PersonalizationFlutterApi: PersonalizationFlutterApiProtocol { private let binaryMessenger: FlutterBinaryMessenger @@ -860,10 +910,10 @@ class PersonalizationFlutterApi: PersonalizationFlutterApiProtocol { var codec: PersonalizationApiPigeonCodec { return PersonalizationApiPigeonCodec.shared } - func onPushReceived(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { + func onPushReceived(shopId shopIdArg: String?, payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { let channelName: String = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage([payloadArg] as [Any?]) { response in + channel.sendMessage([shopIdArg, payloadArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { completion(.failure(createConnectionError(withChannelName: channelName))) return @@ -878,10 +928,10 @@ class PersonalizationFlutterApi: PersonalizationFlutterApiProtocol { } } } - func onPushDelivered(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { + func onPushDelivered(shopId shopIdArg: String?, payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { let channelName: String = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage([payloadArg] as [Any?]) { response in + channel.sendMessage([shopIdArg, payloadArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { completion(.failure(createConnectionError(withChannelName: channelName))) return @@ -896,10 +946,10 @@ class PersonalizationFlutterApi: PersonalizationFlutterApiProtocol { } } } - func onPushClicked(payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { + func onPushClicked(shopId shopIdArg: String?, payload payloadArg: [String: String?], completion: @escaping (Result) -> Void) { let channelName: String = "dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked\(messageChannelSuffix)" let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) - channel.sendMessage([payloadArg] as [Any?]) { response in + channel.sendMessage([shopIdArg, payloadArg] as [Any?]) { response in guard let listResponse = response as? [Any?] else { completion(.failure(createConnectionError(withChannelName: channelName))) return diff --git a/ios/rees46_sdk.podspec b/ios/rees46_sdk.podspec index 93e15ba..1a75adf 100644 --- a/ios/rees46_sdk.podspec +++ b/ios/rees46_sdk.podspec @@ -14,7 +14,7 @@ Flutter plugin wrapper around REES46 native SDK. s.source = { :path => '.' } s.source_files = 'Classes/**/*' s.dependency 'Flutter' - s.dependency 'REES46', '3.29.0' + s.dependency 'REES46', '3.30.0' s.platform = :ios, '13.0' # Flutter.framework does not contain a i386 slice. diff --git a/lib/rees46_sdk.dart b/lib/rees46_sdk.dart index ef48577..38ba4e0 100644 --- a/lib/rees46_sdk.dart +++ b/lib/rees46_sdk.dart @@ -1,4 +1,8 @@ export 'src/personalization_sdk.dart'; +export 'src/multi_instance/rees46.dart'; +export 'src/multi_instance/rees46_config.dart'; +export 'src/multi_instance/push_event.dart'; +export 'src/multi_instance/sdk_exceptions.dart'; export 'src/loyalty/loyalty_response.dart'; export 'src/category/category_response.dart'; export 'src/collection/collection_response.dart'; diff --git a/lib/src/multi_instance/instance_resolver.dart b/lib/src/multi_instance/instance_resolver.dart new file mode 100644 index 0000000..7615deb --- /dev/null +++ b/lib/src/multi_instance/instance_resolver.dart @@ -0,0 +1,107 @@ +/// Pure decision logic behind [Rees46.getInstance]: given the requested `shopId` +/// (or none) and the sets of live and pending shops, decides which instance to +/// return, whether one must be lazily materialized, or which error to raise. +/// +/// A faithful port of the native `InstanceResolver` (Android/iOS) — the rules +/// must stay byte-for-byte identical across platforms, so this file intentionally +/// mirrors the Kotlin original line for line. Kept side-effect-free so the rules +/// can be tested without constructing or initializing an SDK. +library; + +/// Outcome of resolving a requested shop against the live and pending sets. +sealed class Resolution { + const Resolution(); +} + +/// An initialized instance exists for this shop — return it. +class ExistingResolution extends Resolution { + const ExistingResolution(this.shopId); + final String shopId; + + @override + bool operator ==(Object other) => + other is ExistingResolution && other.shopId == shopId; + + @override + int get hashCode => Object.hash(ExistingResolution, shopId); + + @override + String toString() => 'Existing($shopId)'; +} + +/// A registration exists but is not initialized yet — materialize it now. +class PendingResolution extends Resolution { + const PendingResolution(this.shopId); + final String shopId; + + @override + bool operator ==(Object other) => + other is PendingResolution && other.shopId == shopId; + + @override + int get hashCode => Object.hash(PendingResolution, shopId); + + @override + String toString() => 'Pending($shopId)'; +} + +/// No live instance and no registration matches — raise +/// [UnknownShopIdException]. +class NotRegisteredResolution extends Resolution { + const NotRegisteredResolution(); + + @override + bool operator ==(Object other) => other is NotRegisteredResolution; + + @override + int get hashCode => (NotRegisteredResolution).hashCode; + + @override + String toString() => 'NotRegistered'; +} + +/// No shopId given and more than one shop registered — raise +/// [AmbiguousShopException]. +class AmbiguousResolution extends Resolution { + const AmbiguousResolution(); + + @override + bool operator ==(Object other) => other is AmbiguousResolution; + + @override + int get hashCode => (AmbiguousResolution).hashCode; + + @override + String toString() => 'Ambiguous'; +} + +abstract final class InstanceResolver { + static Resolution resolve({ + required String? requestedShopId, + required Set liveShopIds, + required Set pendingShopIds, + }) { + if (requestedShopId != null) { + if (liveShopIds.contains(requestedShopId)) { + return ExistingResolution(requestedShopId); + } + if (pendingShopIds.contains(requestedShopId)) { + return PendingResolution(requestedShopId); + } + return const NotRegisteredResolution(); + } + + final allShopIds = {...liveShopIds, ...pendingShopIds}; + switch (allShopIds.length) { + case 0: + return const NotRegisteredResolution(); + case 1: + final only = allShopIds.first; + return liveShopIds.contains(only) + ? ExistingResolution(only) + : PendingResolution(only); + default: + return const AmbiguousResolution(); + } + } +} diff --git a/lib/src/multi_instance/push_dispatcher.dart b/lib/src/multi_instance/push_dispatcher.dart new file mode 100644 index 0000000..1337600 --- /dev/null +++ b/lib/src/multi_instance/push_dispatcher.dart @@ -0,0 +1,70 @@ +import 'package:flutter/foundation.dart'; + +import '../pigeon/personalization_api.g.dart' as pigeon; +import '../push/push_notification_callbacks.dart'; + +/// Process-global sink for inbound push callbacks from native. +/// +/// There is one Pigeon `PersonalizationFlutterApi` channel per plugin, so a +/// single dispatcher owns it (set up once) and routes each callback — which now +/// carries the `shopId` the push resolved to natively — to that shop's +/// registered [PushNotificationCallbacks]. Every [PersonalizationSdk] registers +/// its callbacks here on construction. +/// +/// Routing mirrors the push contract: a known `shopId` delivers to that shop; an +/// unknown one drops; a null `shopId` (legacy payload without `shop_id`) falls +/// back to the single registered target, and drops when several are registered. +class PushDispatcher implements pigeon.PersonalizationFlutterApi { + PushDispatcher._() { + pigeon.PersonalizationFlutterApi.setUp(this); + } + + static final PushDispatcher instance = PushDispatcher._(); + + final Map _byShop = + {}; + + /// The legacy default handle (`shopId == null`), if any. + PushNotificationCallbacks? _default; + + /// Registers [callbacks] as the sink for [shopId] (or the default when null). + void register(String? shopId, PushNotificationCallbacks callbacks) { + if (shopId != null) { + _byShop[shopId] = callbacks; + } else { + _default = callbacks; + } + } + + PushNotificationCallbacks? _resolve(String? shopId) { + if (shopId != null) { + return _byShop[shopId]; // unknown shop → drop + } + final targets = [..._byShop.values, ?_default]; + return targets.length == 1 + ? targets.first + : null; // several → ambiguous drop + } + + @override + void onPushReceived(String? shopId, Map payload) { + _resolve(shopId)?.onPushReceived(payload); + } + + @override + void onPushDelivered(String? shopId, Map payload) { + _resolve(shopId)?.onPushDelivered(payload); + } + + @override + void onPushClicked(String? shopId, Map payload) { + _resolve(shopId)?.onPushClicked(payload); + } + + /// Test-only: drops all registrations. + @visibleForTesting + void reset() { + _byShop.clear(); + _default = null; + } +} diff --git a/lib/src/multi_instance/push_event.dart b/lib/src/multi_instance/push_event.dart new file mode 100644 index 0000000..9e7f57e --- /dev/null +++ b/lib/src/multi_instance/push_event.dart @@ -0,0 +1,10 @@ +/// Push lifecycle event routed by [Rees46.handlePush]. +/// +/// The Flutter vocabulary — `received` / `delivered` / `clicked` — matches the +/// SDK's existing push callbacks. Native maps it to its own set: Android +/// `PushEventType` has no `delivered`, so both `delivered` and `received` track +/// `track/received`; iOS `PushEvent` keeps `delivered` as a distinct beacon. +/// +/// The declaration ORDER is a wire contract — the enum index is sent to native +/// through the Pigeon `handlePush`. Do not reorder. +enum PushEvent { received, delivered, clicked } diff --git a/lib/src/multi_instance/rees46.dart b/lib/src/multi_instance/rees46.dart new file mode 100644 index 0000000..1704e8c --- /dev/null +++ b/lib/src/multi_instance/rees46.dart @@ -0,0 +1,224 @@ +import 'package:flutter/foundation.dart'; + +import '../personalization_sdk.dart'; +import 'instance_resolver.dart'; +import 'push_event.dart'; +import 'rees46_config.dart'; +import 'sdk_exceptions.dart'; + +/// Builds and initializes a [PersonalizationSdk] handle for [config]. Injectable +/// so tests can resolve shops without touching native or Pigeon. +typedef Rees46SdkFactory = PersonalizationSdk Function(Rees46Config config); + +/// Public entry point for the Flutter SDK — the unified, multi-instance API. +/// +/// A host no longer keeps its own [PersonalizationSdk]: initialize (or register) +/// shops here and reach them by `shopId` through [getInstance]. One instance per +/// shop, each bound to a native instance with isolated storage and state. +/// +/// ```dart +/// // Single shop: +/// final sdk = Rees46.initialize(Rees46Config(shopId: 'SHOP_ID')); +/// sdk.trackEvent('category', ...); +/// +/// // Several shops, initialized lazily on first use: +/// Rees46.registerShops([Rees46Config(shopId: 'shop-a'), Rees46Config(shopId: 'shop-b')]); +/// Rees46.getInstance('shop-a').trackEvent('category', ...); +/// ``` +/// +/// ## Why the facade holds a shop-id mirror +/// +/// The Flutter SDK is a **thin bridge over the native Android/iOS SDKs**, where +/// the real registry, storage partitions, identity migration, push routing and +/// session isolation already live. This facade does **not** reimplement any of +/// that. It keeps a small Dart-side mirror of the *registered shop-ids* purely +/// to (a) resolve which shop a call targets and (b) raise the ambiguous/unknown +/// contract synchronously, identically to native. The native `SdkRegistry` +/// remains the source of truth for the instances themselves. +/// +/// Per-call routing to a specific native instance (threading `shopId` through +/// every Pigeon call) lands once the native `Rees46` facade ships in the +/// consumed artifacts — see the plan (`Multi-instance — Flutter Plan`, step F3). +/// Until then single-shop [initialize] is fully functional and the resolution +/// contract below is complete and tested. +class Rees46 { + Rees46._(); + + static final Rees46 _instance = Rees46._(); + + /// Live (initialized) instances by shop id. The Dart-side mirror of the + /// native registry — used for resolution only. + final Map _live = {}; + + /// Shops registered lazily and not yet initialized. Materialized on the first + /// [getInstance] for the shop. + final Map _pending = {}; + + Rees46SdkFactory _factory = _defaultFactory; + + static PersonalizationSdk _defaultFactory(Rees46Config config) { + final sdk = PersonalizationSdk(shopId: config.shopId); + // F1: delegates to the existing single-shop native init. Per-call `shopId` + // routing to the native `Rees46` facade lands in plan step F3. + sdk.initialize(config.toSdkInitConfig()); + return sdk; + } + + // --------------------------------------------------------------------------- + // Public API (static — delegates to the process-global instance) + // --------------------------------------------------------------------------- + + /// Initializes an SDK instance for [config] immediately and returns it. The + /// instance is registered, so it is also reachable via [getInstance]. Any + /// pending registration for the same shop is cleared. + static PersonalizationSdk initialize(Rees46Config config) => + _instance._initialize(config); + + /// Registers [configs] without initializing them. Initialization happens + /// lazily on the first [getInstance] for a shop — the region case, where only + /// the current region is needed. Pass [eagerInit] = true to initialize every + /// shop up front — the super-shop case, where instances must stay consistent. + static void registerShops( + List configs, { + bool eagerInit = false, + }) => _instance._registerShops(configs, eagerInit: eagerInit); + + /// Returns the SDK instance for [shopId], initializing a pending registration + /// on first use. With no [shopId], returns the single instance when exactly + /// one shop is registered. + /// + /// Throws [AmbiguousShopException] when [shopId] is null and more than one + /// shop is registered; [UnknownShopIdException] when the shop is unknown. + static PersonalizationSdk getInstance([String? shopId]) => + _instance._getInstance(shopId); + + /// True when an instance is available for [shopId] — or, with no [shopId], + /// when exactly one shop is initialized so the default is unambiguous. A + /// pending (registered-but-not-initialized) shop is not counted as initialized. + static bool isInitialized([String? shopId]) => + _instance._isInitialized(shopId); + + /// Routes a push to the shop it belongs to (the payload's `shop_id`) and tracks + /// [event] for it via the native `Rees46.handlePush`, then fires that shop's + /// registered push callbacks. Call this from a host that owns its messaging + /// service. + /// + /// Resolution mirrors [getInstance] (live wins over pending; a single + /// registered shop resolves with no `shop_id`) but **drops** instead of + /// throwing: an unknown shop, or an absent `shop_id` while several shops are + /// registered, is not delivered to the wrong one. A pending shop is + /// materialized so it has a live instance to track on. + /// + /// Returns the shop id the push routed to, or `null` if it was dropped. + static Future handlePush( + Map payload, + PushEvent event, + ) => _instance._handlePush(payload, event); + + /// Shops that are live (initialized), sorted. + static List get liveShopIds => _instance._live.keys.toList()..sort(); + + /// Shops registered lazily and not yet initialized, sorted. + static List get pendingShopIds => + _instance._pending.keys.toList()..sort(); + + // --------------------------------------------------------------------------- + // Instance implementation + // --------------------------------------------------------------------------- + + PersonalizationSdk _initialize(Rees46Config config) { + final sdk = _factory(config); + _live[config.shopId] = sdk; + _pending.remove(config.shopId); + return sdk; + } + + void _registerShops(List configs, {required bool eagerInit}) { + for (final config in configs) { + if (eagerInit) { + _initialize(config); + } else { + _pending[config.shopId] = config; + } + } + } + + PersonalizationSdk _getInstance(String? shopId) { + final resolution = InstanceResolver.resolve( + requestedShopId: shopId, + liveShopIds: _live.keys.toSet(), + pendingShopIds: _pending.keys.toSet(), + ); + return switch (resolution) { + ExistingResolution(:final shopId) => + _live[shopId] ?? (throw UnknownShopIdException(shopId)), + PendingResolution(:final shopId) => _materialize(shopId), + NotRegisteredResolution() => throw UnknownShopIdException(shopId), + AmbiguousResolution() => throw AmbiguousShopException( + _registeredShopIds(), + ), + }; + } + + bool _isInitialized(String? shopId) => + shopId != null ? _live.containsKey(shopId) : _live.length == 1; + + static const String _shopIdKey = 'shop_id'; + + Future _handlePush( + Map payload, + PushEvent event, + ) async { + final resolution = InstanceResolver.resolve( + requestedShopId: payload[_shopIdKey], + liveShopIds: _live.keys.toSet(), + pendingShopIds: _pending.keys.toSet(), + ); + final PersonalizationSdk sdk; + switch (resolution) { + case ExistingResolution(:final shopId): + sdk = _live[shopId]!; + case PendingResolution(:final shopId): + // Materialize so native has a live instance to track on. + sdk = _materialize(shopId); + case NotRegisteredResolution(): + case AmbiguousResolution(): + return null; // drop — unknown shop, or ambiguous (no shop_id, several live) + } + await sdk.handlePush(payload, event); + sdk.dispatchInboundPush(event, payload); + return sdk.shopId; + } + + /// Initializes a pending registration for [shopId]. If the registration is + /// gone (materialized by a concurrent caller), falls back to the live map. + PersonalizationSdk _materialize(String shopId) { + final config = _pending.remove(shopId); + if (config != null) { + return _initialize(config); + } + return _live[shopId] ?? (throw UnknownShopIdException(shopId)); + } + + List _registeredShopIds() => + {..._live.keys, ..._pending.keys}.toList()..sort(); + + // --------------------------------------------------------------------------- + // Test hooks + // --------------------------------------------------------------------------- + + /// Test-only: overrides the factory that builds/initializes instances so shop + /// resolution can be exercised without touching native or Pigeon. + @visibleForTesting + static set debugFactory(Rees46SdkFactory factory) => + _instance._factory = factory; + + /// Test-only: drops all live and pending registrations and restores the + /// default factory. + @visibleForTesting + static void reset() { + _instance._live.clear(); + _instance._pending.clear(); + _instance._factory = _defaultFactory; + } +} diff --git a/lib/src/multi_instance/rees46_config.dart b/lib/src/multi_instance/rees46_config.dart new file mode 100644 index 0000000..15ac466 --- /dev/null +++ b/lib/src/multi_instance/rees46_config.dart @@ -0,0 +1,63 @@ +import '../sdk_init_config.dart'; + +/// Configuration for one SDK instance (one shop), passed to +/// [Rees46.initialize] / [Rees46.registerShops]. +/// +/// Mirrors the native `Rees46Config` (Android/iOS). It is a superset of the +/// legacy [SdkInitConfig]: the same init fields plus an optional [storageKey] +/// for the storage-partition key (defaults to `shopId` natively). +/// +/// `storageKey` is **reserved for parity** and not wired end-to-end yet: the +/// Pigeon `InitConfig` has no `storageKey` field, so the native default +/// (partition == `shopId`) applies until the bridge threads it (plan step F2). +class Rees46Config { + const Rees46Config({ + required this.shopId, + this.apiDomain, + this.stream, + this.enableLogs, + this.autoSendPushToken, + this.sendAdvertisingId, + this.enableAutoPopupPresentation, + this.needReInitialization, + this.storageKey, + }); + + final String shopId; + final String? apiDomain; + final String? stream; + final bool? enableLogs; + final bool? autoSendPushToken; + final bool? sendAdvertisingId; + final bool? enableAutoPopupPresentation; + final bool? needReInitialization; + + /// Storage-partition key. Defaults to [shopId] natively. Reserved — see the + /// class doc. + final String? storageKey; + + /// Bridges to the legacy [SdkInitConfig] consumed by the current single-shop + /// init path. [storageKey] is intentionally dropped here (no wire field yet). + SdkInitConfig toSdkInitConfig() => SdkInitConfig( + shopId: shopId, + apiDomain: apiDomain, + stream: stream, + enableLogs: enableLogs, + autoSendPushToken: autoSendPushToken, + sendAdvertisingId: sendAdvertisingId, + enableAutoPopupPresentation: enableAutoPopupPresentation, + needReInitialization: needReInitialization, + ); + + Rees46Config copyWith({String? shopId, String? storageKey}) => Rees46Config( + shopId: shopId ?? this.shopId, + apiDomain: apiDomain, + stream: stream, + enableLogs: enableLogs, + autoSendPushToken: autoSendPushToken, + sendAdvertisingId: sendAdvertisingId, + enableAutoPopupPresentation: enableAutoPopupPresentation, + needReInitialization: needReInitialization, + storageKey: storageKey ?? this.storageKey, + ); +} diff --git a/lib/src/multi_instance/sdk_exceptions.dart b/lib/src/multi_instance/sdk_exceptions.dart new file mode 100644 index 0000000..86bf4d4 --- /dev/null +++ b/lib/src/multi_instance/sdk_exceptions.dart @@ -0,0 +1,55 @@ +/// Exceptions raised by [Rees46] shop resolution. +/// +/// They mirror the native contract so the same failure modes surface +/// identically on every platform (see `Multi-instance — Contracts`): +/// * Android — `UnknownShopIdException` / `AmbiguousShopException` +/// * iOS — `Rees46Error.unknownShopId` / `.ambiguousShop` +/// +/// The Flutter SDK is a thin bridge over the native SDKs, so these are raised by +/// the Dart facade's own resolver (a mirror of the registered shop-ids) — the +/// resolution rules are identical, only the throw site is in Dart. +library; + +/// Thrown when an instance is requested for a shop that is neither initialized +/// nor registered — nothing registered at all, or no such shop id. +class UnknownShopIdException implements Exception { + const UnknownShopIdException(this.shopId, [this.customMessage]); + + /// The requested shop id, or `null` when [Rees46.getInstance] was called with + /// no id while nothing at all is registered. + final String? shopId; + + /// Optional override for the default human-readable message. + final String? customMessage; + + String get message => + customMessage ?? + (shopId != null + ? 'No shop is registered for shopId=$shopId. ' + 'Call Rees46.initialize(...) or Rees46.registerShops(...) first.' + : 'No shop has been registered. ' + 'Call Rees46.initialize(...) or Rees46.registerShops(...) first.'); + + @override + String toString() => 'UnknownShopIdException: $message'; +} + +/// Thrown when [Rees46.getInstance] is called with no shop id while more than +/// one shop is registered — the default instance is ambiguous. +class AmbiguousShopException implements Exception { + const AmbiguousShopException(this.registeredShopIds, [this.customMessage]); + + /// The shops (live + pending) that made the default ambiguous, sorted. + final List registeredShopIds; + + /// Optional override for the default human-readable message. + final String? customMessage; + + String get message => + customMessage ?? + 'More than one shop is registered — call Rees46.getInstance(shopId) with ' + 'an explicit id. Registered: $registeredShopIds.'; + + @override + String toString() => 'AmbiguousShopException: $message'; +} diff --git a/lib/src/personalization_sdk.dart b/lib/src/personalization_sdk.dart index 402ddca..4870d96 100644 --- a/lib/src/personalization_sdk.dart +++ b/lib/src/personalization_sdk.dart @@ -5,6 +5,8 @@ import 'category/category_response.dart'; import 'collection/collection_response.dart'; import 'init/sdk_init_handler.dart'; import 'loyalty/loyalty_response.dart'; +import 'multi_instance/push_dispatcher.dart'; +import 'multi_instance/push_event.dart'; import 'products/product_counters_response.dart'; import 'profile/profile_params.dart'; import 'profile/profile_response.dart'; @@ -23,10 +25,19 @@ class PersonalizationSdk { final SdkInitHandler _initHandler; final PushNotificationCallbacks _pushCallbacks = PushNotificationCallbacks(); - PersonalizationSdk({pigeon.PersonalizationHostApi? api}) + /// The shop this handle is bound to, or `null` for the legacy default + /// instance. Set by [Rees46.initialize] / [Rees46.getInstance]. Reserved for + /// per-call routing once the native `Rees46` facade is wired (plan step F3); + /// stored now so multi-instance handles carry their identity. + final String? shopId; + + PersonalizationSdk({pigeon.PersonalizationHostApi? api, this.shopId}) : _api = api ?? pigeon.PersonalizationHostApi(), _initHandler = SdkInitHandler(api: api) { - pigeon.PersonalizationFlutterApi.setUp(_pushCallbacks); + // One process-global dispatcher owns the Pigeon push channel and routes each + // inbound push (by shopId) to the right handle — register this handle's + // callbacks with it instead of claiming the channel per instance. + PushDispatcher.instance.register(shopId, _pushCallbacks); } /// Registers optional listeners for push lifecycle events emitted by native code. @@ -47,7 +58,29 @@ class PersonalizationSdk { } Future getStoredPushToken() { - return _api.getStoredPushToken(); + return _api.getStoredPushToken(shopId); + } + + /// Routes [payload] to the native `Rees46.handlePush` for [event] (the entry a + /// host with its own messaging service calls). Prefer [Rees46.handlePush], + /// which resolves the target shop and drops unroutable pushes first. + Future handlePush(Map payload, PushEvent event) { + return _api.handlePush(payload, event.index); + } + + /// Fires this handle's registered push callbacks for [event]. Used by + /// [Rees46.handlePush] to deliver an inbound push to the shop it routed to, + /// independent of the process-global Pigeon push channel (real FCM inbound + /// routing by `shop_id` is FL-5). + void dispatchInboundPush(PushEvent event, Map payload) { + switch (event) { + case PushEvent.received: + _pushCallbacks.onPushReceived(payload); + case PushEvent.delivered: + _pushCallbacks.onPushDelivered(payload); + case PushEvent.clicked: + _pushCallbacks.onPushClicked(payload); + } } Future initialize(SdkInitConfig config) { @@ -55,36 +88,36 @@ class PersonalizationSdk { } Future setProfile(ProfileParams params) { - return _api.setProfile(params.toWire()); + return _api.setProfile(params.toWire(), shopId); } Future getSid() { - return _api.getSid(); + return _api.getSid(shopId); } Future getDid() { - return _api.getDid(); + return _api.getDid(shopId); } Future getProductInfo(String itemId) async { if (itemId.isEmpty) { throw ArgumentError.value(itemId, 'itemId', 'must be non-empty'); } - final json = await _api.getProductInfo(itemId); + final json = await _api.getProductInfo(itemId, shopId); return Product.fromJson(jsonDecode(json) as Map); } Future getProductsList({ ProductsListParams? params, }) async { - final json = await _api.getProductsList(params?.toJson()); + final json = await _api.getProductsList(params?.toJson(), shopId); return ProductsListResponse.fromJson( jsonDecode(json) as Map, ); } Future searchBlank() async { - final json = await _api.searchBlank(); + final json = await _api.searchBlank(shopId); return SearchBlankResponse.fromJson( jsonDecode(json) as Map, ); @@ -97,7 +130,7 @@ class PersonalizationSdk { if (query.isEmpty) { throw ArgumentError.value(query, 'query', 'must be non-empty'); } - final json = await _api.searchInstant(query, params?.toJson()); + final json = await _api.searchInstant(query, params?.toJson(), shopId); return SearchInstantResponse.fromJson( jsonDecode(json) as Map, ); @@ -110,7 +143,7 @@ class PersonalizationSdk { if (query.isEmpty) { throw ArgumentError.value(query, 'query', 'must be non-empty'); } - final json = await _api.searchFull(query, params?.toJson()); + final json = await _api.searchFull(query, params?.toJson(), shopId); return SearchFullResponse.fromJson( jsonDecode(json) as Map, ); @@ -129,7 +162,13 @@ class PersonalizationSdk { if (phone.isEmpty) { throw ArgumentError.value(phone, 'phone', 'must be non-empty'); } - final json = await _api.joinLoyalty(phone, email, firstName, lastName); + final json = await _api.joinLoyalty( + phone, + email, + firstName, + lastName, + shopId, + ); return LoyaltyJoinResponse.fromJson( jsonDecode(json) as Map, ); @@ -142,7 +181,7 @@ class PersonalizationSdk { if (identifier.isEmpty) { throw ArgumentError.value(identifier, 'identifier', 'must be non-empty'); } - final json = await _api.getLoyaltyStatus(identifier); + final json = await _api.getLoyaltyStatus(identifier, shopId); return LoyaltyStatusResponse.fromJson( jsonDecode(json) as Map, ); @@ -150,7 +189,7 @@ class PersonalizationSdk { /// Returns the current user's profile (native `ProfileManager.getProfile`). Future getProfile() async { - final json = await _api.getProfile(); + final json = await _api.getProfile(shopId); return ProfileResponse.fromJson(jsonDecode(json) as Map); } @@ -160,7 +199,7 @@ class PersonalizationSdk { if (item.isEmpty) { throw ArgumentError.value(item, 'item', 'must be non-empty'); } - final json = await _api.getProductCounters(item); + final json = await _api.getProductCounters(item, shopId); return ProductCountersResponse.fromJson( jsonDecode(json) as Map, ); @@ -177,7 +216,7 @@ class PersonalizationSdk { if (category.isEmpty) { throw ArgumentError.value(category, 'category', 'must be non-empty'); } - final json = await _api.getCategory(category, limit, page); + final json = await _api.getCategory(category, limit, page, shopId); return CategoryResponse.fromJson(jsonDecode(json) as Map); } @@ -191,7 +230,7 @@ class PersonalizationSdk { 'must be non-empty', ); } - final json = await _api.getCollection(collectionId); + final json = await _api.getCollection(collectionId, shopId); return CollectionResponse.fromJson( jsonDecode(json) as Map, ); @@ -204,7 +243,7 @@ class PersonalizationSdk { if (code.isEmpty) { throw ArgumentError.value(code, 'code', 'must be non-empty'); } - final json = await _api.getRecommendation(code, params?.toJson()); + final json = await _api.getRecommendation(code, params?.toJson(), shopId); return RecommendationResponse.fromJson( jsonDecode(json) as Map, ); @@ -232,6 +271,7 @@ class PersonalizationSdk { label, value, customFieldsJson, + shopId, ); } @@ -290,6 +330,7 @@ class PersonalizationSdk { recommendedSource == null ? null : jsonEncode(recommendedSource), stream, segment, + shopId, ); } } diff --git a/lib/src/pigeon/personalization_api.g.dart b/lib/src/pigeon/personalization_api.g.dart index 6d994a0..8bfb780 100644 --- a/lib/src/pigeon/personalization_api.g.dart +++ b/lib/src/pigeon/personalization_api.g.dart @@ -15,11 +15,7 @@ PlatformException _createConnectionError(String channelName) { ); } -List wrapResponse({ - Object? result, - PlatformException? error, - bool empty = false, -}) { +List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { if (empty) { return []; } @@ -28,25 +24,21 @@ List wrapResponse({ } return [error.code, error.message, error.details]; } - bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { return a.length == b.length && - a.indexed.every( - ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]), - ); + a.indexed + .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { - return a.length == b.length && - a.entries.every( - (MapEntry entry) => - (b as Map).containsKey(entry.key) && - _deepEquals(entry.value, b[entry.key]), - ); + return a.length == b.length && a.entries.every((MapEntry entry) => + (b as Map).containsKey(entry.key) && + _deepEquals(entry.value, b[entry.key])); } return a == b; } + class InitConfig { InitConfig({ required this.shopId, @@ -89,8 +81,7 @@ class InitConfig { } Object encode() { - return _toList(); - } + return _toList(); } static InitConfig decode(Object result) { result as List; @@ -120,7 +111,8 @@ class InitConfig { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } /// Wire format for one purchase line (maps to native `PurchaseItemRequest`). @@ -147,12 +139,17 @@ class PurchaseLineItemWire { String? fashionSize; List _toList() { - return [id, amount, price, lineId, fashionSize]; + return [ + id, + amount, + price, + lineId, + fashionSize, + ]; } Object encode() { - return _toList(); - } + return _toList(); } static PurchaseLineItemWire decode(Object result) { result as List; @@ -179,7 +176,8 @@ class PurchaseLineItemWire { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } /// Wire format for profile fields sent to native SDK. @@ -277,8 +275,7 @@ class ProfileParamsWire { } Object encode() { - return _toList(); - } + return _toList(); } static ProfileParamsWire decode(Object result) { result as List; @@ -320,9 +317,11 @@ class ProfileParamsWire { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes - int get hashCode => Object.hashAll(_toList()); + int get hashCode => Object.hashAll(_toList()) +; } + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -330,13 +329,13 @@ class _PigeonCodec extends StandardMessageCodec { if (value is int) { buffer.putUint8(4); buffer.putInt64(value); - } else if (value is InitConfig) { + } else if (value is InitConfig) { buffer.putUint8(129); writeValue(buffer, value.encode()); - } else if (value is PurchaseLineItemWire) { + } else if (value is PurchaseLineItemWire) { buffer.putUint8(130); writeValue(buffer, value.encode()); - } else if (value is ProfileParamsWire) { + } else if (value is ProfileParamsWire) { buffer.putUint8(131); writeValue(buffer, value.encode()); } else { @@ -347,11 +346,11 @@ class _PigeonCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { switch (type) { - case 129: + case 129: return InitConfig.decode(readValue(buffer)!); - case 130: + case 130: return PurchaseLineItemWire.decode(readValue(buffer)!); - case 131: + case 131: return ProfileParamsWire.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -363,13 +362,9 @@ class PersonalizationHostApi { /// Constructor for [PersonalizationHostApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. - PersonalizationHostApi({ - BinaryMessenger? binaryMessenger, - String messageChannelSuffix = '', - }) : pigeonVar_binaryMessenger = binaryMessenger, - pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty - ? '.$messageChannelSuffix' - : ''; + PersonalizationHostApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); @@ -377,17 +372,13 @@ class PersonalizationHostApi { final String pigeonVar_messageChannelSuffix; Future initialize(InitConfig config) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.initialize$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [config], + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.initialize$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([config]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -404,14 +395,12 @@ class PersonalizationHostApi { } Future getPlatformVersion() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getPlatformVersion$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getPlatformVersion$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; @@ -434,16 +423,14 @@ class PersonalizationHostApi { } /// Returns the push token stored by the native SDK (if any). - Future getStoredPushToken() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getStoredPushToken$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + Future getStoredPushToken(String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getStoredPushToken$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -460,25 +447,14 @@ class PersonalizationHostApi { } /// [customFieldsJson] is JSON object string or null (maps to native custom fields map). - Future trackEvent( - String event, - int? time, - String? category, - String? label, - int? value, - String? customFieldsJson, - ) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackEvent$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [event, time, category, label, value, customFieldsJson], + Future trackEvent(String event, int? time, String? category, String? label, int? value, String? customFieldsJson, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackEvent$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([event, time, category, label, value, customFieldsJson, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -494,18 +470,14 @@ class PersonalizationHostApi { } } - Future setProfile(ProfileParamsWire params) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.setProfile$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [params], + Future setProfile(ProfileParamsWire params, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.setProfile$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([params, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -524,18 +496,14 @@ class PersonalizationHostApi { /// Returns the recommendation block as a JSON string. /// [paramsJson] is a JSON object string with optional filter parameters. /// Dart layer parses the result into [RecommendationResponse]. - Future getRecommendation(String code, String? paramsJson) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getRecommendation$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [code, paramsJson], + Future getRecommendation(String code, String? paramsJson, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getRecommendation$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([code, paramsJson, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -557,16 +525,14 @@ class PersonalizationHostApi { } /// Returns the current session ID from the native SDK. - Future getSid() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + Future getSid(String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -588,16 +554,14 @@ class PersonalizationHostApi { } /// Returns the device ID assigned by the native SDK, or null before first sync. - Future getDid() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + Future getDid(String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getDid$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -615,18 +579,14 @@ class PersonalizationHostApi { /// Returns a single product's details as a JSON string. /// Dart layer parses the result into [Product]. - Future getProductInfo(String itemId) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductInfo$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [itemId], + Future getProductInfo(String itemId, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductInfo$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([itemId, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -650,18 +610,14 @@ class PersonalizationHostApi { /// Returns a paginated product catalog list as a JSON string. /// [paramsJson] is a JSON object with optional filter fields. /// Dart layer parses the result into [ProductsListResponse]. - Future getProductsList(String? paramsJson) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductsList$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [paramsJson], + Future getProductsList(String? paramsJson, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductsList$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([paramsJson, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -685,16 +641,14 @@ class PersonalizationHostApi { /// Returns blank search results (trending/popular) as a JSON string. /// No parameters — the native SDK decides what to return based on shop config. /// Dart layer parses the result into [SearchBlankResponse]. - Future searchBlank() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchBlank$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + Future searchBlank(String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchBlank$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -718,18 +672,14 @@ class PersonalizationHostApi { /// Returns instant (typeahead) search results as a JSON string. /// [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). /// Dart layer parses the result into [SearchInstantResponse]. - Future searchInstant(String query, String? paramsJson) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchInstant$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [query, paramsJson], + Future searchInstant(String query, String? paramsJson, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchInstant$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([query, paramsJson, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -753,18 +703,14 @@ class PersonalizationHostApi { /// Returns full search results as a JSON string. /// [paramsJson] is a JSON object string with optional search parameters. /// Dart layer parses the result into [SearchFullResponse]. - Future searchFull(String query, String? paramsJson) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [query, paramsJson], + Future searchFull(String query, String? paramsJson, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.searchFull$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([query, paramsJson, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -789,23 +735,14 @@ class PersonalizationHostApi { /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. /// The shop is identified by the SDK's configured `shop_id`; [phone] is required. /// Dart layer parses the result into [LoyaltyJoinResponse]. - Future joinLoyalty( - String phone, - String? email, - String? firstName, - String? lastName, - ) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [phone, email, firstName, lastName], + Future joinLoyalty(String phone, String? email, String? firstName, String? lastName, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.joinLoyalty$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([phone, email, firstName, lastName, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -830,18 +767,14 @@ class PersonalizationHostApi { /// string `{ "status": ..., "payload": { "member": ..., "level": { ... } } }`. /// [identifier] is the member identifier (phone). /// Dart layer parses the result into [LoyaltyStatusResponse]. - Future getLoyaltyStatus(String identifier) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [identifier], + Future getLoyaltyStatus(String identifier, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getLoyaltyStatus$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([identifier, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -864,16 +797,14 @@ class PersonalizationHostApi { /// Returns the current user's profile as a JSON string. /// Dart layer parses the result into [ProfileResponse]. - Future getProfile() async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + Future getProfile(String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProfile$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -896,18 +827,14 @@ class PersonalizationHostApi { /// Returns view / cart / purchase counters for [item] as a JSON string. /// Dart layer parses the result into [ProductCountersResponse]. - Future getProductCounters(String item) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductCounters$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [item], + Future getProductCounters(String item, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getProductCounters$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([item, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -931,18 +858,14 @@ class PersonalizationHostApi { /// Returns a category product listing as a JSON string. /// [limit] and [page] paginate the result; both are optional. /// Dart layer parses the result into [CategoryResponse]. - Future getCategory(String category, int? limit, int? page) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCategory$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [category, limit, page], + Future getCategory(String category, int? limit, int? page, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCategory$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([category, limit, page, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -965,18 +888,14 @@ class PersonalizationHostApi { /// Returns a merchandised collection's products as a JSON string. /// Dart layer parses the result into [CollectionResponse]. - Future getCollection(String collectionId) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCollection$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel.send( - [collectionId], + Future getCollection(String collectionId, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getCollection$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([collectionId, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -997,54 +916,42 @@ class PersonalizationHostApi { } } + /// Routes a push to the shop it belongs to (payload `shop_id`) and tracks it + /// via the native `Rees46.handlePush`. [event] is the index of the Dart + /// `PushEvent` enum: 0 = received, 1 = delivered, 2 = clicked. The native side + /// maps it to its own vocabulary (Android `PushEventType`, iOS `PushEvent`). + Future handlePush(Map payload, int event) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.handlePush$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([payload, event]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + /// [customJson] and [recommendedSourceJson] are JSON object strings or null. - Future trackPurchase( - String orderId, - double orderPrice, - List items, - String? deliveryType, - String? deliveryAddress, - String? paymentType, - bool isTaxFree, - String? promocode, - double? orderCash, - double? orderBonuses, - double? orderDelivery, - double? orderDiscount, - String? channel, - String? customJson, - String? recommendedSourceJson, - String? stream, - String? segment, - ) async { - final String pigeonVar_channelName = - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( - pigeonVar_channelName, - pigeonChannelCodec, - binaryMessenger: pigeonVar_binaryMessenger, - ); - final Future pigeonVar_sendFuture = pigeonVar_channel - .send([ - orderId, - orderPrice, - items, - deliveryType, - deliveryAddress, - paymentType, - isTaxFree, - promocode, - orderCash, - orderBonuses, - orderDelivery, - orderDiscount, - channel, - customJson, - recommendedSourceJson, - stream, - segment, - ]); + Future trackPurchase(String orderId, double orderPrice, List items, String? deliveryType, String? deliveryAddress, String? paymentType, bool isTaxFree, String? promocode, double? orderCash, double? orderBonuses, double? orderDelivery, double? orderDiscount, String? channel, String? customJson, String? recommendedSourceJson, String? stream, String? segment, String? shopId) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackPurchase$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([orderId, orderPrice, items, deliveryType, deliveryAddress, paymentType, isTaxFree, promocode, orderCash, orderBonuses, orderDelivery, orderDiscount, channel, customJson, recommendedSourceJson, stream, segment, shopId]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -1064,121 +971,88 @@ class PersonalizationHostApi { abstract class PersonalizationFlutterApi { static const MessageCodec pigeonChannelCodec = _PigeonCodec(); - void onPushReceived(Map payload); + void onPushReceived(String? shopId, Map payload); - void onPushDelivered(Map payload); + void onPushDelivered(String? shopId, Map payload); - void onPushClicked(Map payload); + void onPushClicked(String? shopId, Map payload); - static void setUp( - PersonalizationFlutterApi? api, { - BinaryMessenger? binaryMessenger, - String messageChannelSuffix = '', - }) { - messageChannelSuffix = messageChannelSuffix.isNotEmpty - ? '.$messageChannelSuffix' - : ''; + static void setUp(PersonalizationFlutterApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; { - final BasicMessageChannel - pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger, - ); + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { - assert( - message != null, - 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived was null.', - ); + assert(message != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived was null.'); final List args = (message as List?)!; - final Map? arg_payload = - (args[0] as Map?)?.cast(); - assert( - arg_payload != null, - 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived was null, expected non-null Map.', - ); + final String? arg_shopId = (args[0] as String?); + final Map? arg_payload = (args[1] as Map?)?.cast(); + assert(arg_payload != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushReceived was null, expected non-null Map.'); try { - api.onPushReceived(arg_payload!); + api.onPushReceived(arg_shopId, arg_payload!); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { - return wrapResponse( - error: PlatformException(code: 'error', message: e.toString()), - ); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); } } { - final BasicMessageChannel - pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger, - ); + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { - assert( - message != null, - 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered was null.', - ); + assert(message != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered was null.'); final List args = (message as List?)!; - final Map? arg_payload = - (args[0] as Map?)?.cast(); - assert( - arg_payload != null, - 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered was null, expected non-null Map.', - ); + final String? arg_shopId = (args[0] as String?); + final Map? arg_payload = (args[1] as Map?)?.cast(); + assert(arg_payload != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushDelivered was null, expected non-null Map.'); try { - api.onPushDelivered(arg_payload!); + api.onPushDelivered(arg_shopId, arg_payload!); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { - return wrapResponse( - error: PlatformException(code: 'error', message: e.toString()), - ); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); } } { - final BasicMessageChannel - pigeonVar_channel = BasicMessageChannel( - 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked$messageChannelSuffix', - pigeonChannelCodec, - binaryMessenger: binaryMessenger, - ); + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); if (api == null) { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { - assert( - message != null, - 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked was null.', - ); + assert(message != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked was null.'); final List args = (message as List?)!; - final Map? arg_payload = - (args[0] as Map?)?.cast(); - assert( - arg_payload != null, - 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked was null, expected non-null Map.', - ); + final String? arg_shopId = (args[0] as String?); + final Map? arg_payload = (args[1] as Map?)?.cast(); + assert(arg_payload != null, + 'Argument for dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationFlutterApi.onPushClicked was null, expected non-null Map.'); try { - api.onPushClicked(arg_payload!); + api.onPushClicked(arg_shopId, arg_payload!); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); - } catch (e) { - return wrapResponse( - error: PlatformException(code: 'error', message: e.toString()), - ); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); } }); } diff --git a/lib/src/push/push_notification_callbacks.dart b/lib/src/push/push_notification_callbacks.dart index 6e46114..a673eab 100644 --- a/lib/src/push/push_notification_callbacks.dart +++ b/lib/src/push/push_notification_callbacks.dart @@ -1,7 +1,9 @@ -import '../pigeon/personalization_api.g.dart' as pigeon; - -/// Holds optional Dart callbacks for push-related events coming from native code. -class PushNotificationCallbacks implements pigeon.PersonalizationFlutterApi { +/// Holds one [PersonalizationSdk] handle's optional push callbacks. +/// +/// No longer the Pigeon `PersonalizationFlutterApi` itself: with multi-instance, +/// a single process-global `PushDispatcher` implements that channel and routes +/// each inbound push (by `shopId`) to the matching handle's callbacks here. +class PushNotificationCallbacks { void Function(Map payload)? _onReceived; void Function(Map payload)? _onDelivered; void Function(Map payload)? _onClicked; @@ -16,17 +18,14 @@ class PushNotificationCallbacks implements pigeon.PersonalizationFlutterApi { _onClicked = onClicked; } - @override void onPushReceived(Map payload) { _onReceived?.call(Map.from(payload)); } - @override void onPushDelivered(Map payload) { _onDelivered?.call(Map.from(payload)); } - @override void onPushClicked(Map payload) { _onClicked?.call(Map.from(payload)); } diff --git a/pigeons/personalization_api.dart b/pigeons/personalization_api.dart index 7fe95aa..f479fb3 100644 --- a/pigeons/personalization_api.dart +++ b/pigeons/personalization_api.dart @@ -109,6 +109,11 @@ class ProfileParamsWire { }); } +// Multi-instance: every per-instance method carries a trailing `String? shopId` +// so the native bridge can resolve the target instance via the native `Rees46` +// facade. `shopId == null` means the legacy single/default instance (the +// back-compat fallback). `initialize` needs no extra param — its shop id is +// inside [InitConfig]; `getPlatformVersion` touches no instance. @HostApi() abstract class PersonalizationHostApi { @async @@ -117,7 +122,7 @@ abstract class PersonalizationHostApi { String getPlatformVersion(); /// Returns the push token stored by the native SDK (if any). - String? getStoredPushToken(); + String? getStoredPushToken(String? shopId); /// [customFieldsJson] is JSON object string or null (maps to native custom fields map). @async @@ -128,51 +133,52 @@ abstract class PersonalizationHostApi { String? label, int? value, String? customFieldsJson, + String? shopId, ); @async - void setProfile(ProfileParamsWire params); + void setProfile(ProfileParamsWire params, String? shopId); /// Returns the recommendation block as a JSON string. /// [paramsJson] is a JSON object string with optional filter parameters. /// Dart layer parses the result into [RecommendationResponse]. @async - String getRecommendation(String code, String? paramsJson); + String getRecommendation(String code, String? paramsJson, String? shopId); /// Returns the current session ID from the native SDK. - String getSid(); + String getSid(String? shopId); /// Returns the device ID assigned by the native SDK, or null before first sync. - String? getDid(); + String? getDid(String? shopId); /// Returns a single product's details as a JSON string. /// Dart layer parses the result into [Product]. @async - String getProductInfo(String itemId); + String getProductInfo(String itemId, String? shopId); /// Returns a paginated product catalog list as a JSON string. /// [paramsJson] is a JSON object with optional filter fields. /// Dart layer parses the result into [ProductsListResponse]. @async - String getProductsList(String? paramsJson); + String getProductsList(String? paramsJson, String? shopId); /// Returns blank search results (trending/popular) as a JSON string. /// No parameters — the native SDK decides what to return based on shop config. /// Dart layer parses the result into [SearchBlankResponse]. @async - String searchBlank(); + String searchBlank(String? shopId); /// Returns instant (typeahead) search results as a JSON string. /// [paramsJson] may contain optional "locations" (String) and "excluded_brands" ([String]). /// Dart layer parses the result into [SearchInstantResponse]. @async - String searchInstant(String query, String? paramsJson); + String searchInstant(String query, String? paramsJson, String? shopId); /// Returns full search results as a JSON string. /// [paramsJson] is a JSON object string with optional search parameters. /// Dart layer parses the result into [SearchFullResponse]. @async - String searchFull(String query, String? paramsJson); + String searchFull(String query, String? paramsJson, String? shopId); /// Joins the loyalty program (`loyalty/members/join`) and returns the /// response envelope as a JSON string `{ "status": ..., "payload": { ... } }`. @@ -184,6 +190,7 @@ abstract class PersonalizationHostApi { String? email, String? firstName, String? lastName, + String? shopId, ); /// Returns the loyalty membership status (`loyalty/members/status`) as a JSON @@ -191,28 +198,35 @@ abstract class PersonalizationHostApi { /// [identifier] is the member identifier (phone). /// Dart layer parses the result into [LoyaltyStatusResponse]. @async - String getLoyaltyStatus(String identifier); + String getLoyaltyStatus(String identifier, String? shopId); /// Returns the current user's profile as a JSON string. /// Dart layer parses the result into [ProfileResponse]. @async - String getProfile(); + String getProfile(String? shopId); /// Returns view / cart / purchase counters for [item] as a JSON string. /// Dart layer parses the result into [ProductCountersResponse]. @async - String getProductCounters(String item); + String getProductCounters(String item, String? shopId); /// Returns a category product listing as a JSON string. /// [limit] and [page] paginate the result; both are optional. /// Dart layer parses the result into [CategoryResponse]. @async - String getCategory(String category, int? limit, int? page); + String getCategory(String category, int? limit, int? page, String? shopId); /// Returns a merchandised collection's products as a JSON string. /// Dart layer parses the result into [CollectionResponse]. @async - String getCollection(String collectionId); + String getCollection(String collectionId, String? shopId); + + /// Routes a push to the shop it belongs to (payload `shop_id`) and tracks it + /// via the native `Rees46.handlePush`. [event] is the index of the Dart + /// `PushEvent` enum: 0 = received, 1 = delivered, 2 = clicked. The native side + /// maps it to its own vocabulary (Android `PushEventType`, iOS `PushEvent`). + @async + void handlePush(Map payload, int event); /// [customJson] and [recommendedSourceJson] are JSON object strings or null. @async @@ -234,14 +248,19 @@ abstract class PersonalizationHostApi { String? recommendedSourceJson, String? stream, String? segment, + String? shopId, ); } +// Multi-instance: each inbound push callback carries the `shopId` the push +// routed to (resolved natively from the payload's `shop_id`), so the Dart +// dispatcher delivers it to that shop's registered callbacks. `shopId == null` +// means the legacy single/default instance. @FlutterApi() abstract class PersonalizationFlutterApi { - void onPushReceived(Map payload); + void onPushReceived(String? shopId, Map payload); - void onPushDelivered(Map payload); + void onPushDelivered(String? shopId, Map payload); - void onPushClicked(Map payload); + void onPushClicked(String? shopId, Map payload); } diff --git a/test/multi_instance/handle_push_test.dart b/test/multi_instance/handle_push_test.dart new file mode 100644 index 0000000..ade26e3 --- /dev/null +++ b/test/multi_instance/handle_push_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rees46_sdk/src/multi_instance/push_event.dart'; +import 'package:rees46_sdk/src/multi_instance/rees46.dart'; +import 'package:rees46_sdk/src/multi_instance/rees46_config.dart'; +import 'package:rees46_sdk/src/personalization_sdk.dart'; +import 'package:rees46_sdk/src/pigeon/personalization_api.g.dart' as pigeon; + +/// F4 contract: [Rees46.handlePush] resolves the target shop from the payload's +/// `shop_id` (drop on unknown/ambiguous), tracks natively, and fires that shop's +/// callbacks — mirror of the native `Rees46.handlePush` routing. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const handlePushChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.handlePush'; + + late List> nativeCalls; + late Map handles; + + Rees46Config cfg(String shopId) => Rees46Config(shopId: shopId); + + Map push(String? shopId) => { + 'shop_id': ?shopId, + 'type': 'bulk', + 'id': 'mi-demo', + 'title': 't', + 'body': 'b', + }; + + setUp(() { + nativeCalls = []; + handles = {}; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(handlePushChannel, (message) async { + nativeCalls.add( + pigeon.PersonalizationHostApi.pigeonChannelCodec.decodeMessage( + message, + ) + as List, + ); + return pigeon.PersonalizationHostApi.pigeonChannelCodec.encodeMessage( + [], + ); + }); + Rees46.debugFactory = (config) { + final sdk = PersonalizationSdk(shopId: config.shopId); + handles[config.shopId] = sdk; + return sdk; + }; + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(handlePushChannel, null); + Rees46.reset(); + }); + + test('routes to the shop named by shop_id and tracks natively', () async { + Rees46.initialize(cfg('A')); + Rees46.initialize(cfg('B')); + + final routed = await Rees46.handlePush(push('B'), PushEvent.received); + + expect(routed, 'B'); + expect(nativeCalls, hasLength(1)); + expect(nativeCalls.single[1], PushEvent.received.index); // event index + }); + + test('unknown shop is dropped — no native call', () async { + Rees46.initialize(cfg('A')); + + final routed = await Rees46.handlePush(push('zzz'), PushEvent.received); + + expect(routed, isNull); + expect(nativeCalls, isEmpty); + }); + + test('no shop_id with a single live shop falls back to it', () async { + Rees46.initialize(cfg('A')); + + final routed = await Rees46.handlePush(push(null), PushEvent.received); + + expect(routed, 'A'); + expect(nativeCalls, hasLength(1)); + }); + + test('no shop_id with two live shops is ambiguous and dropped', () async { + Rees46.initialize(cfg('A')); + Rees46.initialize(cfg('B')); + + final routed = await Rees46.handlePush(push(null), PushEvent.received); + + expect(routed, isNull); + expect(nativeCalls, isEmpty); + }); + + test('materializes a pending shop and routes to it', () async { + Rees46.registerShops([cfg('A')]); + expect(Rees46.liveShopIds, isEmpty); + + final routed = await Rees46.handlePush(push('A'), PushEvent.received); + + expect(routed, 'A'); + expect(Rees46.liveShopIds, ['A']); // materialized on the push + }); + + test('fires only the target shop callbacks', () async { + Rees46.initialize(cfg('A')); + Rees46.initialize(cfg('B')); + Map? gotA; + Map? gotB; + handles['A']!.setPushNotificationCallbacks(onReceived: (p) => gotA = p); + handles['B']!.setPushNotificationCallbacks(onReceived: (p) => gotB = p); + + await Rees46.handlePush(push('B'), PushEvent.received); + + expect(gotB, isNotNull); + expect(gotB!['shop_id'], 'B'); + expect(gotA, isNull); + }); +} diff --git a/test/multi_instance/instance_resolver_test.dart b/test/multi_instance/instance_resolver_test.dart new file mode 100644 index 0000000..03cf38a --- /dev/null +++ b/test/multi_instance/instance_resolver_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rees46_sdk/src/multi_instance/instance_resolver.dart'; + +/// Table-driven port of the native `InstanceResolverTest` — the rules must stay +/// byte-for-byte identical across platforms. +void main() { + Resolution resolve({ + String? requested, + Set live = const {}, + Set pending = const {}, + }) => InstanceResolver.resolve( + requestedShopId: requested, + liveShopIds: live, + pendingShopIds: pending, + ); + + group('explicit shopId', () { + test('live shop → Existing', () { + expect( + resolve(requested: 'a', live: {'a'}), + const ExistingResolution('a'), + ); + }); + + test('pending shop → Pending', () { + expect( + resolve(requested: 'a', pending: {'a'}), + const PendingResolution('a'), + ); + }); + + test('live wins over pending for the same id', () { + expect( + resolve(requested: 'a', live: {'a'}, pending: {'a'}), + const ExistingResolution('a'), + ); + }); + + test('unknown shop → NotRegistered', () { + expect( + resolve(requested: 'x', live: {'a'}, pending: {'b'}), + const NotRegisteredResolution(), + ); + }); + + test('unknown shop with nothing registered → NotRegistered', () { + expect(resolve(requested: 'x'), const NotRegisteredResolution()); + }); + }); + + group('no shopId', () { + test('nothing registered → NotRegistered', () { + expect(resolve(), const NotRegisteredResolution()); + }); + + test('exactly one live → Existing', () { + expect(resolve(live: {'a'}), const ExistingResolution('a')); + }); + + test('exactly one pending → Pending', () { + expect(resolve(pending: {'a'}), const PendingResolution('a')); + }); + + test('two live → Ambiguous', () { + expect(resolve(live: {'a', 'b'}), const AmbiguousResolution()); + }); + + test('one live + one pending → Ambiguous', () { + expect(resolve(live: {'a'}, pending: {'b'}), const AmbiguousResolution()); + }); + + test('two pending → Ambiguous', () { + expect(resolve(pending: {'a', 'b'}), const AmbiguousResolution()); + }); + }); +} diff --git a/test/multi_instance/push_dispatcher_test.dart b/test/multi_instance/push_dispatcher_test.dart new file mode 100644 index 0000000..4405e91 --- /dev/null +++ b/test/multi_instance/push_dispatcher_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rees46_sdk/src/multi_instance/push_dispatcher.dart'; +import 'package:rees46_sdk/src/push/push_notification_callbacks.dart'; + +/// FL-5 contract: the process-global [PushDispatcher] routes each inbound push — +/// tagged with the `shopId` native resolved — to that shop's callbacks; unknown +/// drops, a null `shopId` falls back to the single registered target. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final dispatcher = PushDispatcher.instance; + + PushNotificationCallbacks sink({ + void Function(Map)? onReceived, + void Function(Map)? onDelivered, + void Function(Map)? onClicked, + }) => PushNotificationCallbacks() + ..setCallbacks( + onReceived: onReceived, + onDelivered: onDelivered, + onClicked: onClicked, + ); + + setUp(dispatcher.reset); + + test('routes a shop_id-tagged push to that shop only', () { + Map? gotA; + Map? gotB; + dispatcher.register('A', sink(onReceived: (p) => gotA = p)); + dispatcher.register('B', sink(onReceived: (p) => gotB = p)); + + dispatcher.onPushReceived('B', {'shop_id': 'B', 'title': 'hi'}); + + expect(gotB, isNotNull); + expect(gotB!['title'], 'hi'); + expect(gotA, isNull); + }); + + test('unknown shop_id is dropped', () { + var fired = 0; + dispatcher.register('A', sink(onReceived: (_) => fired++)); + + dispatcher.onPushReceived('zzz', {'shop_id': 'zzz'}); + + expect(fired, 0); + }); + + test('null shop_id with one registered falls back to it', () { + Map? got; + dispatcher.register('A', sink(onReceived: (p) => got = p)); + + dispatcher.onPushReceived(null, {'title': 'x'}); + + expect(got, isNotNull); + }); + + test('null shop_id with two registered is ambiguous and dropped', () { + var fired = 0; + dispatcher.register('A', sink(onReceived: (_) => fired++)); + dispatcher.register('B', sink(onReceived: (_) => fired++)); + + dispatcher.onPushReceived(null, {}); + + expect(fired, 0); + }); + + test('delivered and clicked route to the resolved shop', () { + final events = []; + dispatcher.register( + 'A', + sink( + onDelivered: (_) => events.add('delivered'), + onClicked: (_) => events.add('clicked'), + ), + ); + + dispatcher.onPushDelivered('A', {}); + dispatcher.onPushClicked('A', {}); + + expect(events, ['delivered', 'clicked']); + }); + + test('legacy default (null-shop registration) receives a null-shop push', () { + Map? got; + dispatcher.register(null, sink(onReceived: (p) => got = p)); + + dispatcher.onPushReceived(null, {'title': 'legacy'}); + + expect(got, isNotNull); + expect(got!['title'], 'legacy'); + }); +} diff --git a/test/multi_instance/rees46_facade_test.dart b/test/multi_instance/rees46_facade_test.dart new file mode 100644 index 0000000..466dca4 --- /dev/null +++ b/test/multi_instance/rees46_facade_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rees46_sdk/src/multi_instance/rees46.dart'; +import 'package:rees46_sdk/src/multi_instance/rees46_config.dart'; +import 'package:rees46_sdk/src/multi_instance/sdk_exceptions.dart'; +import 'package:rees46_sdk/src/personalization_sdk.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Records every config the facade asks to build/initialize, and returns a + // real (but un-initialized-over-native) handle carrying the shop id. Keeps the + // resolution contract testable without native or a live Pigeon channel. + late List built; + + Rees46Config cfg(String shopId) => Rees46Config(shopId: shopId); + + setUp(() { + built = []; + Rees46.debugFactory = (config) { + built.add(config.shopId); + return PersonalizationSdk(shopId: config.shopId); + }; + }); + + tearDown(Rees46.reset); + + group('initialize', () { + test('returns a handle bound to the shop and marks it live', () { + final sdk = Rees46.initialize(cfg('a')); + + expect(sdk.shopId, 'a'); + expect(built, ['a']); + expect(Rees46.isInitialized('a'), isTrue); + expect(Rees46.liveShopIds, ['a']); + }); + + test('clears any pending registration for the same shop', () { + Rees46.registerShops([cfg('a')]); + expect(Rees46.pendingShopIds, ['a']); + + Rees46.initialize(cfg('a')); + + expect(Rees46.pendingShopIds, isEmpty); + expect(Rees46.liveShopIds, ['a']); + }); + }); + + group('registerShops', () { + test('lazy by default — registers without building', () { + Rees46.registerShops([cfg('a'), cfg('b')]); + + expect(built, isEmpty); + expect(Rees46.pendingShopIds, ['a', 'b']); + expect(Rees46.isInitialized('a'), isFalse); + }); + + test('eagerInit builds every shop up front', () { + Rees46.registerShops([cfg('a'), cfg('b')], eagerInit: true); + + expect(built, ['a', 'b']); + expect(Rees46.liveShopIds, ['a', 'b']); + expect(Rees46.pendingShopIds, isEmpty); + }); + }); + + group('getInstance', () { + test('no id, single live shop → that instance', () { + Rees46.initialize(cfg('a')); + expect(Rees46.getInstance().shopId, 'a'); + }); + + test('explicit id returns the matching live instance', () { + Rees46.initialize(cfg('a')); + Rees46.initialize(cfg('b')); + expect(Rees46.getInstance('b').shopId, 'b'); + }); + + test('materializes a pending shop on first use', () { + Rees46.registerShops([cfg('a')]); + expect(built, isEmpty); + + final sdk = Rees46.getInstance('a'); + + expect(sdk.shopId, 'a'); + expect(built, ['a']); + expect(Rees46.liveShopIds, ['a']); + expect(Rees46.pendingShopIds, isEmpty); + }); + + test('materializes a pending shop only once', () { + Rees46.registerShops([cfg('a')]); + final first = Rees46.getInstance('a'); + final second = Rees46.getInstance('a'); + + expect(built, ['a']); // built once + expect(identical(first, second), isTrue); + }); + + test('no id with several shops → AmbiguousShopException', () { + Rees46.initialize(cfg('a')); + Rees46.registerShops([cfg('b')]); + + expect( + () => Rees46.getInstance(), + throwsA( + isA().having( + (e) => e.registeredShopIds, + 'registeredShopIds', + ['a', 'b'], + ), + ), + ); + }); + + test('unknown id → UnknownShopIdException', () { + Rees46.initialize(cfg('a')); + expect( + () => Rees46.getInstance('nope'), + throwsA( + isA().having( + (e) => e.shopId, + 'shopId', + 'nope', + ), + ), + ); + }); + + test('no id with nothing registered → UnknownShopIdException', () { + expect( + () => Rees46.getInstance(), + throwsA(isA()), + ); + }); + }); + + group('isInitialized', () { + test('null id true only when exactly one live shop', () { + expect(Rees46.isInitialized(), isFalse); + Rees46.initialize(cfg('a')); + expect(Rees46.isInitialized(), isTrue); + Rees46.initialize(cfg('b')); + expect(Rees46.isInitialized(), isFalse); // ambiguous default + }); + + test('pending shop is not counted as initialized', () { + Rees46.registerShops([cfg('a')]); + expect(Rees46.isInitialized('a'), isFalse); + expect(Rees46.isInitialized(), isFalse); + }); + }); +} diff --git a/test/multi_instance/shopid_threading_test.dart b/test/multi_instance/shopid_threading_test.dart new file mode 100644 index 0000000..4f02d7e --- /dev/null +++ b/test/multi_instance/shopid_threading_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rees46_sdk/src/personalization_sdk.dart'; +import 'package:rees46_sdk/src/pigeon/personalization_api.g.dart' as pigeon; + +/// F2 contract: a [PersonalizationSdk] handle threads its own `shopId` as the +/// trailing argument of every per-instance Pigeon call, so the native bridge can +/// route to that shop. A legacy default handle (`shopId == null`) sends `null` — +/// the single/default-instance fallback. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const sidChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.getSid'; + const trackChannel = + 'dev.flutter.pigeon.personalization_flutter_sdk.PersonalizationHostApi.trackEvent'; + + late List capturedGetSidArgs; + late List capturedTrackArgs; + + setUp(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMessageHandler(sidChannel, (message) async { + capturedGetSidArgs = + pigeon.PersonalizationHostApi.pigeonChannelCodec.decodeMessage( + message, + ) + as List; + return pigeon.PersonalizationHostApi.pigeonChannelCodec.encodeMessage( + ['sid-123'], + ); + }); + messenger.setMockMessageHandler(trackChannel, (message) async { + capturedTrackArgs = + pigeon.PersonalizationHostApi.pigeonChannelCodec.decodeMessage( + message, + ) + as List; + return pigeon.PersonalizationHostApi.pigeonChannelCodec.encodeMessage( + [], + ); + }); + }); + + tearDown(() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMessageHandler(sidChannel, null); + messenger.setMockMessageHandler(trackChannel, null); + }); + + test('getSid sends the handle shopId as the sole argument', () async { + await PersonalizationSdk(shopId: 'shop-A').getSid(); + expect(capturedGetSidArgs, ['shop-A']); + }); + + test('default handle (no shopId) sends null', () async { + await PersonalizationSdk().getSid(); + expect(capturedGetSidArgs, [null]); + }); + + test('trackEvent sends shopId as the trailing argument', () async { + await PersonalizationSdk(shopId: 'shop-B').trackEvent('view'); + // event, time, category, label, value, customFieldsJson, shopId + expect(capturedTrackArgs.length, 7); + expect(capturedTrackArgs.first, 'view'); + expect(capturedTrackArgs.last, 'shop-B'); + }); +}