Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 16 additions & 3 deletions android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -58,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 {
Expand Down
11 changes: 11 additions & 0 deletions android/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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.**
8 changes: 6 additions & 2 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.lenra.monext_payment">
<application>
<!-- Full-screen wrapper Activity hosting Monext's PaymentBox (see PaymentActivity.kt). -->
<!--
Wrapper Activity hosting Monext's PaymentBox (see PaymentActivity.kt), which renders as
a bottom sheet. A transparent theme lets the host app's own Activity show through behind
it instead of an opaque black backdrop (see styles.xml).
-->
<activity
android:name=".PaymentActivity"
android:exported="false"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
android:theme="@style/Theme.MonextPayment.Transparent" />
</application>
</manifest>
118 changes: 69 additions & 49 deletions android/src/main/kotlin/io/lenra/monext_payment/MonextPaymentPlugin.kt
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
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
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
Expand All @@ -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<PaymentActivity.LaunchArgs>? = 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")
Expand Down Expand Up @@ -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",
Expand All @@ -89,14 +97,16 @@ class MonextPaymentPlugin :
val googlePayConfigurationJson =
call.argument<Map<String, Any?>>("googlePayConfiguration")?.let { JSONObject(it).toString() }
val language = call.argument<String>("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)
}
Expand All @@ -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()
Expand All @@ -121,7 +128,33 @@ class MonextPaymentPlugin :
override fun onDetachedFromActivity() = detachFromActivity()

private fun detachFromActivity() {
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
}

/**
Expand All @@ -139,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<PaymentActivity.LaunchArgs, String?>() {
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
}
}
14 changes: 4 additions & 10 deletions android/src/main/kotlin/io/lenra/monext_payment/PaymentActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -104,15 +107,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<String, Any?>` for [MonextAppearanceMapper]. */
Expand Down
16 changes: 16 additions & 0 deletions android/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<resources>
<!--
PaymentActivity hosts Monext's PaymentBox, which renders itself as a bottom sheet (see
PaymentActivity.kt: it calls a `showSheet()` callback, not a full-page layout). A plain
fullscreen theme paints an opaque window background behind that sheet, showing as an empty
black screen instead of the host app underneath. Translucent + a transparent window
background let the app Activity keep showing through behind the sheet, like any other
bottom-sheet/dialog presentation.
-->
<style name="Theme.MonextPayment.Transparent" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
Loading
Loading