Skip to content

Commit d460740

Browse files
Android inconsistent paymentOption.image bug (#2266)
* update getBitmapFromDrawable * waitForDrawableToLoad * ui in example screens * use convertDrawableToBase64 to get paymentOption image * Update PaymentSheetManager.kt * use async drawable logic in customerSheetManager * tests * yarn update
1 parent 2ce0a30 commit d460740

File tree

14 files changed

+971
-379
lines changed

14 files changed

+971
-379
lines changed

android/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ dependencies {
183183
testImplementation "org.mockito:mockito-core:3.+"
184184
testImplementation "org.robolectric:robolectric:4.10"
185185
testImplementation "androidx.test:core:1.4.0"
186+
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"
186187

187188
implementation "androidx.compose.ui:ui:1.7.8"
188189
implementation "androidx.compose.foundation:foundation-layout:1.7.8"

android/src/main/java/com/reactnativestripesdk/PaymentSheetManager.kt

Lines changed: 131 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ import kotlinx.coroutines.CoroutineScope
5858
import kotlinx.coroutines.Dispatchers
5959
import kotlinx.coroutines.delay
6060
import kotlinx.coroutines.launch
61+
import kotlinx.coroutines.suspendCancellableCoroutine
62+
import kotlinx.coroutines.withTimeoutOrNull
6163
import java.io.ByteArrayOutputStream
6264
import kotlin.Exception
65+
import kotlin.coroutines.resume
6366

6467
@OptIn(
6568
ReactNativeSdkInternal::class,
@@ -140,28 +143,42 @@ class PaymentSheetManager(
140143

141144
val paymentOptionCallback =
142145
PaymentOptionResultCallback { paymentOptionResult ->
143-
val result =
144-
paymentOptionResult.paymentOption?.let {
145-
val bitmap = getBitmapFromDrawable(it.icon())
146-
val imageString = getBase64FromBitmap(bitmap)
146+
paymentOptionResult.paymentOption?.let { paymentOption ->
147+
// Convert drawable to bitmap asynchronously to avoid shared state issues
148+
CoroutineScope(Dispatchers.Default).launch {
149+
val imageString =
150+
try {
151+
convertDrawableToBase64(paymentOption.icon())
152+
} catch (e: Exception) {
153+
val result =
154+
createError(
155+
PaymentSheetErrorType.Failed.toString(),
156+
"Failed to process payment option image: ${e.message}",
157+
)
158+
resolvePresentPromise(result)
159+
return@launch
160+
}
161+
147162
val option: WritableMap = Arguments.createMap()
148-
option.putString("label", it.label)
163+
option.putString("label", paymentOption.label)
149164
option.putString("image", imageString)
150165
val additionalFields: Map<String, Any> = mapOf("didCancel" to paymentOptionResult.didCancel)
151-
createResult("paymentOption", option, additionalFields)
166+
val result = createResult("paymentOption", option, additionalFields)
167+
resolvePresentPromise(result)
152168
}
153-
?: run {
154-
if (paymentSheetTimedOut) {
155-
paymentSheetTimedOut = false
156-
createError(PaymentSheetErrorType.Timeout.toString(), "The payment has timed out")
157-
} else {
158-
createError(
159-
PaymentSheetErrorType.Canceled.toString(),
160-
"The payment option selection flow has been canceled",
161-
)
162-
}
169+
} ?: run {
170+
val result =
171+
if (paymentSheetTimedOut) {
172+
paymentSheetTimedOut = false
173+
createError(PaymentSheetErrorType.Timeout.toString(), "The payment has timed out")
174+
} else {
175+
createError(
176+
PaymentSheetErrorType.Canceled.toString(),
177+
"The payment option selection flow has been canceled",
178+
)
163179
}
164-
resolvePresentPromise(result)
180+
resolvePresentPromise(result)
181+
}
165182
}
166183

167184
val paymentResultCallback =
@@ -413,16 +430,31 @@ class PaymentSheetManager(
413430
private fun configureFlowController() {
414431
val onFlowControllerConfigure =
415432
PaymentSheet.FlowController.ConfigCallback { _, _ ->
416-
val result =
417-
flowController?.getPaymentOption()?.let {
418-
val bitmap = getBitmapFromDrawable(it.icon())
419-
val imageString = getBase64FromBitmap(bitmap)
433+
flowController?.getPaymentOption()?.let { paymentOption ->
434+
// Launch async job to convert drawable, but resolve promise synchronously
435+
CoroutineScope(Dispatchers.Default).launch {
436+
val imageString =
437+
try {
438+
convertDrawableToBase64(paymentOption.icon())
439+
} catch (e: Exception) {
440+
val result =
441+
createError(
442+
PaymentSheetErrorType.Failed.toString(),
443+
"Failed to process payment option image: ${e.message}",
444+
)
445+
initPromise.resolve(result)
446+
return@launch
447+
}
448+
420449
val option: WritableMap = Arguments.createMap()
421-
option.putString("label", it.label)
450+
option.putString("label", paymentOption.label)
422451
option.putString("image", imageString)
423-
createResult("paymentOption", option)
424-
} ?: run { Arguments.createMap() }
425-
initPromise.resolve(result)
452+
val result = createResult("paymentOption", option)
453+
initPromise.resolve(result)
454+
}
455+
} ?: run {
456+
initPromise.resolve(Arguments.createMap())
457+
}
426458
}
427459

428460
if (!paymentIntentClientSecret.isNullOrEmpty()) {
@@ -550,17 +582,86 @@ class PaymentSheetManager(
550582
}
551583
}
552584

585+
suspend fun waitForDrawableToLoad(
586+
drawable: Drawable,
587+
timeoutMs: Long = 3000,
588+
): Drawable {
589+
// If already loaded, return immediately
590+
if (drawable.intrinsicWidth > 1 && drawable.intrinsicHeight > 1) {
591+
return drawable
592+
}
593+
594+
// Use callback to be notified when drawable finishes loading
595+
return withTimeoutOrNull(timeoutMs) {
596+
suspendCancellableCoroutine { continuation ->
597+
val callback =
598+
object : Drawable.Callback {
599+
override fun invalidateDrawable(who: Drawable) {
600+
// Drawable has changed/loaded - check if it's ready now
601+
if (who.intrinsicWidth > 1 && who.intrinsicHeight > 1) {
602+
who.callback = null // Remove callback
603+
if (continuation.isActive) {
604+
continuation.resume(who)
605+
}
606+
}
607+
}
608+
609+
override fun scheduleDrawable(
610+
who: Drawable,
611+
what: Runnable,
612+
`when`: Long,
613+
) {}
614+
615+
override fun unscheduleDrawable(
616+
who: Drawable,
617+
what: Runnable,
618+
) {}
619+
}
620+
621+
drawable.callback = callback
622+
623+
// Trigger an invalidation to check if it loads immediately
624+
drawable.invalidateSelf()
625+
626+
continuation.invokeOnCancellation { drawable.callback = null }
627+
}
628+
} ?: drawable // Return drawable even if timeout (best effort)
629+
}
630+
631+
suspend fun convertDrawableToBase64(drawable: Drawable): String? {
632+
val loadedDrawable = waitForDrawableToLoad(drawable)
633+
val bitmap = getBitmapFromDrawable(loadedDrawable)
634+
return getBase64FromBitmap(bitmap)
635+
}
636+
553637
fun getBitmapFromDrawable(drawable: Drawable): Bitmap? {
554638
val drawableCompat = DrawableCompat.wrap(drawable).mutate()
555-
if (drawableCompat.intrinsicWidth <= 0 || drawableCompat.intrinsicHeight <= 0) {
639+
640+
// Determine the size to use - prefer intrinsic size, fall back to bounds
641+
val width =
642+
if (drawableCompat.intrinsicWidth > 0) {
643+
drawableCompat.intrinsicWidth
644+
} else {
645+
drawableCompat.bounds.width()
646+
}
647+
648+
val height =
649+
if (drawableCompat.intrinsicHeight > 0) {
650+
drawableCompat.intrinsicHeight
651+
} else {
652+
drawableCompat.bounds.height()
653+
}
654+
655+
if (width <= 0 || height <= 0) {
556656
return null
557657
}
558-
val bitmap =
559-
createBitmap(drawableCompat.intrinsicWidth, drawableCompat.intrinsicHeight)
658+
659+
val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
560660
bitmap.eraseColor(Color.TRANSPARENT)
561661
val canvas = Canvas(bitmap)
562-
drawable.setBounds(0, 0, canvas.width, canvas.height)
563-
drawable.draw(canvas)
662+
drawableCompat.setBounds(0, 0, canvas.width, canvas.height)
663+
drawableCompat.draw(canvas)
664+
564665
return bitmap
565666
}
566667

android/src/main/java/com/reactnativestripesdk/customersheet/CustomerSheetManager.kt

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ import com.reactnativestripesdk.ReactNativeCustomerSessionProvider
1717
import com.reactnativestripesdk.buildBillingDetails
1818
import com.reactnativestripesdk.buildBillingDetailsCollectionConfiguration
1919
import com.reactnativestripesdk.buildPaymentSheetAppearance
20-
import com.reactnativestripesdk.getBase64FromBitmap
21-
import com.reactnativestripesdk.getBitmapFromDrawable
20+
import com.reactnativestripesdk.convertDrawableToBase64
2221
import com.reactnativestripesdk.mapToCardBrandAcceptance
2322
import com.reactnativestripesdk.utils.CreateTokenErrorType
2423
import com.reactnativestripesdk.utils.ErrorType
@@ -163,25 +162,49 @@ class CustomerSheetManager(
163162
}
164163

165164
private fun handleResult(result: CustomerSheetResult) {
166-
var promiseResult = Arguments.createMap()
167165
when (result) {
168166
is CustomerSheetResult.Failed -> {
169167
resolvePresentPromise(createError(ErrorType.Failed.toString(), result.exception))
170168
}
171169

172170
is CustomerSheetResult.Selected -> {
173-
promiseResult = createPaymentOptionResult(result.selection)
171+
// Convert drawable asynchronously to avoid shared state issues
172+
CoroutineScope(Dispatchers.Default).launch {
173+
try {
174+
val promiseResult = createPaymentOptionResult(result.selection)
175+
resolvePresentPromise(promiseResult)
176+
} catch (e: Exception) {
177+
resolvePresentPromise(
178+
createError(
179+
ErrorType.Failed.toString(),
180+
"Failed to process payment option image: ${e.message}",
181+
),
182+
)
183+
}
184+
}
174185
}
175186

176187
is CustomerSheetResult.Canceled -> {
177-
promiseResult = createPaymentOptionResult(result.selection)
178-
promiseResult.putMap(
179-
"error",
180-
Arguments.createMap().also { it.putString("code", ErrorType.Canceled.toString()) },
181-
)
188+
// Convert drawable asynchronously to avoid shared state issues
189+
CoroutineScope(Dispatchers.Default).launch {
190+
try {
191+
val promiseResult = createPaymentOptionResult(result.selection)
192+
promiseResult.putMap(
193+
"error",
194+
Arguments.createMap().also { it.putString("code", ErrorType.Canceled.toString()) },
195+
)
196+
resolvePresentPromise(promiseResult)
197+
} catch (e: Exception) {
198+
resolvePresentPromise(
199+
createError(
200+
ErrorType.Failed.toString(),
201+
"Failed to process payment option image: ${e.message}",
202+
),
203+
)
204+
}
205+
}
182206
}
183207
}
184-
resolvePresentPromise(promiseResult)
185208
}
186209

187210
override fun onPresent() {
@@ -355,7 +378,7 @@ class CustomerSheetManager(
355378
)
356379
}
357380

358-
internal fun createPaymentOptionResult(selection: PaymentOptionSelection?): WritableMap {
381+
internal suspend fun createPaymentOptionResult(selection: PaymentOptionSelection?): WritableMap {
359382
var paymentOptionResult = Arguments.createMap()
360383

361384
when (selection) {
@@ -392,16 +415,18 @@ class CustomerSheetManager(
392415
}.build()
393416
}
394417

395-
private fun buildResult(
418+
private suspend fun buildResult(
396419
label: String,
397420
drawable: Drawable,
398421
paymentMethod: PaymentMethod?,
399422
): WritableMap {
423+
val imageString = convertDrawableToBase64(drawable)
424+
400425
val result = Arguments.createMap()
401426
val paymentOption =
402427
Arguments.createMap().also {
403428
it.putString("label", label)
404-
it.putString("image", getBase64FromBitmap(getBitmapFromDrawable(drawable)))
429+
it.putString("image", imageString)
405430
}
406431
result.putMap("paymentOption", paymentOption)
407432
if (paymentMethod != null) {

0 commit comments

Comments
 (0)