@stripe/stripe-react-native 0.57.2 → 0.57.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/android/build.gradle +1 -0
  2. package/android/src/main/java/com/reactnativestripesdk/PaymentSheetManager.kt +131 -30
  3. package/android/src/main/java/com/reactnativestripesdk/customersheet/CustomerSheetManager.kt +38 -13
  4. package/android/src/test/java/com/reactnativestripesdk/DrawableConversionPropertyTest.kt +224 -0
  5. package/android/src/test/java/com/reactnativestripesdk/DrawableConversionTest.kt +146 -0
  6. package/android/src/test/java/com/reactnativestripesdk/DrawableLoadingTest.kt +150 -0
  7. package/android/src/test/java/com/reactnativestripesdk/PaymentOptionImageConsistencyTest.kt +186 -0
  8. package/lib/commonjs/components/AddToWalletButton.js +1 -1
  9. package/lib/commonjs/components/AddToWalletButton.js.map +1 -1
  10. package/lib/commonjs/components/AddressSheet.js +1 -1
  11. package/lib/commonjs/components/AddressSheet.js.map +1 -1
  12. package/lib/commonjs/components/AuBECSDebitForm.js +1 -1
  13. package/lib/commonjs/components/AuBECSDebitForm.js.map +1 -1
  14. package/lib/commonjs/components/CardField.js +1 -1
  15. package/lib/commonjs/components/CardField.js.map +1 -1
  16. package/lib/commonjs/components/CardForm.js +1 -1
  17. package/lib/commonjs/components/CardForm.js.map +1 -1
  18. package/lib/commonjs/components/PlatformPayButton.js +1 -1
  19. package/lib/commonjs/components/PlatformPayButton.js.map +1 -1
  20. package/lib/commonjs/components/StripeContainer.js +1 -1
  21. package/lib/commonjs/components/StripeContainer.js.map +1 -1
  22. package/lib/commonjs/connect/Components.js +1 -1
  23. package/lib/commonjs/connect/Components.js.map +1 -1
  24. package/lib/commonjs/connect/ConnectComponentsProvider.js +1 -1
  25. package/lib/commonjs/connect/ConnectComponentsProvider.js.map +1 -1
  26. package/lib/commonjs/connect/EmbeddedComponent.js +1 -1
  27. package/lib/commonjs/connect/EmbeddedComponent.js.map +1 -1
  28. package/lib/commonjs/connect/ModalCloseButton.js +1 -1
  29. package/lib/commonjs/connect/ModalCloseButton.js.map +1 -1
  30. package/lib/commonjs/connect/NavigationBar.js +1 -1
  31. package/lib/commonjs/connect/NavigationBar.js.map +1 -1
  32. package/lib/commonjs/helpers.js +1 -1
  33. package/lib/commonjs/hooks/useOnramp.js +1 -1
  34. package/lib/commonjs/hooks/useOnramp.js.map +1 -1
  35. package/lib/commonjs/specs/NativeAddToWalletButton.js +1 -1
  36. package/lib/commonjs/specs/NativeAddressSheet.js +1 -1
  37. package/lib/commonjs/specs/NativeApplePayButton.js +1 -1
  38. package/lib/commonjs/specs/NativeAuBECSDebitForm.js +1 -1
  39. package/lib/commonjs/specs/NativeCardField.js +1 -1
  40. package/lib/commonjs/specs/NativeCardField.js.map +1 -1
  41. package/lib/commonjs/specs/NativeCardForm.js +1 -1
  42. package/lib/commonjs/specs/NativeCardForm.js.map +1 -1
  43. package/lib/commonjs/specs/NativeConnectAccountOnboardingView.js +1 -1
  44. package/lib/commonjs/specs/NativeEmbeddedPaymentElement.js +1 -1
  45. package/lib/commonjs/specs/NativeEmbeddedPaymentElement.js.map +1 -1
  46. package/lib/commonjs/specs/NativeGooglePayButton.js +1 -1
  47. package/lib/commonjs/specs/NativeNavigationBar.js +1 -1
  48. package/lib/commonjs/specs/NativeStripeContainer.js +1 -1
  49. package/lib/commonjs/types/EmbeddedPaymentElement.js +1 -1
  50. package/lib/commonjs/types/EmbeddedPaymentElement.js.map +1 -1
  51. package/lib/module/components/AddToWalletButton.js +1 -1
  52. package/lib/module/components/AddToWalletButton.js.map +1 -1
  53. package/lib/module/components/AddressSheet.js +1 -1
  54. package/lib/module/components/AddressSheet.js.map +1 -1
  55. package/lib/module/components/AuBECSDebitForm.js +1 -1
  56. package/lib/module/components/AuBECSDebitForm.js.map +1 -1
  57. package/lib/module/components/CardField.js +1 -1
  58. package/lib/module/components/CardField.js.map +1 -1
  59. package/lib/module/components/CardForm.js +1 -1
  60. package/lib/module/components/CardForm.js.map +1 -1
  61. package/lib/module/components/PlatformPayButton.js +1 -1
  62. package/lib/module/components/PlatformPayButton.js.map +1 -1
  63. package/lib/module/components/StripeContainer.js +1 -1
  64. package/lib/module/components/StripeContainer.js.map +1 -1
  65. package/lib/module/connect/Components.js +1 -1
  66. package/lib/module/connect/Components.js.map +1 -1
  67. package/lib/module/connect/ConnectComponentsProvider.js +1 -1
  68. package/lib/module/connect/ConnectComponentsProvider.js.map +1 -1
  69. package/lib/module/connect/EmbeddedComponent.js +1 -1
  70. package/lib/module/connect/EmbeddedComponent.js.map +1 -1
  71. package/lib/module/connect/ModalCloseButton.js +1 -1
  72. package/lib/module/connect/ModalCloseButton.js.map +1 -1
  73. package/lib/module/connect/NavigationBar.js +1 -1
  74. package/lib/module/connect/NavigationBar.js.map +1 -1
  75. package/lib/module/helpers.js +1 -1
  76. package/lib/module/hooks/useOnramp.js +1 -1
  77. package/lib/module/hooks/useOnramp.js.map +1 -1
  78. package/lib/module/specs/NativeAddToWalletButton.js +1 -1
  79. package/lib/module/specs/NativeAddressSheet.js +1 -1
  80. package/lib/module/specs/NativeApplePayButton.js +1 -1
  81. package/lib/module/specs/NativeAuBECSDebitForm.js +1 -1
  82. package/lib/module/specs/NativeCardField.js +1 -1
  83. package/lib/module/specs/NativeCardField.js.map +1 -1
  84. package/lib/module/specs/NativeCardForm.js +1 -1
  85. package/lib/module/specs/NativeCardForm.js.map +1 -1
  86. package/lib/module/specs/NativeConnectAccountOnboardingView.js +1 -1
  87. package/lib/module/specs/NativeEmbeddedPaymentElement.js +1 -1
  88. package/lib/module/specs/NativeEmbeddedPaymentElement.js.map +1 -1
  89. package/lib/module/specs/NativeGooglePayButton.js +1 -1
  90. package/lib/module/specs/NativeNavigationBar.js +1 -1
  91. package/lib/module/specs/NativeStripeContainer.js +1 -1
  92. package/lib/module/types/EmbeddedPaymentElement.js +1 -1
  93. package/lib/module/types/EmbeddedPaymentElement.js.map +1 -1
  94. package/lib/typescript/src/connect/EmbeddedComponent.d.ts.map +1 -1
  95. package/lib/typescript/src/hooks/useOnramp.d.ts +2 -1
  96. package/lib/typescript/src/hooks/useOnramp.d.ts.map +1 -1
  97. package/package.json +12 -5
  98. package/src/connect/EmbeddedComponent.tsx +10 -10
  99. package/src/hooks/useOnramp.tsx +5 -1
  100. package/android/.idea/AndroidProjectSystem.xml +0 -6
  101. package/android/.idea/caches/deviceStreaming.xml +0 -1029
  102. package/android/.idea/compiler.xml +0 -6
  103. package/android/.idea/gradle.xml +0 -19
  104. package/android/.idea/migrations.xml +0 -10
  105. package/android/.idea/misc.xml +0 -10
  106. package/android/.idea/runConfigurations.xml +0 -17
  107. package/android/.idea/vcs.xml +0 -6
  108. package/android/local.properties +0 -8
  109. package/ios/StripeSdk.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  110. package/ios/StripeSdk.xcodeproj/project.xcworkspace/xcuserdata/tianzhao.xcuserdatad/UserInterfaceState.xcuserstate +0 -0
  111. package/ios/StripeSdk.xcodeproj/xcuserdata/tianzhao.xcuserdatad/xcschemes/xcschememanagement.plist +0 -19
@@ -183,6 +183,7 @@ dependencies {
183
183
  testImplementation "org.mockito:mockito-core:3.+"
184
184
  testImplementation "org.robolectric:robolectric:4.10"
185
185
  testImplementation "androidx.test:core:1.4.0"
186
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0"
186
187
 
187
188
  implementation "androidx.compose.ui:ui:1.7.8"
188
189
  implementation "androidx.compose.foundation:foundation-layout:1.7.8"
@@ -58,8 +58,11 @@ import kotlinx.coroutines.CoroutineScope
58
58
  import kotlinx.coroutines.Dispatchers
59
59
  import kotlinx.coroutines.delay
60
60
  import kotlinx.coroutines.launch
61
+ import kotlinx.coroutines.suspendCancellableCoroutine
62
+ import kotlinx.coroutines.withTimeoutOrNull
61
63
  import java.io.ByteArrayOutputStream
62
64
  import kotlin.Exception
65
+ import kotlin.coroutines.resume
63
66
 
64
67
  @OptIn(
65
68
  ReactNativeSdkInternal::class,
@@ -140,28 +143,42 @@ class PaymentSheetManager(
140
143
 
141
144
  val paymentOptionCallback =
142
145
  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
+
147
162
  val option: WritableMap = Arguments.createMap()
148
- option.putString("label", it.label)
163
+ option.putString("label", paymentOption.label)
149
164
  option.putString("image", imageString)
150
165
  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)
152
168
  }
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
+ )
163
179
  }
164
- resolvePresentPromise(result)
180
+ resolvePresentPromise(result)
181
+ }
165
182
  }
166
183
 
167
184
  val paymentResultCallback =
@@ -413,16 +430,31 @@ class PaymentSheetManager(
413
430
  private fun configureFlowController() {
414
431
  val onFlowControllerConfigure =
415
432
  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
+
420
449
  val option: WritableMap = Arguments.createMap()
421
- option.putString("label", it.label)
450
+ option.putString("label", paymentOption.label)
422
451
  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
+ }
426
458
  }
427
459
 
428
460
  if (!paymentIntentClientSecret.isNullOrEmpty()) {
@@ -550,17 +582,86 @@ class PaymentSheetManager(
550
582
  }
551
583
  }
552
584
 
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
+
553
637
  fun getBitmapFromDrawable(drawable: Drawable): Bitmap? {
554
638
  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) {
556
656
  return null
557
657
  }
558
- val bitmap =
559
- createBitmap(drawableCompat.intrinsicWidth, drawableCompat.intrinsicHeight)
658
+
659
+ val bitmap = createBitmap(width, height, Bitmap.Config.ARGB_8888)
560
660
  bitmap.eraseColor(Color.TRANSPARENT)
561
661
  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
+
564
665
  return bitmap
565
666
  }
566
667
 
@@ -17,8 +17,7 @@ import com.reactnativestripesdk.ReactNativeCustomerSessionProvider
17
17
  import com.reactnativestripesdk.buildBillingDetails
18
18
  import com.reactnativestripesdk.buildBillingDetailsCollectionConfiguration
19
19
  import com.reactnativestripesdk.buildPaymentSheetAppearance
20
- import com.reactnativestripesdk.getBase64FromBitmap
21
- import com.reactnativestripesdk.getBitmapFromDrawable
20
+ import com.reactnativestripesdk.convertDrawableToBase64
22
21
  import com.reactnativestripesdk.mapToCardBrandAcceptance
23
22
  import com.reactnativestripesdk.utils.CreateTokenErrorType
24
23
  import com.reactnativestripesdk.utils.ErrorType
@@ -163,25 +162,49 @@ class CustomerSheetManager(
163
162
  }
164
163
 
165
164
  private fun handleResult(result: CustomerSheetResult) {
166
- var promiseResult = Arguments.createMap()
167
165
  when (result) {
168
166
  is CustomerSheetResult.Failed -> {
169
167
  resolvePresentPromise(createError(ErrorType.Failed.toString(), result.exception))
170
168
  }
171
169
 
172
170
  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
+ }
174
185
  }
175
186
 
176
187
  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
+ }
182
206
  }
183
207
  }
184
- resolvePresentPromise(promiseResult)
185
208
  }
186
209
 
187
210
  override fun onPresent() {
@@ -355,7 +378,7 @@ class CustomerSheetManager(
355
378
  )
356
379
  }
357
380
 
358
- internal fun createPaymentOptionResult(selection: PaymentOptionSelection?): WritableMap {
381
+ internal suspend fun createPaymentOptionResult(selection: PaymentOptionSelection?): WritableMap {
359
382
  var paymentOptionResult = Arguments.createMap()
360
383
 
361
384
  when (selection) {
@@ -392,16 +415,18 @@ class CustomerSheetManager(
392
415
  }.build()
393
416
  }
394
417
 
395
- private fun buildResult(
418
+ private suspend fun buildResult(
396
419
  label: String,
397
420
  drawable: Drawable,
398
421
  paymentMethod: PaymentMethod?,
399
422
  ): WritableMap {
423
+ val imageString = convertDrawableToBase64(drawable)
424
+
400
425
  val result = Arguments.createMap()
401
426
  val paymentOption =
402
427
  Arguments.createMap().also {
403
428
  it.putString("label", label)
404
- it.putString("image", getBase64FromBitmap(getBitmapFromDrawable(drawable)))
429
+ it.putString("image", imageString)
405
430
  }
406
431
  result.putMap("paymentOption", paymentOption)
407
432
  if (paymentMethod != null) {
@@ -0,0 +1,224 @@
1
+ package com.reactnativestripesdk
2
+
3
+ import android.graphics.Color
4
+ import android.graphics.drawable.ColorDrawable
5
+ import kotlinx.coroutines.test.runTest
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertNotEquals
8
+ import org.junit.Assert.assertNotNull
9
+ import org.junit.Assert.assertTrue
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import kotlin.random.Random
14
+
15
+ /**
16
+ * Property-based tests for drawable conversion.
17
+ * These tests verify mathematical properties that should always hold true.
18
+ */
19
+ @RunWith(RobolectricTestRunner::class)
20
+ class DrawableConversionPropertyTest {
21
+ @Test
22
+ fun `PROPERTY - conversion is idempotent`() =
23
+ runTest {
24
+ // Property: Converting the same drawable multiple times yields same result
25
+ repeat(20) {
26
+ val color =
27
+ Color.rgb(
28
+ Random.nextInt(256),
29
+ Random.nextInt(256),
30
+ Random.nextInt(256),
31
+ )
32
+ val width = Random.nextInt(50, 200)
33
+ val height = Random.nextInt(50, 200)
34
+
35
+ val drawable =
36
+ ColorDrawable(color).apply {
37
+ setBounds(0, 0, width, height)
38
+ }
39
+
40
+ val result1 = convertDrawableToBase64(drawable)
41
+ val result2 = convertDrawableToBase64(drawable)
42
+
43
+ assertEquals(
44
+ "Same drawable should always produce same base64 (iteration $it, ${width}x$height)",
45
+ result1,
46
+ result2,
47
+ )
48
+ }
49
+ }
50
+
51
+ @Test
52
+ fun `PROPERTY - different drawables produce different base64`() =
53
+ runTest {
54
+ val drawable1 =
55
+ ColorDrawable(Color.RED).apply {
56
+ setBounds(0, 0, 100, 100)
57
+ }
58
+ val drawable2 =
59
+ ColorDrawable(Color.BLUE).apply {
60
+ setBounds(0, 0, 100, 100)
61
+ }
62
+
63
+ val result1 = convertDrawableToBase64(drawable1)
64
+ val result2 = convertDrawableToBase64(drawable2)
65
+
66
+ assertNotEquals(
67
+ "Different colored drawables should produce different base64",
68
+ result1,
69
+ result2,
70
+ )
71
+ }
72
+
73
+ @Test
74
+ fun `PROPERTY - base64 length scales with drawable size`() =
75
+ runTest {
76
+ val small =
77
+ ColorDrawable(Color.RED).apply {
78
+ setBounds(0, 0, 50, 50)
79
+ }
80
+ val medium =
81
+ ColorDrawable(Color.RED).apply {
82
+ setBounds(0, 0, 100, 100)
83
+ }
84
+ val large =
85
+ ColorDrawable(Color.RED).apply {
86
+ setBounds(0, 0, 200, 200)
87
+ }
88
+
89
+ val smallResult = convertDrawableToBase64(small)
90
+ val mediumResult = convertDrawableToBase64(medium)
91
+ val largeResult = convertDrawableToBase64(large)
92
+
93
+ assertNotNull(smallResult)
94
+ assertNotNull(mediumResult)
95
+ assertNotNull(largeResult)
96
+
97
+ assertTrue(
98
+ "Medium drawable should produce longer base64 than small",
99
+ mediumResult!!.length > smallResult!!.length,
100
+ )
101
+ assertTrue(
102
+ "Large drawable should produce longer base64 than medium",
103
+ largeResult!!.length > mediumResult.length,
104
+ )
105
+ }
106
+
107
+ @Test
108
+ fun `PROPERTY - conversion preserves drawable dimensions`() =
109
+ runTest {
110
+ val testSizes =
111
+ listOf(
112
+ Pair(50, 50),
113
+ Pair(100, 75),
114
+ Pair(147, 105), // Typical card icon
115
+ Pair(200, 150),
116
+ Pair(250, 200),
117
+ )
118
+
119
+ testSizes.forEach { (width, height) ->
120
+ val drawable =
121
+ ColorDrawable(Color.GREEN).apply {
122
+ setBounds(0, 0, width, height)
123
+ }
124
+
125
+ val bitmap = getBitmapFromDrawable(drawable)
126
+
127
+ assertNotNull("Bitmap should not be null for ${width}x$height", bitmap)
128
+ assertEquals("Width should be preserved for ${width}x$height", width, bitmap!!.width)
129
+ assertEquals("Height should be preserved for ${width}x$height", height, bitmap.height)
130
+ }
131
+ }
132
+
133
+ @Test
134
+ fun `PROPERTY - null input produces null output consistently`() =
135
+ runTest {
136
+ // Invalid drawables (no bounds set) should consistently return null
137
+ val invalidDrawable1 = ColorDrawable(Color.RED)
138
+ val invalidDrawable2 = ColorDrawable(Color.BLUE)
139
+
140
+ val result1 = convertDrawableToBase64(invalidDrawable1)
141
+ val result2 = convertDrawableToBase64(invalidDrawable2)
142
+
143
+ assertNotNull("Result1 should be null for invalid drawable", result1 == null)
144
+ assertNotNull("Result2 should be null for invalid drawable", result2 == null)
145
+ }
146
+
147
+ @Test
148
+ fun `PROPERTY - aspect ratio is preserved in bitmap`() {
149
+ val testCases =
150
+ listOf(
151
+ Triple(100, 100, 1.0), // Square
152
+ Triple(200, 100, 2.0), // 2:1
153
+ Triple(147, 105, 1.4), // Card icon ratio
154
+ Triple(100, 200, 0.5), // Portrait
155
+ )
156
+
157
+ testCases.forEach { (width, height, expectedRatio) ->
158
+ val drawable =
159
+ ColorDrawable(Color.YELLOW).apply {
160
+ setBounds(0, 0, width, height)
161
+ }
162
+
163
+ val bitmap = getBitmapFromDrawable(drawable)
164
+
165
+ assertNotNull(bitmap)
166
+ val actualRatio = bitmap!!.width.toDouble() / bitmap.height.toDouble()
167
+ assertEquals(
168
+ "Aspect ratio should be preserved for ${width}x$height",
169
+ expectedRatio,
170
+ actualRatio,
171
+ 0.01, // Allow small floating point error
172
+ )
173
+ }
174
+ }
175
+
176
+ @Test
177
+ fun `PROPERTY - conversion is deterministic across multiple runs`() =
178
+ runTest {
179
+ // Same drawable, converted multiple times in sequence, should always yield same result
180
+ val drawable =
181
+ ColorDrawable(Color.CYAN).apply {
182
+ setBounds(0, 0, 120, 90)
183
+ }
184
+
185
+ val results = (1..15).map { convertDrawableToBase64(drawable) }
186
+
187
+ // All results should be identical
188
+ val firstResult = results.first()
189
+ results.forEach { result ->
190
+ assertEquals("All conversions should be identical", firstResult, result)
191
+ }
192
+ }
193
+
194
+ @Test
195
+ fun `PROPERTY - different sizes with same color produce different base64`() =
196
+ runTest {
197
+ val color = Color.rgb(100, 150, 200)
198
+
199
+ val sizes =
200
+ listOf(
201
+ Pair(50, 50),
202
+ Pair(75, 75),
203
+ Pair(100, 100),
204
+ Pair(125, 125),
205
+ )
206
+
207
+ val results =
208
+ sizes.map { (width, height) ->
209
+ val drawable =
210
+ ColorDrawable(color).apply {
211
+ setBounds(0, 0, width, height)
212
+ }
213
+ convertDrawableToBase64(drawable)
214
+ }
215
+
216
+ // All results should be different (different sizes → different images)
217
+ val uniqueResults = results.toSet()
218
+ assertEquals(
219
+ "Different sizes should produce different base64 even with same color",
220
+ results.size,
221
+ uniqueResults.size,
222
+ )
223
+ }
224
+ }
@@ -0,0 +1,146 @@
1
+ package com.reactnativestripesdk
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Color
5
+ import android.graphics.drawable.ColorDrawable
6
+ import kotlinx.coroutines.test.runTest
7
+ import org.junit.Assert.assertEquals
8
+ import org.junit.Assert.assertNotNull
9
+ import org.junit.Assert.assertNull
10
+ import org.junit.Assert.assertTrue
11
+ import org.junit.Test
12
+ import org.junit.runner.RunWith
13
+ import org.robolectric.RobolectricTestRunner
14
+
15
+ @RunWith(RobolectricTestRunner::class)
16
+ class DrawableConversionTest {
17
+ // ============================================
18
+ // convertDrawableToBase64 Tests
19
+ // ============================================
20
+
21
+ @Test
22
+ fun `convertDrawableToBase64 returns non-null for valid drawable`() =
23
+ runTest {
24
+ val drawable =
25
+ ColorDrawable(Color.RED).apply {
26
+ setBounds(0, 0, 100, 100)
27
+ }
28
+
29
+ val result = convertDrawableToBase64(drawable)
30
+
31
+ assertNotNull("Base64 should not be null for valid drawable", result)
32
+ assertTrue("Base64 should not be empty", result!!.isNotEmpty())
33
+ }
34
+
35
+ @Test
36
+ fun `convertDrawableToBase64 returns consistent results for same drawable`() =
37
+ runTest {
38
+ val drawable =
39
+ ColorDrawable(Color.BLUE).apply {
40
+ setBounds(0, 0, 100, 100)
41
+ }
42
+
43
+ val result1 = convertDrawableToBase64(drawable)
44
+ val result2 = convertDrawableToBase64(drawable)
45
+ val result3 = convertDrawableToBase64(drawable)
46
+
47
+ assertEquals("Multiple calls should return identical base64", result1, result2)
48
+ assertEquals("Multiple calls should return identical base64", result2, result3)
49
+ }
50
+
51
+ @Test
52
+ fun `convertDrawableToBase64 returns null for invalid drawable`() =
53
+ runTest {
54
+ // Drawable with 0 intrinsic size (never set bounds)
55
+ val drawable = ColorDrawable(Color.RED)
56
+
57
+ val result = convertDrawableToBase64(drawable)
58
+
59
+ assertNull("Base64 should be null for invalid drawable", result)
60
+ }
61
+
62
+ @Test
63
+ fun `convertDrawableToBase64 result is valid base64 format`() =
64
+ runTest {
65
+ val drawable =
66
+ ColorDrawable(Color.GREEN).apply {
67
+ setBounds(0, 0, 50, 50)
68
+ }
69
+
70
+ val result = convertDrawableToBase64(drawable)
71
+
72
+ assertNotNull(result)
73
+ // Valid base64 should match pattern: [A-Za-z0-9+/=]+
74
+ assertTrue(
75
+ "Result should be valid base64",
76
+ result!!.matches(Regex("^[A-Za-z0-9+/=\\n]+$")),
77
+ )
78
+ }
79
+
80
+ @Test
81
+ fun `convertDrawableToBase64 result size is reasonable`() =
82
+ runTest {
83
+ val drawable =
84
+ ColorDrawable(Color.YELLOW).apply {
85
+ setBounds(0, 0, 100, 100)
86
+ }
87
+
88
+ val result = convertDrawableToBase64(drawable)
89
+
90
+ assertNotNull(result)
91
+ // Should be larger than tiny placeholder (134 bytes)
92
+ // but smaller than unreasonably large
93
+ assertTrue(
94
+ "Base64 should be larger than 200 chars (not a 1x1 placeholder)",
95
+ result!!.length > 200,
96
+ )
97
+ assertTrue(
98
+ "Base64 should be smaller than 100KB",
99
+ result.length < 100_000,
100
+ )
101
+ }
102
+
103
+ // ============================================
104
+ // getBitmapFromDrawable Tests
105
+ // ============================================
106
+
107
+ @Test
108
+ fun `getBitmapFromDrawable returns correct size bitmap`() {
109
+ val drawable =
110
+ ColorDrawable(Color.CYAN).apply {
111
+ setBounds(0, 0, 150, 100)
112
+ }
113
+
114
+ val bitmap = getBitmapFromDrawable(drawable)
115
+
116
+ assertNotNull("Bitmap should not be null", bitmap)
117
+ assertEquals("Bitmap width should match drawable", 150, bitmap!!.width)
118
+ assertEquals("Bitmap height should match drawable", 100, bitmap.height)
119
+ assertEquals("Bitmap should use ARGB_8888", Bitmap.Config.ARGB_8888, bitmap.config)
120
+ }
121
+
122
+ @Test
123
+ fun `getBitmapFromDrawable returns null for zero-size drawable`() {
124
+ val drawable = ColorDrawable(Color.MAGENTA)
125
+ // Don't set bounds, intrinsic size will be -1
126
+
127
+ val bitmap = getBitmapFromDrawable(drawable)
128
+
129
+ assertNull("Bitmap should be null for invalid drawable", bitmap)
130
+ }
131
+
132
+ @Test
133
+ fun `getBitmapFromDrawable preserves drawable dimensions`() {
134
+ // Test with typical card icon dimensions
135
+ val drawable =
136
+ ColorDrawable(Color.RED).apply {
137
+ setBounds(0, 0, 147, 105)
138
+ }
139
+
140
+ val bitmap = getBitmapFromDrawable(drawable)
141
+
142
+ assertNotNull(bitmap)
143
+ assertEquals("Width should be preserved", 147, bitmap!!.width)
144
+ assertEquals("Height should be preserved", 105, bitmap.height)
145
+ }
146
+ }