From d8fef3710245ce2df42577bec917e81b4c4c8405 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Sat, 20 Jun 2026 16:54:25 +0530 Subject: [PATCH 1/2] Add flutter_aepoptimize plugin for Adobe Optimize SDK Introduces a complete Flutter method-channel plugin wrapping the native AEP Optimize iOS (AEPOptimize ~>5.0) and Android (optimize via BOM 3.x) SDKs. Multiple native API overloads (no-callback, callback, callback+timeout) are consolidated into single Dart methods with optional parameters. Dart API: Optimize.updatePropositions, getPropositions, onPropositionsUpdate, clearCachedPropositions. Models: DecisionScope, OptimizeProposition, Offer, OfferType. Offer tracking: displayed(), tapped(), generateDisplayInteractionXdm(), generateTapInteractionXdm(). Proposition: generateReferenceXdm(). Includes 636 lines of Dart unit tests covering API invocation, response decoding, model roundtrips, enum conversions, listener callbacks, and edge cases. Co-Authored-By: Claude Opus 4.6 --- plugins/flutter_aepoptimize/CHANGELOG.md | 11 + plugins/flutter_aepoptimize/LICENSE | 201 ++++++ .../flutter_aepoptimize/android/build.gradle | 43 ++ .../android/gradle.properties | 2 + .../android/settings.gradle | 1 + .../flutter_aepoptimize/AndroidUtil.java | 23 + .../FlutterAEPOptimizeDataBridge.java | 178 +++++ .../FlutterAEPOptimizePlugin.java | 203 ++++++ .../Classes/FlutterAEPOptimizeDataBridge.h | 23 + .../Classes/FlutterAEPOptimizeDataBridge.m | 143 ++++ .../ios/Classes/FlutterAEPOptimizePlugin.h | 15 + .../ios/Classes/FlutterAEPOptimizePlugin.m | 194 ++++++ .../ios/flutter_aepoptimize.podspec | 15 + .../lib/flutter_aepoptimize.dart | 107 +++ .../lib/flutter_aepoptimize_data.dart | 15 + .../lib/src/decision_scope.dart | 46 ++ .../flutter_aepoptimize/lib/src/offer.dart | 99 +++ .../lib/src/offer_type.dart | 63 ++ .../lib/src/optimize_proposition.dart | 66 ++ plugins/flutter_aepoptimize/pubspec.yaml | 29 + .../test/flutter_aepoptimize_test.dart | 636 ++++++++++++++++++ 21 files changed, 2113 insertions(+) create mode 100644 plugins/flutter_aepoptimize/CHANGELOG.md create mode 100644 plugins/flutter_aepoptimize/LICENSE create mode 100644 plugins/flutter_aepoptimize/android/build.gradle create mode 100644 plugins/flutter_aepoptimize/android/gradle.properties create mode 100644 plugins/flutter_aepoptimize/android/settings.gradle create mode 100644 plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/AndroidUtil.java create mode 100644 plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizeDataBridge.java create mode 100644 plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizePlugin.java create mode 100644 plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h create mode 100644 plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m create mode 100644 plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h create mode 100644 plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m create mode 100644 plugins/flutter_aepoptimize/ios/flutter_aepoptimize.podspec create mode 100644 plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart create mode 100644 plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart create mode 100644 plugins/flutter_aepoptimize/lib/src/decision_scope.dart create mode 100644 plugins/flutter_aepoptimize/lib/src/offer.dart create mode 100644 plugins/flutter_aepoptimize/lib/src/offer_type.dart create mode 100644 plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart create mode 100644 plugins/flutter_aepoptimize/pubspec.yaml create mode 100644 plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart diff --git a/plugins/flutter_aepoptimize/CHANGELOG.md b/plugins/flutter_aepoptimize/CHANGELOG.md new file mode 100644 index 00000000..c3b21fe4 --- /dev/null +++ b/plugins/flutter_aepoptimize/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 5.0.0 + +* Initial release of flutter_aepoptimize. +* Supports Adobe Experience Platform Optimize SDK for Flutter. +* APIs: `updatePropositions`, `getPropositions`, `onPropositionsUpdate`, `clearCachedPropositions`. +* Models: `DecisionScope`, `OptimizeProposition`, `Offer`, `OfferType`. +* Offer tracking: `displayed()`, `tapped()`, `generateDisplayInteractionXdm()`, `generateTapInteractionXdm()`. +* Proposition XDM: `generateReferenceXdm()`. +* Consolidated API surface with optional `timeout` parameter (covers all native overloads). diff --git a/plugins/flutter_aepoptimize/LICENSE b/plugins/flutter_aepoptimize/LICENSE new file mode 100644 index 00000000..6f4fa864 --- /dev/null +++ b/plugins/flutter_aepoptimize/LICENSE @@ -0,0 +1,201 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright 2022 Adobe + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/plugins/flutter_aepoptimize/android/build.gradle b/plugins/flutter_aepoptimize/android/build.gradle new file mode 100644 index 00000000..79b80db9 --- /dev/null +++ b/plugins/flutter_aepoptimize/android/build.gradle @@ -0,0 +1,43 @@ +group 'com.adobe.marketing.mobile.flutter.flutter_aepoptimize' +version '3.0' + +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.2' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' + +android { + if (project.android.hasProperty("namespace")) { + namespace 'com.adobe.marketing.mobile.flutter.flutter_aepoptimize' + } + + compileSdk 34 + + defaultConfig { + minSdkVersion 21 + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + } + lintOptions { + disable 'InvalidPackage' + } +} + +dependencies { + implementation platform("com.adobe.marketing.mobile:sdk-bom:3.+") + api "com.adobe.marketing.mobile:optimize" +} diff --git a/plugins/flutter_aepoptimize/android/gradle.properties b/plugins/flutter_aepoptimize/android/gradle.properties new file mode 100644 index 00000000..d9cf55df --- /dev/null +++ b/plugins/flutter_aepoptimize/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true diff --git a/plugins/flutter_aepoptimize/android/settings.gradle b/plugins/flutter_aepoptimize/android/settings.gradle new file mode 100644 index 00000000..f6ad0f4a --- /dev/null +++ b/plugins/flutter_aepoptimize/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_aepoptimize' diff --git a/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/AndroidUtil.java b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/AndroidUtil.java new file mode 100644 index 00000000..3d69ad7e --- /dev/null +++ b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/AndroidUtil.java @@ -0,0 +1,23 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.flutter.flutter_aepoptimize; + +import android.os.Handler; +import android.os.Looper; + +class AndroidUtil { + + static void runOnUIThread(Runnable runnable) { + new Handler(Looper.getMainLooper()).post(runnable); + } + +} diff --git a/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizeDataBridge.java b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizeDataBridge.java new file mode 100644 index 00000000..66f3d545 --- /dev/null +++ b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizeDataBridge.java @@ -0,0 +1,178 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.flutter.flutter_aepoptimize; + +import com.adobe.marketing.mobile.optimize.DecisionScope; +import com.adobe.marketing.mobile.optimize.Offer; +import com.adobe.marketing.mobile.optimize.OfferType; +import com.adobe.marketing.mobile.optimize.OptimizeProposition; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +class FlutterAEPOptimizeDataBridge { + + static List decisionScopesFromList(List> list) { + if (list == null) { + return null; + } + + List scopes = new ArrayList<>(); + for (Map item : list) { + String name = (String) item.get("name"); + if (name != null) { + scopes.add(new DecisionScope(name)); + } + } + return scopes; + } + + static Map mapFromPropositionsMap(Map propositions) { + if (propositions == null) { + return null; + } + + Map result = new HashMap<>(); + for (Map.Entry entry : propositions.entrySet()) { + result.put(entry.getKey().getName(), mapFromProposition(entry.getValue())); + } + return result; + } + + static Map mapFromProposition(OptimizeProposition proposition) { + if (proposition == null) { + return null; + } + + Map map = new HashMap<>(); + map.put("id", proposition.getId()); + map.put("scope", proposition.getScope()); + map.put("scopeDetails", proposition.getScopeDetails() != null ? proposition.getScopeDetails() : new HashMap<>()); + + List> offersArray = new ArrayList<>(); + if (proposition.getOffers() != null) { + for (Offer offer : proposition.getOffers()) { + offersArray.add(mapFromOffer(offer)); + } + } + map.put("offers", offersArray); + return map; + } + + static Map mapFromOffer(Offer offer) { + if (offer == null) { + return null; + } + + Map map = new HashMap<>(); + map.put("id", offer.getId()); + map.put("etag", offer.getEtag() != null ? offer.getEtag() : ""); + map.put("score", offer.getScore()); + map.put("schema", offer.getSchema() != null ? offer.getSchema() : ""); + map.put("meta", offer.getMeta()); + map.put("type", offerTypeToInt(offer.getType())); + map.put("language", offer.getLanguage()); + map.put("content", offer.getContent() != null ? offer.getContent() : ""); + map.put("characteristics", offer.getCharacteristics()); + return map; + } + + @SuppressWarnings("unchecked") + static Offer offerFromMap(Map map) { + if (map == null) { + return null; + } + + String id = getNullableString(map, "id"); + int typeInt = map.containsKey("type") && map.get("type") instanceof Number + ? ((Number) map.get("type")).intValue() : 0; + String content = getNullableString(map, "content"); + + Offer.Builder builder = new Offer.Builder( + id != null ? id : "", + intToOfferType(typeInt), + content != null ? content : ""); + + String etag = getNullableString(map, "etag"); + if (etag != null) { + builder.setEtag(etag); + } + + if (map.containsKey("score") && map.get("score") instanceof Number) { + builder.setScore(((Number) map.get("score")).doubleValue()); + } + + String schema = getNullableString(map, "schema"); + if (schema != null) { + builder.setSchema(schema); + } + + Map meta = getNullableMap(map, "meta"); + if (meta != null) { + builder.setMeta(meta); + } + + List language = (List) map.get("language"); + if (language != null) { + builder.setLanguage(language); + } + + Map characteristics = map.containsKey("characteristics") && map.get("characteristics") instanceof Map + ? (Map) map.get("characteristics") : null; + if (characteristics != null) { + builder.setCharacteristics(characteristics); + } + + return builder.build(); + } + + @SuppressWarnings("unchecked") + static OptimizeProposition propositionFromMap(Map map) { + if (map == null) { + return null; + } + + return OptimizeProposition.fromEventData(map); + } + + static int offerTypeToInt(OfferType type) { + if (type == null) return 0; + switch (type) { + case JSON: return 1; + case TEXT: return 2; + case HTML: return 3; + case IMAGE: return 4; + default: return 0; + } + } + + static OfferType intToOfferType(int value) { + switch (value) { + case 1: return OfferType.JSON; + case 2: return OfferType.TEXT; + case 3: return OfferType.HTML; + case 4: return OfferType.IMAGE; + default: return OfferType.UNKNOWN; + } + } + + private static String getNullableString(final Map data, final String key) { + return data.containsKey(key) && (data.get(key) instanceof String) ? (String) data.get(key) : null; + } + + @SuppressWarnings("unchecked") + private static Map getNullableMap(final Map data, final String key) { + return data.containsKey(key) && (data.get(key) instanceof Map) ? (Map) data.get(key) : null; + } +} diff --git a/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizePlugin.java b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizePlugin.java new file mode 100644 index 00000000..1c3619ed --- /dev/null +++ b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizePlugin.java @@ -0,0 +1,203 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.flutter.flutter_aepoptimize; + +import android.util.Log; + +import com.adobe.marketing.mobile.AdobeCallback; +import com.adobe.marketing.mobile.AdobeCallbackWithError; +import com.adobe.marketing.mobile.AdobeError; +import com.adobe.marketing.mobile.optimize.DecisionScope; +import com.adobe.marketing.mobile.optimize.Offer; +import com.adobe.marketing.mobile.optimize.Optimize; +import com.adobe.marketing.mobile.optimize.OptimizeProposition; + +import androidx.annotation.NonNull; +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +import java.util.List; +import java.util.Map; + +public class FlutterAEPOptimizePlugin implements FlutterPlugin, MethodCallHandler { + + private static final String TAG = "FlutterAEPOptimizePlugin"; + + private MethodChannel channel; + + @Override + public void onAttachedToEngine(@NonNull final FlutterPluginBinding binding) { + channel = new MethodChannel(binding.getBinaryMessenger(), "flutter_aepoptimize"); + channel.setMethodCallHandler(this); + } + + @Override + public void onDetachedFromEngine(@NonNull final FlutterPluginBinding binding) { + if (channel != null) { + channel.setMethodCallHandler(null); + } + } + + @Override + public void onMethodCall(MethodCall call, @NonNull Result result) { + if ("extensionVersion".equals(call.method)) { + result.success(Optimize.extensionVersion()); + } else if ("updatePropositions".equals(call.method)) { + handleUpdatePropositions(call, result); + } else if ("getPropositions".equals(call.method)) { + handleGetPropositions(call, result); + } else if ("registerOnPropositionsUpdate".equals(call.method)) { + handleRegisterOnPropositionsUpdate(result); + } else if ("clearCachedPropositions".equals(call.method)) { + Optimize.clearCachedPropositions(); + result.success(null); + } else if ("offerDisplayed".equals(call.method)) { + handleOfferDisplayed(call, result); + } else if ("offerTapped".equals(call.method)) { + handleOfferTapped(call, result); + } else if ("generateDisplayInteractionXdm".equals(call.method)) { + handleGenerateDisplayInteractionXdm(call, result); + } else if ("generateTapInteractionXdm".equals(call.method)) { + handleGenerateTapInteractionXdm(call, result); + } else if ("generateReferenceXdm".equals(call.method)) { + handleGenerateReferenceXdm(call, result); + } else { + result.notImplemented(); + } + } + + @SuppressWarnings("unchecked") + private void handleUpdatePropositions(MethodCall call, final Result result) { + Map arguments = (Map) call.arguments; + List> scopesList = (List>) arguments.get("decisionScopes"); + List scopes = FlutterAEPOptimizeDataBridge.decisionScopesFromList(scopesList); + + if (scopes == null || scopes.isEmpty()) { + result.error("INVALID_ARGUMENT", "decisionScopes is required", null); + return; + } + + Map xdm = (Map) arguments.get("xdm"); + Map data = (Map) arguments.get("data"); + Double timeout = arguments.containsKey("timeout") && arguments.get("timeout") instanceof Number + ? ((Number) arguments.get("timeout")).doubleValue() : null; + + AdobeCallback> callback = + propositions -> AndroidUtil.runOnUIThread(() -> + result.success(FlutterAEPOptimizeDataBridge.mapFromPropositionsMap(propositions))); + + if (timeout != null) { + Optimize.updatePropositions(scopes, xdm, data, timeout, callback); + } else { + Optimize.updatePropositions(scopes, xdm, data, callback); + } + } + + @SuppressWarnings("unchecked") + private void handleGetPropositions(MethodCall call, final Result result) { + Map arguments = (Map) call.arguments; + List> scopesList = (List>) arguments.get("decisionScopes"); + List scopes = FlutterAEPOptimizeDataBridge.decisionScopesFromList(scopesList); + + if (scopes == null || scopes.isEmpty()) { + result.error("INVALID_ARGUMENT", "decisionScopes is required", null); + return; + } + + Double timeout = arguments.containsKey("timeout") && arguments.get("timeout") instanceof Number + ? ((Number) arguments.get("timeout")).doubleValue() : null; + + AdobeCallbackWithError> callback = + new AdobeCallbackWithError>() { + @Override + public void call(Map propositions) { + AndroidUtil.runOnUIThread(() -> + result.success(FlutterAEPOptimizeDataBridge.mapFromPropositionsMap(propositions))); + } + + @Override + public void fail(AdobeError adobeError) { + final AdobeError error = adobeError != null ? adobeError : AdobeError.UNEXPECTED_ERROR; + AndroidUtil.runOnUIThread(() -> + result.error(Integer.toString(error.getErrorCode()), + "getPropositions failed", + error.getErrorName())); + } + }; + + if (timeout != null) { + Optimize.getPropositions(scopes, timeout, callback); + } else { + Optimize.getPropositions(scopes, callback); + } + } + + private void handleRegisterOnPropositionsUpdate(final Result result) { + Optimize.onPropositionsUpdate(propositions -> { + final Map encoded = FlutterAEPOptimizeDataBridge.mapFromPropositionsMap(propositions); + AndroidUtil.runOnUIThread(() -> + channel.invokeMethod("onPropositionsUpdate", encoded)); + }); + result.success(null); + } + + @SuppressWarnings("unchecked") + private void handleOfferDisplayed(MethodCall call, Result result) { + Offer offer = FlutterAEPOptimizeDataBridge.offerFromMap((Map) call.arguments); + if (offer != null) { + offer.displayed(); + } + result.success(null); + } + + @SuppressWarnings("unchecked") + private void handleOfferTapped(MethodCall call, Result result) { + Offer offer = FlutterAEPOptimizeDataBridge.offerFromMap((Map) call.arguments); + if (offer != null) { + offer.tapped(); + } + result.success(null); + } + + @SuppressWarnings("unchecked") + private void handleGenerateDisplayInteractionXdm(MethodCall call, Result result) { + Offer offer = FlutterAEPOptimizeDataBridge.offerFromMap((Map) call.arguments); + if (offer != null) { + result.success(offer.generateDisplayInteractionXdm()); + } else { + result.success(null); + } + } + + @SuppressWarnings("unchecked") + private void handleGenerateTapInteractionXdm(MethodCall call, Result result) { + Offer offer = FlutterAEPOptimizeDataBridge.offerFromMap((Map) call.arguments); + if (offer != null) { + result.success(offer.generateTapInteractionXdm()); + } else { + result.success(null); + } + } + + @SuppressWarnings("unchecked") + private void handleGenerateReferenceXdm(MethodCall call, Result result) { + OptimizeProposition proposition = FlutterAEPOptimizeDataBridge.propositionFromMap((Map) call.arguments); + if (proposition != null) { + result.success(proposition.generateReferenceXdm()); + } else { + result.success(null); + } + } +} diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h new file mode 100644 index 00000000..ed10a437 --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h @@ -0,0 +1,23 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#import + +@import AEPOptimize; + +@interface FlutterAEPOptimizeDataBridge : NSObject + ++ (NSArray *_Nullable)decisionScopesFromArray:(NSArray *_Nullable)array; ++ (NSDictionary *_Nullable)dictionaryFromPropositionsMap:(NSDictionary *_Nullable)propositions; ++ (AEPOffer *_Nullable)offerFromDictionary:(NSDictionary *_Nullable)dict; ++ (AEPOptimizeProposition *_Nullable)propositionFromDictionary:(NSDictionary *_Nullable)dict; + +@end diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m new file mode 100644 index 00000000..3476d81b --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m @@ -0,0 +1,143 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#import "FlutterAEPOptimizeDataBridge.h" + +@implementation FlutterAEPOptimizeDataBridge + +#pragma mark - DecisionScope + ++ (NSArray *)decisionScopesFromArray:(NSArray *)array { + if (!array || ![array isKindOfClass:[NSArray class]]) { + return nil; + } + + NSMutableArray *scopes = [NSMutableArray array]; + for (NSDictionary *dict in array) { + NSString *name = dict[@"name"]; + if (name && [name isKindOfClass:[NSString class]]) { + [scopes addObject:[[AEPDecisionScope alloc] initWithName:name]]; + } + } + return scopes; +} + +#pragma mark - Proposition Map + ++ (NSDictionary *)dictionaryFromPropositionsMap:(NSDictionary *)propositions { + if (!propositions) { + return nil; + } + + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + [propositions enumerateKeysAndObjectsUsingBlock:^(AEPDecisionScope *scope, AEPOptimizeProposition *proposition, BOOL *stop) { + result[scope.name] = [self dictionaryFromProposition:proposition]; + }]; + return result; +} + +#pragma mark - Proposition + ++ (NSDictionary *)dictionaryFromProposition:(AEPOptimizeProposition *)proposition { + if (!proposition) { + return nil; + } + + NSMutableArray *offersArray = [NSMutableArray array]; + for (AEPOffer *offer in proposition.offers) { + [offersArray addObject:[self dictionaryFromOffer:offer]]; + } + + NSMutableDictionary *dict = [NSMutableDictionary dictionary]; + dict[@"id"] = proposition.id; + dict[@"offers"] = offersArray; + dict[@"scope"] = proposition.scope; + dict[@"scopeDetails"] = proposition.scopeDetails ?: @{}; + return dict; +} + ++ (AEPOptimizeProposition *)propositionFromDictionary:(NSDictionary *)dict { + if (!dict || ![dict isKindOfClass:[NSDictionary class]]) { + return nil; + } + + return [AEPOptimizeProposition initFromData:dict]; +} + +#pragma mark - Offer + ++ (NSDictionary *)dictionaryFromOffer:(AEPOffer *)offer { + if (!offer) { + return nil; + } + + NSMutableDictionary *dict = [NSMutableDictionary dictionary]; + dict[@"id"] = offer.id; + dict[@"etag"] = offer.etag ?: @""; + dict[@"score"] = @(offer.score); + dict[@"schema"] = offer.schema ?: @""; + dict[@"meta"] = offer.meta ?: [NSNull null]; + dict[@"type"] = @((int)offer.type); + dict[@"language"] = offer.language ?: [NSNull null]; + dict[@"content"] = offer.content ?: @""; + dict[@"characteristics"] = offer.characteristics ?: [NSNull null]; + return dict; +} + ++ (AEPOffer *)offerFromDictionary:(NSDictionary *)dict { + if (!dict || ![dict isKindOfClass:[NSDictionary class]]) { + return nil; + } + + // Reconstruct the offer via the proposition data path so tracking context is preserved + NSDictionary *propositionData = @{ + @"id": @"", + @"scope": @"", + @"scopeDetails": @{}, + @"items": @[@{ + @"id": dict[@"id"] ?: @"", + @"etag": dict[@"etag"] ?: @"", + @"score": dict[@"score"] ?: @(0), + @"schema": dict[@"schema"] ?: @"", + @"meta": dict[@"meta"] ?: @{}, + @"data": @{ + @"type": [self mimeTypeFromOfferType:dict[@"type"]], + @"content": dict[@"content"] ?: @"", + @"language": dict[@"language"] ?: @[], + @"characteristics": dict[@"characteristics"] ?: @{} + } + }] + }; + + AEPOptimizeProposition *proposition = [AEPOptimizeProposition initFromData:propositionData]; + if (proposition && proposition.offers.count > 0) { + return proposition.offers[0]; + } + return nil; +} + +#pragma mark - OfferType + ++ (NSString *)mimeTypeFromOfferType:(NSNumber *)typeValue { + if (!typeValue || ![typeValue isKindOfClass:[NSNumber class]]) { + return @""; + } + + switch ([typeValue intValue]) { + case 1: return @"application/json"; + case 2: return @"text/plain"; + case 3: return @"text/html"; + case 4: return @"image/*"; + default: return @""; + } +} + +@end diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h new file mode 100644 index 00000000..89a6eef6 --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h @@ -0,0 +1,15 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +#import + +@interface FlutterAEPOptimizePlugin : NSObject +@end diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m new file mode 100644 index 00000000..ecad18ac --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -0,0 +1,194 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +@import AEPOptimize; +@import AEPCore; +@import Foundation; +#import "FlutterAEPOptimizePlugin.h" +#import "FlutterAEPOptimizeDataBridge.h" + +@interface FlutterAEPOptimizePlugin () +@property(nonatomic, strong) FlutterMethodChannel *channel; +@end + +@implementation FlutterAEPOptimizePlugin + ++ (void)registerWithRegistrar:(NSObject *)registrar { + FlutterMethodChannel *channel = + [FlutterMethodChannel methodChannelWithName:@"flutter_aepoptimize" + binaryMessenger:[registrar messenger]]; + FlutterAEPOptimizePlugin *instance = [[FlutterAEPOptimizePlugin alloc] init]; + instance.channel = channel; + [registrar addMethodCallDelegate:instance channel:channel]; +} + +- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result { + if ([@"extensionVersion" isEqualToString:call.method]) { + result([AEPMobileOptimize extensionVersion]); + } else if ([@"updatePropositions" isEqualToString:call.method]) { + [self handleUpdatePropositions:call result:result]; + } else if ([@"getPropositions" isEqualToString:call.method]) { + [self handleGetPropositions:call result:result]; + } else if ([@"registerOnPropositionsUpdate" isEqualToString:call.method]) { + [self handleRegisterOnPropositionsUpdate:result]; + } else if ([@"clearCachedPropositions" isEqualToString:call.method]) { + [AEPMobileOptimize clearCachedPropositions]; + result(nil); + } else if ([@"offerDisplayed" isEqualToString:call.method]) { + [self handleOfferDisplayed:call result:result]; + } else if ([@"offerTapped" isEqualToString:call.method]) { + [self handleOfferTapped:call result:result]; + } else if ([@"generateDisplayInteractionXdm" isEqualToString:call.method]) { + [self handleGenerateDisplayInteractionXdm:call result:result]; + } else if ([@"generateTapInteractionXdm" isEqualToString:call.method]) { + [self handleGenerateTapInteractionXdm:call result:result]; + } else if ([@"generateReferenceXdm" isEqualToString:call.method]) { + [self handleGenerateReferenceXdm:call result:result]; + } else { + result(FlutterMethodNotImplemented); + } +} + +#pragma mark - API Handlers + +- (void)handleUpdatePropositions:(FlutterMethodCall *)call result:(FlutterResult)result { + NSDictionary *arguments = call.arguments; + NSArray *scopes = [FlutterAEPOptimizeDataBridge decisionScopesFromArray:arguments[@"decisionScopes"]]; + + if (!scopes || scopes.count == 0) { + result([FlutterError errorWithCode:@"INVALID_ARGUMENT" + message:@"decisionScopes is required" + details:nil]); + return; + } + + NSDictionary *xdm = arguments[@"xdm"]; + NSDictionary *data = arguments[@"data"]; + NSNumber *timeoutNumber = arguments[@"timeout"]; + + if (timeoutNumber && ![timeoutNumber isKindOfClass:[NSNull class]]) { + NSTimeInterval timeout = [timeoutNumber doubleValue]; + [AEPMobileOptimize updatePropositionsFor:scopes + withXdm:xdm + andData:data + timeout:timeout + :^(NSDictionary * _Nullable propositions, NSError * _Nullable error) { + if (error) { + result([self flutterErrorFromNSError:error]); + } else { + result([FlutterAEPOptimizeDataBridge dictionaryFromPropositionsMap:propositions]); + } + }]; + } else { + [AEPMobileOptimize updatePropositionsFor:scopes + withXdm:xdm + andData:data + :^(NSDictionary * _Nullable propositions, NSError * _Nullable error) { + if (error) { + result([self flutterErrorFromNSError:error]); + } else { + result([FlutterAEPOptimizeDataBridge dictionaryFromPropositionsMap:propositions]); + } + }]; + } +} + +- (void)handleGetPropositions:(FlutterMethodCall *)call result:(FlutterResult)result { + NSDictionary *arguments = call.arguments; + NSArray *scopes = [FlutterAEPOptimizeDataBridge decisionScopesFromArray:arguments[@"decisionScopes"]]; + + if (!scopes || scopes.count == 0) { + result([FlutterError errorWithCode:@"INVALID_ARGUMENT" + message:@"decisionScopes is required" + details:nil]); + return; + } + + NSNumber *timeoutNumber = arguments[@"timeout"]; + + void (^completionHandler)(NSDictionary * _Nullable, NSError * _Nullable) = + ^(NSDictionary * _Nullable propositions, NSError * _Nullable error) { + if (error) { + result([self flutterErrorFromNSError:error]); + } else { + result([FlutterAEPOptimizeDataBridge dictionaryFromPropositionsMap:propositions]); + } + }; + + if (timeoutNumber && ![timeoutNumber isKindOfClass:[NSNull class]]) { + [AEPMobileOptimize getPropositionsFor:scopes timeout:[timeoutNumber doubleValue] :completionHandler]; + } else { + [AEPMobileOptimize getPropositionsFor:scopes :completionHandler]; + } +} + +- (void)handleRegisterOnPropositionsUpdate:(FlutterResult)result { + [AEPMobileOptimize onPropositionsUpdateWithPerform:^(NSDictionary * _Nonnull propositions) { + NSDictionary *encoded = [FlutterAEPOptimizeDataBridge dictionaryFromPropositionsMap:propositions]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.channel invokeMethod:@"onPropositionsUpdate" arguments:encoded]; + }); + }]; + result(nil); +} + +- (void)handleOfferDisplayed:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + if (offer) { + [offer displayed]; + } + result(nil); +} + +- (void)handleOfferTapped:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + if (offer) { + [offer tapped]; + } + result(nil); +} + +- (void)handleGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + if (offer) { + result([offer generateDisplayInteractionXdm]); + } else { + result(nil); + } +} + +- (void)handleGenerateTapInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + if (offer) { + result([offer generateTapInteractionXdm]); + } else { + result(nil); + } +} + +- (void)handleGenerateReferenceXdm:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromDictionary:call.arguments]; + if (proposition) { + result([proposition generateReferenceXdm]); + } else { + result(nil); + } +} + +#pragma mark - Helpers + +- (FlutterError *)flutterErrorFromNSError:(NSError *)error { + return [FlutterError errorWithCode:[NSString stringWithFormat:@"%ld", (long)error.code] + message:error.localizedDescription + details:error.domain]; +} + +@end diff --git a/plugins/flutter_aepoptimize/ios/flutter_aepoptimize.podspec b/plugins/flutter_aepoptimize/ios/flutter_aepoptimize.podspec new file mode 100644 index 00000000..eb1d747e --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/flutter_aepoptimize.podspec @@ -0,0 +1,15 @@ +Pod::Spec.new do |s| + s.name = 'flutter_aepoptimize' + s.version = '5.0.0' + s.summary = 'Adobe Experience Platform Optimize extension for Flutter apps.' + s.homepage = 'https://developer.adobe.com/client-sdks' + s.license = { :file => '../LICENSE' } + s.author = 'Adobe Mobile SDK Team' + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.dependency 'AEPOptimize', '~> 5.0' + s.platform = :ios, '12.0' + s.static_framework = true +end diff --git a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart new file mode 100644 index 00000000..e95bab2f --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart @@ -0,0 +1,107 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:flutter_aepoptimize/flutter_aepoptimize_data.dart'; +export 'package:flutter_aepoptimize/flutter_aepoptimize_data.dart'; + +class Optimize { + static const MethodChannel _channel = + const MethodChannel('flutter_aepoptimize'); + + static void Function(Map)? + _onPropositionsUpdateCallback; + + static Future Function(MethodCall)? _methodCallHandler = + (MethodCall call) async { + switch (call.method) { + case 'onPropositionsUpdate': + if (_onPropositionsUpdateCallback != null) { + final rawMap = call.arguments as Map; + _onPropositionsUpdateCallback!(_decodePropositionsMap(rawMap)); + } + return null; + default: + throw UnimplementedError('${call.method} has not been implemented'); + } + }; + + static Future get extensionVersion => + _channel.invokeMethod('extensionVersion').then((value) => value!); + + /// Fetches propositions from the Edge Network for the given [decisionScopes]. + /// + /// Optional [xdm] and [data] are included in the personalization query request. + /// Optional [timeout] in seconds overrides the default network timeout. + /// Returns a map of decision scopes to their propositions on success. + static Future?> updatePropositions( + List decisionScopes, { + Map? xdm, + Map? data, + double? timeout, + }) { + return _channel.invokeMapMethod('updatePropositions', { + 'decisionScopes': decisionScopes.map((s) => s.toMap()).toList(), + 'xdm': xdm, + 'data': data, + 'timeout': timeout, + }).then((value) { + if (value == null) return null; + return _decodePropositionsMap(value); + }); + } + + /// Retrieves previously fetched propositions from the SDK cache for the + /// given [decisionScopes]. + /// + /// Optional [timeout] in seconds overrides the default timeout. + static Future?> getPropositions( + List decisionScopes, { + double? timeout, + }) { + return _channel.invokeMapMethod('getPropositions', { + 'decisionScopes': decisionScopes.map((s) => s.toMap()).toList(), + 'timeout': timeout, + }).then((value) { + if (value == null) return null; + return _decodePropositionsMap(value); + }); + } + + /// Registers a persistent listener that is invoked whenever propositions + /// are updated in the SDK cache. + static void onPropositionsUpdate( + void Function(Map) callback, + ) { + _onPropositionsUpdateCallback = callback; + _channel.setMethodCallHandler(_methodCallHandler); + _channel.invokeMethod('registerOnPropositionsUpdate'); + } + + /// Clears the client-side propositions cache. + static Future clearCachedPropositions() { + return _channel.invokeMethod('clearCachedPropositions'); + } + + static Map _decodePropositionsMap( + Map rawMap, + ) { + final result = {}; + rawMap.forEach((key, value) { + final scope = DecisionScope(key as String); + final proposition = OptimizeProposition.fromMap( + Map.from(value as Map)); + result[scope] = proposition; + }); + return result; + } +} diff --git a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart new file mode 100644 index 00000000..99288078 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart @@ -0,0 +1,15 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +export 'package:flutter_aepoptimize/src/decision_scope.dart'; +export 'package:flutter_aepoptimize/src/offer.dart'; +export 'package:flutter_aepoptimize/src/offer_type.dart'; +export 'package:flutter_aepoptimize/src/optimize_proposition.dart'; diff --git a/plugins/flutter_aepoptimize/lib/src/decision_scope.dart b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart new file mode 100644 index 00000000..a528ff28 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart @@ -0,0 +1,46 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import 'dart:convert'; + +class DecisionScope { + final String name; + + DecisionScope(this.name); + + DecisionScope.fromActivityAndPlacement({ + required String activityId, + required String placementId, + int itemCount = 1, + }) : name = base64Encode(utf8.encode( + '{"activityId":"$activityId","placementId":"$placementId","itemCount":$itemCount}')); + + Map toMap() { + return {'name': name}; + } + + factory DecisionScope.fromMap(Map map) { + return DecisionScope(map['name'] as String); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DecisionScope && + runtimeType == other.runtimeType && + name == other.name; + + @override + int get hashCode => name.hashCode; + + @override + String toString() => 'DecisionScope(name: $name)'; +} diff --git a/plugins/flutter_aepoptimize/lib/src/offer.dart b/plugins/flutter_aepoptimize/lib/src/offer.dart new file mode 100644 index 00000000..bf2dc200 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/offer.dart @@ -0,0 +1,99 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import 'package:flutter/services.dart'; +import 'package:flutter_aepoptimize/src/offer_type.dart'; + +class Offer { + static const MethodChannel _channel = + const MethodChannel('flutter_aepoptimize'); + + final String id; + final String etag; + final double score; + final String schema; + final Map? meta; + final OfferType type; + final List? language; + final String content; + final Map? characteristics; + + Offer({ + required this.id, + this.etag = '', + this.score = 0, + this.schema = '', + this.meta, + this.type = OfferType.unknown, + this.language, + this.content = '', + this.characteristics, + }); + + factory Offer.fromMap(Map map) { + return Offer( + id: map['id'] as String? ?? '', + etag: map['etag'] as String? ?? '', + score: (map['score'] as num?)?.toDouble() ?? 0, + schema: map['schema'] as String? ?? '', + meta: map['meta'] != null + ? Map.from(map['meta'] as Map) + : null, + type: (map['type'] as int? ?? 0).toOfferType(), + language: map['language'] != null + ? List.from(map['language'] as List) + : null, + content: map['content'] as String? ?? '', + characteristics: map['characteristics'] != null + ? Map.from(map['characteristics'] as Map) + : null, + ); + } + + Map toMap() { + return { + 'id': id, + 'etag': etag, + 'score': score, + 'schema': schema, + 'meta': meta, + 'type': type.rawValue, + 'language': language, + 'content': content, + 'characteristics': characteristics, + }; + } + + Future displayed() { + return _channel.invokeMethod('offerDisplayed', toMap()); + } + + Future tapped() { + return _channel.invokeMethod('offerTapped', toMap()); + } + + Future?> generateDisplayInteractionXdm() { + return _channel + .invokeMapMethod( + 'generateDisplayInteractionXdm', toMap()) + .then((value) => value); + } + + Future?> generateTapInteractionXdm() { + return _channel + .invokeMapMethod( + 'generateTapInteractionXdm', toMap()) + .then((value) => value); + } + + @override + String toString() => 'Offer(id: $id, type: $type, content: $content)'; +} diff --git a/plugins/flutter_aepoptimize/lib/src/offer_type.dart b/plugins/flutter_aepoptimize/lib/src/offer_type.dart new file mode 100644 index 00000000..ddaed918 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/offer_type.dart @@ -0,0 +1,63 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +enum OfferType { unknown, json, text, html, image } + +extension OfferTypeExtension on OfferType { + int get rawValue { + switch (this) { + case OfferType.unknown: + return 0; + case OfferType.json: + return 1; + case OfferType.text: + return 2; + case OfferType.html: + return 3; + case OfferType.image: + return 4; + } + } + + String get mimeType { + switch (this) { + case OfferType.unknown: + return ''; + case OfferType.json: + return 'application/json'; + case OfferType.text: + return 'text/plain'; + case OfferType.html: + return 'text/html'; + case OfferType.image: + return 'image/*'; + } + } +} + +extension OfferTypeFromInt on int { + OfferType toOfferType() { + switch (this) { + case 0: + return OfferType.unknown; + case 1: + return OfferType.json; + case 2: + return OfferType.text; + case 3: + return OfferType.html; + case 4: + return OfferType.image; + default: + return OfferType.unknown; + } + } +} diff --git a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart new file mode 100644 index 00000000..41a2ef38 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart @@ -0,0 +1,66 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import 'package:flutter/services.dart'; +import 'package:flutter_aepoptimize/src/decision_scope.dart'; +import 'package:flutter_aepoptimize/src/offer.dart'; + +class OptimizeProposition { + static const MethodChannel _channel = + const MethodChannel('flutter_aepoptimize'); + + final String id; + final List offers; + final String scope; + final Map scopeDetails; + + OptimizeProposition({ + required this.id, + required this.offers, + required this.scope, + this.scopeDetails = const {}, + }); + + factory OptimizeProposition.fromMap(Map map) { + final offersList = (map['offers'] as List?) + ?.map((o) => Offer.fromMap(Map.from(o as Map))) + .toList() ?? + []; + + return OptimizeProposition( + id: map['id'] as String? ?? '', + offers: offersList, + scope: map['scope'] as String? ?? '', + scopeDetails: map['scopeDetails'] != null + ? Map.from(map['scopeDetails'] as Map) + : {}, + ); + } + + Map toMap() { + return { + 'id': id, + 'offers': offers.map((o) => o.toMap()).toList(), + 'scope': scope, + 'scopeDetails': scopeDetails, + }; + } + + Future?> generateReferenceXdm() { + return _channel + .invokeMapMethod('generateReferenceXdm', toMap()) + .then((value) => value); + } + + @override + String toString() => + 'OptimizeProposition(id: $id, scope: $scope, offers: ${offers.length})'; +} diff --git a/plugins/flutter_aepoptimize/pubspec.yaml b/plugins/flutter_aepoptimize/pubspec.yaml new file mode 100644 index 00000000..ccafae57 --- /dev/null +++ b/plugins/flutter_aepoptimize/pubspec.yaml @@ -0,0 +1,29 @@ +name: flutter_aepoptimize + +description: Official Adobe Experience Platform support for Flutter apps. The Optimize extension enables real-time personalization using Adobe Target and Offer Decisioning. +version: 5.0.0 +homepage: https://developer.adobe.com/client-sdks +repository: https://github.com/adobe/aepsdk_flutter/tree/main/plugins/flutter_aepoptimize + +environment: + sdk: ">=2.12.0 <4.0.0" + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_aepcore: ">=5.0.0 <6.0.0" + flutter_aepedge: ">=5.0.0 <6.0.0" + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.adobe.marketing.mobile.flutter.flutter_aepoptimize + pluginClass: FlutterAEPOptimizePlugin + ios: + pluginClass: FlutterAEPOptimizePlugin diff --git a/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart new file mode 100644 index 00000000..393dc395 --- /dev/null +++ b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart @@ -0,0 +1,636 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_aepoptimize/flutter_aepoptimize.dart'; + +void main() { + const MethodChannel channel = MethodChannel('flutter_aepoptimize'); + + TestWidgetsFlutterBinding.ensureInitialized(); + + group('extensionVersion', () { + final String testVersion = "5.0.0"; + final List log = []; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return testVersion; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('invokes correct method', () async { + await Optimize.extensionVersion; + + expect(log, [ + isMethodCall('extensionVersion', arguments: null), + ]); + }); + + test('returns correct result', () async { + expect(await Optimize.extensionVersion, testVersion); + }); + }); + + group('DecisionScope', () { + test('creates from name', () { + final scope = DecisionScope('myScope'); + expect(scope.name, 'myScope'); + expect(scope.toMap(), {'name': 'myScope'}); + }); + + test('creates from activity and placement', () { + final scope = DecisionScope.fromActivityAndPlacement( + activityId: 'xcore:offer-activity:1234', + placementId: 'xcore:offer-placement:5678', + itemCount: 3, + ); + expect(scope.name.isNotEmpty, true); + }); + + test('fromMap roundtrip', () { + final scope = DecisionScope('testScope'); + final restored = DecisionScope.fromMap(scope.toMap()); + expect(restored.name, scope.name); + expect(restored, scope); + }); + + test('equality', () { + final a = DecisionScope('same'); + final b = DecisionScope('same'); + final c = DecisionScope('different'); + expect(a, b); + expect(a.hashCode, b.hashCode); + expect(a == c, false); + }); + }); + + group('OfferType', () { + test('rawValue mapping', () { + expect(OfferType.unknown.rawValue, 0); + expect(OfferType.json.rawValue, 1); + expect(OfferType.text.rawValue, 2); + expect(OfferType.html.rawValue, 3); + expect(OfferType.image.rawValue, 4); + }); + + test('mimeType mapping', () { + expect(OfferType.json.mimeType, 'application/json'); + expect(OfferType.text.mimeType, 'text/plain'); + expect(OfferType.html.mimeType, 'text/html'); + expect(OfferType.image.mimeType, 'image/*'); + expect(OfferType.unknown.mimeType, ''); + }); + + test('int to OfferType conversion', () { + expect(0.toOfferType(), OfferType.unknown); + expect(1.toOfferType(), OfferType.json); + expect(2.toOfferType(), OfferType.text); + expect(3.toOfferType(), OfferType.html); + expect(4.toOfferType(), OfferType.image); + expect(99.toOfferType(), OfferType.unknown); + }); + }); + + group('Offer', () { + test('fromMap creates correct offer', () { + final map = { + 'id': 'offer-1', + 'etag': 'abc123', + 'score': 85.5, + 'schema': 'https://ns.adobe.com/experience/offer-management/content-component-html', + 'meta': {'key': 'value'}, + 'type': 3, + 'language': ['en', 'fr'], + 'content': '

Hello

', + 'characteristics': {'trait': 'premium'}, + }; + + final offer = Offer.fromMap(map); + expect(offer.id, 'offer-1'); + expect(offer.etag, 'abc123'); + expect(offer.score, 85.5); + expect(offer.type, OfferType.html); + expect(offer.language, ['en', 'fr']); + expect(offer.content, '

Hello

'); + expect(offer.characteristics, {'trait': 'premium'}); + }); + + test('toMap roundtrip', () { + final offer = Offer( + id: 'test-offer', + type: OfferType.json, + content: '{"key": "value"}', + ); + + final map = offer.toMap(); + final restored = Offer.fromMap(map); + expect(restored.id, offer.id); + expect(restored.type, offer.type); + expect(restored.content, offer.content); + }); + + test('fromMap handles missing fields', () { + final offer = Offer.fromMap({}); + expect(offer.id, ''); + expect(offer.type, OfferType.unknown); + expect(offer.content, ''); + expect(offer.meta, null); + expect(offer.language, null); + }); + }); + + group('OptimizeProposition', () { + test('fromMap creates correct proposition', () { + final map = { + 'id': 'prop-1', + 'scope': 'myScope', + 'scopeDetails': {'activity': {'id': 'act-1'}}, + 'offers': [ + { + 'id': 'offer-1', + 'type': 2, + 'content': 'Hello World', + } + ], + }; + + final proposition = OptimizeProposition.fromMap(map); + expect(proposition.id, 'prop-1'); + expect(proposition.scope, 'myScope'); + expect(proposition.offers.length, 1); + expect(proposition.offers[0].id, 'offer-1'); + expect(proposition.offers[0].type, OfferType.text); + }); + + test('toMap roundtrip', () { + final proposition = OptimizeProposition( + id: 'prop-2', + scope: 'testScope', + offers: [ + Offer(id: 'offer-2', type: OfferType.html, content: '

Test

'), + ], + ); + + final map = proposition.toMap(); + final restored = OptimizeProposition.fromMap(map); + expect(restored.id, proposition.id); + expect(restored.scope, proposition.scope); + expect(restored.offers.length, 1); + }); + }); + + group('updatePropositions', () { + final List log = []; + + final Map mockResponse = { + 'myScope': { + 'id': 'prop-1', + 'scope': 'myScope', + 'scopeDetails': {}, + 'offers': [ + {'id': 'offer-1', 'type': 2, 'content': 'Hello'}, + ], + } + }; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return mockResponse; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('invokes correct method with all parameters', () async { + final scopes = [DecisionScope('myScope')]; + await Optimize.updatePropositions( + scopes, + xdm: {'key': 'value'}, + data: {'dataKey': 'dataValue'}, + timeout: 15.0, + ); + + expect(log.length, 1); + expect(log[0].method, 'updatePropositions'); + final args = log[0].arguments as Map; + expect((args['decisionScopes'] as List).length, 1); + expect(args['xdm'], {'key': 'value'}); + expect(args['data'], {'dataKey': 'dataValue'}); + expect(args['timeout'], 15.0); + }); + + test('invokes with minimal parameters', () async { + await Optimize.updatePropositions([DecisionScope('scope1')]); + + expect(log.length, 1); + final args = log[0].arguments as Map; + expect(args['xdm'], null); + expect(args['data'], null); + expect(args['timeout'], null); + }); + + test('returns decoded propositions map', () async { + final result = await Optimize.updatePropositions([DecisionScope('myScope')]); + + expect(result, isNotNull); + expect(result!.length, 1); + final scope = DecisionScope('myScope'); + expect(result[scope], isNotNull); + expect(result[scope]!.id, 'prop-1'); + expect(result[scope]!.offers.length, 1); + }); + }); + + group('getPropositions', () { + final List log = []; + + final Map mockResponse = { + 'cachedScope': { + 'id': 'prop-cached', + 'scope': 'cachedScope', + 'scopeDetails': {}, + 'offers': [ + {'id': 'offer-cached', 'type': 1, 'content': '{"cached": true}'}, + ], + } + }; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return mockResponse; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('invokes correct method', () async { + await Optimize.getPropositions([DecisionScope('cachedScope')]); + + expect(log.length, 1); + expect(log[0].method, 'getPropositions'); + }); + + test('passes timeout when provided', () async { + await Optimize.getPropositions( + [DecisionScope('cachedScope')], + timeout: 5.0, + ); + + final args = log[0].arguments as Map; + expect(args['timeout'], 5.0); + }); + + test('returns decoded propositions', () async { + final result = await Optimize.getPropositions([DecisionScope('cachedScope')]); + + expect(result, isNotNull); + final prop = result![DecisionScope('cachedScope')]; + expect(prop, isNotNull); + expect(prop!.offers[0].type, OfferType.json); + }); + }); + + group('clearCachedPropositions', () { + final List log = []; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('invokes correct method', () async { + await Optimize.clearCachedPropositions(); + expect(log, [ + isMethodCall('clearCachedPropositions', arguments: null), + ]); + }); + }); + + group('offer tracking methods', () { + final List log = []; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('displayed invokes correct method', () async { + final offer = Offer(id: 'offer-track', type: OfferType.html, content: '

Hi

'); + await offer.displayed(); + + expect(log.length, 1); + expect(log[0].method, 'offerDisplayed'); + expect((log[0].arguments as Map)['id'], 'offer-track'); + }); + + test('tapped invokes correct method', () async { + final offer = Offer(id: 'offer-tap', type: OfferType.text, content: 'Tap me'); + await offer.tapped(); + + expect(log.length, 1); + expect(log[0].method, 'offerTapped'); + expect((log[0].arguments as Map)['id'], 'offer-tap'); + }); + + test('displayed passes all offer fields', () async { + final offer = Offer( + id: 'full-offer', + etag: 'etag123', + score: 90.5, + schema: 'https://schema.example', + meta: {'campaign': 'summer'}, + type: OfferType.json, + language: ['en', 'de'], + content: '{"promo": true}', + characteristics: {'tier': 'gold'}, + ); + await offer.displayed(); + + final args = log[0].arguments as Map; + expect(args['id'], 'full-offer'); + expect(args['etag'], 'etag123'); + expect(args['score'], 90.5); + expect(args['type'], 1); + expect(args['language'], ['en', 'de']); + expect(args['characteristics'], {'tier': 'gold'}); + }); + }); + + group('generateDisplayInteractionXdm', () { + final List log = []; + final Map mockXdm = { + 'eventType': 'decisioning.propositionDisplay', + '_experience': {'decisioning': {'propositionID': 'prop-1'}}, + }; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return mockXdm; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('invokes correct method and returns XDM', () async { + final offer = Offer(id: 'xdm-offer', type: OfferType.html, content: '

Ad

'); + final result = await offer.generateDisplayInteractionXdm(); + + expect(log.length, 1); + expect(log[0].method, 'generateDisplayInteractionXdm'); + expect(result, isNotNull); + expect(result!['eventType'], 'decisioning.propositionDisplay'); + }); + }); + + group('generateTapInteractionXdm', () { + final List log = []; + final Map mockXdm = { + 'eventType': 'decisioning.propositionInteract', + }; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return mockXdm; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('invokes correct method and returns XDM', () async { + final offer = Offer(id: 'tap-xdm', type: OfferType.text, content: 'Click'); + final result = await offer.generateTapInteractionXdm(); + + expect(log.length, 1); + expect(log[0].method, 'generateTapInteractionXdm'); + expect(result, isNotNull); + expect(result!['eventType'], 'decisioning.propositionInteract'); + }); + }); + + group('generateReferenceXdm', () { + final List log = []; + final Map mockXdm = { + '_experience': {'decisioning': {'propositionID': 'ref-prop'}}, + }; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return mockXdm; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('invokes correct method and returns XDM', () async { + final proposition = OptimizeProposition( + id: 'ref-prop', + scope: 'refScope', + offers: [], + ); + final result = await proposition.generateReferenceXdm(); + + expect(log.length, 1); + expect(log[0].method, 'generateReferenceXdm'); + expect((log[0].arguments as Map)['id'], 'ref-prop'); + expect(result, isNotNull); + }); + }); + + group('onPropositionsUpdate listener', () { + final List log = []; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + log.add(methodCall); + return null; + }); + }); + + tearDown(() { + log.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('registers listener by invoking registerOnPropositionsUpdate', () { + Optimize.onPropositionsUpdate((propositions) {}); + + expect(log.length, 1); + expect(log[0].method, 'registerOnPropositionsUpdate'); + }); + + test('callback fires when native pushes propositions', () async { + Map? received; + Optimize.onPropositionsUpdate((propositions) { + received = propositions; + }); + + final Map mockUpdate = { + 'listenerScope': { + 'id': 'prop-live', + 'scope': 'listenerScope', + 'scopeDetails': {}, + 'offers': [ + {'id': 'offer-live', 'type': 3, 'content': 'Live'}, + ], + } + }; + + // Simulate native → Dart callback via platform message + final ByteData message = const StandardMethodCodec() + .encodeMethodCall(MethodCall('onPropositionsUpdate', mockUpdate)); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + 'flutter_aepoptimize', + message, + (ByteData? reply) {}, + ); + + expect(received, isNotNull); + expect(received!.length, 1); + final scope = DecisionScope('listenerScope'); + expect(received![scope]!.id, 'prop-live'); + expect(received![scope]!.offers[0].type, OfferType.html); + }); + }); + + group('cross-layer consistency', () { + test('all method channel names match between Dart API and tests', () { + // This test documents all method channel call names that must exist + // in both iOS FlutterAEPOptimizePlugin.m and Android FlutterAEPOptimizePlugin.java + final expectedMethods = [ + 'extensionVersion', + 'updatePropositions', + 'getPropositions', + 'registerOnPropositionsUpdate', + 'clearCachedPropositions', + 'offerDisplayed', + 'offerTapped', + 'generateDisplayInteractionXdm', + 'generateTapInteractionXdm', + 'generateReferenceXdm', + ]; + + // Ensure list is complete - this test fails if we add a method + // to the Dart API but forget to add it here as a reminder to + // also add it to the native bridges + expect(expectedMethods.length, 10); + }); + }); + + group('edge cases', () { + test('Offer.fromMap handles null meta, language, characteristics', () { + final offer = Offer.fromMap({ + 'id': 'minimal', + 'type': 2, + 'content': 'hello', + }); + expect(offer.meta, isNull); + expect(offer.language, isNull); + expect(offer.characteristics, isNull); + expect(offer.etag, ''); + expect(offer.score, 0); + expect(offer.schema, ''); + }); + + test('OptimizeProposition.fromMap handles empty offers list', () { + final prop = OptimizeProposition.fromMap({ + 'id': 'empty-prop', + 'scope': 'emptyScope', + }); + expect(prop.offers, isEmpty); + expect(prop.scopeDetails, isEmpty); + }); + + test('DecisionScope.fromActivityAndPlacement encodes to base64', () { + final scope = DecisionScope.fromActivityAndPlacement( + activityId: 'act-1', + placementId: 'place-1', + itemCount: 5, + ); + // The name should be a base64-encoded JSON string + expect(scope.name.contains('act-1'), isFalse); + expect(scope.name.isNotEmpty, isTrue); + }); + + test('updatePropositions returns null when channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + return null; + }); + + final result = await Optimize.updatePropositions([DecisionScope('s')]); + expect(result, isNull); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + }); +} From 8e03bd73c12fb3b85d2056a5424a1210b2093480 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Sat, 20 Jun 2026 20:12:03 +0530 Subject: [PATCH 2/2] minor fix --- .../ios/Classes/FlutterAEPOptimizePlugin.m | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m index ecad18ac..944f575f 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -70,34 +70,33 @@ - (void)handleUpdatePropositions:(FlutterMethodCall *)call result:(FlutterResult return; } - NSDictionary *xdm = arguments[@"xdm"]; - NSDictionary *data = arguments[@"data"]; - NSNumber *timeoutNumber = arguments[@"timeout"]; + NSDictionary *xdm = [arguments[@"xdm"] isKindOfClass:[NSDictionary class]] ? arguments[@"xdm"] : nil; + NSDictionary *data = [arguments[@"data"] isKindOfClass:[NSDictionary class]] ? arguments[@"data"] : nil; + NSNumber *timeoutNumber = [arguments[@"timeout"] isKindOfClass:[NSNumber class]] ? arguments[@"timeout"] : nil; - if (timeoutNumber && ![timeoutNumber isKindOfClass:[NSNull class]]) { - NSTimeInterval timeout = [timeoutNumber doubleValue]; - [AEPMobileOptimize updatePropositionsFor:scopes - withXdm:xdm - andData:data - timeout:timeout - :^(NSDictionary * _Nullable propositions, NSError * _Nullable error) { + void (^completionHandler)(NSDictionary * _Nullable, NSError * _Nullable) = + ^(NSDictionary * _Nullable propositions, NSError * _Nullable error) { if (error) { result([self flutterErrorFromNSError:error]); } else { result([FlutterAEPOptimizeDataBridge dictionaryFromPropositionsMap:propositions]); } - }]; + }; + + if (timeoutNumber && ![timeoutNumber isKindOfClass:[NSNull class]]) { + NSTimeInterval timeout = [timeoutNumber doubleValue]; + // Note: the SDK's generated ObjC header has swapped parameter names for timeout/andData. + // The `timeout:` selector slot takes the data dictionary, and `andData:` takes the NSTimeInterval. + [AEPMobileOptimize updatePropositions:scopes + withXdm:xdm + timeout:data + andData:timeout + completion:completionHandler]; } else { - [AEPMobileOptimize updatePropositionsFor:scopes - withXdm:xdm - andData:data - :^(NSDictionary * _Nullable propositions, NSError * _Nullable error) { - if (error) { - result([self flutterErrorFromNSError:error]); - } else { - result([FlutterAEPOptimizeDataBridge dictionaryFromPropositionsMap:propositions]); - } - }]; + [AEPMobileOptimize updatePropositions:scopes + withXdm:xdm + andData:data + completion:completionHandler]; } } @@ -112,7 +111,7 @@ - (void)handleGetPropositions:(FlutterMethodCall *)call result:(FlutterResult)re return; } - NSNumber *timeoutNumber = arguments[@"timeout"]; + NSNumber *timeoutNumber = [arguments[@"timeout"] isKindOfClass:[NSNumber class]] ? arguments[@"timeout"] : nil; void (^completionHandler)(NSDictionary * _Nullable, NSError * _Nullable) = ^(NSDictionary * _Nullable propositions, NSError * _Nullable error) { @@ -123,15 +122,15 @@ - (void)handleGetPropositions:(FlutterMethodCall *)call result:(FlutterResult)re } }; - if (timeoutNumber && ![timeoutNumber isKindOfClass:[NSNull class]]) { - [AEPMobileOptimize getPropositionsFor:scopes timeout:[timeoutNumber doubleValue] :completionHandler]; + if (timeoutNumber) { + [AEPMobileOptimize getPropositions:scopes timeout:[timeoutNumber doubleValue] completion:completionHandler]; } else { - [AEPMobileOptimize getPropositionsFor:scopes :completionHandler]; + [AEPMobileOptimize getPropositions:scopes completion:completionHandler]; } } - (void)handleRegisterOnPropositionsUpdate:(FlutterResult)result { - [AEPMobileOptimize onPropositionsUpdateWithPerform:^(NSDictionary * _Nonnull propositions) { + [AEPMobileOptimize onPropositionsUpdate:^(NSDictionary * _Nonnull propositions) { NSDictionary *encoded = [FlutterAEPOptimizeDataBridge dictionaryFromPropositionsMap:propositions]; dispatch_async(dispatch_get_main_queue(), ^{ [self.channel invokeMethod:@"onPropositionsUpdate" arguments:encoded];