Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/stripe/lib/flutter_stripe.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export 'src/widgets/aubecs_debit_form.dart';
export 'src/widgets/card_field.dart';
export 'src/widgets/card_form_field.dart';
// export 'src/widgets/google_pay_button.dart';
export 'src/widgets/payment_method_messaging.dart';
export 'src/widgets/platform_pay_button.dart';
122 changes: 122 additions & 0 deletions packages/stripe/lib/src/widgets/payment_method_messaging.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:stripe_platform_interface/stripe_platform_interface.dart';

/// Renders Stripe's Klarna / Afterpay / Affirm promotional messaging element.
///
/// Backed by Stripe's `PaymentMethodMessagingElement` on both iOS (via
/// `StripePaymentSheet`) and Android (via the `com.stripe:payment-method-messaging`
/// artifact). The element is currently marked preview by Stripe (`@_spi` on
/// iOS, `@OptIn(PaymentMethodMessagingElementPreview::class)` on Android), so
/// its visual output may change in future SDK releases.
///
/// The widget auto-resizes: the native element reports its rendered height
/// back to Dart, which drives the surrounding `SizedBox`. The optional
/// [onHeightChange] callback receives the same value if callers want to react
/// to height changes themselves.
class PaymentMethodMessaging extends StatefulWidget {
const PaymentMethodMessaging({
required this.configuration,
this.onHeightChange,
super.key,
});

final PaymentMethodMessagingConfiguration configuration;
final ValueChanged<double>? onHeightChange;

@override
State<PaymentMethodMessaging> createState() =>
_PaymentMethodMessagingState();
}

class _PaymentMethodMessagingState extends State<PaymentMethodMessaging> {
static const _viewType = 'flutter.stripe/payment_method_messaging';

MethodChannel? _methodChannel;
double _height = 50;

void onPlatformViewCreated(int viewId) {
_methodChannel =
MethodChannel('flutter.stripe/payment_method_messaging/$viewId');
_methodChannel!.setMethodCallHandler((call) async {
if (call.method == 'onHeightChange') {
final args = Map<String, dynamic>.from(call.arguments);
final height = (args['height'] as num).toDouble();
if (mounted) {
setState(() {
_height = height;
});
}
widget.onHeightChange?.call(height);
}
});
}

@override
void didUpdateWidget(covariant PaymentMethodMessaging oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.configuration != oldWidget.configuration) {
_methodChannel?.invokeMethod(
'updateConfiguration',
widget.configuration.toJson(),
);
}
}

@override
void dispose() {
_methodChannel?.setMethodCallHandler(null);
super.dispose();
}

@override
Widget build(BuildContext context) {
final creationParams = widget.configuration.toJson();

Widget platform;
if (defaultTargetPlatform == TargetPlatform.iOS) {
platform = UiKitView(
viewType: _viewType,
creationParamsCodec: const StandardMessageCodec(),
creationParams: creationParams,
onPlatformViewCreated: onPlatformViewCreated,
);
} else if (defaultTargetPlatform == TargetPlatform.android) {
platform = PlatformViewLink(
viewType: _viewType,
surfaceFactory: (context, controller) {
return AndroidViewSurface(
controller: controller as AndroidViewController,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
gestureRecognizers:
const <Factory<OneSequenceGestureRecognizer>>{},
);
},
onCreatePlatformView: (params) {
onPlatformViewCreated(params.id);
return PlatformViewsService.initSurfaceAndroidView(
id: params.id,
viewType: _viewType,
layoutDirection: TextDirection.ltr,
creationParams: creationParams,
creationParamsCodec: const StandardMessageCodec(),
)
..addOnPlatformViewCreatedListener(
params.onPlatformViewCreated,
)
..create();
},
);
} else {
throw UnsupportedError('Unsupported platform view');
}

return SizedBox(
height: _height,
child: platform,
);
}
}
7 changes: 7 additions & 0 deletions packages/stripe_android/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.1"
implementation "com.stripe:stripe-android:$stripe_version"
implementation "com.stripe:financial-connections:$stripe_version"
implementation "com.stripe:payment-method-messaging:$stripe_version"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List published versions of com.stripe:payment-method-messaging on Maven Central
# and show the newest version in the 23.1.x train, if any.
curl -s 'https://search.maven.org/solrsearch/select?q=g:com.stripe+AND+a:payment-method-messaging&core=gav&rows=50&wt=json' \
  | jq -r '.response.docs[] | "\(.v)"' \
  | sort -V

Repository: flutter-stripe/flutter_stripe

Length of output: 454


Fix version mismatch: com.stripe:payment-method-messaging does not publish 23.1.x versions.

The artifact com.stripe:payment-method-messaging on Maven Central only provides versions up to 21.19.0. There are no versions in the 23.x train or 23.1.x range. Using 23.1.+ will cause Gradle resolution to fail at build time.

Pin this dependency to 21.19.0 or the lowest compatible version that provides PaymentMethodMessagingElement, or align it with a version range that actually exists for this artifact.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stripe_android/android/build.gradle` at line 67, The dependency
declaration implementation "com.stripe:payment-method-messaging:$stripe_version"
references a non-existent 23.1.x train; replace the dynamic/incorrect version
usage by pinning this artifact to a known published version (e.g., 21.19.0) or
set a valid explicit version range that exists on Maven Central so Gradle
resolution succeeds; update the implementation line for
com.stripe:payment-method-messaging and, if necessary, adjust the stripe_version
variable or use a hardcoded version string to ensure
PaymentMethodMessagingElement is provided.

// Compose modules used by StripePaymentMethodMessagingPlatformView. The
// payment-method-messaging artifact declares these at runtime scope only,
// so consuming modules must add them to the compile classpath explicitly.
implementation "androidx.compose.ui:ui:1.10.4"
implementation "androidx.compose.foundation:foundation:1.10.4"
implementation "androidx.compose.runtime:runtime:1.10.4"
implementation 'com.google.android.material:material:1.6.0'
implementation 'androidx.appcompat:appcompat:1.4.1'
implementation 'com.squareup.okhttp3:okhttp:5.3.2'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class StripeAndroidPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
.platformViewRegistry
.registerViewFactory("flutter.stripe/add_to_wallet", StripeAddToWalletPlatformViewFactory(flutterPluginBinding, AddToWalletButtonManager()){stripeSdk})
flutterPluginBinding.platformViewRegistry.registerViewFactory("flutter.stripe/address_sheet", StripeAddressSheetPlatformViewFactory(flutterPluginBinding, addressSheetFormViewManager ){stripeSdk})
flutterPluginBinding.platformViewRegistry.registerViewFactory("flutter.stripe/payment_method_messaging", StripePaymentMethodMessagingPlatformViewFactory(flutterPluginBinding))
}

override fun onMethodCall(call: MethodCall, result: Result) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package com.flutter.stripe

import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.ContextWrapper
import android.view.View
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.ViewCompositionStrategy
import com.stripe.android.paymentmethodmessaging.element.PaymentMethodMessagingElement
import com.stripe.android.paymentmethodmessaging.element.PaymentMethodMessagingElementPreview
import com.stripe.android.model.PaymentMethod
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.platform.PlatformView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

@OptIn(PaymentMethodMessagingElementPreview::class)
class StripePaymentMethodMessagingPlatformView(
private val context: Context,
private val channel: MethodChannel,
creationParams: Map<String?, Any?>?,
) : PlatformView {

private val container: FrameLayout = FrameLayout(context)
private val composeView: ComposeView = ComposeView(context).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
}
private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var configureJob: Job? = null
private var elementState: ((PaymentMethodMessagingElement?) -> Unit)? = null
private var lastReportedHeightPx: Int = -1

init {
container.addView(
composeView,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
),
)
composeView.setContent {
var element by remember { mutableStateOf<PaymentMethodMessagingElement?>(null) }
elementState = { element = it }
val density = LocalDensity.current
Box(
modifier = Modifier.onSizeChanged { size ->
if (size.height != lastReportedHeightPx) {
lastReportedHeightPx = size.height
val heightDp = with(density) { size.height.toDp().value }
channel.invokeMethod(
"onHeightChange",
mapOf("height" to heightDp.toDouble()),
)
}
},
) {
element?.Content(PaymentMethodMessagingElement.Appearance())
}
}

channel.setMethodCallHandler { call, result -> handleMethodCall(call, result) }
applyConfiguration(creationParams)
Comment on lines +45 to +77
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripePaymentMethodMessagingPlatformView.kt | head -150

Repository: flutter-stripe/flutter_stripe

Length of output: 6860


🏁 Script executed:

# Check the entire file to understand the full context
wc -l packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripePaymentMethodMessagingPlatformView.kt

Repository: flutter-stripe/flutter_stripe

Length of output: 187


🏁 Script executed:

# Look for the applyConfiguration method
rg -A 30 "fun applyConfiguration" packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripePaymentMethodMessagingPlatformView.kt

Repository: flutter-stripe/flutter_stripe

Length of output: 1405


Move element state to class-level Compose state to avoid race condition.

applyConfiguration(creationParams) runs synchronously after composeView.setContent() returns, but AndroidX ComposeView defers its initial composition until the view is attached or createComposition() is explicitly called. This creates a race where the coroutine in applyConfiguration can complete and call elementState?.invoke(element) before the composition has even started—meaning elementState is still null and the configured element is silently dropped.

Suggested fix
 import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
@@
     private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
     private var configureJob: Job? = null
-    private var elementState: ((PaymentMethodMessagingElement?) -> Unit)? = null
+    private var currentElement by mutableStateOf<PaymentMethodMessagingElement?>(null)
     private var lastReportedHeightPx: Int = -1
@@
         )
         composeView.setContent {
-            var element by remember { mutableStateOf<PaymentMethodMessagingElement?>(null) }
-            elementState = { element = it }
             val density = LocalDensity.current
             Box(
@@
             ) {
-                element?.Content(PaymentMethodMessagingElement.Appearance())
+                currentElement?.Content(PaymentMethodMessagingElement.Appearance())
             }
         }
@@
     private fun applyConfiguration(params: Map<String?, Any?>?) {
         configureJob?.cancel()
-        elementState?.invoke(null)
+        currentElement = null
         emitCollapsedHeight()
@@
         configureJob = scope.launch {
-            val element = PaymentMethodMessagingElement.create(application)
-            val result = element.configure(configuration)
+            val configuredElement = PaymentMethodMessagingElement.create(application)
+            val result = configuredElement.configure(configuration)
             when (result) {
                 is PaymentMethodMessagingElement.ConfigureResult.Succeeded -> {
-                    elementState?.invoke(element)
+                    currentElement = configuredElement
                 }
                 is PaymentMethodMessagingElement.ConfigureResult.NoContent,
                 is PaymentMethodMessagingElement.ConfigureResult.Failed -> {
-                    elementState?.invoke(null)
+                    currentElement = null
                     emitCollapsedHeight()
                 }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/stripe_android/android/src/main/kotlin/com/flutter/stripe/StripePaymentMethodMessagingPlatformView.kt`
around lines 45 - 77, The element state is declared inside
composeView.setContent which can be null when applyConfiguration runs; move the
state to a class-level Compose MutableState so updates from applyConfiguration
aren't lost. Concretely: replace the local "var element by remember {
mutableStateOf<PaymentMethodMessagingElement?>(null) }" and the function
property "private var elementState: ((PaymentMethodMessagingElement?) -> Unit)?
= null" with a class-level "private val elementState =
mutableStateOf<PaymentMethodMessagingElement?>(null)" (or similar MutableState
variable), update composeView.setContent to read elementState.value (and call
elementState.value = ... instead of invoking a lambda), and update
applyConfiguration to set elementState.value when configuring the element; keep
lastReportedHeightPx and the onSizeChanged logic as-is.

}

private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"updateConfiguration" -> {
@Suppress("UNCHECKED_CAST")
applyConfiguration(call.arguments as? Map<String?, Any?>)
result.success(null)
}
else -> result.notImplemented()
}
}

private fun applyConfiguration(params: Map<String?, Any?>?) {
configureJob?.cancel()
elementState?.invoke(null)
emitCollapsedHeight()

if (params == null) return

@Suppress("UNCHECKED_CAST")
val methodStrings = params["paymentMethods"] as? List<String> ?: return
val currency = params["currency"] as? String ?: return
val amount = (params["amount"] as? Number)?.toLong() ?: return
val countryCode = params["countryCode"] as? String
val locale = params["locale"] as? String

val types = methodStrings.mapNotNull { PaymentMethod.Type.fromCode(it) }
val application = context.findApplication() ?: return

val configuration = PaymentMethodMessagingElement.Configuration().apply {
amount(amount)
currency(currency)
locale?.let { locale(it) }
countryCode?.let { countryCode(it) }
if (types.isNotEmpty()) paymentMethodTypes(types)
}

configureJob = scope.launch {
val element = PaymentMethodMessagingElement.create(application)
val result = element.configure(configuration)
when (result) {
is PaymentMethodMessagingElement.ConfigureResult.Succeeded -> {
elementState?.invoke(element)
}
is PaymentMethodMessagingElement.ConfigureResult.NoContent,
is PaymentMethodMessagingElement.ConfigureResult.Failed -> {
elementState?.invoke(null)
emitCollapsedHeight()
}
}
}
}

private fun emitCollapsedHeight() {
if (lastReportedHeightPx != 0) {
lastReportedHeightPx = 0
channel.invokeMethod("onHeightChange", mapOf("height" to 0.0))
}
}

override fun getView(): View = container

override fun dispose() {
configureJob?.cancel()
scope.cancel()
composeView.disposeComposition()
channel.setMethodCallHandler(null)
}
}

private fun Context.findApplication(): Application? {
var ctx: Context = this
while (ctx is ContextWrapper) {
if (ctx is Application) return ctx
if (ctx is Activity) return ctx.application
ctx = ctx.baseContext
}
return applicationContext as? Application
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.flutter.stripe

import android.content.Context
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory

class StripePaymentMethodMessagingPlatformViewFactory(
private val flutterPluginBinding: FlutterPlugin.FlutterPluginBinding,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context?, viewId: Int, args: Any?): PlatformView {
if (context == null) {
throw AssertionError("Context is not allowed to be null when launching PaymentMethodMessaging view.")
}
val channel = MethodChannel(
flutterPluginBinding.binaryMessenger,
"flutter.stripe/payment_method_messaging/$viewId",
)
@Suppress("UNCHECKED_CAST")
val creationParams = args as? Map<String?, Any?>?
return StripePaymentMethodMessagingPlatformView(context, channel, creationParams)
}
}
4 changes: 2 additions & 2 deletions packages/stripe_ios/ios/stripe_ios/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading