package com.reactnativestripesdk import android.annotation.SuppressLint import androidx.activity.ComponentActivity import androidx.compose.ui.graphics.Color import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.lifecycleScope import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableMap import com.reactnativestripesdk.utils.ErrorType import com.reactnativestripesdk.utils.createError import com.reactnativestripesdk.utils.mapFromPaymentMethod import com.stripe.android.link.LinkAppearance import com.stripe.android.link.LinkController import com.stripe.android.link.LinkControllerPreview import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext internal const val LINK_CONTROLLER_NOT_INITIALIZED_ERROR = "LinkController has not been initialized. Call initLinkController first." /** * Manages [LinkController] initialization and presentation for the React Native bridge. * * @PrivatePreview */ @OptIn(LinkControllerPreview::class) internal class LinkControllerManager( private val context: ReactApplicationContext, private val publishableKey: String, private val stripeAccountId: String?, ) { private var linkController: LinkController? = null private var linkPresenter: LinkController.Presenter? = null private var presentPromise: Promise? = null private var confirmSetupIntentPromise: Promise? = null @SuppressLint("RestrictedApi", "VisibleForTests") fun configure(params: ReadableMap, promise: Promise) { val email = params.getString("email") val merchantDisplayName = params.getString("merchantDisplayName") if (merchantDisplayName == null) { promise.resolve(createError(ErrorType.Failed.toString(), "merchantDisplayName is required.")) return } val activity = context.currentActivity as? ComponentActivity if (activity == null) { promise.resolve(createError(ErrorType.Failed.toString(), "Activity is not available. Retry this method.")) return } val config = buildLinkConfiguration(params, merchantDisplayName, email) // Build a new controller if this is the first call or after a reset. // SavedStateHandle() is empty — state will not survive process death, which is // the same trade-off made by other Stripe RN SDK APIs. if (linkController == null) { linkController = LinkController.Builder( application = activity.application, savedStateHandle = SavedStateHandle() ).build() } val controller = linkController!! activity.lifecycleScope.launch { val result = controller.configure(config) if (result.isSuccess) { // Create once: presenter may register ActivityResultLaunchers, which must happen before onStart(). if (linkPresenter == null) { linkPresenter = controller.createPresenter( activity = activity, presentPaymentMethodsCallback = { }, authenticationCallback = { }, authorizeCallback = { }, presentCallback = { presentResult -> handlePresentResult(presentResult) }, confirmSetupIntentCallback = { result -> handleConfirmSetupIntentResult(result) } ) } promise.resolve(Arguments.createMap()) } else { promise.resolve( createError( ErrorType.Failed.toString(), result.exceptionOrNull()?.message ?: "LinkController configuration failed." ) ) } } } fun present(promise: Promise) { val presenter = linkPresenter if (presenter == null) { promise.resolve(createError(ErrorType.Failed.toString(), LINK_CONTROLLER_NOT_INITIALIZED_ERROR)) return } presentPromise = promise presenter.present() } fun confirmSetupIntent(clientSecret: String, promise: Promise) { val presenter = linkPresenter if (presenter == null) { promise.resolve(createError(ErrorType.Failed.toString(), LINK_CONTROLLER_NOT_INITIALIZED_ERROR)) return } confirmSetupIntentPromise = promise presenter.confirmSetupIntent(clientSecret) } fun destroy() { linkController = null linkPresenter = null presentPromise = null confirmSetupIntentPromise = null } private fun buildLinkConfiguration( params: ReadableMap, merchantDisplayName: String, email: String?, ): LinkController.Configuration { val phoneNumber = params.getString("phoneNumber") val allowLogout = if (params.hasKey("allowLogout")) params.getBoolean("allowLogout") else true val supportedTypes = parseSupportedPaymentMethodTypes(params) val paymentMethodTypes = parsePaymentMethodTypes(params) return LinkController.Configuration( merchantDisplayName = merchantDisplayName, publishableKey = publishableKey, stripeAccountId = stripeAccountId ) .also { if (email != null) it.email(email) } .phoneNumber(phoneNumber) .supportedPaymentMethodTypes(supportedTypes) .also { if (paymentMethodTypes != null) it.paymentMethodTypes(paymentMethodTypes) } .allowLogout(allowLogout) .also { val billingMap = params.getMap("billingDetailsCollectionConfiguration") if (billingMap != null) { it.billingDetailsCollectionConfiguration( buildBillingDetailsCollectionConfiguration(billingMap) ) } } .also { val appearanceMap = params.getMap("appearance") if (appearanceMap != null) { it.appearance(buildLinkAppearance(appearanceMap)) } } } private fun buildLinkAppearance(appearanceMap: ReadableMap): LinkAppearance { val lightColorsMap = appearanceMap.getMap("lightColors") val darkColorsMap = appearanceMap.getMap("darkColors") val styleStr = appearanceMap.getString("style") val primaryButtonMap = appearanceMap.getMap("primaryButton") val style = when (styleStr) { "ALWAYS_LIGHT" -> LinkAppearance.Style.ALWAYS_LIGHT "ALWAYS_DARK" -> LinkAppearance.Style.ALWAYS_DARK else -> LinkAppearance.Style.AUTOMATIC } return LinkAppearance() .also { lightColorsMap?.let { m -> it.lightColors(buildLinkColors(m)) } } .also { darkColorsMap?.let { m -> it.darkColors(buildLinkColors(m)) } } .style(style) .also { primaryButtonMap?.let { m -> it.primaryButton(buildPrimaryButton(m)) } } .also { if (appearanceMap.hasKey("reduceLinkBranding")) { it.reduceLinkBranding(appearanceMap.getBoolean("reduceLinkBranding")) } } } private fun buildPrimaryButton(map: ReadableMap): LinkAppearance.PrimaryButton { val cornerRadius = if (map.hasKey("cornerRadius")) map.getDouble("cornerRadius").toFloat() else null val height = if (map.hasKey("height")) map.getDouble("height").toFloat() else null return LinkAppearance.PrimaryButton().cornerRadiusDp(cornerRadius).heightDp(height) } private fun parseHexColor(hex: String) = Color(android.graphics.Color.parseColor(hex)) private fun buildLinkColors(map: ReadableMap): LinkAppearance.Colors { val colors = LinkAppearance.Colors() map.getString("primary")?.let { colors.primary(parseHexColor(it)) } map.getString("contentOnPrimary")?.let { colors.contentOnPrimary(parseHexColor(it)) } map.getString("borderSelected")?.let { colors.borderSelected(parseHexColor(it)) } return colors } private fun parseSupportedPaymentMethodTypes(params: ReadableMap): List? = params.getArray("supportedPaymentMethodTypes")?.let { arr -> (0 until arr.size()).mapNotNull { i -> when (arr.getString(i)) { "card" -> LinkController.PaymentMethodType.Card "bankAccount" -> LinkController.PaymentMethodType.BankAccount else -> null } }.ifEmpty { null } } private fun parsePaymentMethodTypes(params: ReadableMap): List? = params.getArray("paymentMethodTypes")?.let { arr -> (0 until arr.size()).mapNotNull { i -> arr.getString(i) }.ifEmpty { null } } private fun handleConfirmSetupIntentResult(result: LinkController.ConfirmSetupIntentResult) { val promise = confirmSetupIntentPromise ?: return confirmSetupIntentPromise = null when (result) { is LinkController.ConfirmSetupIntentResult.Success -> { promise.resolve(Arguments.createMap()) } is LinkController.ConfirmSetupIntentResult.Canceled -> { promise.resolve(createError(ErrorType.Canceled.toString(), "SetupIntent confirmation was canceled.")) } is LinkController.ConfirmSetupIntentResult.Failed -> { promise.resolve(createError(ErrorType.Failed.toString(), result.error)) } } } private fun handlePresentResult(result: LinkController.PresentResult) { val promise = presentPromise ?: return val controller = linkController ?: return when (result) { is LinkController.PresentResult.Completed -> { val response = Arguments.createMap() response.putMap("paymentMethod", mapFromPaymentMethod(result.paymentMethod)) val currentScope = (context.currentActivity as? ComponentActivity)?.lifecycleScope if (currentScope != null) { currentScope.launch { val preview = controller.paymentMethodPreview.value if (preview != null) { val iconBase64 = withContext(Dispatchers.IO) { try { convertDrawableToBase64(preview.icon) } catch (_: Exception) { null } } val previewMap = Arguments.createMap() previewMap.putString("label", preview.label) preview.sublabel?.let { previewMap.putString("sublabel", it) } if (iconBase64 != null) { previewMap.putString("icon", "data:image/png;base64,$iconBase64") } response.putMap("paymentMethodPreview", previewMap) } promise.resolve(response) } } else { promise.resolve(response) } } is LinkController.PresentResult.Canceled -> { promise.resolve( createError( ErrorType.Canceled.toString(), "The customer canceled the Link flow." ) ) } is LinkController.PresentResult.Failed -> { promise.resolve(createError(ErrorType.Failed.toString(), result.error)) } } } }