// Copyright © 2022 Olo Inc. All rights reserved. // This software is made available under the Olo Pay SDK License (See LICENSE.md file) package com.olo.olopaysdk.capacitorplugin import android.content.pm.PackageManager import androidx.annotation.VisibleForTesting import androidx.fragment.app.FragmentManager import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.PluginMethod import com.getcapacitor.PluginCall import com.getcapacitor.JSObject import com.getcapacitor.Plugin import com.olo.olopay.api.IOloPayApiInitializer import com.olo.olopay.api.OloPayApiInitializer import com.olo.olopay.data.CurrencyCode import com.olo.olopay.data.OloPayEnvironment import com.olo.olopay.data.SdkBuildType import com.olo.olopay.data.SdkWrapperInfo import com.olo.olopay.data.SdkWrapperPlatform import com.olo.olopay.googlepay.GooglePayCheckoutStatus import com.olo.olopay.googlepay.GooglePayConfig import com.olo.olopay.googlepay.GooglePayEnvironment import com.olo.olopay.googlepay.GooglePayException import com.olo.olopay.googlepay.GooglePayLineItem import com.olo.olopay.googlepay.GooglePayLineItemStatus import com.olo.olopay.googlepay.GooglePayLineItemType import com.olo.olopay.googlepay.GooglePayReadyCallback import com.olo.olopay.googlepay.GooglePayResult import com.olo.olopaysdk.capacitorplugin.data.DataKeys import com.olo.olopaysdk.capacitorplugin.data.ErrorCodes import com.olo.olopaysdk.capacitorplugin.data.safeRelease import com.olo.olopaysdk.capacitorplugin.data.toJSObject import com.olo.olopaysdk.capacitorplugin.extensions.getOrReject import com.olo.olopaysdk.capacitorplugin.extensions.getStringOrReject import com.olo.olopaysdk.capacitorplugin.extensions.mapOrReject import com.olo.olopaysdk.capacitorplugin.extensions.toCapacitorErrorCode import com.olo.olopaysdk.capacitorplugin.fragments.CapacitorGooglePayResultCallback import com.olo.olopaysdk.capacitorplugin.fragments.GooglePayFragment 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 import org.json.JSONArray import org.json.JSONObject @CapacitorPlugin(name = "OloPaySDK") class OloPaySDKPlugin : Plugin() { 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() = (activity)?.supportFragmentManager @PluginMethod fun initializeInternal(call: PluginCall) { // First call happens while the plugin is getting initialized... Subsequent calls should be ignored if (!_initializeInternalCalled) { _initializeInternalCalled = true val hybridVersion = call.getString(DataKeys.HybridSdkVersionKey, DefaultVersionString)!!.trim() val hybridBuildType = call.getString(DataKeys.HybridBuildTypeKey, DataKeys.HybridBuildTypeInternalValue)!!.trim() setSdkWrapperInfo(hybridVersion, hybridBuildType) } call.resolve() } @PluginMethod fun initialize(call: PluginCall) = backgroundOperation { _sdkInitializingSemaphore.withPermit { setSdkInitialized(false) val environment = try { val envBool = call.getOrReject( DataKeys.ProductionEnvironmentKey, "Unable to initialize Olo Pay SDK", DefaultProductionEnvironment ) if (envBool) OloPayEnvironment.Production else OloPayEnvironment.Test } catch (e: Exception) { return@backgroundOperation } val initializer = OloPayApiInitializer() initializer.setup(activity.applicationContext, environment) setSdkInitialized(true) if (call.data.has(DataKeys.DigitalWalletConfigKey)) { updateDigitalWalletConfiguration(call) } else { call.resolve() } } } @PluginMethod fun updateDigitalWalletConfiguration(call: PluginCall) = 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()) { call.reject( "${baseError}: Olo Pay SDK has not been initialized", ErrorCodes.UninitializedSdk ) return@withContext } // If the configuration comes back null that means an error was reported, so we need to return newConfiguration = getGooglePayConfiguration(call, baseError) ?: return@withContext val appMetadata = context.packageManager.getApplicationInfo( context.packageName, PackageManager.GET_META_DATA ).metaData if (!appMetadata.containsKey("com.google.android.gms.wallet.api.enabled")) { newConfiguration = null call.reject( "${baseError}: AndroidManifest missing com.google.android.gms.wallet.api.enabled entry", ErrorCodes.GooglePayInvalidSetup ) return@withContext } } newConfiguration?.let { config -> val fragment = getGooglePayFragment() if (fragment == null) { val message = "${baseError}: Unexpected error occurred" call.reject(message, ErrorCodes.UnexpectedError) return@googlePayLockingOperation } updateGooglePayConfig(config) fragment.readyCallback = GooglePayReadyCallback { isReady -> onGooglePayReady(isReady) } fragment.setConfiguration(config) call.resolve(null) } } @PluginMethod fun createDigitalWalletPaymentMethod(call: PluginCall) = 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()) { call.reject( "${baseError}: Olo Pay SDK has not been initialized", ErrorCodes.UninitializedSdk ) return@uiOperation } if (!isGooglePayInitialized()) { call.reject( "${baseError}: Google Pay not initialized", ErrorCodes.DigitalWalletUninitialized ) return@uiOperation } if (!isGooglePayReady()) { call.reject( "${baseError}: Google Pay isn't ready yet.", ErrorCodes.DigitalWalletNotReady ) return@uiOperation } val amount: Double? val totalPriceLabel: String? val lineItems: List? val validateLineItems: Boolean val googlePayCheckoutStatusString: String val checkoutStatus: GooglePayCheckoutStatus try { amount = call.getOrReject(DataKeys.GPayAmountKey, baseError) if (amount < 0) { call.reject( "${baseError}: ${DataKeys.GPayAmountKey} cannot be negative", ErrorCodes.InvalidParameter ) return@uiOperation } totalPriceLabel = call.getStringOrReject( DataKeys.GPayTotalPriceLabelKey, baseError, true, "" ) validateLineItems = call.getOrReject( DataKeys.GPayValidateLineItems, baseError, true ) googlePayCheckoutStatusString = call.getStringOrReject( DataKeys.GPayCheckoutStatusKey, baseError, false, DefaultCheckoutStatus ) checkoutStatus = try { GooglePayCheckoutStatus.valueOf(googlePayCheckoutStatusString) } catch (e: Exception) { call.reject( "${baseError}: '${googlePayCheckoutStatusString}' is not a GooglePayCheckoutStatus enum value", ErrorCodes.InvalidParameter ) return@uiOperation } val capacitorLineItems = call.getOrReject( DataKeys.GPayLineItemsKey, baseError, JSONArray() ) val lineItemError = "$baseError - Unable to parse LineItems" lineItems = call.mapOrReject(lineItemError, capacitorLineItems) { item -> val typeString = call.getStringOrReject( DataKeys.LineItemTypeKey, lineItemError, false, data = item ) val lineItemType = try { GooglePayLineItemType.valueOf(typeString) } catch (e: Exception) { val errorMessage = "$lineItemError: '$typeString' is not a LineItemType enum value" call.reject(errorMessage, ErrorCodes.InvalidParameter) return@uiOperation } val statusString = call.getStringOrReject( DataKeys.LineItemStatusKey, lineItemError, false, GooglePayLineItemStatus.Final.name, data = item ) val lineItemStatus = try { GooglePayLineItemStatus.valueOf(statusString) } catch (e: Exception) { val errorMessage = "$lineItemError: '$statusString' is not a LineItemStatus enum value" call.reject(errorMessage, ErrorCodes.InvalidParameter) return@uiOperation } GooglePayLineItem( label = call.getStringOrReject( DataKeys.LineItemLabelKey, lineItemError, false, data = item ), price = ( call.getOrReject( DataKeys.LineItemAmountKey, lineItemError, data = item ) * _currencyMultiplier ).toInt(), type = lineItemType, status = lineItemStatus ) } } catch (e: Exception) { return@uiOperation } _googlePaySemaphore.acquire() val fragment = getGooglePayFragment() if (fragment == null) { call.reject("${baseError}: Google Pay not initialized", ErrorCodes.DigitalWalletUninitialized) _googlePaySemaphore.safeRelease() return@uiOperation } if (!fragment.isReady) { val reason = if (fragment.configuration == null) { "Google Pay not initialized" } else { "Google Pay isn't ready yet" } call.reject("${baseError}: $reason", ErrorCodes.DigitalWalletNotReady) _googlePaySemaphore.safeRelease() return@uiOperation } val amountInSmallestCurrencyUnit = (amount * _currencyMultiplier).toInt() fragment.resultCallback = CapacitorGooglePayResultCallback{ result, call -> onGooglePayResult(result, call) } try { fragment.present( amountInSmallestCurrencyUnit, checkoutStatus, totalPriceLabel, lineItems, validateLineItems, call ) } catch (e: GooglePayException) { call.reject( "${baseError}: ${e.message}", e.errorType.toCapacitorErrorCode() ) _googlePaySemaphore.safeRelease() return@uiOperation } catch (e: Exception) { call.reject( "${baseError}: An unexpected error occurred", ErrorCodes.UnexpectedError ) _googlePaySemaphore.safeRelease() return@uiOperation } } @PluginMethod fun isDigitalWalletReady(call: PluginCall) = backgroundOperation { val data = JSObject() data.put(DataKeys.GPayIsReadyKey, isGooglePayReady()) call.resolve(data) } @PluginMethod fun isInitialized(call: PluginCall) = backgroundOperation { val data = JSObject() data.put(DataKeys.IsInitializedKey, isSdkInitialized()) call.resolve(data) } @PluginMethod fun isDigitalWalletInitialized(call: PluginCall) = backgroundOperation { val data = JSObject() data.put(DataKeys.IsInitializedKey, isGooglePayInitialized()) call.resolve(data) } private fun getGooglePayConfiguration(call: PluginCall, baseError: String): GooglePayConfig? { try { val digitalWalletConfig = call.getOrReject( DataKeys.DigitalWalletConfigKey, baseError ) val shouldInitializeGooglePay = call.getOrReject( DataKeys.InitializeGooglePayKey, "Unable to initialize Google Pay", false, digitalWalletConfig ) if (!shouldInitializeGooglePay) { call.resolve(null) return null } val googlePayConfig = call.getOrReject( DataKeys.GPayConfigOptionsKey, baseError, JSONObject(), // `googlePayConfig` and all of its nested parameters are optional with defaults for the nested parameters set here in native code - we need to provide a default empty JSObject to prevent unintended rejections if one did not come from JS digitalWalletConfig, ) val productionEnvironment = if (call.getOrReject( DataKeys.ProductionEnvironmentKey, baseError, DefaultGooglePayProductionEnvironment, googlePayConfig ) ) { GooglePayEnvironment.Production } else { GooglePayEnvironment.Test } val merchantName = call.getStringOrReject( DataKeys.GPayCompanyLabel, baseError, false, data = digitalWalletConfig ) val currencyCodeString = call.getStringOrReject( DataKeys.GPayCurrencyCodeKey, baseError, false, DefaultCurrencyCode, digitalWalletConfig ) val currencyCode = try { CurrencyCode.valueOf(currencyCodeString.uppercase()) } catch (_: Exception) { call.reject("${baseError}: '$currencyCodeString' is not supported", ErrorCodes.InvalidParameter) return null } val countryCode = call.getStringOrReject( DataKeys.GPayCountryCodeKey, baseError, false, DefaultCountryCode, digitalWalletConfig ) val existingPaymentMethodRequired = call.getOrReject( DataKeys.GPayExistingPaymentMethodRequiredKey, baseError, DefaultExistingPaymentMethodRequired, googlePayConfig ) val emailRequired = call.getOrReject( DataKeys.GPayEmailRequiredKey, baseError, DefaultEmailRequired, digitalWalletConfig ) val phoneNumberRequired = call.getOrReject( DataKeys.GPayPhoneNumberRequiredKey, baseError, DefaultPhoneNumberRequired, digitalWalletConfig ) val fullNameRequired = call.getOrReject( DataKeys.GPayFullNameRequiredKey, baseError, DefaultFullNameRequired, digitalWalletConfig ) val fullBillingAddressRequired = call.getOrReject( DataKeys.GPayFullBillingAddressRequiredKey, baseError, DefaultFullBillingAddressRequired, digitalWalletConfig ) _currencyMultiplier = call.getOrReject( DataKeys.GPayCurrencyMultiplierKey, baseError, DefaultCurrencyMultiplier, googlePayConfig ) return GooglePayConfig( environment = productionEnvironment, companyName = merchantName, companyCountryCode = countryCode, existingPaymentMethodRequired = existingPaymentMethodRequired, emailRequired = emailRequired, phoneNumberRequired = phoneNumberRequired, fullNameRequired = fullNameRequired, fullBillingAddressRequired = fullBillingAddressRequired, currencyCode = currencyCode ) } catch (e: Exception) { return null } } 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, call: PluginCall) { _googlePaySemaphore.safeRelease() when (result) { is GooglePayResult.Completed -> { val data = JSObject() data.put(DataKeys.PaymentMethodKey, result.paymentMethod.toJSObject()) call.resolve(data) } is GooglePayResult.Canceled -> { val data = JSObject() data.put(DataKeys.PaymentMethodKey, JSONObject.NULL) call.resolve(data) } is GooglePayResult.Failed -> { call.reject( "Unable to create payment method: ${result.error.message}", result.error.errorType.toCapacitorErrorCode() ) } } } private fun emitDigitalWalletReadyEvent(previousState: Boolean) = backgroundOperation { val newState = isGooglePayReady() if (_hasEmittedDigitalWalletReadyEvent && newState == previousState) { return@backgroundOperation } _hasEmittedDigitalWalletReadyEvent = true val data = JSObject() data.put(DataKeys.GPayIsReadyKey, newState) notifyListeners(DataKeys.DigitalWalletReadyEvent, data) } private fun googlePayLockingOperation(operation: suspend() -> Unit) { CoroutineScope(Dispatchers.Main).launch { _googlePaySemaphore.withPermit { operation() } } } private fun backgroundOperation(operation: suspend() -> Unit) { CoroutineScope(Dispatchers.IO).launch { operation() } } private fun uiOperation(operation: suspend() -> Unit) { CoroutineScope(Dispatchers.Main).launch { 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.Capacitor ) IOloPayApiInitializer.sdkWrapperInfo = wrapperInfo } companion object { // Default Initialization Options const val DefaultProductionEnvironment = true // Default Google Pay Initialization Options const val DefaultGooglePayProductionEnvironment = true const val DefaultExistingPaymentMethodRequired = false 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" } }