// Copyright © 2022 Olo Inc. All rights reserved. // This software is made available under the Olo Pay SDK License (See LICENSE.md file) package com.olopaysdkreactnative import android.content.pm.PackageManager import android.util.Log import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentManager import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableMap import com.facebook.react.modules.core.DeviceEventManagerModule import com.olo.olopay.api.IOloPayApiInitializer import com.olo.olopay.api.OloPayApiInitializer import com.olo.olopay.data.* import com.olo.olopay.googlepay.* import com.olopaysdkreactnative.data.DataKeys import com.olopaysdkreactnative.data.ErrorCodes import com.olopaysdkreactnative.extensions.* import com.olopaysdkreactnative.googlepay.GooglePayFragment import com.olopaysdkreactnative.googlepay.ReactNativeGooglePayResultCallback import com.olopaysdkreactnative.helpers.backgroundOperation import com.olopaysdkreactnative.helpers.uiOperation import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext class OlopaysdkReactNativeModule(val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { private var _sdkInitializingSemaphore = Semaphore(1) private var _googlePaySemaphore = Semaphore(1) private var _sdkInitializedSemaphore = Semaphore(1) private var _googlePayReadySemaphore = Semaphore(1) private var _googlePayInitializedSemaphore = Semaphore(1) private var _initializeInternalCalled = false private var _currencyMultiplier = DefaultCurrencyMultiplier private var _hasEmittedDigitalWalletReadyEvent = false //WARNING: DO NOT ACCESS OR MODIFY THESE DIRECTLY... USE THREAD-SAFE GETTERS/SETTERS @VisibleForTesting internal var _googlePayConfig: GooglePayConfig? = null private var _sdkInitialized = false private var _googlePayReady = false private val fragmentManager: FragmentManager? get() = (reactContext.currentActivity as? AppCompatActivity)?.supportFragmentManager override fun getName(): String { return "OlopaysdkReactNative" } @ReactMethod fun initializeOloPay(args: ReadableMap, promise: Promise) = backgroundOperation { val arch = if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) "FABRIC" else "PAPER" Log.d("OP SDK MODULE", "==================================") Log.d("OP SDK MODULE", "ARCHITECTURE (SDK): ${arch}") Log.d("OP SDK MODULE", "==================================") _sdkInitializingSemaphore.withPermit { setSdkInitialized(false) val environment = if (args.getBoolean(DataKeys.ProductionEnvironmentKey, DefaultProductionEnvironment)) { OloPayEnvironment.Production } else { OloPayEnvironment.Test } val initializer = OloPayApiInitializer() initializer.setup(reactApplicationContext, environment) setSdkInitialized(true) promise.resolve(null) } } @ReactMethod fun initializeMetadata(args: ReadableMap, promise: Promise) { // First call happens while the plugin is getting initialized... Subsequent calls should be ignored if (!_initializeInternalCalled) { _initializeInternalCalled = true val hybridVersion = args.getString(DataKeys.HybridSdkVersionKey, DefaultVersionString).trim() val hybridBuildType = args.getString(DataKeys.HybridBuildTypeKey, DataKeys.HybridBuildTypeInternalValue).trim() setSdkWrapperInfo(hybridVersion, hybridBuildType) } promise.resolve(null) } @ReactMethod fun updateDigitalWalletConfig(args: ReadableMap, promise: Promise) = googlePayLockingOperation { val baseError = if(_googlePayConfig == null) { "Unable to update Google Pay configuration" } else { "Unable to initialize Google Pay" } var newConfiguration: GooglePayConfig? = null withContext(Dispatchers.IO) { if (!isSdkInitialized()) { promise.reject( ErrorCodes.SdkUninitialized, "${baseError}: Olo Pay SDK has not been initialized" ) return@withContext } // If the configuration comes back null that means an error was reported, so we need to return newConfiguration = getGooglePayConfiguration(args, promise, baseError) ?: return@withContext val appMetadata = reactApplicationContext.packageManager.getApplicationInfo( reactApplicationContext.packageName, PackageManager.GET_META_DATA ).metaData if (!appMetadata.containsKey("com.google.android.gms.wallet.api.enabled")) { newConfiguration = null promise.reject( ErrorCodes.GooglePayInvalidSetup, "${baseError}: AndroidManifest missing com.google.android.gms.wallet.api.enabled entry" ) return@withContext } } newConfiguration?.let { config -> val fragment = getGooglePayFragment() if (fragment == null) { val message = "${baseError}: Unexpected error occurred" promise.reject(ErrorCodes.UnexpectedError, message) return@googlePayLockingOperation } updateGooglePayConfig(config) fragment.readyCallback = GooglePayReadyCallback { isReady -> onGooglePayReady(isReady) } fragment.setConfiguration(config) promise.resolve(null) } } @ReactMethod fun getDigitalWalletPaymentMethod(args: ReadableMap, promise: Promise) = uiOperation { // Unable to use googlePayLockingOperation because this call waits for a callback method... we need to unlock // the semaphore manually val baseError = "Unable to create payment method" if (!isSdkInitialized()) { promise.reject( ErrorCodes.SdkUninitialized, "${baseError}: Olo Pay SDK has not been initialized" ) return@uiOperation } if (!isGooglePayInitialized()) { promise.reject( ErrorCodes.DigitalWalletUninitialized, "${baseError}: Google Pay not initialized" ) return@uiOperation } if (!isGooglePayReady()) { promise.reject( ErrorCodes.DigitalWalletNotReady, "${baseError}: Google Pay isn't ready yet." ) return@uiOperation } val argsMap = args.toNullableValueHashMap() val amount: Double? val totalPriceLabel: String? val lineItems: List? val validateLineItems: Boolean val googlePayCheckoutStatusString: String val checkoutStatus: GooglePayCheckoutStatus try { amount = argsMap.getArgOrReject(DataKeys.GPayAmountKey, baseError, promise) totalPriceLabel = argsMap.getStringArgOrReject(DataKeys.GPayTotalPriceLabelKey, "", baseError, true, promise) validateLineItems = argsMap.getArgOrReject(DataKeys.GPayValidateLineItems,true, baseError, promise) googlePayCheckoutStatusString = argsMap.getStringArgOrReject(DataKeys.GPayCheckoutStatusKey, DefaultCheckoutStatus, baseError, false, promise) checkoutStatus = try { GooglePayCheckoutStatus.valueOf(googlePayCheckoutStatusString) } catch (e: Exception) { promise.reject(ErrorCodes.InvalidParameter, "${baseError}: '$googlePayCheckoutStatusString' is not a GooglePayCheckoutStatus enum value") return@uiOperation } val reactNativeLineItems = argsMap.getArgOrReject>?>( DataKeys.GPayLineItemsKey, null, baseError, promise ) val lineItemError = "$baseError - Unable to parse LineItems" lineItems = reactNativeLineItems?.map { item -> val typeString = item.getStringArgOrReject(DataKeys.LineItemTypeKey, lineItemError, false, promise) val lineItemType = try { GooglePayLineItemType.valueOf(typeString) } catch (e: Exception) { val errorMessage = "$lineItemError: '$typeString' is not a LineItemType enum value" promise.reject(ErrorCodes.InvalidParameter, errorMessage) return@uiOperation } val statusString = item.getStringArgOrReject(DataKeys.LineItemStatusKey, GooglePayLineItemStatus.Final.name, lineItemError, false, promise) val lineItemStatus = try { GooglePayLineItemStatus.valueOf(statusString) } catch (e: Exception) { val errorMessage = "$lineItemError: '$statusString' is not a LineItemStatus enum value" promise.reject(ErrorCodes.InvalidParameter, errorMessage) return@uiOperation } GooglePayLineItem( item.getStringArgOrReject(DataKeys.LineItemLabelKey, lineItemError, false, promise), (item.getArgOrReject(DataKeys.LineItemAmountKey, lineItemError, promise) * _currencyMultiplier).toInt(), lineItemType, lineItemStatus ) } } catch (e: Exception) { return@uiOperation } if (amount < 0){ promise.reject(ErrorCodes.InvalidParameter, "${baseError}: ${DataKeys.GPayAmountKey} cannot be negative") return@uiOperation } _googlePaySemaphore.acquire() val fragment = getGooglePayFragment() if (fragment == null) { promise.reject(ErrorCodes.UnexpectedError, "${baseError}: An unexpected error occurred") _googlePaySemaphore.safeRelease() return@uiOperation } if (!fragment.isReady) { val reason = if (fragment.configuration == null) { "Google Pay not initialized" } else { "Google Pay isn't ready yet" } promise.reject(ErrorCodes.DigitalWalletNotReady, "${baseError}: $reason") _googlePaySemaphore.safeRelease() return@uiOperation } val amountInSmallestCurrencyUnit = (amount * _currencyMultiplier).toInt() fragment.resultCallback = ReactNativeGooglePayResultCallback{ result, promise -> onGooglePayResult(result, promise) } try { fragment.present(amountInSmallestCurrencyUnit, checkoutStatus, totalPriceLabel, lineItems, validateLineItems, promise) } catch (e: GooglePayException) { promise.reject( e.errorType.toRNErrorCode(), "${baseError}: ${e.message}", ) _googlePaySemaphore.safeRelease() return@uiOperation } catch (e: Exception) { promise.reject(ErrorCodes.UnexpectedError, "${baseError}: An unexpected error occurred") _googlePaySemaphore.safeRelease() return@uiOperation } } @ReactMethod fun isDigitalWalletReady(promise: Promise) = backgroundOperation { val data = Arguments.createMap() data.putBoolean(DataKeys.GPayIsReadyKey, isGooglePayReady()) promise.resolve(data) } @ReactMethod fun isOloPayInitialized(promise: Promise) = backgroundOperation { val data = Arguments.createMap() data.putBoolean(DataKeys.IsInitializedKey, isSdkInitialized()) promise.resolve(data) } @ReactMethod fun isDigitalWalletInitialized(promise: Promise) = backgroundOperation { val data = Arguments.createMap() data.putBoolean(DataKeys.IsInitializedKey, isGooglePayInitialized()) promise.resolve(data) } private fun getGooglePayConfiguration(args: ReadableMap, promise: Promise, baseError: String): GooglePayConfig? { val digitalWalletConfig = args.getMap(DataKeys.DigitalWalletConfigKey) if (digitalWalletConfig == null) { promise.reject(ErrorCodes.MissingParameter, "$baseError: Missing parameter '${DataKeys.DigitalWalletConfigKey}'") return null } val googlePayConfig = digitalWalletConfig.getMap(DataKeys.GPayConfigOptionsKey) if (googlePayConfig == null) { promise.reject(ErrorCodes.MissingParameter, "$baseError: Missing parameter '${DataKeys.GPayConfigOptionsKey}'") return null } val productionEnvironment = if(googlePayConfig.getBoolean(DataKeys.ProductionEnvironmentKey, DefaultProductionEnvironment)) { GooglePayEnvironment.Production } else { GooglePayEnvironment.Test } val merchantName = digitalWalletConfig.getString(DataKeys.GPayCompanyLabel) if (merchantName == null) { promise.reject(ErrorCodes.MissingParameter, "$baseError: Missing parameter '${DataKeys.GPayCompanyLabel}'") return null } else if (merchantName.isBlank()) { promise.reject(ErrorCodes.InvalidParameter, "$baseError: Value for '${DataKeys.GPayCompanyLabel}' cannot be empty") return null } val currencyCodeString = digitalWalletConfig.getString(DataKeys.GPayCurrencyCodeKey, DefaultCurrencyCode) val currencyCode = try { CurrencyCode.valueOf(currencyCodeString.uppercase()) } catch (_: Exception) { promise.reject(ErrorCodes.InvalidParameter, "${baseError}: '$currencyCodeString' is not supported") return null } val countryCode = digitalWalletConfig.getString(DataKeys.GPayCountryCodeKey, DefaultCountryCode) val existingPaymentMethodRequired = googlePayConfig.getBoolean(DataKeys.GPayExistingPaymentMethodRequiredKey, DefaultExistingPaymentMethodRequired) val emailRequired = digitalWalletConfig.getBoolean(DataKeys.GPayEmailRequiredKey, DefaultEmailRequired) val phoneNumberRequired = digitalWalletConfig.getBoolean(DataKeys.GPayPhoneNumberRequiredKey, DefaultPhoneNumberRequired) val fullNameRequired = digitalWalletConfig.getBoolean(DataKeys.GPayFullNameRequiredKey, DefaultFullNameRequired) val fullBillingAddressRequired = digitalWalletConfig.getBoolean(DataKeys.GPayFullBillingAddressRequiredKey, DefaultFullBillingAddressRequired) _currencyMultiplier = digitalWalletConfig.getInt(DataKeys.GPayCurrencyMultiplierKey, DefaultCurrencyMultiplier) return GooglePayConfig( environment = productionEnvironment, companyName = merchantName, companyCountryCode = countryCode, existingPaymentMethodRequired = existingPaymentMethodRequired, emailRequired = emailRequired, phoneNumberRequired = phoneNumberRequired, fullNameRequired = fullNameRequired, fullBillingAddressRequired = fullBillingAddressRequired, currencyCode = currencyCode ) } private suspend fun updateGooglePayConfig(config: GooglePayConfig?) { val previousState = isGooglePayReady() _googlePayInitializedSemaphore.withPermit { _googlePayConfig = config } emitDigitalWalletReadyEvent(previousState) } private suspend fun isSdkInitialized(): Boolean { _sdkInitializedSemaphore.withPermit { return _sdkInitialized } } private suspend fun setSdkInitialized(initialized: Boolean) { val previousState = isGooglePayReady() _sdkInitializedSemaphore.withPermit { _sdkInitialized = initialized } emitDigitalWalletReadyEvent(previousState) } private suspend fun isGooglePayInitialized(): Boolean { _googlePayInitializedSemaphore.withPermit { return _googlePayConfig != null } } private suspend fun isGooglePayReady() : Boolean { _googlePayReadySemaphore.withPermit { return isSdkInitialized() && isGooglePayInitialized() && _googlePayReady } } private fun setGooglePayReady(ready: Boolean) = backgroundOperation { val previousState = isGooglePayReady() _googlePayReadySemaphore.withPermit { _googlePayReady = ready } emitDigitalWalletReadyEvent(previousState) } private fun getGooglePayFragment(): GooglePayFragment? { val fragmentManager = fragmentManager ?: return null var fragment = fragmentManager.findFragmentByTag(GooglePayFragment.Tag) as GooglePayFragment? if (fragment == null ) { fragment = GooglePayFragment() fragmentManager.beginTransaction().add(fragment, GooglePayFragment.Tag).commitAllowingStateLoss() } return fragment } private fun onGooglePayReady(isReady: Boolean) { setGooglePayReady(isReady) } private fun onGooglePayResult(result: GooglePayResult, promise: Promise) { _googlePaySemaphore.safeRelease() when (result) { is GooglePayResult.Completed -> { val data = Arguments.createMap() data.putMap(DataKeys.PaymentMethodKey, result.paymentMethod.toMap()) promise.resolve(data) } is GooglePayResult.Canceled -> { val data = Arguments.createMap() data.putMap(DataKeys.PaymentMethodKey, null) promise.resolve(data) } is GooglePayResult.Failed -> { promise.reject( result.error.errorType.toRNErrorCode(), "Unable to create payment method: ${result.error.message}", ) } } } private fun emitDigitalWalletReadyEvent(previousState: Boolean) = backgroundOperation { val newState = isGooglePayReady() if (_hasEmittedDigitalWalletReadyEvent && newState == previousState) { return@backgroundOperation } val module = reactApplicationContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) if (module == null) { Log.d("OloPaySDK", "Unable to emit ${DataKeys.DigitalWalletReadyEvent}. RCTDeviceEventEmitter JS Module instance is null") return@backgroundOperation } _hasEmittedDigitalWalletReadyEvent = true val data = Arguments.createMap() data.putBoolean(DataKeys.GPayIsReadyKey, newState) module.emit(DataKeys.DigitalWalletReadyEvent, data) } private fun googlePayLockingOperation(operation: suspend() -> Unit) { CoroutineScope(Dispatchers.Main).launch { _googlePaySemaphore.withPermit { operation() } } } private fun setSdkWrapperInfo(version: String, buildType: String) { val versionStrings = version.split(".") val wrapperInfo = SdkWrapperInfo( (versionStrings.getOrElse(MajorVersionIndex) { "0" }).toIntOrNull() ?: 0, (versionStrings.getOrElse(MinorVersionIndex) { "0" }).toIntOrNull() ?: 0, (versionStrings.getOrElse(BuildVersionIndex) { "0" }).toIntOrNull() ?: 0, when (buildType) { DataKeys.HybridBuildTypePublicValue -> SdkBuildType.Public else -> SdkBuildType.Internal }, SdkWrapperPlatform.ReactNative ) IOloPayApiInitializer.sdkWrapperInfo = wrapperInfo } companion object { // Default Initialization Options const val DefaultProductionEnvironment = true // Default Google Pay Initialization Options const val DefaultGooglePayProductionEnvironment = true const val DefaultExistingPaymentMethodRequired = true const val DefaultEmailRequired = false const val DefaultPhoneNumberRequired = false const val DefaultFullNameRequired = false const val DefaultFullBillingAddressRequired = false const val DefaultCountryCode = "US" // Default Digital Wallet Payment Method Request Options const val DefaultCurrencyMultiplier = 100 const val DefaultCurrencyCode = "USD" val DefaultCheckoutStatus = GooglePayCheckoutStatus.FinalImmediatePurchase.name const val MajorVersionIndex = 0 const val MinorVersionIndex = 1 const val BuildVersionIndex = 2 const val DefaultVersionString = "0.0.0" } }