From 1e411e933df305c6086ef5b51d02a83d7b2e7524 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 08:51:10 +0000 Subject: [PATCH 1/7] Add a session-token/buy test screen to the example app Replace the boilerplate platform-version screen with a minimal form (session token, environment picker, Buy button) that exercises MonextPayment.startPayment and shows the resulting MonextPaymentResult, so the example app doubles as a manual test harness. Document how to use it in example/README.md and link it from the root README. --- README.md | 6 ++ example/README.md | 53 +++++++++++++--- example/lib/main.dart | 139 +++++++++++++++++++++++++++++++++--------- 3 files changed, 159 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 8716117..f91df60 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,9 @@ await MonextPayment.startPayment( `ArgumentError` if `sessionToken` is empty, and a `MonextPaymentChannelUnavailableException` if no native handler is registered for the `MethodChannel`. + +## Try it + +The [`example`](example) app is a runnable test screen with a session token field, an environment +picker and a **Buy** button wired to `startPayment`. See [`example/README.md`](example/README.md) +for how to run it and get a test session token. diff --git a/example/README.md b/example/README.md index ffd9041..0026d62 100644 --- a/example/README.md +++ b/example/README.md @@ -1,17 +1,50 @@ # monext_payment_example -Demonstrates how to use the monext_payment plugin. +Demonstrates how to use the `monext_payment` plugin: a single screen with a session token field, +an environment picker, and a **Buy** button that launches Monext's native payment UI and displays +the resulting `MonextPaymentResult`. -## Getting Started +## Prerequisites -This project is a starting point for a Flutter application. +- A working Flutter setup (`flutter doctor`). +- This app depends on `monext_payment` via a local path dependency (`../`), so no extra setup is + needed for the plugin itself. See the root + [Project Setup Requirements](../docs/guides/project-setup-requirements.md) for native minimum + versions if you fork this example into a standalone app. +- A **session token**. This app never generates one itself — Monext requires session tokens to be + created server-side (see + [Security: Server-Side Session Token Handling](../docs/guides/security-server-side-session-token-handling.md)). + For quick manual testing without a full backend, use a sandbox token generated through Monext's + own tools/dashboard for your test merchant account. -A few resources to get you started if this is your first Flutter project: +## Running the app -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) +``` +cd example +flutter pub get +flutter run +``` -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +Pick a device/simulator when prompted. Google Pay/Apple Pay wallet buttons require extra native +setup ([Google Pay Setup](../docs/guides/google-pay-setup.md), +[Apple Pay Setup](../docs/guides/apple-pay-setup.md)) and are not wired up in this demo. + +## Using the test screen + +1. Paste a sandbox session token into the **Session token** field. +2. Pick **sandbox** or **production** in the **Environment** dropdown (leave it on `sandbox` for + testing). +3. Tap **Buy**. This calls `MonextPayment.startPayment(...)`, which opens Monext's native payment + UI full-screen on top of the app. +4. Complete or cancel the flow in the native UI. Once it closes, the screen below the button shows + the decoded `MonextPaymentResult` (e.g. `captured`, `failed`, `cancelled`) or an error if + `startPayment` itself threw (e.g. empty token). + +`MonextPayment.initialize()` is called once in `initState`, before the button becomes usable, as +required by the plugin's API. + +## Code + +See [`lib/main.dart`](lib/main.dart) for the full integration: it's intentionally minimal so it +doubles as a copy-pasteable starting point. For the full Dart API (all `startPayment` parameters, +exceptions, wallet configuration), see the [root README](../README.md). diff --git a/example/lib/main.dart b/example/lib/main.dart index 53c874f..b3af09e 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,58 +1,139 @@ import 'package:flutter/material.dart'; -import 'dart:async'; - -import 'package:flutter/services.dart'; import 'package:monext_payment/monext_payment.dart'; void main() { runApp(const MyApp()); } -class MyApp extends StatefulWidget { +class MyApp extends StatelessWidget { const MyApp({super.key}); @override - State createState() => _MyAppState(); + Widget build(BuildContext context) { + return MaterialApp( + title: 'monext_payment example', + home: const PaymentDemoPage(), + ); + } +} + +/// Demo page showing the minimal integration of `monext_payment`: a form to +/// fill in a session token/environment and a "Buy" button that starts the +/// native payment flow and displays the resulting [MonextPaymentResult]. +/// +/// The session token must come from a merchant backend (see +/// `docs/guides/security-server-side-session-token-handling.md`); this demo +/// only lets you paste one in manually for testing, it never generates one. +class PaymentDemoPage extends StatefulWidget { + const PaymentDemoPage({super.key}); + + @override + State createState() => _PaymentDemoPageState(); } -class _MyAppState extends State { - String _platformVersion = 'Unknown'; - final _monextPaymentPlugin = MonextPayment(); +class _PaymentDemoPageState extends State { + final _sessionTokenController = TextEditingController(); + MonextEnvironment _environment = MonextEnvironment.sandbox; + bool _isPaying = false; + String _status = 'No payment started yet.'; @override void initState() { super.initState(); - initPlatformState(); + MonextPayment.initialize(); } - // Platform messages are asynchronous, so we initialize in an async method. - Future initPlatformState() async { - String platformVersion; - // Platform messages may fail, so we use a try/catch PlatformException. - // We also handle the message potentially returning null. - try { - platformVersion = - await _monextPaymentPlugin.getPlatformVersion() ?? 'Unknown platform version'; - } on PlatformException { - platformVersion = 'Failed to get platform version.'; - } + @override + void dispose() { + _sessionTokenController.dispose(); + super.dispose(); + } - // If the widget was removed from the tree while the asynchronous platform - // message was in flight, we want to discard the reply rather than calling - // setState to update our non-existent appearance. - if (!mounted) return; + Future _buy() async { + final sessionToken = _sessionTokenController.text.trim(); + if (sessionToken.isEmpty) { + setState(() => _status = 'Enter a session token before paying.'); + return; + } setState(() { - _platformVersion = platformVersion; + _isPaying = true; + _status = 'Starting payment...'; }); + + try { + await MonextPayment.startPayment( + sessionToken: sessionToken, + environment: _environment, + appearance: const MonextAppearance(headerTitle: 'monext_payment example'), + onPaymentResult: (result) { + if (!mounted) return; + setState(() { + _isPaying = false; + _status = 'Result: ${MonextPaymentResult.fromValue(result).name}'; + }); + }, + ); + } catch (e) { + if (!mounted) return; + setState(() { + _isPaying = false; + _status = 'Error: $e'; + }); + } } @override Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - appBar: AppBar(title: const Text('Plugin example app')), - body: Center(child: Text('Running on: $_platformVersion\n')), + return Scaffold( + appBar: AppBar(title: const Text('monext_payment example')), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Paste a Monext session token generated by your backend ' + '(see docs/guides/security-server-side-session-token-handling.md), ' + 'pick an environment, then tap Buy to launch the native payment UI.', + ), + const SizedBox(height: 16), + TextField( + controller: _sessionTokenController, + decoration: const InputDecoration( + labelText: 'Session token', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _environment, + decoration: const InputDecoration( + labelText: 'Environment', + border: OutlineInputBorder(), + ), + items: MonextEnvironment.values + .map((e) => DropdownMenuItem(value: e, child: Text(e.name))) + .toList(), + onChanged: (value) { + if (value != null) setState(() => _environment = value); + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isPaying ? null : _buy, + child: _isPaying + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Buy'), + ), + const SizedBox(height: 24), + Text(_status), + ], + ), ), ); } From 3de1011ba4bf96c8598e94da7240deb856d50553 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:30:15 +0000 Subject: [PATCH 2/7] Stop applying the Kotlin Gradle Plugin directly on AGP 9+ Flutter is dropping support for plugins that apply KGP themselves, now that AGP 9+ ships built-in Kotlin support (see Flutter's migrate-to-built-in-kotlin guide for plugin authors). Only fall back to applying org.jetbrains.kotlin.android for AGP < 9, which has no built-in Kotlin support of its own. --- android/build.gradle.kts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 726a661..6d56cbe 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -23,13 +23,22 @@ allprojects { plugins { id("com.android.library") - id("org.jetbrains.kotlin.android") - // Unlike the plugins above (bundled in kotlin-gradle-plugin/AGP and already resolvable via - // the buildscript classpath below without a version), the Compose compiler is a separate + // Unlike the plugin below (bundled in kotlin-gradle-plugin/AGP and already resolvable via + // the buildscript classpath above without a version), the Compose compiler is a separate // Gradle Plugin Portal artifact and needs an explicit version here to resolve. id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" } +// AGP 9+ ships Kotlin support built in, so applying the Kotlin Gradle Plugin (KGP) directly is +// both redundant and, per Flutter's migration guide, will eventually break consuming apps' builds +// (Flutter is dropping support for plugins that apply KGP). Only apply it for AGP < 9, which has +// no built-in Kotlin support of its own. +// See https://docs.flutter.dev/release/breaking-changes/migrate-to-built-in-kotlin/for-plugin-authors +val agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.substringBefore('.').toInt() +if (agpMajor < 9) { + apply(plugin = "org.jetbrains.kotlin.android") +} + android { namespace = "io.lenra.monext_payment" From 021d5a5d4869e601903cff4e4b783e8fd7890617 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:24:51 +0000 Subject: [PATCH 3/7] Add pub.dev publishing metadata (LICENSE, repository, topics) Add an MIT LICENSE (the plugin wraps Monext's native SDKs via method channel rather than embedding their source, so MIT applies regardless of how those SDKs are themselves licensed) and fill in the pubspec.yaml fields pub.dev scores/displays: repository, issue_tracker and topics. No CHANGELOG.md: changelogs for this repo are generated from GitHub releases via convco, not maintained by hand. --- LICENSE | 21 +++++++++++++++++++++ pubspec.yaml | 8 ++++++++ 2 files changed, 29 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..87d8b52 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lenra + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pubspec.yaml b/pubspec.yaml index b726a92..2039fc5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,6 +2,14 @@ name: monext_payment description: "Flutter plugin bridging Monext's native Android/iOS payment SDKs to a unified Dart API." version: 0.0.1 homepage: "https://github.com/lenra-io/monext-flutter" +repository: "https://github.com/lenra-io/monext-flutter" +issue_tracker: "https://github.com/lenra-io/monext-flutter/issues" +topics: + - payments + - checkout + - flutter-plugin + - android + - ios environment: sdk: ^3.12.2 From a656da6dfb0919075f8fbac0b159acfba6f58df8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:37:03 +0000 Subject: [PATCH 4/7] Unregister the payment launcher on Activity detach detachFromActivity() only dropped the local paymentLauncher reference without unregistering it from the Activity's ActivityResultRegistry, which owns the registration by a fixed key. A later onAttachedToActivity() call on the same Activity (e.g. after a hot restart) then re-registered that same key, which throws and leaves the new plugin instance's paymentLauncher null - so every startPayment() call failed with "activity_unavailable" from then on. --- .../monext_payment/MonextPaymentPlugin.kt | 8 ++++ .../monext_payment/MonextPaymentPluginTest.kt | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt b/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt index bb9d960..960907a 100644 --- a/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt +++ b/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt @@ -121,6 +121,14 @@ class MonextPaymentPlugin : override fun onDetachedFromActivity() = detachFromActivity() private fun detachFromActivity() { + // Unregister before dropping the reference: the registration lives on the Activity's + // ActivityResultRegistry, keyed by a fixed string, not on this plugin instance. Without + // this, a later onAttachedToActivity() (e.g. after a hot restart, or a config change that + // doesn't go through onReattachedToActivityForConfigChanges) re-registers the same key on + // an Activity that already holds a stale registration, which throws — leaving the new + // plugin instance's paymentLauncher null and every startPayment() call failing with + // "activity_unavailable". + paymentLauncher?.unregister() paymentLauncher = null } diff --git a/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt b/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt index 64d0feb..d757d09 100644 --- a/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt +++ b/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt @@ -1,6 +1,10 @@ package io.lenra.monext_payment +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultRegistry import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -151,4 +155,39 @@ internal class MonextPaymentPluginTest { Mockito.isNull() ) } + + /** + * Regression test for a bug where a stale registration on the Activity's + * [ActivityResultRegistry] survived detach, so a later [MonextPaymentPlugin.onAttachedToActivity] + * call (e.g. after a hot restart) re-registering the same key would throw, leaving the new + * plugin instance's launcher null and every startPayment() failing with "activity_unavailable". + */ + @Test + fun onDetachedFromActivity_unregistersThePaymentLauncher() { + val plugin = attachedPlugin() + + @Suppress("UNCHECKED_CAST") + val mockLauncher = + Mockito.mock(ActivityResultLauncher::class.java) as ActivityResultLauncher + val mockRegistry: ActivityResultRegistry = Mockito.mock(ActivityResultRegistry::class.java) + Mockito + .`when`( + mockRegistry.register( + Mockito.eq("monext_payment/paymentActivity"), + Mockito.any(), + Mockito.any() + ) + ).thenReturn(mockLauncher) + + val mockActivity: ComponentActivity = Mockito.mock(ComponentActivity::class.java) + Mockito.`when`(mockActivity.activityResultRegistry).thenReturn(mockRegistry) + + val mockBinding: ActivityPluginBinding = Mockito.mock(ActivityPluginBinding::class.java) + Mockito.`when`(mockBinding.activity).thenReturn(mockActivity) + + plugin.onAttachedToActivity(mockBinding) + plugin.onDetachedFromActivity() + + Mockito.verify(mockLauncher).unregister() + } } From 87afc78559bc4219f9a55e5b2d96952674c38d48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:47:12 +0000 Subject: [PATCH 5/7] Fix startPayment() always failing with activity_unavailable FlutterActivity (the Activity subclass used by the vast majority of Flutter apps, including this plugin's own example app) extends plain android.app.Activity, not AndroidX's ComponentActivity. The plugin's onAttachedToActivity() cast the bound Activity to ComponentActivity and silently returned on failure, so paymentLauncher was never set and every startPayment() call failed with "activity_unavailable" - deterministically, not just on hot restart. Replace the ActivityResultRegistry-based launcher with the PluginRegistry.ActivityResultListener + Activity.startActivityForResult APIs, which work against any Activity subclass and are the mechanism Flutter itself documents for this use case. Drop the now-unused PaymentActivity.LaunchArgs/ActivityResultContract plumbing, and update the unit tests accordingly (the onActivityResult tests drive the listener directly with a mocked Intent, since constructing a real one throws in this project's Robolectric-less unit tests). --- .../monext_payment/MonextPaymentPlugin.kt | 126 ++++++++++-------- .../lenra/monext_payment/PaymentActivity.kt | 9 -- .../monext_payment/MonextPaymentPluginTest.kt | 113 ++++++++++++---- 3 files changed, 154 insertions(+), 94 deletions(-) diff --git a/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt b/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt index 960907a..45c0f2f 100644 --- a/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt +++ b/android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt @@ -1,9 +1,7 @@ package io.lenra.monext_payment import android.app.Activity -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContract +import android.content.Intent import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -11,13 +9,15 @@ 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 io.flutter.plugin.common.PluginRegistry.ActivityResultListener import org.json.JSONObject /** MonextPaymentPlugin */ class MonextPaymentPlugin : FlutterPlugin, MethodCallHandler, - ActivityAware { + ActivityAware, + ActivityResultListener { // The MethodChannel that will the communication between Flutter and native Android // // This local reference serves to register the plugin with the Flutter Engine and unregister it @@ -31,12 +31,20 @@ class MonextPaymentPlugin : // plugin's public API. internal lateinit var sdkClient: MonextSdkClient - // Launches PaymentActivity and delivers its result. Only available while an Activity is - // attached (see onAttachedToActivity/onDetachedFromActivity); registered via - // ActivityResultRegistry directly (rather than ComponentActivity.registerForActivityResult) - // because the plugin doesn't control the host Activity's onCreate, so it can't rely on - // registering before the Activity reaches STARTED. - private var paymentLauncher: ActivityResultLauncher? = null + // The Activity currently hosting the Flutter engine, and the binding used to (un)register this + // plugin as an ActivityResultListener. Only available while an Activity is attached (see + // onAttachedToActivity/onDetachedFromActivity). + // + // Launching PaymentActivity goes through the legacy Activity.startActivityForResult() / + // PluginRegistry.ActivityResultListener APIs rather than AndroidX's ActivityResultRegistry: + // the latter requires the host Activity to be a ComponentActivity, but + // io.flutter.embedding.android.FlutterActivity — the Activity subclass used by the vast + // majority of Flutter apps, including this plugin's own example app — extends plain + // android.app.Activity, not ComponentActivity. Relying on ActivityResultRegistry meant + // startPayment() failed with "activity_unavailable" on every single app using a stock + // FlutterActivity. + private var activity: Activity? = null + private var activityBinding: ActivityPluginBinding? = null override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "monext_payment") @@ -75,8 +83,8 @@ class MonextPaymentPlugin : return } - val launcher = paymentLauncher - if (launcher == null) { + val currentActivity = activity + if (currentActivity == null) { result.error( "activity_unavailable", "No Android Activity is attached to host the payment UI", @@ -89,14 +97,16 @@ class MonextPaymentPlugin : val googlePayConfigurationJson = call.argument>("googlePayConfiguration")?.let { JSONObject(it).toString() } val language = call.argument("language") - launcher.launch( - PaymentActivity.LaunchArgs( - sessionToken = sessionToken, - environmentName = environmentName!!, - appearanceJson = appearanceJson, - language = language, - googlePayConfigurationJson = googlePayConfigurationJson, - ) + currentActivity.startActivityForResult( + PaymentActivity.createIntent( + currentActivity, + sessionToken, + environmentName!!, + appearanceJson, + language, + googlePayConfigurationJson + ), + PAYMENT_REQUEST_CODE ) result.success(null) } @@ -106,12 +116,9 @@ class MonextPaymentPlugin : } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - val activity = binding.activity as? ComponentActivity ?: return - paymentLauncher = - activity.activityResultRegistry.register( - "monext_payment/paymentActivity", - PaymentActivityResultContract() - ) { wireValue -> sendPaymentResult(wireValue) } + activity = binding.activity + activityBinding = binding + binding.addActivityResultListener(this) } override fun onDetachedFromActivityForConfigChanges() = detachFromActivity() @@ -121,15 +128,33 @@ class MonextPaymentPlugin : override fun onDetachedFromActivity() = detachFromActivity() private fun detachFromActivity() { - // Unregister before dropping the reference: the registration lives on the Activity's - // ActivityResultRegistry, keyed by a fixed string, not on this plugin instance. Without - // this, a later onAttachedToActivity() (e.g. after a hot restart, or a config change that - // doesn't go through onReattachedToActivityForConfigChanges) re-registers the same key on - // an Activity that already holds a stale registration, which throws — leaving the new - // plugin instance's paymentLauncher null and every startPayment() call failing with - // "activity_unavailable". - paymentLauncher?.unregister() - paymentLauncher = null + activityBinding?.removeActivityResultListener(this) + activityBinding = null + activity = null + } + + /** + * Receives [PaymentActivity]'s result once it finishes, decodes it into a `MonextPaymentResult` + * wire value, and relays it to Dart. Falls back to `"cancelled"` for a bare + * [Activity.RESULT_CANCELED] with no result extra (e.g. the system back gesture finishing the + * Activity before it had a chance to set its own result). + * + * Returns `true` only for [PAYMENT_REQUEST_CODE], as required by + * [ActivityResultListener]'s contract: other listeners registered on the same Activity still + * need a chance to handle unrelated request codes. + */ + override fun onActivityResult( + requestCode: Int, + resultCode: Int, + data: Intent? + ): Boolean { + if (requestCode != PAYMENT_REQUEST_CODE) return false + + val wireValue = + data?.getStringExtra(PaymentActivity.EXTRA_RESULT) + ?: if (resultCode == Activity.RESULT_CANCELED) PaymentActivity.RESULT_CANCELLED else null + sendPaymentResult(wireValue) + return true } /** @@ -147,27 +172,14 @@ class MonextPaymentPlugin : companion object { private const val UNKNOWN_RESULT = "unknown" - } -} -/** - * [androidx.activity.result.contract.ActivityResultContract] launching [PaymentActivity] and - * decoding its result extra back into a `MonextPaymentResult` wire value, falling back to - * `"cancelled"` for a bare [Activity.RESULT_CANCELED] (e.g. the system back gesture finishing the - * Activity before it had a chance to set its own result). - */ -private class PaymentActivityResultContract : ActivityResultContract() { - override fun createIntent(context: android.content.Context, input: PaymentActivity.LaunchArgs) = - PaymentActivity.createIntent( - context, - input.sessionToken, - input.environmentName, - input.appearanceJson, - input.language, - input.googlePayConfigurationJson, - ) - - override fun parseResult(resultCode: Int, intent: android.content.Intent?): String? = - intent?.getStringExtra(PaymentActivity.EXTRA_RESULT) - ?: if (resultCode == Activity.RESULT_CANCELED) PaymentActivity.RESULT_CANCELLED else null + // Arbitrary but distinctive request code identifying PaymentActivity's result among any + // other startActivityForResult() calls the host app/other plugins might make. Kept within + // 16 bits: some AndroidX call paths (e.g. Fragment.startActivityForResult) reserve the + // upper bits for their own bookkeeping and reject request codes outside that range. + // + // Internal (not private) so it stays testable, e.g. so tests can drive onActivityResult() + // directly without going through a real (unmockable in plain JVM unit tests) Intent. + internal const val PAYMENT_REQUEST_CODE = 0x4D4E + } } diff --git a/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt b/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt index f900767..9d5e055 100644 --- a/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt +++ b/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt @@ -104,15 +104,6 @@ internal class PaymentActivity : ComponentActivity() { .putExtra(EXTRA_LANGUAGE, language) .putExtra(EXTRA_GOOGLE_PAY_CONFIGURATION, googlePayConfigurationJson) } - - /** Input type for the [androidx.activity.result.contract.ActivityResultContract] launching this Activity. */ - internal data class LaunchArgs( - val sessionToken: String, - val environmentName: String, - val appearanceJson: String?, - val language: String?, - val googlePayConfigurationJson: String?, - ) } /** Shallow-converts a [JSONObject] into a `Map` for [MonextAppearanceMapper]. */ diff --git a/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt b/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt index d757d09..e2148c1 100644 --- a/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt +++ b/android/src/test/kotlin/io/lenra/monext_payment/MonextPaymentPluginTest.kt @@ -1,8 +1,7 @@ package io.lenra.monext_payment -import androidx.activity.ComponentActivity -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.ActivityResultRegistry +import android.app.Activity +import android.content.Intent import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.BinaryMessenger @@ -10,6 +9,7 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.mockito.Mockito import kotlin.test.Test +import kotlin.test.assertFalse import kotlin.test.assertTrue /* @@ -156,38 +156,95 @@ internal class MonextPaymentPluginTest { ) } + private fun mockActivityBinding(mockActivity: Activity): ActivityPluginBinding { + val mockBinding: ActivityPluginBinding = Mockito.mock(ActivityPluginBinding::class.java) + Mockito.`when`(mockBinding.activity).thenReturn(mockActivity) + return mockBinding + } + /** - * Regression test for a bug where a stale registration on the Activity's - * [ActivityResultRegistry] survived detach, so a later [MonextPaymentPlugin.onAttachedToActivity] - * call (e.g. after a hot restart) re-registering the same key would throw, leaving the new - * plugin instance's launcher null and every startPayment() failing with "activity_unavailable". + * Regression test for a bug where startPayment() always failed with "activity_unavailable": + * the plugin relied on AndroidX's ActivityResultRegistry, which requires the host Activity to + * be a ComponentActivity — but io.flutter.embedding.android.FlutterActivity (used by the vast + * majority of Flutter apps) extends plain android.app.Activity, not ComponentActivity, so the + * registration silently never happened. onAttachedToActivity() must accept a plain Activity + * (no cast/early-return) and onDetachedFromActivity() must release it again, rather than + * leaking a stale registration/listener across re-attachment. + * + * (The full startPayment() happy path — actually launching PaymentActivity — isn't covered by + * a unit test here: it goes through PaymentActivity.createIntent(), which builds a real + * android.content.Intent, and this project has no Robolectric to back that in plain JVM unit + * tests. That path is exercised by the "Build example app for Android" CI step instead.) */ @Test - fun onDetachedFromActivity_unregistersThePaymentLauncher() { + fun onDetachedFromActivity_removesTheActivityResultListener() { val plugin = attachedPlugin() - - @Suppress("UNCHECKED_CAST") - val mockLauncher = - Mockito.mock(ActivityResultLauncher::class.java) as ActivityResultLauncher - val mockRegistry: ActivityResultRegistry = Mockito.mock(ActivityResultRegistry::class.java) - Mockito - .`when`( - mockRegistry.register( - Mockito.eq("monext_payment/paymentActivity"), - Mockito.any(), - Mockito.any() - ) - ).thenReturn(mockLauncher) - - val mockActivity: ComponentActivity = Mockito.mock(ComponentActivity::class.java) - Mockito.`when`(mockActivity.activityResultRegistry).thenReturn(mockRegistry) - - val mockBinding: ActivityPluginBinding = Mockito.mock(ActivityPluginBinding::class.java) - Mockito.`when`(mockBinding.activity).thenReturn(mockActivity) + val mockActivity: Activity = Mockito.mock(Activity::class.java) + val mockBinding = mockActivityBinding(mockActivity) plugin.onAttachedToActivity(mockBinding) plugin.onDetachedFromActivity() - Mockito.verify(mockLauncher).unregister() + Mockito.verify(mockBinding).removeActivityResultListener(plugin) + + // With the Activity detached, startPayment() must fail again instead of using a stale + // reference to the Activity it's no longer attached to. + val call = + MethodCall( + "startPayment", + mapOf("sessionToken" to "a-session-token", "environment" to "sandbox") + ) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).error( + Mockito.eq("activity_unavailable"), + Mockito.anyString(), + Mockito.isNull() + ) + } + + // These onActivityResult tests drive the listener callback directly with a *mocked* Intent + // (or none at all) rather than going through startPayment()'s real PaymentActivity.createIntent() + // call: constructing/mutating a real android.content.Intent throws in plain JVM unit tests + // (no Robolectric in this project), since it hits the unimplemented Android SDK stub jar. + + @Test + fun onActivityResult_forThePaymentRequestCode_relaysTheResultExtraAndReturnsTrue() { + val plugin = attachedPlugin() + val mockChannel: MethodChannel = Mockito.mock(MethodChannel::class.java) + plugin.channel = mockChannel + val mockResultIntent: Intent = Mockito.mock(Intent::class.java) + Mockito.`when`(mockResultIntent.getStringExtra(PaymentActivity.EXTRA_RESULT)).thenReturn("captured") + + val handled = + plugin.onActivityResult(MonextPaymentPlugin.PAYMENT_REQUEST_CODE, Activity.RESULT_OK, mockResultIntent) + + assertTrue(handled) + Mockito.verify(mockChannel).invokeMethod("onPaymentResult", "captured") + } + + @Test + fun onActivityResult_forAnUnrelatedRequestCode_returnsFalseWithoutRelayingAnything() { + val plugin = attachedPlugin() + val mockChannel: MethodChannel = Mockito.mock(MethodChannel::class.java) + plugin.channel = mockChannel + + val handled = plugin.onActivityResult(-1, Activity.RESULT_OK, null) + + assertFalse(handled) + Mockito.verify(mockChannel, Mockito.never()).invokeMethod(Mockito.anyString(), Mockito.any()) + } + + @Test + fun onActivityResult_withBareResultCanceledAndNoExtra_relaysCancelled() { + val plugin = attachedPlugin() + val mockChannel: MethodChannel = Mockito.mock(MethodChannel::class.java) + plugin.channel = mockChannel + + val handled = plugin.onActivityResult(MonextPaymentPlugin.PAYMENT_REQUEST_CODE, Activity.RESULT_CANCELED, null) + + assertTrue(handled) + Mockito.verify(mockChannel).invokeMethod("onPaymentResult", "cancelled") } } From 3d72617c2afe753b3d6b86edff48d6a34e6a4784 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 15:56:02 +0000 Subject: [PATCH 6/7] Make the payment wrapper screen transparent behind the sheet Both PaymentBox (Android) and presentPaymentSheet (iOS) render as a bottom sheet, not a full page, but their host Activity/ViewController was styled/presented as opaque fullscreen - showing as an empty black backdrop behind the sheet instead of the app underneath. - Android: give PaymentActivity a transparent theme (windowIsTranslucent + transparent windowBackground) instead of Theme.NoTitleBar.Fullscreen. - iOS: present the hosting controller with .overFullScreen instead of .fullScreen, and clear its view's background, so the presenting app stays visible/live behind the transparent SwiftUI content. --- android/src/main/AndroidManifest.xml | 8 ++++++-- .../io/lenra/monext_payment/PaymentActivity.kt | 5 ++++- android/src/main/res/values/styles.xml | 16 ++++++++++++++++ .../monext_payment/MonextPaymentPlugin.swift | 8 +++++++- 4 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 android/src/main/res/values/styles.xml diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 4e3b56d..1460d25 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,10 +1,14 @@ - + + android:theme="@style/Theme.MonextPayment.Transparent" /> diff --git a/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt b/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt index 9d5e055..4cf6071 100644 --- a/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt +++ b/android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt @@ -14,7 +14,10 @@ import com.monext.sdk.PaymentBox import org.json.JSONObject /** - * Full-screen wrapper `Activity` hosting Monext's `PaymentBox` in a `ComposeView` (see ADR 0001). + * Wrapper `Activity` hosting Monext's `PaymentBox` in a `ComposeView` (see ADR 0001). Declared + * with a transparent theme in the manifest (see `styles.xml`) since `PaymentBox` renders itself + * as a bottom sheet, not a full page — a transparent window lets the host app's Activity show + * through behind it instead of an opaque backdrop. * * `PaymentBox` is a Jetpack Compose component with no direct equivalent to iOS's * `presentPaymentSheet`, so this Activity exists solely to give it somewhere to live. It carries diff --git a/android/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml new file mode 100644 index 0000000..2ecdc21 --- /dev/null +++ b/android/src/main/res/values/styles.xml @@ -0,0 +1,16 @@ + + + + diff --git a/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift b/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift index 87a3f2f..09fce3e 100644 --- a/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift +++ b/ios/monext_payment/Sources/monext_payment/MonextPaymentPlugin.swift @@ -90,7 +90,13 @@ public class MonextPaymentPlugin: NSObject, FlutterPlugin { self?.finishPaymentPresentation(with: wireValue) } )) - hostingController.modalPresentationStyle = .fullScreen + // PaymentHostingView's own content is just Color.clear applying the presentPaymentSheet + // modifier (see PaymentHostingView.swift), which renders as a sheet, not a full page. With + // .fullScreen, UIKit paints an opaque backdrop behind that transparent content instead of + // showing the presenting app underneath. .overFullScreen keeps the presenter visible/live + // behind this controller, and clearing the hosting view's background lets it show through. + hostingController.modalPresentationStyle = .overFullScreen + hostingController.view.backgroundColor = .clear presentedController = hostingController presenter.present(hostingController, animated: true) From cad89063a9275a94b34d7d4a62aea0ef599ad8b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:49:09 +0000 Subject: [PATCH 7/7] Fix release R8 build failure from Compose UI's XR support Consuming apps failed full-mode R8 minification with "Missing classes detected" for com.android.extensions.xr.*/ com.google.androidxr.splitengine.* - Compose UI's optional Android XR (SceneCore) support references OEM-only classes that aren't on the classpath for a normal (non-XR) device. Ship a consumer-rules.pro with -dontwarn for those packages so apps depending on this plugin don't each have to work this out themselves. --- android/build.gradle.kts | 4 ++++ android/consumer-rules.pro | 11 +++++++++++ 2 files changed, 15 insertions(+) create mode 100644 android/consumer-rules.pro diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 6d56cbe..07e2922 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -67,6 +67,10 @@ android { defaultConfig { // Monext's Android SDK requires Android 8.0 (API 26) or later. minSdk = 26 + + // See consumer-rules.pro: Compose UI's optional Android XR support otherwise fails full + // R8 minification ("Missing classes detected") for any app consuming this plugin. + consumerProguardFiles("consumer-rules.pro") } testOptions { diff --git a/android/consumer-rules.pro b/android/consumer-rules.pro new file mode 100644 index 0000000..d83ad71 --- /dev/null +++ b/android/consumer-rules.pro @@ -0,0 +1,11 @@ +# Jetpack Compose UI's optional Android XR (SceneCore/SplitEngine) support code references +# OEM-only XR extension classes (com.android.extensions.xr.*) that don't ship in the standard +# Android SDK/on non-XR devices. Full-mode R8 fails minification with "Missing classes detected" +# for apps consuming this plugin (which pulls in Compose UI to host Monext's PaymentBox) even +# though this code path is never reached on a normal device. See +# https://issuetracker.google.com/issues/326315287 and Flutter/AGP's own "Missing classes +# detected while running R8" guidance: add -dontwarn rather than a fully qualified keep rule, +# since these classes intentionally aren't on the classpath. +-dontwarn com.android.extensions.xr.** +-dontwarn com.google.androidxr.splitengine.** +-dontwarn com.google.imp.splitengine.**