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/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/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/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 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 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..298be12f --- /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 2026 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/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) 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..b4851b9b --- /dev/null +++ b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/AndroidUtil.java @@ -0,0 +1,23 @@ +/* +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 +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..7d366fa2 --- /dev/null +++ b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizeDataBridge.java @@ -0,0 +1,181 @@ +/* +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 +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<>()); + 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) { + for (Offer offer : proposition.getOffers()) { + offersArray.add(mapFromOffer(offer)); + } + } + map.put("items", 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 OptimizeProposition propositionFromOfferTrackingMap(Map map) { + if (map == null) { + return null; + } + + 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") : ""); + 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") : ""); + + 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("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")); + } + if (map.get("characteristics") instanceof Map) { + dataPayload.put("characteristics", map.get("characteristics")); + } + offerItemData.put("data", dataPayload); + + if (map.get("meta") instanceof Map) { + offerItemData.put("meta", map.get("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("activity", activity != null ? activity : new HashMap<>()); + propositionData.put("placement", placement != null ? placement : new HashMap<>()); + propositionData.put("items", items); + + return OptimizeProposition.fromEventData(propositionData); + } + + 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") + 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; + } + } + + 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..ebd3e021 --- /dev/null +++ b/plugins/flutter_aepoptimize/android/src/main/java/com/adobe/marketing/mobile/flutter/flutter_aepoptimize/FlutterAEPOptimizePlugin.java @@ -0,0 +1,259 @@ +/* +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 +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.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.OfferUtils; +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.ArrayList; +import java.util.List; +import java.util.Map; + +public class FlutterAEPOptimizePlugin implements FlutterPlugin, MethodCallHandler { + + 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 if ("batchDisplayed".equals(call.method)) { + handleBatchDisplayed(call, result); + } else if ("batchGenerateDisplayInteractionXdm".equals(call.method)) { + handleBatchGenerateDisplayInteractionXdm(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; + + 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); + } 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) { + 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) { + 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) { + 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); + } + } + + @SuppressWarnings("unchecked") + private void handleGenerateTapInteractionXdm(MethodCall call, Result result) { + 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); + } + } + + @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); + } + } + + @SuppressWarnings("unchecked") + 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)); + } + } + if (!offers.isEmpty()) { + OfferUtils.displayed(offers); + } + result.success(null); + } + + @SuppressWarnings("unchecked") + 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)); + } + } + 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 new file mode 100644 index 00000000..1d2a80c4 --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.h @@ -0,0 +1,23 @@ +/* +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 +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; ++ (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 new file mode 100644 index 00000000..6dee7986 --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizeDataBridge.m @@ -0,0 +1,154 @@ +/* +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 +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[@"items"] = offersArray; + dict[@"scope"] = proposition.scope; + dict[@"scopeDetails"] = proposition.scopeDetails ?: @{}; + dict[@"activity"] = proposition.activity ?: @{}; + dict[@"placement"] = proposition.placement ?: @{}; + 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; +} + ++ (AEPOptimizeProposition *)propositionFromOfferTrackingDictionary:(NSDictionary *)dict { + if (!dict || ![dict isKindOfClass:[NSDictionary class]]) { + return nil; + } + + 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"] ?: @"", + @"score": dict[@"score"] ?: @(0), + @"schema": dict[@"schema"] ?: @"", + @"meta": [dict[@"meta"] isKindOfClass:[NSDictionary class]] ? dict[@"meta"] : @{}, + @"data": @{ + @"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"] : @{} + } + }] + }; + + AEPOptimizeProposition *proposition = [AEPOptimizeProposition initFromData:propositionData]; + if (proposition) { + // Access offers to trigger lazy property that sets offer.proposition = self + (void)proposition.offers; + } + return proposition; +} + +#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..671e4892 --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.h @@ -0,0 +1,15 @@ +/* +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 +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..5108f441 --- /dev/null +++ b/plugins/flutter_aepoptimize/ios/Classes/FlutterAEPOptimizePlugin.m @@ -0,0 +1,238 @@ +/* +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 +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 if ([@"batchDisplayed" isEqualToString:call.method]) { + [self handleBatchDisplayed:call result:result]; + } else if ([@"batchGenerateDisplayInteractionXdm" isEqualToString:call.method]) { + [self handleBatchGenerateDisplayInteractionXdm: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"] 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; + + 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 updatePropositions:scopes + withXdm:xdm + andData:data + completion:completionHandler]; + } +} + +- (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"] isKindOfClass:[NSNumber class]] ? arguments[@"timeout"] : nil; + + 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) { + [AEPMobileOptimize getPropositions:scopes timeout:[timeoutNumber doubleValue] completion:completionHandler]; + } else { + [AEPMobileOptimize getPropositions:scopes completion:completionHandler]; + } +} + +- (void)handleRegisterOnPropositionsUpdate:(FlutterResult)result { + [AEPMobileOptimize onPropositionsUpdate:^(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 { + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; + if (offer) { + [offer displayed]; + } + result(nil); +} + +- (void)handleOfferTapped:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; + if (offer) { + [offer tapped]; + } + result(nil); +} + +- (void)handleGenerateDisplayInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; + if (offer) { + result([offer generateDisplayInteractionXdm]); + } else { + result(nil); + } +} + +- (void)handleGenerateTapInteractionXdm:(FlutterMethodCall *)call result:(FlutterResult)result { + AEPOptimizeProposition *proposition = [FlutterAEPOptimizeDataBridge propositionFromOfferTrackingDictionary:call.arguments]; + AEPOffer *offer = proposition.offers.firstObject; + 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); + } +} + +- (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]; + 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 { + 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]; + 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); + } +} + +#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..05bc624f --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize.dart @@ -0,0 +1,127 @@ +/* +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 +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'; + +/// Adobe Experience Platform Optimize API. +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'); + } + }; + + /// Returns the version of the AEPOptimize extension. + 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'); + } + + /// 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, + ) { + 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..0a5fe531 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/flutter_aepoptimize_data.dart @@ -0,0 +1,15 @@ +/* +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 +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..bc599fec --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/decision_scope.dart @@ -0,0 +1,57 @@ +/* +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 +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'; + +/// 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, + int itemCount = 1, + }) : name = base64Encode(utf8.encode(jsonEncode({ + 'activityId': activityId, + 'placementId': placementId, + '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); + } + + @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..8b7e4de3 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/offer.dart @@ -0,0 +1,156 @@ +/* +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 +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'; + +/// 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 = ''; + String _propositionScope = ''; + Map _propositionScopeDetails = {}; + Map _propositionActivity = {}; + Map _propositionPlacement = {}; + + Offer({ + required this.id, + this.etag = '', + this.score = 0, + this.schema = '', + this.meta, + this.type = OfferType.unknown, + this.language, + this.content = '', + this.characteristics, + }); + + /// Creates an offer from a platform channel [map]. + 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, + ); + } + + /// 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) { + _propositionId = propositionId; + _propositionScope = scope; + _propositionScopeDetails = scopeDetails; + _propositionActivity = activity; + _propositionPlacement = placement; + } + + /// Converts this offer into a map for the platform channel. + Map toMap() { + return { + 'id': id, + 'etag': etag, + 'score': score, + 'schema': schema, + 'meta': meta, + 'type': type.rawValue, + 'language': language, + 'content': content, + 'characteristics': characteristics, + }; + } + + /// Converts this offer, along with its parent proposition context, into a + /// map used by the native SDK for tracking. + Map toTrackingMap() { + return { + ...toMap(), + 'propositionId': _propositionId, + 'propositionScope': _propositionScope, + 'propositionScopeDetails': _propositionScopeDetails, + 'propositionActivity': _propositionActivity, + 'propositionPlacement': _propositionPlacement, + }; + } + + /// 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( + 'generateDisplayInteractionXdm', toTrackingMap()) + .then((value) => value); + } + + /// Generates XDM-formatted data for a tap interaction of this offer. + Future?> generateTapInteractionXdm() { + return _channel + .invokeMapMethod( + 'generateTapInteractionXdm', toTrackingMap()) + .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..c566fec9 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/offer_type.dart @@ -0,0 +1,72 @@ +/* +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 +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 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: + return 0; + case OfferType.json: + return 1; + case OfferType.text: + return 2; + case OfferType.html: + return 3; + case OfferType.image: + return 4; + } + } + + /// The MIME type string corresponding to this [OfferType]. + 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/*'; + } + } +} + +/// 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: + 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..0b8b4078 --- /dev/null +++ b/plugins/flutter_aepoptimize/lib/src/optimize_proposition.dart @@ -0,0 +1,106 @@ +/* +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 +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'; + +/// 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({ + required this.id, + required this.offers, + required this.scope, + this.scopeDetails = const {}, + this.activity = const {}, + 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? ?? ''; + 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['items'] as List?) + ?.map((o) => Offer.fromMap(Map.from(o as Map))) + .toList() ?? + []; + + for (final offer in offersList) { + offer.setPropositionContext( + propId, propScope, propScopeDetails, propActivity, propPlacement); + } + + return OptimizeProposition( + id: propId, + offers: offersList, + scope: propScope, + scopeDetails: propScopeDetails, + activity: propActivity, + placement: propPlacement, + ); + } + + /// Converts this proposition into a map for the platform channel. + Map toMap() { + return { + 'id': id, + 'items': offers.map((o) => o.toMap()).toList(), + 'scope': scope, + 'scopeDetails': scopeDetails, + 'activity': activity, + 'placement': placement, + }; + } + + /// Generates XDM-formatted data for the `Experience Event - Proposition + /// Reference` field group for this proposition. + 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..627b6327 --- /dev/null +++ b/plugins/flutter_aepoptimize/test/flutter_aepoptimize_test.dart @@ -0,0 +1,638 @@ +/* +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 +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'}}, + 'items': [ + { + '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': {}, + 'items': [ + {'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': {}, + 'items': [ + {'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': {}, + 'items': [ + {'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', + '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, 12); + }); + }); + + 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); + }); + }); +}