Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,10 @@
<string name="error_title_onrampGooglePayNotReady">Google Pay Not Ready</string>
<string name="error_description_onrampGooglePayNotReady">Please add a payment method to Google Pay and try again</string>

<string name="error_title_onrampNonStableWebView">Unsupported WebView</string>
<string name="error_description_onrampNonStableWebView">Your device is using a %1$s version of Android System WebView, which may cause issues with purchases. Google recommends switching to the stable channel before continuing with your purchase</string>
<string name="action_continueAnyway">Continue Anyway</string>

<string name="error_title_onrampUnknownFailure">Something Went Wrong</string>
<string name="error_description_onrampUnknownFailure">Please contact support@flipcash.com</string>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import com.flipcash.app.core.ui.CurrencyHolder
import com.flipcash.app.onramp.CoinbaseOnRampState
import com.flipcash.app.onramp.OnRampAuthError
import com.flipcash.app.onramp.CoinbaseOnRampController
import com.flipcash.app.onramp.OnRampPaymentError
import com.flipcash.app.onramp.PurchaseGate
import com.flipcash.features.onramp.R
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.services.internal.model.thirdparty.OnRampProvider
import com.flipcash.services.internal.model.thirdparty.OnRampType
import com.getcode.manager.BottomBarAction
import com.getcode.manager.BottomBarManager
import com.getcode.opencode.controllers.TokenController
import com.getcode.opencode.controllers.TransactionOperations
Expand All @@ -39,6 +40,7 @@ import com.getcode.view.LoadingSuccessState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
Expand Down Expand Up @@ -83,7 +85,7 @@ internal class OnRampViewModel @Inject constructor(
tokenController: TokenController,
transactionController: TransactionOperations,
dispatchers: DispatcherProvider,
analytics: FlipcashAnalyticsService,
private val analytics: FlipcashAnalyticsService,
) : BaseViewModel2<OnRampViewModel.State, OnRampViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -139,7 +141,7 @@ internal class OnRampViewModel @Inject constructor(
data class UpdateOrderLookupState(
val loading: Boolean = false,
val success: Boolean = false
): Event
) : Event

data class OnAmountAccepted(val amount: VerifiedFiat) : Event

Expand Down Expand Up @@ -169,16 +171,21 @@ internal class OnRampViewModel @Inject constructor(
onRampController.state
.onEach { s ->
when (s) {
is CoinbaseOnRampState.Completed -> dispatchEvent(Event.UpdateOrderLookupState(success = true))
is CoinbaseOnRampState.Completed ->
dispatchEvent(Event.UpdateOrderLookupState(success = true))

is CoinbaseOnRampState.Failed -> {
dispatchEvent(Event.UpdateConfirmingAmountState())
dispatchEvent(Event.UpdateOrderLookupState())
}

CoinbaseOnRampState.Idle -> {
dispatchEvent(Event.UpdateConfirmingAmountState())
dispatchEvent(Event.UpdateOrderLookupState())
}
is CoinbaseOnRampState.Paying -> dispatchEvent(Event.UpdateOrderLookupState(loading = true))

is CoinbaseOnRampState.Paying ->
dispatchEvent(Event.UpdateOrderLookupState(loading = true))
}
}
.launchIn(viewModelScope)
Expand Down Expand Up @@ -343,62 +350,7 @@ internal class OnRampViewModel @Inject constructor(
return@onEach
}

onRampController.placeOrderAndStartPayment(
amount = selectedAmount.localFiat.underlyingTokenAmount,
token = token,
verifiedFiat = selectedAmount,
).onSuccess {
analytics.buy(
method = Analytics.PurchaseMethod.Coinbase,
amount = selectedAmount.localFiat.nativeAmount,
mint = token.address,
)
}.onFailure { error ->
dispatchEvent(Event.UpdateConfirmingAmountState())

when (error) {
is OnRampAuthError.CoinbasePhoneVerificationRequired -> {
dispatchEvent(Event.OnVerificationNeeded(phone = true))
}

is OnRampAuthError.VerificationRequired -> {
dispatchEvent(
Event.OnVerificationNeeded(
phone = error.phone,
email = error.email
)
)
}

is OnRampPaymentError.GooglePayNotSupported -> {
BottomBarManager.showAlert(
title = resources.getString(R.string.error_title_onrampGooglePayNotSupported),
message = resources.getString(R.string.error_description_onrampGooglePayNotSupported),
)
}

is OnRampPaymentError.GooglePayNoPaymentMethod -> {
BottomBarManager.showAlert(
title = resources.getString(R.string.error_title_onrampGooglePayNotReady),
message = resources.getString(R.string.error_description_onrampGooglePayNotReady),
)
}

else -> {
analytics.buy(
method = Analytics.PurchaseMethod.Coinbase,
amount = selectedAmount.localFiat.nativeAmount,
mint = token.address,
error = error
)

BottomBarManager.showError(
title = "Something Went Wrong",
message = error.message ?: "Please try again",
)
}
}
}
executeCoinbasePurchase(selectedAmount, token)
}

else -> Unit
Expand All @@ -412,6 +364,109 @@ internal class OnRampViewModel @Inject constructor(
}.launchIn(viewModelScope)
}

private suspend fun executeCoinbasePurchase(
selectedAmount: VerifiedFiat,
token: Token,
) {
onRampController.checkPurchaseGates()
.fold(
onSuccess = { proceedWithCoinbasePurchase(selectedAmount, token) },
onFailure = { gate ->
when (gate) {
is PurchaseGate.GooglePayNotSupported -> {
dispatchEvent(Event.UpdateConfirmingAmountState())
BottomBarManager.showAlert(
title = resources.getString(R.string.error_title_onrampGooglePayNotSupported),
message = resources.getString(R.string.error_description_onrampGooglePayNotSupported),
)
}

is PurchaseGate.GooglePayNoPaymentMethod -> {
dispatchEvent(Event.UpdateConfirmingAmountState())
BottomBarManager.showAlert(
title = resources.getString(R.string.error_title_onrampGooglePayNotReady),
message = resources.getString(R.string.error_description_onrampGooglePayNotReady),
)
}

is PurchaseGate.WebViewWarning -> {
BottomBarManager.showAlert(
title = resources.getString(R.string.error_title_onrampNonStableWebView),
message = resources.getString(
R.string.error_description_onrampNonStableWebView,
gate.channel.name,
),
actions = listOf(
BottomBarAction(
text = resources.getString(R.string.action_cancel),
style = BottomBarManager.BottomBarButtonStyle.Filled,
) {
dispatchEvent(Event.UpdateConfirmingAmountState())
},
BottomBarAction(
text = resources.getString(R.string.action_continueAnyway),
style = BottomBarManager.BottomBarButtonStyle.Text,
) {
viewModelScope.launch {
proceedWithCoinbasePurchase(selectedAmount, token)
}
},
),
)
}
}
},
)
}

private suspend fun proceedWithCoinbasePurchase(
selectedAmount: VerifiedFiat,
token: Token,
) {
onRampController.placeOrderAndStartPayment(
amount = selectedAmount.localFiat.underlyingTokenAmount,
token = token,
verifiedFiat = selectedAmount,
).onSuccess {
analytics.buy(
method = Analytics.PurchaseMethod.Coinbase,
amount = selectedAmount.localFiat.nativeAmount,
mint = token.address,
)
}.onFailure { error ->
dispatchEvent(Event.UpdateConfirmingAmountState())

when (error) {
is OnRampAuthError.CoinbasePhoneVerificationRequired -> {
dispatchEvent(Event.OnVerificationNeeded(phone = true))
}

is OnRampAuthError.VerificationRequired -> {
dispatchEvent(
Event.OnVerificationNeeded(
phone = error.phone,
email = error.email
)
)
}

else -> {
analytics.buy(
method = Analytics.PurchaseMethod.Coinbase,
amount = selectedAmount.localFiat.nativeAmount,
mint = token.address,
error = error
)

BottomBarManager.showError(
title = "Something Went Wrong",
message = error.message ?: "Please try again",
)
}
}
}
}

override fun onCleared() {
exchange.resetEntryToBalance()
}
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/shared/onramp/coinbase/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies {
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.robolectric)

implementation(libs.androidx.webkit)
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.bundles.kotlinx.serialization)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ import kotlinx.serialization.json.JsonIgnoreUnknownKeys
import retrofit2.HttpException
import javax.inject.Inject

sealed class PurchaseGate : Throwable() {
data class WebViewWarning(val channel: WebViewChannel) : PurchaseGate()
data object GooglePayNotSupported : PurchaseGate()
data object GooglePayNoPaymentMethod : PurchaseGate()
}

typealias OrderWithPaymentLink = Pair<String, OnRampPurchaseResponse.PaymentLink>

private val json = Json {
Expand All @@ -63,6 +69,7 @@ class CoinbaseOnRampController @Inject constructor(
private val featureFlags: FeatureFlagController,
private val transactionController: TransactionOperations,
private val googlePayReadiness: GooglePayReadiness,
private val webViewChannelDetector: WebViewChannelDetector,
) {

private val _state = MutableStateFlow<CoinbaseOnRampState>(CoinbaseOnRampState.Idle)
Expand Down Expand Up @@ -100,19 +107,30 @@ class CoinbaseOnRampController @Inject constructor(
_state.update { CoinbaseOnRampState.Idle }
}

suspend fun checkPurchaseGates(): Result<Unit> {
when (googlePayReadiness.check()) {
GooglePayReadiness.Status.NotSupported -> return Result.failure(PurchaseGate.GooglePayNotSupported)
GooglePayReadiness.Status.NoPaymentMethod -> return Result.failure(PurchaseGate.GooglePayNoPaymentMethod)
GooglePayReadiness.Status.Ready -> Unit
}

webViewChannelDetector.detect()?.let { channel ->
trace(
tag = "CoinbaseOnRamp",
message = "Non-stable WebView detected at purchase gate",
metadata = { "channel" to channel.name },
)
return Result.failure(PurchaseGate.WebViewWarning(channel))
}

return Result.success(Unit)
}

suspend fun placeOrderAndStartPayment(
amount: Fiat,
token: Token,
verifiedFiat: VerifiedFiat,
): Result<Unit> {
when (googlePayReadiness.check()) {
GooglePayReadiness.Status.NotSupported ->
return Result.failure(OnRampPaymentError.GooglePayNotSupported())
GooglePayReadiness.Status.NoPaymentMethod ->
return Result.failure(OnRampPaymentError.GooglePayNoPaymentMethod())
GooglePayReadiness.Status.Ready -> Unit
}

return placeOrderInclusiveOfFees(amount)
.mapCatching { (orderId, paymentLink) ->
val owner = userManager.accountCluster
Expand Down Expand Up @@ -376,6 +394,8 @@ sealed class OnRampPaymentError(
) : CodeServerError(message) {
class GooglePayNotSupported : OnRampPaymentError("Google Pay is not available on this device")
class GooglePayNoPaymentMethod : OnRampPaymentError("No payment method enrolled in Google Pay")
class NonStableWebViewChannel(val channel: WebViewChannel) :
OnRampPaymentError("Non-stable WebView channel detected: ${channel.name}")
}

sealed class OnRampAuthError(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.flipcash.app.onramp

import android.content.Context
import androidx.webkit.WebViewCompat
import com.getcode.utils.trace
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject

enum class WebViewChannel { Beta, Dev, Canary }

private val KNOWN_CHROMIUM_PREFIXES = listOf(
"com.google.android.webview",
"com.android.webview",
"com.android.chrome",
"com.chrome",
)

// Chromium's versionCode = (build * 1000 + patch) * 100 + packagePart + abiPart.
// The _PACKAGE_NAMES value is added directly, placing the package type in the
// tens digit and ABI in the ones digit of the last two digits.
// Relevant beta values:
// WEBVIEW_BETA = 10 → tens digit 1
// TRICHROME_BETA = 40 → tens digit 4
// References:
// https://chromium.googlesource.com/chromium/src/+/HEAD/build/util/android_chrome_version.py
// https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/channels.md
private val BETA_PACKAGE_DIGITS = setOf(1L, 4L)

internal fun classifyWebViewChannel(
packageName: String?,
versionCode: Long?,
): WebViewChannel? {
if (packageName == null) return null
if (KNOWN_CHROMIUM_PREFIXES.none { packageName.startsWith(it) }) return null

when {
packageName.endsWith(".canary") -> return WebViewChannel.Canary
packageName.endsWith(".dev") -> return WebViewChannel.Dev
packageName.endsWith(".beta") -> return WebViewChannel.Beta
}

if (versionCode != null) {
val packageDigit = (versionCode / 10L) % 10L
if (packageDigit in BETA_PACKAGE_DIGITS) return WebViewChannel.Beta
}

return null
}

fun detectNonStableWebViewChannel(context: Context): WebViewChannel? {
val pkg = WebViewCompat.getCurrentWebViewPackage(context)
trace(
tag = "CoinbaseOnRamp",
message = "WebView provider lookup",
metadata = {
"packageName" to (pkg?.packageName ?: "null")
"versionName" to (pkg?.versionName ?: "null")
"longVersionCode" to (pkg?.longVersionCode?.toString() ?: "null")
},
)
if (pkg == null) return null
return classifyWebViewChannel(pkg.packageName, pkg.longVersionCode)
}

class WebViewChannelDetector @Inject constructor(
@ApplicationContext private val context: Context
) {
fun detect(): WebViewChannel? = detectNonStableWebViewChannel(context)
}
Loading
Loading