From d8fef3710245ce2df42577bec917e81b4c4c8405 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Sat, 20 Jun 2026 16:54:25 +0530 Subject: [PATCH 01/14] 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 02/14] 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]; From 107854f43bbfb9385b0c545116d15a3ac4bdb6bd Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Tue, 30 Jun 2026 22:52:49 +0530 Subject: [PATCH 03/14] fixed displayed tapped and generateInteractionXDM apis by adding proposition object reference to --- .../FlutterAEPOptimizeDataBridge.java | 82 ++++++++++++------- .../FlutterAEPOptimizePlugin.java | 24 ++++++ .../Classes/FlutterAEPOptimizeDataBridge.h | 1 + .../Classes/FlutterAEPOptimizeDataBridge.m | 32 ++++++-- .../ios/Classes/FlutterAEPOptimizePlugin.m | 21 +++++ .../lib/flutter_aepoptimize.dart | 18 ++++ .../flutter_aepoptimize/lib/src/offer.dart | 27 +++++- .../lib/src/optimize_proposition.dart | 18 ++-- 8 files changed, 179 insertions(+), 44 deletions(-) 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 index 66f3d545..17bf4b4f 100644 --- 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 @@ -94,47 +94,73 @@ static Offer offerFromMap(Map map) { 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"); + String propositionId = getNullableString(map, "propositionId"); + String propositionScope = getNullableString(map, "propositionScope"); + Map scopeDetails = getNullableMap(map, "propositionScopeDetails"); - Offer.Builder builder = new Offer.Builder( - id != null ? id : "", - intToOfferType(typeInt), - content != null ? content : ""); + Map offerItemData = new HashMap<>(); + offerItemData.put("id", map.get("id") != null ? map.get("id") : ""); + offerItemData.put("etag", map.get("etag") != null ? map.get("etag") : ""); + offerItemData.put("score", map.get("score") != null ? map.get("score") : 0); + offerItemData.put("schema", map.get("schema") != null ? map.get("schema") : ""); - String etag = getNullableString(map, "etag"); - if (etag != null) { - builder.setEtag(etag); + Map dataPayload = new HashMap<>(); + int typeInt = map.containsKey("type") && map.get("type") instanceof Number + ? ((Number) map.get("type")).intValue() : 0; + dataPayload.put("type", mimeTypeFromOfferType(typeInt)); + dataPayload.put("content", map.get("content") != null ? map.get("content") : ""); + if (map.get("language") instanceof List) { + dataPayload.put("language", map.get("language")); } - - if (map.containsKey("score") && map.get("score") instanceof Number) { - builder.setScore(((Number) map.get("score")).doubleValue()); + if (map.get("characteristics") instanceof Map) { + dataPayload.put("characteristics", map.get("characteristics")); } + offerItemData.put("data", dataPayload); - String schema = getNullableString(map, "schema"); - if (schema != null) { - builder.setSchema(schema); + if (map.get("meta") instanceof Map) { + offerItemData.put("meta", map.get("meta")); } - Map meta = getNullableMap(map, "meta"); - if (meta != null) { - builder.setMeta(meta); + List> items = new ArrayList<>(); + items.add(offerItemData); + + Map propositionData = new HashMap<>(); + propositionData.put("id", propositionId != null ? propositionId : ""); + propositionData.put("scope", propositionScope != null ? propositionScope : ""); + propositionData.put("scopeDetails", scopeDetails != null ? scopeDetails : new HashMap<>()); + propositionData.put("items", items); + + OptimizeProposition proposition = OptimizeProposition.fromEventData(propositionData); + if (proposition != null && proposition.getOffers() != null && !proposition.getOffers().isEmpty()) { + return proposition.getOffers().get(0); } + return null; + } - List language = (List) map.get("language"); - if (language != null) { - builder.setLanguage(language); + @SuppressWarnings("unchecked") + static List offersFromList(List> list) { + if (list == null) { + return null; } - Map characteristics = map.containsKey("characteristics") && map.get("characteristics") instanceof Map - ? (Map) map.get("characteristics") : null; - if (characteristics != null) { - builder.setCharacteristics(characteristics); + List offers = new ArrayList<>(); + for (Map item : list) { + Offer offer = offerFromMap(item); + if (offer != null) { + offers.add(offer); + } } + return offers; + } - return builder.build(); + static String mimeTypeFromOfferType(int typeValue) { + switch (typeValue) { + case 1: return "application/json"; + case 2: return "text/plain"; + case 3: return "text/html"; + case 4: return "image/*"; + default: return ""; + } } @SuppressWarnings("unchecked") 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 index 1c3619ed..dc919dc9 100644 --- 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 @@ -18,6 +18,7 @@ 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.OfferUtils; import com.adobe.marketing.mobile.optimize.Optimize; import com.adobe.marketing.mobile.optimize.OptimizeProposition; @@ -73,6 +74,10 @@ public void onMethodCall(MethodCall call, @NonNull Result result) { handleGenerateTapInteractionXdm(call, result); } else if ("generateReferenceXdm".equals(call.method)) { handleGenerateReferenceXdm(call, result); + } else if ("batchDisplayed".equals(call.method)) { + handleBatchDisplayed(call, result); + } else if ("batchGenerateDisplayInteractionXdm".equals(call.method)) { + handleBatchGenerateDisplayInteractionXdm(call, result); } else { result.notImplemented(); } @@ -200,4 +205,23 @@ private void handleGenerateReferenceXdm(MethodCall call, Result result) { result.success(null); } } + + @SuppressWarnings("unchecked") + private void handleBatchDisplayed(MethodCall call, Result result) { + List offers = FlutterAEPOptimizeDataBridge.offersFromList((List>) call.arguments); + if (offers != null && !offers.isEmpty()) { + OfferUtils.displayed(offers); + } + result.success(null); + } + + @SuppressWarnings("unchecked") + private void handleBatchGenerateDisplayInteractionXdm(MethodCall call, Result result) { + List offers = FlutterAEPOptimizeDataBridge.offersFromList((List>) call.arguments); + if (offers != null && !offers.isEmpty()) { + result.success(OfferUtils.generateDisplayInteractionXdm(offers)); + } else { + result.success(null); + } + } } diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h index ed10a437..e4f1eb0f 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h @@ -18,6 +18,7 @@ governing permissions and limitations under the License. + (NSArray *_Nullable)decisionScopesFromArray:(NSArray *_Nullable)array; + (NSDictionary *_Nullable)dictionaryFromPropositionsMap:(NSDictionary *_Nullable)propositions; + (AEPOffer *_Nullable)offerFromDictionary:(NSDictionary *_Nullable)dict; ++ (NSArray *_Nullable)offersFromArray:(NSArray *_Nullable)array; + (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 index 3476d81b..56e25785 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m @@ -97,22 +97,25 @@ + (AEPOffer *)offerFromDictionary:(NSDictionary *)dict { return nil; } - // Reconstruct the offer via the proposition data path so tracking context is preserved + NSString *propositionId = [dict[@"propositionId"] isKindOfClass:[NSString class]] ? dict[@"propositionId"] : @""; + NSString *propositionScope = [dict[@"propositionScope"] isKindOfClass:[NSString class]] ? dict[@"propositionScope"] : @""; + NSDictionary *scopeDetails = [dict[@"propositionScopeDetails"] isKindOfClass:[NSDictionary class]] ? dict[@"propositionScopeDetails"] : @{}; + NSDictionary *propositionData = @{ - @"id": @"", - @"scope": @"", - @"scopeDetails": @{}, + @"id": propositionId, + @"scope": propositionScope, + @"scopeDetails": scopeDetails, @"items": @[@{ @"id": dict[@"id"] ?: @"", @"etag": dict[@"etag"] ?: @"", @"score": dict[@"score"] ?: @(0), @"schema": dict[@"schema"] ?: @"", - @"meta": dict[@"meta"] ?: @{}, + @"meta": [dict[@"meta"] isKindOfClass:[NSDictionary class]] ? dict[@"meta"] : @{}, @"data": @{ @"type": [self mimeTypeFromOfferType:dict[@"type"]], @"content": dict[@"content"] ?: @"", - @"language": dict[@"language"] ?: @[], - @"characteristics": dict[@"characteristics"] ?: @{} + @"language": [dict[@"language"] isKindOfClass:[NSArray class]] ? dict[@"language"] : @[], + @"characteristics": [dict[@"characteristics"] isKindOfClass:[NSDictionary class]] ? dict[@"characteristics"] : @{} } }] }; @@ -124,6 +127,21 @@ + (AEPOffer *)offerFromDictionary:(NSDictionary *)dict { return nil; } ++ (NSArray *)offersFromArray:(NSArray *)array { + if (!array || ![array isKindOfClass:[NSArray class]]) { + return nil; + } + + NSMutableArray *offers = [NSMutableArray array]; + for (NSDictionary *dict in array) { + AEPOffer *offer = [self offerFromDictionary:dict]; + if (offer) { + [offers addObject:offer]; + } + } + return offers; +} + #pragma mark - OfferType + (NSString *)mimeTypeFromOfferType:(NSNumber *)typeValue { diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m index 944f575f..c20e9241 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -52,6 +52,10 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result [self handleGenerateTapInteractionXdm:call result:result]; } else if ([@"generateReferenceXdm" isEqualToString:call.method]) { [self handleGenerateReferenceXdm:call result:result]; + } else if ([@"batchDisplayed" isEqualToString:call.method]) { + [self handleBatchDisplayed:call result:result]; + } else if ([@"batchGenerateDisplayInteractionXdm" isEqualToString:call.method]) { + [self handleBatchGenerateDisplayInteractionXdm:call result:result]; } else { result(FlutterMethodNotImplemented); } @@ -182,6 +186,23 @@ - (void)handleGenerateReferenceXdm:(FlutterMethodCall *)call result:(FlutterResu } } +- (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)result { + NSArray *offers = [FlutterAEPOptimizeDataBridge offersFromArray:call.arguments]; + if (offers && offers.count > 0) { + [AEPMobileOptimize displayed:offers]; + } + result(nil); +} + +- (void)handleBatchGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { + NSArray *offers = [FlutterAEPOptimizeDataBridge offersFromArray:call.arguments]; + if (offers && offers.count > 0) { + result([AEPMobileOptimize generateDisplayInteractionXdm:offers]); + } else { + result(nil); + } +} + #pragma mark - Helpers - (FlutterError *)flutterErrorFromNSError:(NSError *)error { diff --git a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart index e95bab2f..911eb0c4 100644 --- a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart @@ -92,6 +92,24 @@ class Optimize { return _channel.invokeMethod('clearCachedPropositions'); } + /// Tracks display events for the given [offers] in a single batch. + /// + /// Offers can belong to different propositions; the SDK de-duplicates + /// them into unique propositions before dispatching the tracking event. + static Future displayed(List offers) { + return _channel.invokeMethod( + 'batchDisplayed', offers.map((o) => o.toTrackingMap()).toList()); + } + + /// Generates XDM-formatted data for display interactions of the given + /// [offers] in a single batch. + static Future?> generateDisplayInteractionXdm( + List offers) { + return _channel.invokeMapMethod( + 'batchGenerateDisplayInteractionXdm', + offers.map((o) => o.toTrackingMap()).toList()); + } + static Map _decodePropositionsMap( Map rawMap, ) { diff --git a/plugins/flutter_aepoptimize/lib/src/offer.dart b/plugins/flutter_aepoptimize/lib/src/offer.dart index bf2dc200..752871c7 100644 --- a/plugins/flutter_aepoptimize/lib/src/offer.dart +++ b/plugins/flutter_aepoptimize/lib/src/offer.dart @@ -26,6 +26,10 @@ class Offer { final String content; final Map? characteristics; + String _propositionId = ''; + String _propositionScope = ''; + Map _propositionScopeDetails = {}; + Offer({ required this.id, this.etag = '', @@ -58,6 +62,12 @@ class Offer { ); } + void setPropositionContext(String propositionId, String scope, Map scopeDetails) { + _propositionId = propositionId; + _propositionScope = scope; + _propositionScopeDetails = scopeDetails; + } + Map toMap() { return { 'id': id, @@ -72,25 +82,34 @@ class Offer { }; } + Map toTrackingMap() { + return { + ...toMap(), + 'propositionId': _propositionId, + 'propositionScope': _propositionScope, + 'propositionScopeDetails': _propositionScopeDetails, + }; + } + Future displayed() { - return _channel.invokeMethod('offerDisplayed', toMap()); + return _channel.invokeMethod('offerDisplayed', toTrackingMap()); } Future tapped() { - return _channel.invokeMethod('offerTapped', toMap()); + return _channel.invokeMethod('offerTapped', toTrackingMap()); } Future?> generateDisplayInteractionXdm() { return _channel .invokeMapMethod( - 'generateDisplayInteractionXdm', toMap()) + 'generateDisplayInteractionXdm', toTrackingMap()) .then((value) => value); } Future?> generateTapInteractionXdm() { return _channel .invokeMapMethod( - 'generateTapInteractionXdm', toMap()) + 'generateTapInteractionXdm', toTrackingMap()) .then((value) => value); } diff --git a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart index 41a2ef38..3de57a7f 100644 --- a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart +++ b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart @@ -30,18 +30,26 @@ class OptimizeProposition { }); factory OptimizeProposition.fromMap(Map map) { + final propId = map['id'] as String? ?? ''; + final propScope = map['scope'] as String? ?? ''; + final propScopeDetails = map['scopeDetails'] != null + ? Map.from(map['scopeDetails'] as Map) + : {}; + final offersList = (map['offers'] as List?) ?.map((o) => Offer.fromMap(Map.from(o as Map))) .toList() ?? []; + for (final offer in offersList) { + offer.setPropositionContext(propId, propScope, propScopeDetails); + } + return OptimizeProposition( - id: map['id'] as String? ?? '', + id: propId, offers: offersList, - scope: map['scope'] as String? ?? '', - scopeDetails: map['scopeDetails'] != null - ? Map.from(map['scopeDetails'] as Map) - : {}, + scope: propScope, + scopeDetails: propScopeDetails, ); } From 1cda4d62a1e124cda709b51eeb5b4da4a15654cf Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Wed, 1 Jul 2026 00:37:37 +0530 Subject: [PATCH 04/14] fixed proposition tracking as native AEP Offers have weak proposition reference which becomes null once offer are retrieved and tracking calls are performed later on it. --- .../FlutterAEPOptimizeDataBridge.java | 37 ++------------ .../FlutterAEPOptimizePlugin.java | 51 +++++++++++++------ .../Classes/FlutterAEPOptimizeDataBridge.h | 3 +- .../Classes/FlutterAEPOptimizeDataBridge.m | 27 +++------- .../ios/Classes/FlutterAEPOptimizePlugin.m | 36 ++++++++++--- 5 files changed, 75 insertions(+), 79 deletions(-) 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 index 17bf4b4f..a0e1c03b 100644 --- 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 @@ -89,7 +89,7 @@ static Map mapFromOffer(Offer offer) { } @SuppressWarnings("unchecked") - static Offer offerFromMap(Map map) { + static OptimizeProposition propositionFromOfferTrackingMap(Map map) { if (map == null) { return null; } @@ -105,9 +105,10 @@ static Offer offerFromMap(Map map) { offerItemData.put("schema", map.get("schema") != null ? map.get("schema") : ""); Map dataPayload = new HashMap<>(); + dataPayload.put("id", map.get("id") != null ? map.get("id") : ""); int typeInt = map.containsKey("type") && map.get("type") instanceof Number ? ((Number) map.get("type")).intValue() : 0; - dataPayload.put("type", mimeTypeFromOfferType(typeInt)); + dataPayload.put("format", mimeTypeFromOfferType(typeInt)); dataPayload.put("content", map.get("content") != null ? map.get("content") : ""); if (map.get("language") instanceof List) { dataPayload.put("language", map.get("language")); @@ -130,27 +131,7 @@ static Offer offerFromMap(Map map) { propositionData.put("scopeDetails", scopeDetails != null ? scopeDetails : new HashMap<>()); propositionData.put("items", items); - OptimizeProposition proposition = OptimizeProposition.fromEventData(propositionData); - if (proposition != null && proposition.getOffers() != null && !proposition.getOffers().isEmpty()) { - return proposition.getOffers().get(0); - } - return null; - } - - @SuppressWarnings("unchecked") - static List offersFromList(List> list) { - if (list == null) { - return null; - } - - List offers = new ArrayList<>(); - for (Map item : list) { - Offer offer = offerFromMap(item); - if (offer != null) { - offers.add(offer); - } - } - return offers; + return OptimizeProposition.fromEventData(propositionData); } static String mimeTypeFromOfferType(int typeValue) { @@ -183,16 +164,6 @@ static int offerTypeToInt(OfferType type) { } } - 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; } 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 index dc919dc9..be38451f 100644 --- 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 @@ -29,6 +29,7 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler; import io.flutter.plugin.common.MethodChannel.Result; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -160,27 +161,27 @@ private void handleRegisterOnPropositionsUpdate(final Result result) { @SuppressWarnings("unchecked") private void handleOfferDisplayed(MethodCall call, Result result) { - Offer offer = FlutterAEPOptimizeDataBridge.offerFromMap((Map) call.arguments); - if (offer != null) { - offer.displayed(); + OptimizeProposition proposition = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap((Map) call.arguments); + if (proposition != null && proposition.getOffers() != null && !proposition.getOffers().isEmpty()) { + proposition.getOffers().get(0).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(); + OptimizeProposition proposition = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap((Map) call.arguments); + if (proposition != null && proposition.getOffers() != null && !proposition.getOffers().isEmpty()) { + proposition.getOffers().get(0).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()); + OptimizeProposition proposition = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap((Map) call.arguments); + if (proposition != null && proposition.getOffers() != null && !proposition.getOffers().isEmpty()) { + result.success(proposition.getOffers().get(0).generateDisplayInteractionXdm()); } else { result.success(null); } @@ -188,9 +189,9 @@ private void handleGenerateDisplayInteractionXdm(MethodCall call, Result result) @SuppressWarnings("unchecked") private void handleGenerateTapInteractionXdm(MethodCall call, Result result) { - Offer offer = FlutterAEPOptimizeDataBridge.offerFromMap((Map) call.arguments); - if (offer != null) { - result.success(offer.generateTapInteractionXdm()); + OptimizeProposition proposition = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap((Map) call.arguments); + if (proposition != null && proposition.getOffers() != null && !proposition.getOffers().isEmpty()) { + result.success(proposition.getOffers().get(0).generateTapInteractionXdm()); } else { result.success(null); } @@ -208,8 +209,17 @@ private void handleGenerateReferenceXdm(MethodCall call, Result result) { @SuppressWarnings("unchecked") private void handleBatchDisplayed(MethodCall call, Result result) { - List offers = FlutterAEPOptimizeDataBridge.offersFromList((List>) call.arguments); - if (offers != null && !offers.isEmpty()) { + List> items = (List>) call.arguments; + List propositions = new ArrayList<>(); + List offers = new ArrayList<>(); + for (Map item : items) { + OptimizeProposition prop = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap(item); + if (prop != null && prop.getOffers() != null && !prop.getOffers().isEmpty()) { + propositions.add(prop); + offers.add(prop.getOffers().get(0)); + } + } + if (!offers.isEmpty()) { OfferUtils.displayed(offers); } result.success(null); @@ -217,8 +227,17 @@ private void handleBatchDisplayed(MethodCall call, Result result) { @SuppressWarnings("unchecked") private void handleBatchGenerateDisplayInteractionXdm(MethodCall call, Result result) { - List offers = FlutterAEPOptimizeDataBridge.offersFromList((List>) call.arguments); - if (offers != null && !offers.isEmpty()) { + List> items = (List>) call.arguments; + List propositions = new ArrayList<>(); + List offers = new ArrayList<>(); + for (Map item : items) { + OptimizeProposition prop = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap(item); + if (prop != null && prop.getOffers() != null && !prop.getOffers().isEmpty()) { + propositions.add(prop); + offers.add(prop.getOffers().get(0)); + } + } + if (!offers.isEmpty()) { result.success(OfferUtils.generateDisplayInteractionXdm(offers)); } else { result.success(null); diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h index e4f1eb0f..de8c1226 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h @@ -17,8 +17,7 @@ governing permissions and limitations under the License. + (NSArray *_Nullable)decisionScopesFromArray:(NSArray *_Nullable)array; + (NSDictionary *_Nullable)dictionaryFromPropositionsMap:(NSDictionary *_Nullable)propositions; -+ (AEPOffer *_Nullable)offerFromDictionary:(NSDictionary *_Nullable)dict; -+ (NSArray *_Nullable)offersFromArray:(NSArray *_Nullable)array; + (AEPOptimizeProposition *_Nullable)propositionFromDictionary:(NSDictionary *_Nullable)dict; ++ (AEPOptimizeProposition *_Nullable)propositionFromOfferTrackingDictionary:(NSDictionary *_Nullable)dict; @end diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m index 56e25785..a916a481 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m @@ -92,7 +92,7 @@ + (NSDictionary *)dictionaryFromOffer:(AEPOffer *)offer { return dict; } -+ (AEPOffer *)offerFromDictionary:(NSDictionary *)dict { ++ (AEPOptimizeProposition *)propositionFromOfferTrackingDictionary:(NSDictionary *)dict { if (!dict || ![dict isKindOfClass:[NSDictionary class]]) { return nil; } @@ -112,7 +112,8 @@ + (AEPOffer *)offerFromDictionary:(NSDictionary *)dict { @"schema": dict[@"schema"] ?: @"", @"meta": [dict[@"meta"] isKindOfClass:[NSDictionary class]] ? dict[@"meta"] : @{}, @"data": @{ - @"type": [self mimeTypeFromOfferType:dict[@"type"]], + @"id": dict[@"id"] ?: @"", + @"format": [self mimeTypeFromOfferType:dict[@"type"]], @"content": dict[@"content"] ?: @"", @"language": [dict[@"language"] isKindOfClass:[NSArray class]] ? dict[@"language"] : @[], @"characteristics": [dict[@"characteristics"] isKindOfClass:[NSDictionary class]] ? dict[@"characteristics"] : @{} @@ -121,25 +122,11 @@ + (AEPOffer *)offerFromDictionary:(NSDictionary *)dict { }; AEPOptimizeProposition *proposition = [AEPOptimizeProposition initFromData:propositionData]; - if (proposition && proposition.offers.count > 0) { - return proposition.offers[0]; + if (proposition) { + // Access offers to trigger lazy property that sets offer.proposition = self + (void)proposition.offers; } - return nil; -} - -+ (NSArray *)offersFromArray:(NSArray *)array { - if (!array || ![array isKindOfClass:[NSArray class]]) { - return nil; - } - - NSMutableArray *offers = [NSMutableArray array]; - for (NSDictionary *dict in array) { - AEPOffer *offer = [self offerFromDictionary:dict]; - if (offer) { - [offers addObject:offer]; - } - } - return offers; + return proposition; } #pragma mark - OfferType diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m index c20e9241..22344637 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -144,7 +144,8 @@ - (void)handleRegisterOnPropositionsUpdate:(FlutterResult)result { } - (void)handleOfferDisplayed:(FlutterMethodCall *)call result:(FlutterResult)result { - AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; if (offer) { [offer displayed]; } @@ -152,7 +153,8 @@ - (void)handleOfferDisplayed:(FlutterMethodCall *)call result:(FlutterResult)res } - (void)handleOfferTapped:(FlutterMethodCall *)call result:(FlutterResult)result { - AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; if (offer) { [offer tapped]; } @@ -160,7 +162,8 @@ - (void)handleOfferTapped:(FlutterMethodCall *)call result:(FlutterResult)result } - (void)handleGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { - AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; if (offer) { result([offer generateDisplayInteractionXdm]); } else { @@ -169,7 +172,8 @@ - (void)handleGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(Fl } - (void)handleGenerateTapInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { - AEPOffer *offer = [FlutterAEPOptimizeDataBridge offerFromDictionary:call.arguments]; + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; if (offer) { result([offer generateTapInteractionXdm]); } else { @@ -187,16 +191,32 @@ - (void)handleGenerateReferenceXdm:(FlutterMethodCall *)call result:(FlutterResu } - (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)result { - NSArray *offers = [FlutterAEPOptimizeDataBridge offersFromArray:call.arguments]; - if (offers && offers.count > 0) { + NSMutableArray *propositions = [NSMutableArray array]; + NSMutableArray *offers = [NSMutableArray array]; + for (NSDictionary *dict in call.arguments) { + AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; + if (prop && prop.offers.count > 0) { + [propositions addObject:prop]; + [offers addObject:prop.offers[0]]; + } + } + if (offers.count > 0) { [AEPMobileOptimize displayed:offers]; } result(nil); } - (void)handleBatchGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { - NSArray *offers = [FlutterAEPOptimizeDataBridge offersFromArray:call.arguments]; - if (offers && offers.count > 0) { + NSMutableArray *propositions = [NSMutableArray array]; + NSMutableArray *offers = [NSMutableArray array]; + for (NSDictionary *dict in call.arguments) { + AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; + if (prop && prop.offers.count > 0) { + [propositions addObject:prop]; + [offers addObject:prop.offers[0]]; + } + } + if (offers.count > 0) { result([AEPMobileOptimize generateDisplayInteractionXdm:offers]); } else { result(nil); From e3715b825a46df917dcf9ea2d05d88a1b06f31c9 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Tue, 14 Jul 2026 16:29:36 +0530 Subject: [PATCH 05/14] test app changes for AEPOptimize Plugin --- .../plugins/GeneratedPluginRegistrant.java | 5 + example/lib/main.dart | 8 + example/lib/optimize.dart | 317 ++++++++++++++++++ example/pubspec.yaml | 5 + 4 files changed, 335 insertions(+) create mode 100644 example/lib/optimize.dart diff --git a/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index 108eb1de..8170ac39 100644 --- a/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/example/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -50,6 +50,11 @@ public static void registerWith(@NonNull FlutterEngine flutterEngine) { } catch (Exception e) { Log.e(TAG, "Error registering plugin flutter_aepmessaging, com.adobe.marketing.mobile.flutter.flutter_aepmessaging.FlutterAEPMessagingPlugin", e); } + try { + flutterEngine.getPlugins().add(new com.adobe.marketing.mobile.flutter.flutter_aepoptimize.FlutterAEPOptimizePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_aepoptimize, com.adobe.marketing.mobile.flutter.flutter_aepoptimize.FlutterAEPOptimizePlugin", e); + } try { flutterEngine.getPlugins().add(new com.adobe.marketing.mobile.flutter.flutter_aepuserprofile.FlutterAEPUserProfilePlugin()); } catch (Exception e) { diff --git a/example/lib/main.dart b/example/lib/main.dart index 25c7cf85..9cd91d10 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -20,6 +20,7 @@ import 'identity.dart'; import 'edgeIdentity.dart'; import 'edgebridge.dart'; import 'userprofile.dart'; +import 'optimize.dart'; void main() async { @@ -133,6 +134,13 @@ class _HomePageState extends State { MaterialPageRoute(builder: (context) => MessagingPage())); }, ), + ElevatedButton( + child: const Text('OPTIMIZE'), + onPressed: () { + Navigator.push(context, + MaterialPageRoute(builder: (context) => OptimizePage())); + }, + ), ]), )); } diff --git a/example/lib/optimize.dart b/example/lib/optimize.dart new file mode 100644 index 00000000..9366c8c5 --- /dev/null +++ b/example/lib/optimize.dart @@ -0,0 +1,317 @@ +/* +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:developer'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_aepoptimize/flutter_aepoptimize.dart'; +import 'util.dart'; + +class OptimizePage extends StatefulWidget { + @override + _OptimizePageState createState() => _OptimizePageState(); +} + +class _OptimizePageState extends State { + String _optimizeVersion = 'Unknown'; + String _propositionsResult = ''; + String _listenerResult = ''; + String _trackingResult = ''; + List _lastOffers = []; + + @override + void initState() { + super.initState(); + initPlatformState(); + } + + Future initPlatformState() async { + late String optimizeVersion; + + try { + optimizeVersion = await Optimize.extensionVersion; + } on PlatformException { + log("Failed to get Optimize extension version"); + optimizeVersion = 'Unknown'; + } + + if (!mounted) return; + + setState(() { + _optimizeVersion = optimizeVersion; + }); + } + + Future updatePropositions() async { + final scopes = [ + DecisionScope('akhil-test-mbox'), + ]; + + try { + final result = await Optimize.updatePropositions( + scopes, + xdm: {'eventType': 'personalization.request'}, + data: {'dataKey': 'dataValue'}, + ); + + if (!mounted) return; + + setState(() { + if (result != null) { + _lastOffers = result.values.expand((p) => p.offers).toList(); + _propositionsResult = + 'Received ${result.length} scope(s):\n${result.entries.map((e) => ' ${e.key.name}: ${e.value.offers.length} offer(s)').join('\n')}'; + } else { + _propositionsResult = 'No propositions returned'; + } + }); + } on PlatformException catch (e) { + if (!mounted) return; + setState(() { + _propositionsResult = 'Error: ${e.message}'; + }); + } + } + + Future updatePropositionsWithTimeout() async { + final scopes = [ + DecisionScope('xcore:offer-activity:1111111111111111'), + ]; + + try { + final result = await Optimize.updatePropositions( + scopes, + timeout: 5.0, + ); + + if (!mounted) return; + + setState(() { + _propositionsResult = result != null + ? 'Received ${result.length} scope(s) (with timeout)' + : 'No propositions returned (with timeout)'; + }); + } on PlatformException catch (e) { + if (!mounted) return; + setState(() { + _propositionsResult = 'Error: ${e.message}'; + }); + } + } + + Future getPropositions() async { + final scopes = [ + DecisionScope('akhil-test-mbox'), + ]; + + try { + final result = await Optimize.getPropositions(scopes); + + if (!mounted) return; + + setState(() { + _propositionsResult = result != null + ? 'Cached ${result.length} scope(s):\n${result.entries.map((e) => ' ${e.key.name}: ${e.value.offers.length} offer(s)').join('\n')}' + : 'No cached propositions'; + }); + } on PlatformException catch (e) { + if (!mounted) return; + setState(() { + _propositionsResult = 'Error: ${e.message}'; + }); + } + } + + void registerPropositionsListener() { + Optimize.onPropositionsUpdate((propositions) { + if (!mounted) return; + setState(() { + _listenerResult = + 'Listener fired: ${propositions.length} scope(s) updated'; + }); + }); + + setState(() { + _listenerResult = 'Listener registered'; + }); + } + + Future clearCachedPropositions() async { + await Optimize.clearCachedPropositions(); + + if (!mounted) return; + + setState(() { + _propositionsResult = 'Cache cleared'; + }); + } + + Future updateWithActivityPlacement() async { + final scope = DecisionScope.fromActivityAndPlacement( + activityId: 'xcore:offer-activity:1111111111111111', + placementId: 'xcore:offer-placement:2222222222222222', + ); + + try { + final result = await Optimize.updatePropositions([scope]); + + if (!mounted) return; + + setState(() { + _propositionsResult = result != null + ? 'Activity/Placement: ${result.length} scope(s)' + : 'No propositions returned'; + }); + } on PlatformException catch (e) { + if (!mounted) return; + setState(() { + _propositionsResult = 'Error: ${e.message}'; + }); + } + } + + // --- Offer instance tracking --- + + Future offerDisplayed() async { + if (_lastOffers.isEmpty) { + setState(() => _trackingResult = 'No offers — call updatePropositions first'); + return; + } + await _lastOffers.first.displayed(); + if (!mounted) return; + setState(() => _trackingResult = 'offer.displayed() sent for "${_lastOffers.first.id}"'); + } + + Future offerTapped() async { + if (_lastOffers.isEmpty) { + setState(() => _trackingResult = 'No offers — call updatePropositions first'); + return; + } + await _lastOffers.first.tapped(); + if (!mounted) return; + setState(() => _trackingResult = 'offer.tapped() sent for "${_lastOffers.first.id}"'); + } + + Future offerGenerateDisplayXdm() async { + if (_lastOffers.isEmpty) { + setState(() => _trackingResult = 'No offers — call updatePropositions first'); + return; + } + final xdm = await _lastOffers.first.generateDisplayInteractionXdm(); + if (!mounted) return; + setState(() => _trackingResult = 'generateDisplayInteractionXdm:\n$xdm'); + } + + Future offerGenerateTapXdm() async { + if (_lastOffers.isEmpty) { + setState(() => _trackingResult = 'No offers — call updatePropositions first'); + return; + } + final xdm = await _lastOffers.first.generateTapInteractionXdm(); + if (!mounted) return; + setState(() => _trackingResult = 'generateTapInteractionXdm:\n$xdm'); + } + + // --- Batch (static) tracking --- + + Future batchDisplayed() async { + if (_lastOffers.isEmpty) { + setState(() => _trackingResult = 'No offers — call updatePropositions first'); + return; + } + await Optimize.displayed(_lastOffers); + if (!mounted) return; + setState(() => _trackingResult = 'Optimize.displayed() sent for ${_lastOffers.length} offer(s)'); + } + + Future batchGenerateDisplayXdm() async { + if (_lastOffers.isEmpty) { + setState(() => _trackingResult = 'No offers — call updatePropositions first'); + return; + } + final xdm = await Optimize.generateDisplayInteractionXdm(_lastOffers); + if (!mounted) return; + setState(() => _trackingResult = 'Optimize.generateDisplayInteractionXdm:\n$xdm'); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text("Optimize Screen")), + body: Center( + child: ListView(shrinkWrap: true, children: [ + getRichText( + 'AEPOptimize extension version: ', '$_optimizeVersion\n'), + getRichText('Propositions result: ', '$_propositionsResult\n'), + getRichText('Listener status: ', '$_listenerResult\n'), + getRichText('Tracking result: ', '$_trackingResult\n'), + ElevatedButton( + child: Text("updatePropositions"), + onPressed: () => updatePropositions(), + ), + ElevatedButton( + child: Text("updatePropositions (with timeout)"), + onPressed: () => updatePropositionsWithTimeout(), + ), + ElevatedButton( + child: Text("updatePropositions (activity/placement)"), + onPressed: () => updateWithActivityPlacement(), + ), + ElevatedButton( + child: Text("getPropositions (from cache)"), + onPressed: () => getPropositions(), + ), + ElevatedButton( + child: Text("registerOnPropositionsUpdate"), + onPressed: () => registerPropositionsListener(), + ), + ElevatedButton( + child: Text("clearCachedPropositions"), + onPressed: () => clearCachedPropositions(), + ), + Divider(thickness: 2, height: 32), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Offer Tracking (uses first offer from last updatePropositions)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + ), + ElevatedButton( + child: Text("offer.displayed()"), + onPressed: () => offerDisplayed(), + ), + ElevatedButton( + child: Text("offer.tapped()"), + onPressed: () => offerTapped(), + ), + ElevatedButton( + child: Text("offer.generateDisplayInteractionXdm()"), + onPressed: () => offerGenerateDisplayXdm(), + ), + ElevatedButton( + child: Text("offer.generateTapInteractionXdm()"), + onPressed: () => offerGenerateTapXdm(), + ), + Divider(thickness: 2, height: 32), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text('Batch Tracking (all offers from last updatePropositions)', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)), + ), + ElevatedButton( + child: Text("Optimize.displayed(offers)"), + onPressed: () => batchDisplayed(), + ), + ElevatedButton( + child: Text("Optimize.generateDisplayInteractionXdm(offers)"), + onPressed: () => batchGenerateDisplayXdm(), + ), + ]), + )); +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1e504c38..c5d90681 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -30,6 +30,8 @@ dependencies: flutter_aepuserprofile: ">=5.0.0 <6.0.0" + flutter_aepoptimize: ">=5.0.0 <6.0.0" + dependency_overrides: flutter_aepcore: path: ../plugins/flutter_aepcore @@ -55,6 +57,9 @@ dependency_overrides: flutter_aepuserprofile: path: ../plugins/flutter_aepuserprofile + flutter_aepoptimize: + path: ../plugins/flutter_aepoptimize + dev_dependencies: flutter_test: sdk: flutter From bfa58f40298d047747fcfb1e33c3b97989bf41a5 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Fri, 24 Jul 2026 16:43:07 +0530 Subject: [PATCH 06/14] =?UTF-8?q?minor=20changes=20for=20CI=20fix=20-=20-X?= =?UTF-8?q?mx1536M=20=E2=86=92=20-Xmx4096M=20=E2=80=94=20gives=20Gradle=20?= =?UTF-8?q?enough=20heap=20to=20complete=20the=20build=20with=20the=20adde?= =?UTF-8?q?d=20Optimize=20plugin=20dependencies.=20enableJetifier=3Dtrue?= =?UTF-8?q?=20=E2=86=92=20false=20=E2=80=94=20Jetifier=20is=20no=20longer?= =?UTF-8?q?=20needed=20since=20all=20AEP=20SDKs=20use=20AndroidX=20nativel?= =?UTF-8?q?y.=20Disabling=20it=20removes=20the=20JetifyTransform=20step=20?= =?UTF-8?q?that=20was=20OOM-ing=20and=20speeds=20up=20builds.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/android/gradle.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/example/android/gradle.properties b/example/android/gradle.properties index e60119f3..5c88dd26 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4096M android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=false From acc612da52278bb385a91c1619dda9bc383e14f5 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Mon, 27 Jul 2026 14:55:45 +0530 Subject: [PATCH 07/14] addressed code review comments --- .github/workflows/dart.yml | 22 +++++++++++++- .../FlutterAEPOptimizePlugin.java | 29 +++++++++++-------- .../ios/Classes/FlutterAEPOptimizePlugin.m | 4 --- .../lib/src/decision_scope.dart | 7 +++-- .../test/flutter_aepoptimize_test.dart | 4 ++- 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 0adf9ae0..0c5a5873 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -189,4 +189,24 @@ jobs: - name: Unit test working-directory: ./plugins/flutter_aepuserprofile run: flutter test - + + job9: + name: Optimize unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + + - uses: actions/setup-java@v1 + with: + java-version: "17.x" + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.x" + + - name: Install dependencies + working-directory: ./plugins/flutter_aepoptimize + run: flutter pub get + + - name: Unit test + working-directory: ./plugins/flutter_aepoptimize + run: flutter test 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 index be38451f..07beb5ef 100644 --- 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 @@ -11,9 +11,6 @@ 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; @@ -35,8 +32,6 @@ public class FlutterAEPOptimizePlugin implements FlutterPlugin, MethodCallHandler { - private static final String TAG = "FlutterAEPOptimizePlugin"; - private MethodChannel channel; @Override @@ -100,9 +95,23 @@ private void handleUpdatePropositions(MethodCall call, final Result result) { 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))); + 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()), + "updatePropositions failed", + error.getErrorName())); + } + }; if (timeout != null) { Optimize.updatePropositions(scopes, xdm, data, timeout, callback); @@ -210,12 +219,10 @@ private void handleGenerateReferenceXdm(MethodCall call, Result result) { @SuppressWarnings("unchecked") private void handleBatchDisplayed(MethodCall call, Result result) { List> items = (List>) call.arguments; - List propositions = new ArrayList<>(); List offers = new ArrayList<>(); for (Map item : items) { OptimizeProposition prop = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap(item); if (prop != null && prop.getOffers() != null && !prop.getOffers().isEmpty()) { - propositions.add(prop); offers.add(prop.getOffers().get(0)); } } @@ -228,12 +235,10 @@ private void handleBatchDisplayed(MethodCall call, Result result) { @SuppressWarnings("unchecked") private void handleBatchGenerateDisplayInteractionXdm(MethodCall call, Result result) { List> items = (List>) call.arguments; - List propositions = new ArrayList<>(); List offers = new ArrayList<>(); for (Map item : items) { OptimizeProposition prop = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap(item); if (prop != null && prop.getOffers() != null && !prop.getOffers().isEmpty()) { - propositions.add(prop); offers.add(prop.getOffers().get(0)); } } diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m index 22344637..bfb59c50 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -191,12 +191,10 @@ - (void)handleGenerateReferenceXdm:(FlutterMethodCall *)call result:(FlutterResu } - (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)result { - NSMutableArray *propositions = [NSMutableArray array]; NSMutableArray *offers = [NSMutableArray array]; for (NSDictionary *dict in call.arguments) { AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; if (prop && prop.offers.count > 0) { - [propositions addObject:prop]; [offers addObject:prop.offers[0]]; } } @@ -207,12 +205,10 @@ - (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)res } - (void)handleBatchGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { - NSMutableArray *propositions = [NSMutableArray array]; NSMutableArray *offers = [NSMutableArray array]; for (NSDictionary *dict in call.arguments) { AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; if (prop && prop.offers.count > 0) { - [propositions addObject:prop]; [offers addObject:prop.offers[0]]; } } diff --git a/plugins/flutter_aepoptimize/lib/src/decision_scope.dart b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart index a528ff28..8cf55d25 100644 --- a/plugins/flutter_aepoptimize/lib/src/decision_scope.dart +++ b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart @@ -20,8 +20,11 @@ class DecisionScope { required String activityId, required String placementId, int itemCount = 1, - }) : name = base64Encode(utf8.encode( - '{"activityId":"$activityId","placementId":"$placementId","itemCount":$itemCount}')); + }) : name = base64Encode(utf8.encode(jsonEncode({ + 'activityId': activityId, + 'placementId': placementId, + 'itemCount': itemCount, + }))); Map toMap() { return {'name': name}; diff --git a/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart index 393dc395..0998f490 100644 --- a/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart +++ b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart @@ -576,12 +576,14 @@ void main() { 'generateDisplayInteractionXdm', 'generateTapInteractionXdm', 'generateReferenceXdm', + 'batchDisplayed', + 'batchGenerateDisplayInteractionXdm', ]; // 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); + expect(expectedMethods.length, 12); }); }); From 73c11d2927b3da375395238e608372ec6d7ac0af Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Mon, 27 Jul 2026 18:08:58 +0530 Subject: [PATCH 08/14] restore proposition retention in batch tracking methods The propositions array was inadvertently removed in acc612d while addressing code review comments. Without it, the loop-scoped proposition objects are deallocated before the SDK batch call, causing the weak Offer.proposition references to become nil and producing empty propositions in the Edge tracking payload. --- .../ios/Classes/FlutterAEPOptimizePlugin.m | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m index bfb59c50..159885fe 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -192,9 +192,11 @@ - (void)handleGenerateReferenceXdm:(FlutterMethodCall *)call result:(FlutterResu - (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)result { NSMutableArray *offers = [NSMutableArray array]; + NSMutableArray *propositions = [NSMutableArray array]; for (NSDictionary *dict in call.arguments) { AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; if (prop && prop.offers.count > 0) { + [propositions addObject:prop]; [offers addObject:prop.offers[0]]; } } @@ -206,9 +208,11 @@ - (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)res - (void)handleBatchGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { NSMutableArray *offers = [NSMutableArray array]; + NSMutableArray *propositions = [NSMutableArray array]; for (NSDictionary *dict in call.arguments) { AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; if (prop && prop.offers.count > 0) { + [propositions addObject:prop]; [offers addObject:prop.offers[0]]; } } From 408a9f1cb840e1f6d5c602a2a48ffec845cd9560 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Mon, 27 Jul 2026 21:48:59 +0530 Subject: [PATCH 09/14] Android - retain propositions in batch tracking methods to prevent empty payloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer holds a SoftReference to its parent proposition on Android (weak on iOS). In batch methods, the loop-scoped variable is the only strong reference — once it goes out of scope, GC can clear the SoftReference, producing empty tracking payloads. Added the same retention pattern already applied on iOS, along with explanatory comments on both platforms. --- .../flutter_aepoptimize/FlutterAEPOptimizePlugin.java | 8 ++++++++ .../ios/Classes/FlutterAEPOptimizePlugin.m | 4 ++++ 2 files changed, 12 insertions(+) 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 index 07beb5ef..a7eab3c6 100644 --- 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 @@ -220,9 +220,13 @@ private void handleGenerateReferenceXdm(MethodCall call, Result result) { private void handleBatchDisplayed(MethodCall call, Result result) { List> items = (List>) call.arguments; List offers = new ArrayList<>(); + // Keep propositions alive — Offer uses a SoftReference to its proposition, which GC can + // clear once the loop-scoped `prop` goes out of scope, resulting in empty tracking payloads. + List propositions = new ArrayList<>(); for (Map item : items) { OptimizeProposition prop = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap(item); if (prop != null && prop.getOffers() != null && !prop.getOffers().isEmpty()) { + propositions.add(prop); offers.add(prop.getOffers().get(0)); } } @@ -236,9 +240,13 @@ private void handleBatchDisplayed(MethodCall call, Result result) { private void handleBatchGenerateDisplayInteractionXdm(MethodCall call, Result result) { List> items = (List>) call.arguments; List offers = new ArrayList<>(); + // Keep propositions alive — Offer uses a SoftReference to its proposition, which GC can + // clear once the loop-scoped `prop` goes out of scope, resulting in empty tracking payloads. + List propositions = new ArrayList<>(); for (Map item : items) { OptimizeProposition prop = FlutterAEPOptimizeDataBridge.propositionFromOfferTrackingMap(item); if (prop != null && prop.getOffers() != null && !prop.getOffers().isEmpty()) { + propositions.add(prop); offers.add(prop.getOffers().get(0)); } } diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m index 159885fe..993c5883 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -192,6 +192,8 @@ - (void)handleGenerateReferenceXdm:(FlutterMethodCall *)call result:(FlutterResu - (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)result { NSMutableArray *offers = [NSMutableArray array]; + // Keep propositions alive — Offer.proposition is weak and becomes nil once the loop-scoped + // `prop` goes out of scope, resulting in empty tracking payloads. NSMutableArray *propositions = [NSMutableArray array]; for (NSDictionary *dict in call.arguments) { AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; @@ -208,6 +210,8 @@ - (void)handleBatchDisplayed:(FlutterMethodCall *)call result:(FlutterResult)res - (void)handleBatchGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { NSMutableArray *offers = [NSMutableArray array]; + // Keep propositions alive — Offer.proposition is weak and becomes nil once the loop-scoped + // `prop` goes out of scope, resulting in empty tracking payloads. NSMutableArray *propositions = [NSMutableArray array]; for (NSDictionary *dict in call.arguments) { AEPOptimizeProposition *prop = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:dict]; From 3bd02049d816b5d3c1fdde0a593a7e35b987f8bd Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Mon, 27 Jul 2026 22:35:02 +0530 Subject: [PATCH 10/14] added github documentation for Optimize Plugin --- README.md | 1 + plugins/flutter_aepoptimize/README.md | 401 ++++++++++++++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 plugins/flutter_aepoptimize/README.md diff --git a/README.md b/README.md index 7e71946c..126f0093 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This repository is a monorepo. It contains a collection of Adobe Experience Plat | [EdgeBridge](plugins/flutter_aepedgebridge/README.md) | [![pub package](https://img.shields.io/pub/v/flutter_aepedgebridge.svg)](https://pub.dartlang.org/packages/flutter_aepedgebridge) | | [UserProfile](plugins/flutter_aepuserprofile/README.md) | [![pub package](https://img.shields.io/pub/v/flutter_aepuserprofile.svg)](https://pub.dartlang.org/packages/flutter_aepuserprofile) | | [Messaging](plugins/flutter_aepmessaging/README.md) | [![pub package](https://img.shields.io/pub/v/flutter_aepmessaging.svg)](https://pub.dartlang.org/packages/flutter_aepmessaging) | +| [Optimize](plugins/flutter_aepoptimize/README.md) | [![pub package](https://img.shields.io/pub/v/flutter_aepoptimize.svg)](https://pub.dartlang.org/packages/flutter_aepoptimize) | > [!NOTE] > The Flutter plugins within this repository are specifically designed to support the Android and iOS platforms only. diff --git a/plugins/flutter_aepoptimize/README.md b/plugins/flutter_aepoptimize/README.md new file mode 100644 index 00000000..f00d2efb --- /dev/null +++ b/plugins/flutter_aepoptimize/README.md @@ -0,0 +1,401 @@ +# flutter_aepoptimize + +[![pub package](https://img.shields.io/pub/v/flutter_aepoptimize.svg)](https://pub.dartlang.org/packages/flutter_aepoptimize) ![Build](https://github.com/adobe/aepsdk_flutter/workflows/Dart%20Unit%20Tests%20+%20Android%20Build%20+%20iOS%20Build/badge.svg) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +`flutter_aepoptimize` is a flutter plugin for the iOS and Android [Adobe Experience Platform Optimize SDK](https://developer.adobe.com/client-sdks/documentation/adobe-journey-optimizer-decisioning/) to allow for integration with Flutter applications. Functionality to enable the Optimize extension is provided entirely through Dart documented below. + +The Optimize extension enables real-time personalization workflows in your mobile applications by leveraging Adobe Target and Adobe Journey Optimizer Offer Decisioning. + +## Prerequisites + +The Optimize extension has the following peer dependencies, which must be installed prior to installing it: + +- [flutter_aepcore](https://github.com/adobe/aepsdk_flutter/blob/main/plugins/flutter_aepcore/README.md) +- [flutter_aepedge](https://github.com/adobe/aepsdk_flutter/blob/main/plugins/flutter_aepedge/README.md) + +## Installation + +Install instructions for this package can be found [here](https://pub.dev/packages/flutter_aepoptimize/install). + +> Note: After you have installed the SDK, don't forget to run `pod install` in your `ios` directory to link the libraries to your Xcode project. + +## Usage + +For more detailed information on the Optimize APIs, visit the documentation [here](https://developer.adobe.com/client-sdks/documentation/adobe-journey-optimizer-decisioning/) + +### Importing the extension: + +In your Flutter application, import the Optimize extension as follows: + +```dart +import 'package:flutter_aepoptimize/flutter_aepoptimize.dart'; +``` + +### Initializing with SDK: + +To initialize the SDK, use the following methods: +- [MobileCore.initializeWithAppId(appId)](https://github.com/adobe/aepsdk_flutter/tree/main/plugins/flutter_aepcore#initializewithappid) +- [MobileCore.initialize(initOptions)](https://github.com/adobe/aepsdk_flutter/tree/main/plugins/flutter_aepcore#initialize) + +Refer to the root [Readme](https://github.com/adobe/aepsdk_flutter/blob/main/README.md) for more information about the SDK setup. + +## API reference + +### extensionVersion +Returns the SDK version of the Optimize extension. + +**Syntax** +```dart +static Future get extensionVersion +``` + +**Example** +```dart +String version = await Optimize.extensionVersion; +``` +------ +### updatePropositions +Fetches the propositions for the provided decision scopes from the Adobe Experience Platform Edge Network. The returned propositions are cached in-memory in the Optimize SDK and can be retrieved using `getPropositions`. + +**Syntax** +```dart +static Future?> updatePropositions( + List decisionScopes, + {Map? xdm, + Map? data, + double? timeout} +) +``` + +**Example** +```dart +final decisionScopes = [ + DecisionScope('myMbox'), + DecisionScope('anotherMbox'), +]; + +try { + Map? propositions = + await Optimize.updatePropositions(decisionScopes); +} on PlatformException { + print("Failed to update propositions"); +} +``` + +**Example with XDM and data** +```dart +final decisionScopes = [DecisionScope('myMbox')]; + +Map xdm = {"eventType": "personalization.request"}; +Map data = {"key": "value"}; + +Map? propositions = + await Optimize.updatePropositions(decisionScopes, xdm: xdm, data: data); +``` + +**Example with timeout** +```dart +final decisionScopes = [DecisionScope('myMbox')]; + +Map? propositions = + await Optimize.updatePropositions(decisionScopes, timeout: 10.0); +``` +------ +### getPropositions +Retrieves the previously fetched propositions from the in-memory SDK cache for the provided decision scopes. If a certain decision scope has not been fetched yet in the current session, its entry will not exist in the returned map. + +**Syntax** +```dart +static Future?> getPropositions( + List decisionScopes, + {double? timeout} +) +``` + +**Example** +```dart +final decisionScopes = [ + DecisionScope('myMbox'), + DecisionScope('anotherMbox'), +]; + +try { + Map? propositions = + await Optimize.getPropositions(decisionScopes); +} on PlatformException { + print("Failed to get propositions"); +} +``` +------ +### onPropositionsUpdate +Registers a persistent listener that is invoked whenever the propositions are updated in the SDK cache. This is useful for listening to real-time proposition changes, such as those triggered by `updatePropositions`. + +**Syntax** +```dart +static void onPropositionsUpdate( + void Function(Map) callback +) +``` + +**Example** +```dart +Optimize.onPropositionsUpdate((propositions) { + propositions.forEach((scope, proposition) { + print('Scope: ${scope.name}'); + for (var offer in proposition.offers) { + print('Offer content: ${offer.content}'); + } + }); +}); +``` +------ +### clearCachedPropositions +Clears the client-side in-memory propositions cache. + +**Syntax** +```dart +static Future clearCachedPropositions() +``` + +**Example** +```dart +await Optimize.clearCachedPropositions(); +``` +------ +### displayed (batch) +Sends display tracking events to the Adobe Experience Platform Edge Network for the provided list of offers. Use this method to report that multiple offers were displayed simultaneously. + +**Syntax** +```dart +static Future displayed(List offers) +``` + +**Example** +```dart +// After retrieving propositions, track display for all offers at once +List allOffers = []; +propositions?.forEach((scope, proposition) { + allOffers.addAll(proposition.offers); +}); + +await Optimize.displayed(allOffers); +``` +------ +### generateDisplayInteractionXdm (batch) +Generates a map containing XDM-formatted data for `Experience Event - Proposition Interactions` field group, with the display event type for the provided list of offers. + +**Syntax** +```dart +static Future?> generateDisplayInteractionXdm(List offers) +``` + +**Example** +```dart +List allOffers = []; +propositions?.forEach((scope, proposition) { + allOffers.addAll(proposition.offers); +}); + +Map? xdm = await Optimize.generateDisplayInteractionXdm(allOffers); +``` + +## Public Classes + +### DecisionScope +`DecisionScope` represents a decision scope used to fetch personalization propositions from the Adobe Experience Platform Edge Network. For Target mboxes, the scope name is the mbox name. For Offer Decisioning, the scope name is a base64-encoded JSON string containing activity and placement IDs. + +**Syntax** +```dart +// Create with a scope name string (e.g. Target mbox name or encoded ODE scope) +DecisionScope(String name) + +// Create from an activity ID and placement ID (for Offer Decisioning) +DecisionScope.fromActivityAndPlacement({ + required String activityId, + required String placementId, + int itemCount = 1, +}) +``` + +**Example** +```dart +// Target mbox scope +final mboxScope = DecisionScope('myTargetMbox'); + +// Offer Decisioning scope using convenience constructor +final odeScope = DecisionScope.fromActivityAndPlacement( + activityId: 'dps:offer-activity:1a789ada14845b06', + placementId: 'dps:offer-placement:1a78674ab508506c', + itemCount: 3, +); + +// Offer Decisioning scope using pre-encoded string +final encodedScope = DecisionScope('eyJ4ZG06YWN0aXZpdHlJZCI6Ii4uLiJ9'); +``` +------ +### OptimizeProposition +`OptimizeProposition` represents the response from the Edge Network for a given decision scope. It contains a list of offers along with scope details used for tracking. + +**Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `String` | Unique proposition identifier | +| `offers` | `List` | List of offers for this proposition | +| `scope` | `String` | The decision scope string | +| `scopeDetails` | `Map` | Additional scope details (e.g. activity info, event tokens) | + +**Example** +```dart +// Propositions are typically obtained from updatePropositions or getPropositions +Map? propositions = + await Optimize.getPropositions([DecisionScope('myMbox')]); + +propositions?.forEach((scope, proposition) { + print('Proposition ID: ${proposition.id}'); + print('Scope: ${proposition.scope}'); + print('Number of offers: ${proposition.offers.length}'); +}); +``` + +#### generateReferenceXdm +Generates a map containing XDM-formatted data for `Experience Event - Proposition Reference` field group for this proposition. + +**Syntax** +```dart +Future?> generateReferenceXdm() +``` + +**Example** +```dart +Map? referenceXdm = await proposition.generateReferenceXdm(); +``` +------ +### Offer +`Offer` represents an individual personalization offer returned in a proposition. An offer contains content (which may be text, HTML, JSON, or an image URL) and metadata such as its type, schema, and tracking characteristics. + +**Properties** + +| Property | Type | Description | +|----------|------|-------------| +| `id` | `String` | Unique offer identifier | +| `etag` | `String` | Offer ETag for caching | +| `score` | `double` | Offer priority score | +| `schema` | `String` | Offer schema string | +| `meta` | `Map?` | Optional metadata | +| `type` | `OfferType` | Content type of the offer | +| `language` | `List?` | Supported languages | +| `content` | `String` | The offer content | +| `characteristics` | `Map?` | Additional characteristics | + +**Example** +```dart +propositions?.forEach((scope, proposition) { + for (var offer in proposition.offers) { + print('Offer ID: ${offer.id}'); + print('Type: ${offer.type}'); + print('Content: ${offer.content}'); + } +}); +``` + +#### displayed (single) +Sends a display tracking event to the Adobe Experience Platform Edge Network for this offer. + +**Syntax** +```dart +Future displayed() +``` + +**Example** +```dart +Offer offer = proposition.offers.first; +await offer.displayed(); +``` + +#### tapped +Sends a tap/click tracking event to the Adobe Experience Platform Edge Network for this offer. + +**Syntax** +```dart +Future tapped() +``` + +**Example** +```dart +Offer offer = proposition.offers.first; +await offer.tapped(); +``` + +#### generateDisplayInteractionXdm (single) +Generates a map containing XDM-formatted data for `Experience Event - Proposition Interactions` field group, with the display event type for this offer. + +**Syntax** +```dart +Future?> generateDisplayInteractionXdm() +``` + +**Example** +```dart +Map? xdm = await offer.generateDisplayInteractionXdm(); +``` + +#### generateTapInteractionXdm +Generates a map containing XDM-formatted data for `Experience Event - Proposition Interactions` field group, with the tap event type for this offer. + +**Syntax** +```dart +Future?> generateTapInteractionXdm() +``` + +**Example** +```dart +Map? xdm = await offer.generateTapInteractionXdm(); +``` +------ +### OfferType +`OfferType` is an enum representing the content type of the offer. + +| Value | Raw Value | MIME Type | +|-------|-----------|-----------| +| `unknown` | 0 | `*/*` | +| `json` | 1 | `application/json` | +| `text` | 2 | `text/plain` | +| `html` | 3 | `text/html` | +| `image` | 4 | `image/*` | + +**Example** +```dart +for (var offer in proposition.offers) { + switch (offer.type) { + case OfferType.json: + // Parse JSON content + break; + case OfferType.html: + // Render HTML content + break; + case OfferType.text: + // Display text content + break; + case OfferType.image: + // Load image from URL + break; + default: + break; + } +} +``` + +## Tests + +Run: + +```bash +flutter test +``` + +## Contributing +See [CONTRIBUTING](https://github.com/adobe/aepsdk_flutter/blob/main/CONTRIBUTING.md) + +## License +See [LICENSE](https://github.com/adobe/aepsdk_flutter/blob/main/LICENSE) From 859d3d4a446130f97cda289f021a2ac14621d130 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Mon, 27 Jul 2026 23:58:13 +0530 Subject: [PATCH 11/14] pass through proposition activity and placement fields for tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native SDK includes activity and placement in every tracking XDM payload, but the Flutter bridge was not carrying these fields from native → Dart → native. This caused reconstructed propositions to have empty activity/placement, producing tracking payloads that differ from pure native apps. Added the pass-through across Dart model, iOS bridge, and Android bridge so the full proposition context is preserved in tracking calls. --- .../FlutterAEPOptimizeDataBridge.java | 6 ++++++ .../ios/Classes/FlutterAEPOptimizeDataBridge.m | 6 ++++++ plugins/flutter_aepoptimize/lib/src/offer.dart | 10 +++++++++- .../lib/src/optimize_proposition.dart | 17 ++++++++++++++++- 4 files changed, 37 insertions(+), 2 deletions(-) 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 index a0e1c03b..d934ef86 100644 --- 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 @@ -59,6 +59,8 @@ static Map mapFromProposition(OptimizeProposition proposition) { map.put("id", proposition.getId()); map.put("scope", proposition.getScope()); map.put("scopeDetails", proposition.getScopeDetails() != null ? proposition.getScopeDetails() : new HashMap<>()); + map.put("activity", proposition.getActivity() != null ? proposition.getActivity() : new HashMap<>()); + map.put("placement", proposition.getPlacement() != null ? proposition.getPlacement() : new HashMap<>()); List> offersArray = new ArrayList<>(); if (proposition.getOffers() != null) { @@ -97,6 +99,8 @@ static OptimizeProposition propositionFromOfferTrackingMap(Map m String propositionId = getNullableString(map, "propositionId"); String propositionScope = getNullableString(map, "propositionScope"); Map scopeDetails = getNullableMap(map, "propositionScopeDetails"); + Map activity = getNullableMap(map, "propositionActivity"); + Map placement = getNullableMap(map, "propositionPlacement"); Map offerItemData = new HashMap<>(); offerItemData.put("id", map.get("id") != null ? map.get("id") : ""); @@ -129,6 +133,8 @@ static OptimizeProposition propositionFromOfferTrackingMap(Map m propositionData.put("id", propositionId != null ? propositionId : ""); propositionData.put("scope", propositionScope != null ? propositionScope : ""); propositionData.put("scopeDetails", scopeDetails != null ? scopeDetails : new HashMap<>()); + propositionData.put("activity", activity != null ? activity : new HashMap<>()); + propositionData.put("placement", placement != null ? placement : new HashMap<>()); propositionData.put("items", items); return OptimizeProposition.fromEventData(propositionData); diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m index a916a481..a135eb5c 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m @@ -61,6 +61,8 @@ + (NSDictionary *)dictionaryFromProposition:(AEPOptimizeProposition *)propositio dict[@"offers"] = offersArray; dict[@"scope"] = proposition.scope; dict[@"scopeDetails"] = proposition.scopeDetails ?: @{}; + dict[@"activity"] = proposition.activity ?: @{}; + dict[@"placement"] = proposition.placement ?: @{}; return dict; } @@ -100,11 +102,15 @@ + (AEPOptimizeProposition *)propositionFromOfferTrackingDictionary:(NSDictionary NSString *propositionId = [dict[@"propositionId"] isKindOfClass:[NSString class]] ? dict[@"propositionId"] : @""; NSString *propositionScope = [dict[@"propositionScope"] isKindOfClass:[NSString class]] ? dict[@"propositionScope"] : @""; NSDictionary *scopeDetails = [dict[@"propositionScopeDetails"] isKindOfClass:[NSDictionary class]] ? dict[@"propositionScopeDetails"] : @{}; + NSDictionary *activity = [dict[@"propositionActivity"] isKindOfClass:[NSDictionary class]] ? dict[@"propositionActivity"] : @{}; + NSDictionary *placement = [dict[@"propositionPlacement"] isKindOfClass:[NSDictionary class]] ? dict[@"propositionPlacement"] : @{}; NSDictionary *propositionData = @{ @"id": propositionId, @"scope": propositionScope, @"scopeDetails": scopeDetails, + @"activity": activity, + @"placement": placement, @"items": @[@{ @"id": dict[@"id"] ?: @"", @"etag": dict[@"etag"] ?: @"", diff --git a/plugins/flutter_aepoptimize/lib/src/offer.dart b/plugins/flutter_aepoptimize/lib/src/offer.dart index 752871c7..0f7f59cc 100644 --- a/plugins/flutter_aepoptimize/lib/src/offer.dart +++ b/plugins/flutter_aepoptimize/lib/src/offer.dart @@ -29,6 +29,8 @@ class Offer { String _propositionId = ''; String _propositionScope = ''; Map _propositionScopeDetails = {}; + Map _propositionActivity = {}; + Map _propositionPlacement = {}; Offer({ required this.id, @@ -62,10 +64,14 @@ class Offer { ); } - void setPropositionContext(String propositionId, String scope, Map scopeDetails) { + void setPropositionContext(String propositionId, String scope, + Map scopeDetails, Map activity, + Map placement) { _propositionId = propositionId; _propositionScope = scope; _propositionScopeDetails = scopeDetails; + _propositionActivity = activity; + _propositionPlacement = placement; } Map toMap() { @@ -88,6 +94,8 @@ class Offer { 'propositionId': _propositionId, 'propositionScope': _propositionScope, 'propositionScopeDetails': _propositionScopeDetails, + 'propositionActivity': _propositionActivity, + 'propositionPlacement': _propositionPlacement, }; } diff --git a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart index 3de57a7f..33c66ed3 100644 --- a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart +++ b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart @@ -21,12 +21,16 @@ class OptimizeProposition { final List offers; final String scope; final Map scopeDetails; + final Map activity; + final Map placement; OptimizeProposition({ required this.id, required this.offers, required this.scope, this.scopeDetails = const {}, + this.activity = const {}, + this.placement = const {}, }); factory OptimizeProposition.fromMap(Map map) { @@ -35,6 +39,12 @@ class OptimizeProposition { final propScopeDetails = map['scopeDetails'] != null ? Map.from(map['scopeDetails'] as Map) : {}; + final propActivity = map['activity'] != null + ? Map.from(map['activity'] as Map) + : {}; + final propPlacement = map['placement'] != null + ? Map.from(map['placement'] as Map) + : {}; final offersList = (map['offers'] as List?) ?.map((o) => Offer.fromMap(Map.from(o as Map))) @@ -42,7 +52,8 @@ class OptimizeProposition { []; for (final offer in offersList) { - offer.setPropositionContext(propId, propScope, propScopeDetails); + offer.setPropositionContext( + propId, propScope, propScopeDetails, propActivity, propPlacement); } return OptimizeProposition( @@ -50,6 +61,8 @@ class OptimizeProposition { offers: offersList, scope: propScope, scopeDetails: propScopeDetails, + activity: propActivity, + placement: propPlacement, ); } @@ -59,6 +72,8 @@ class OptimizeProposition { 'offers': offers.map((o) => o.toMap()).toList(), 'scope': scope, 'scopeDetails': scopeDetails, + 'activity': activity, + 'placement': placement, }; } From 096aadbb50a81cf64c4bb6537a07f378f276070f Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Wed, 29 Jul 2026 14:55:15 +0530 Subject: [PATCH 12/14] updated offers key to items --- .../flutter_aepoptimize/FlutterAEPOptimizeDataBridge.java | 2 +- .../ios/Classes/FlutterAEPOptimizeDataBridge.m | 2 +- .../flutter_aepoptimize/lib/src/optimize_proposition.dart | 4 ++-- .../test/flutter_aepoptimize_test.dart | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) 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 index d934ef86..95583a15 100644 --- 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 @@ -68,7 +68,7 @@ static Map mapFromProposition(OptimizeProposition proposition) { offersArray.add(mapFromOffer(offer)); } } - map.put("offers", offersArray); + map.put("items", offersArray); return map; } diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m index a135eb5c..12059d29 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m @@ -58,7 +58,7 @@ + (NSDictionary *)dictionaryFromProposition:(AEPOptimizeProposition *)propositio NSMutableDictionary *dict = [NSMutableDictionary dictionary]; dict[@"id"] = proposition.id; - dict[@"offers"] = offersArray; + dict[@"items"] = offersArray; dict[@"scope"] = proposition.scope; dict[@"scopeDetails"] = proposition.scopeDetails ?: @{}; dict[@"activity"] = proposition.activity ?: @{}; diff --git a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart index 33c66ed3..6a59d37b 100644 --- a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart +++ b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart @@ -46,7 +46,7 @@ class OptimizeProposition { ? Map.from(map['placement'] as Map) : {}; - final offersList = (map['offers'] as List?) + final offersList = (map['items'] as List?) ?.map((o) => Offer.fromMap(Map.from(o as Map))) .toList() ?? []; @@ -69,7 +69,7 @@ class OptimizeProposition { Map toMap() { return { 'id': id, - 'offers': offers.map((o) => o.toMap()).toList(), + 'items': offers.map((o) => o.toMap()).toList(), 'scope': scope, 'scopeDetails': scopeDetails, 'activity': activity, diff --git a/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart index 0998f490..3bdc828e 100644 --- a/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart +++ b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart @@ -162,7 +162,7 @@ void main() { 'id': 'prop-1', 'scope': 'myScope', 'scopeDetails': {'activity': {'id': 'act-1'}}, - 'offers': [ + 'items': [ { 'id': 'offer-1', 'type': 2, @@ -204,7 +204,7 @@ void main() { 'id': 'prop-1', 'scope': 'myScope', 'scopeDetails': {}, - 'offers': [ + 'items': [ {'id': 'offer-1', 'type': 2, 'content': 'Hello'}, ], } @@ -272,7 +272,7 @@ void main() { 'id': 'prop-cached', 'scope': 'cachedScope', 'scopeDetails': {}, - 'offers': [ + 'items': [ {'id': 'offer-cached', 'type': 1, 'content': '{"cached": true}'}, ], } @@ -536,7 +536,7 @@ void main() { 'id': 'prop-live', 'scope': 'listenerScope', 'scopeDetails': {}, - 'offers': [ + 'items': [ {'id': 'offer-live', 'type': 3, 'content': 'Live'}, ], } From e0d97fdefc324b220b781373e22e6940f966f06c Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Wed, 29 Jul 2026 15:56:13 +0530 Subject: [PATCH 13/14] updated copyright year --- plugins/flutter_aepoptimize/LICENSE | 2 +- .../mobile/flutter/flutter_aepoptimize/AndroidUtil.java | 2 +- .../flutter_aepoptimize/FlutterAEPOptimizeDataBridge.java | 2 +- .../flutter/flutter_aepoptimize/FlutterAEPOptimizePlugin.java | 2 +- .../ios/Classes/FlutterAEPOptimizeDataBridge.h | 2 +- .../ios/Classes/FlutterAEPOptimizeDataBridge.m | 2 +- .../flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h | 2 +- .../flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m | 2 +- plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart | 2 +- plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart | 2 +- plugins/flutter_aepoptimize/lib/src/decision_scope.dart | 2 +- plugins/flutter_aepoptimize/lib/src/offer.dart | 2 +- plugins/flutter_aepoptimize/lib/src/offer_type.dart | 2 +- plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart | 2 +- plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart | 2 +- 15 files changed, 15 insertions(+), 15 deletions(-) diff --git a/plugins/flutter_aepoptimize/LICENSE b/plugins/flutter_aepoptimize/LICENSE index 6f4fa864..298be12f 100644 --- a/plugins/flutter_aepoptimize/LICENSE +++ b/plugins/flutter_aepoptimize/LICENSE @@ -186,7 +186,7 @@ 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 +Copyright 2026 Adobe Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 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 index 3d69ad7e..b4851b9b 100644 --- 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 @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 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 index 95583a15..7d366fa2 100644 --- 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 @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 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 index a7eab3c6..ebd3e021 100644 --- 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 @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h index de8c1226..1d2a80c4 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m index 12059d29..6dee7986 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h index 89a6eef6..671e4892 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m index 993c5883..5108f441 100644 --- a/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart index 911eb0c4..3c793cbe 100644 --- a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart index 99288078..0a5fe531 100644 --- a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/lib/src/decision_scope.dart b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart index 8cf55d25..81c83748 100644 --- a/plugins/flutter_aepoptimize/lib/src/decision_scope.dart +++ b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/lib/src/offer.dart b/plugins/flutter_aepoptimize/lib/src/offer.dart index 0f7f59cc..4192827f 100644 --- a/plugins/flutter_aepoptimize/lib/src/offer.dart +++ b/plugins/flutter_aepoptimize/lib/src/offer.dart @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/lib/src/offer_type.dart b/plugins/flutter_aepoptimize/lib/src/offer_type.dart index ddaed918..45f48b56 100644 --- a/plugins/flutter_aepoptimize/lib/src/offer_type.dart +++ b/plugins/flutter_aepoptimize/lib/src/offer_type.dart @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart index 6a59d37b..4bd03be4 100644 --- a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart +++ b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 diff --git a/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart index 3bdc828e..627b6327 100644 --- a/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart +++ b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart @@ -1,5 +1,5 @@ /* -Copyright 2025 Adobe. All rights reserved. +Copyright 2026 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 From 2b6e30e0d3dc96380d41134e917ce5bfcdcd9d31 Mon Sep 17 00:00:00 2001 From: akhiljain1907 Date: Wed, 29 Jul 2026 16:06:27 +0530 Subject: [PATCH 14/14] Added Docs for all public models and methods --- .../lib/flutter_aepoptimize.dart | 2 ++ .../lib/src/decision_scope.dart | 8 +++++ .../flutter_aepoptimize/lib/src/offer.dart | 30 +++++++++++++++++++ .../lib/src/offer_type.dart | 9 ++++++ .../lib/src/optimize_proposition.dart | 17 +++++++++++ 5 files changed, 66 insertions(+) diff --git a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart index 3c793cbe..05bc624f 100644 --- a/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart @@ -14,6 +14,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_aepoptimize/flutter_aepoptimize_data.dart'; export 'package:flutter_aepoptimize/flutter_aepoptimize_data.dart'; +/// Adobe Experience Platform Optimize API. class Optimize { static const MethodChannel _channel = const MethodChannel('flutter_aepoptimize'); @@ -35,6 +36,7 @@ class Optimize { } }; + /// Returns the version of the AEPOptimize extension. static Future get extensionVersion => _channel.invokeMethod('extensionVersion').then((value) => value!); diff --git a/plugins/flutter_aepoptimize/lib/src/decision_scope.dart b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart index 81c83748..bc599fec 100644 --- a/plugins/flutter_aepoptimize/lib/src/decision_scope.dart +++ b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart @@ -11,11 +11,17 @@ governing permissions and limitations under the License. import 'dart:convert'; +/// Represents a scope used to fetch personalization decisions (propositions) +/// from the Edge Network. class DecisionScope { + /// The encoded scope name. final String name; + /// Creates a decision scope from an already-encoded scope [name]. DecisionScope(this.name); + /// Creates a decision scope by base64-encoding the given [activityId], + /// [placementId], and optional [itemCount]. DecisionScope.fromActivityAndPlacement({ required String activityId, required String placementId, @@ -26,10 +32,12 @@ class DecisionScope { 'itemCount': itemCount, }))); + /// Converts this decision scope into a map for the platform channel. Map toMap() { return {'name': name}; } + /// Creates a decision scope from a platform channel [map]. factory DecisionScope.fromMap(Map map) { return DecisionScope(map['name'] as String); } diff --git a/plugins/flutter_aepoptimize/lib/src/offer.dart b/plugins/flutter_aepoptimize/lib/src/offer.dart index 4192827f..8b7e4de3 100644 --- a/plugins/flutter_aepoptimize/lib/src/offer.dart +++ b/plugins/flutter_aepoptimize/lib/src/offer.dart @@ -12,18 +12,37 @@ governing permissions and limitations under the License. import 'package:flutter/services.dart'; import 'package:flutter_aepoptimize/src/offer_type.dart'; +/// Represents a decision option (offer) contained within an +/// [OptimizeProposition]. class Offer { static const MethodChannel _channel = const MethodChannel('flutter_aepoptimize'); + /// Unique offer identifier. final String id; + + /// Offer revision detail at the time of the request. final String etag; + + /// Offer priority score. final double score; + + /// The schema string describing the offer content. final String schema; + + /// Optional offer metadata. final Map? meta; + + /// The type of the offer content, see [OfferType]. final OfferType type; + + /// Optional list of language codes for the offer content. final List? language; + + /// The offer content string. final String content; + + /// Optional offer characteristics. final Map? characteristics; String _propositionId = ''; @@ -44,6 +63,7 @@ class Offer { this.characteristics, }); + /// Creates an offer from a platform channel [map]. factory Offer.fromMap(Map map) { return Offer( id: map['id'] as String? ?? '', @@ -64,6 +84,9 @@ class Offer { ); } + /// Stores the parent proposition context on this offer so it can be sent + /// back to the native SDK when tracking. Set internally when a proposition + /// is decoded; not intended to be called directly. void setPropositionContext(String propositionId, String scope, Map scopeDetails, Map activity, Map placement) { @@ -74,6 +97,7 @@ class Offer { _propositionPlacement = placement; } + /// Converts this offer into a map for the platform channel. Map toMap() { return { 'id': id, @@ -88,6 +112,8 @@ class Offer { }; } + /// Converts this offer, along with its parent proposition context, into a + /// map used by the native SDK for tracking. Map toTrackingMap() { return { ...toMap(), @@ -99,14 +125,17 @@ class Offer { }; } + /// Tracks a display interaction for this offer with the Edge Network. Future displayed() { return _channel.invokeMethod('offerDisplayed', toTrackingMap()); } + /// Tracks a tap interaction for this offer with the Edge Network. Future tapped() { return _channel.invokeMethod('offerTapped', toTrackingMap()); } + /// Generates XDM-formatted data for a display interaction of this offer. Future?> generateDisplayInteractionXdm() { return _channel .invokeMapMethod( @@ -114,6 +143,7 @@ class Offer { .then((value) => value); } + /// Generates XDM-formatted data for a tap interaction of this offer. Future?> generateTapInteractionXdm() { return _channel .invokeMapMethod( diff --git a/plugins/flutter_aepoptimize/lib/src/offer_type.dart b/plugins/flutter_aepoptimize/lib/src/offer_type.dart index 45f48b56..c566fec9 100644 --- a/plugins/flutter_aepoptimize/lib/src/offer_type.dart +++ b/plugins/flutter_aepoptimize/lib/src/offer_type.dart @@ -9,9 +9,14 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ +/// Enum representing the type of an [Offer]'s content. enum OfferType { unknown, json, text, html, image } +/// Convenience accessors that map an [OfferType] to the values used by the +/// native SDK. extension OfferTypeExtension on OfferType { + /// The integer value used to represent this [OfferType] across the platform + /// channel. int get rawValue { switch (this) { case OfferType.unknown: @@ -27,6 +32,7 @@ extension OfferTypeExtension on OfferType { } } + /// The MIME type string corresponding to this [OfferType]. String get mimeType { switch (this) { case OfferType.unknown: @@ -43,7 +49,10 @@ extension OfferTypeExtension on OfferType { } } +/// Maps an integer received from the native SDK back to an [OfferType]. extension OfferTypeFromInt on int { + /// Converts this integer value into the matching [OfferType], defaulting to + /// [OfferType.unknown] for unrecognized values. OfferType toOfferType() { switch (this) { case 0: diff --git a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart index 4bd03be4..0b8b4078 100644 --- a/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart +++ b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart @@ -13,15 +13,28 @@ import 'package:flutter/services.dart'; import 'package:flutter_aepoptimize/src/decision_scope.dart'; import 'package:flutter_aepoptimize/src/offer.dart'; +/// Represents the propositions received from the Edge Network for a given +/// [DecisionScope]. class OptimizeProposition { static const MethodChannel _channel = const MethodChannel('flutter_aepoptimize'); + /// Unique proposition identifier. final String id; + + /// The list of offers contained in this proposition. final List offers; + + /// The decision scope string this proposition was returned for. final String scope; + + /// Scope details used for tracking this proposition. final Map scopeDetails; + + /// Activity details associated with this proposition. final Map activity; + + /// Placement details associated with this proposition. final Map placement; OptimizeProposition({ @@ -33,6 +46,7 @@ class OptimizeProposition { this.placement = const {}, }); + /// Creates a proposition from a platform channel [map]. factory OptimizeProposition.fromMap(Map map) { final propId = map['id'] as String? ?? ''; final propScope = map['scope'] as String? ?? ''; @@ -66,6 +80,7 @@ class OptimizeProposition { ); } + /// Converts this proposition into a map for the platform channel. Map toMap() { return { 'id': id, @@ -77,6 +92,8 @@ class OptimizeProposition { }; } + /// Generates XDM-formatted data for the `Experience Event - Proposition + /// Reference` field group for this proposition. Future?> generateReferenceXdm() { return _channel .invokeMapMethod('generateReferenceXdm', toMap())