diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml
index 0767617d3..c88e1b4bd 100644
--- a/apps/flipcash/core/src/main/res/values/strings.xml
+++ b/apps/flipcash/core/src/main/res/values/strings.xml
@@ -587,6 +587,10 @@
Google Pay Not Ready
Please add a payment method to Google Pay and try again
+ Unsupported WebView
+ 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
+ Continue Anyway
+
Something Went Wrong
Please contact support@flipcash.com
diff --git a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt
index 142717a00..0427e8cfb 100644
--- a/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt
+++ b/apps/flipcash/features/onramp/src/main/kotlin/com/flipcash/app/onramp/internal/OnRampViewModel.kt
@@ -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
@@ -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
@@ -83,7 +85,7 @@ internal class OnRampViewModel @Inject constructor(
tokenController: TokenController,
transactionController: TransactionOperations,
dispatchers: DispatcherProvider,
- analytics: FlipcashAnalyticsService,
+ private val analytics: FlipcashAnalyticsService,
) : BaseViewModel2(
initialState = State(),
updateStateForEvent = updateStateForEvent,
@@ -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
@@ -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)
@@ -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
@@ -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()
}
diff --git a/apps/flipcash/shared/onramp/coinbase/build.gradle.kts b/apps/flipcash/shared/onramp/coinbase/build.gradle.kts
index 498bd17e1..008444b3e 100644
--- a/apps/flipcash/shared/onramp/coinbase/build.gradle.kts
+++ b/apps/flipcash/shared/onramp/coinbase/build.gradle.kts
@@ -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)
diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt
index bfe128b0d..006816e22 100644
--- a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt
+++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/CoinbaseOnRampController.kt
@@ -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
private val json = Json {
@@ -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.Idle)
@@ -100,19 +107,30 @@ class CoinbaseOnRampController @Inject constructor(
_state.update { CoinbaseOnRampState.Idle }
}
+ suspend fun checkPurchaseGates(): Result {
+ 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 {
- 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
@@ -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(
diff --git a/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/WebViewChannelDetector.kt b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/WebViewChannelDetector.kt
new file mode 100644
index 000000000..ec9f66d96
--- /dev/null
+++ b/apps/flipcash/shared/onramp/coinbase/src/main/kotlin/com/flipcash/app/onramp/WebViewChannelDetector.kt
@@ -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)
+}
diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt
index 754dd45d3..108b66764 100644
--- a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt
+++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt
@@ -12,6 +12,8 @@ import com.getcode.opencode.model.financial.CurrencyCode
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.usdf
+import io.mockk.coEvery
+import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -38,6 +40,8 @@ class CoinbaseOnRampControllerTest {
private val userManager = mockk(relaxed = true)
private val exchange = mockk(relaxed = true)
private val featureFlags = mockk(relaxed = true)
+ private val googlePayReadiness = mockk(relaxed = true)
+ private val webViewChannelDetector = mockk(relaxed = true)
private val onRampApiEndpoint = OnRampApiConfig(
scheme = "https",
@@ -58,6 +62,8 @@ class CoinbaseOnRampControllerTest {
every { Token.usdf } returns fakeToken
every { fakeToken.timelockSwapAccounts(any()) } returns mockk(relaxed = true)
+ every { webViewChannelDetector.detect() } returns null
+
controller = CoinbaseOnRampController(
jwtProvider = jwtProvider,
onRampApiEndpoint = onRampApiEndpoint,
@@ -66,7 +72,8 @@ class CoinbaseOnRampControllerTest {
exchange = exchange,
featureFlags = featureFlags,
transactionController = mockk(relaxed = true),
- googlePayReadiness = mockk(relaxed = true),
+ googlePayReadiness = googlePayReadiness,
+ webViewChannelDetector = webViewChannelDetector,
)
}
@@ -174,6 +181,76 @@ class CoinbaseOnRampControllerTest {
}
// endregion
+
+ // region checkPurchaseGates
+
+ @Test
+ fun `checkPurchaseGates succeeds when stable WebView and GPay ready`() = runTest {
+ coEvery { googlePayReadiness.check() } returns GooglePayReadiness.Status.Ready
+ every { webViewChannelDetector.detect() } returns null
+
+ assertTrue(controller.checkPurchaseGates().isSuccess)
+ }
+
+ @Test
+ fun `checkPurchaseGates fails with GooglePayNotSupported when GPay unavailable`() = runTest {
+ coEvery { googlePayReadiness.check() } returns GooglePayReadiness.Status.NotSupported
+
+ val result = controller.checkPurchaseGates()
+ assertTrue(result.isFailure)
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun `checkPurchaseGates fails with GooglePayNoPaymentMethod when no instrument enrolled`() = runTest {
+ coEvery { googlePayReadiness.check() } returns GooglePayReadiness.Status.NoPaymentMethod
+
+ val result = controller.checkPurchaseGates()
+ assertTrue(result.isFailure)
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun `checkPurchaseGates fails with WebViewWarning for non-stable channel`() = runTest {
+ coEvery { googlePayReadiness.check() } returns GooglePayReadiness.Status.Ready
+ every { webViewChannelDetector.detect() } returns WebViewChannel.Beta
+
+ val result = controller.checkPurchaseGates()
+ assertTrue(result.isFailure)
+ val gate = result.exceptionOrNull()
+ assertIs(gate)
+ assertEquals(WebViewChannel.Beta, gate.channel)
+ }
+
+ @Test
+ fun `checkPurchaseGates prioritizes GPay block over WebView warning`() = runTest {
+ coEvery { googlePayReadiness.check() } returns GooglePayReadiness.Status.NotSupported
+ every { webViewChannelDetector.detect() } returns WebViewChannel.Beta
+
+ val result = controller.checkPurchaseGates()
+ assertTrue(result.isFailure)
+ assertIs(result.exceptionOrNull())
+ }
+
+ @Test
+ fun `placeOrderAndStartPayment does not invoke gate checks`() = runTest {
+ stubValidUser()
+
+ // placeOrderAndStartPayment will fail downstream (no JWT mock), but we only
+ // care that it never calls detect() or check()
+ runCatching {
+ controller.placeOrderAndStartPayment(
+ amount = Fiat(10, CurrencyCode.USD),
+ token = Token.usdf,
+ verifiedFiat = mockk(relaxed = true),
+ )
+ }
+
+ coVerify(exactly = 0) { googlePayReadiness.check() }
+ coVerify(exactly = 0) { webViewChannelDetector.detect() }
+ }
+
+ // endregion
}
class CoinbaseOnRampApiErrorParseTest {
diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/WebViewChannelDetectorTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/WebViewChannelDetectorTest.kt
new file mode 100644
index 000000000..789c9d71e
--- /dev/null
+++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/WebViewChannelDetectorTest.kt
@@ -0,0 +1,154 @@
+package com.flipcash.app.onramp
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class WebViewChannelDetectorTest {
+
+ // region null / missing inputs
+
+ @Test
+ fun `null package name returns null`() {
+ assertNull(classifyWebViewChannel(null, null))
+ }
+
+ // endregion
+
+ // region stable builds
+
+ @Test
+ fun `WEBVIEW_STABLE package digit returns null`() {
+ // WEBVIEW_STABLE=0 → tens digit 0, abi=3 (arm64) → ...03
+ assertNull(classifyWebViewChannel("com.google.android.webview", 782701303L))
+ }
+
+ @Test
+ fun `TRICHROME package digit returns null`() {
+ // TRICHROME=30 → tens digit 3, abi=3 → ...33
+ assertNull(classifyWebViewChannel("com.android.chrome", 782701333L))
+ }
+
+ @Test
+ fun `stable Chrome with null versionCode returns null`() {
+ assertNull(classifyWebViewChannel("com.android.chrome", null))
+ }
+
+ @Test
+ fun `stable android webview with arbitrary versionCode returns null`() {
+ assertNull(classifyWebViewChannel("com.android.webview", 100L))
+ }
+
+ // endregion
+
+ // region WEBVIEW_BETA (package digit 10 → tens digit 1)
+
+ @Test
+ fun `WEBVIEW_BETA on google webview returns Beta`() {
+ // WEBVIEW_BETA=10 → tens digit 1, abi=3 → ...13
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.google.android.webview", 782701313L))
+ }
+
+ @Test
+ fun `WEBVIEW_BETA on android chrome returns Beta`() {
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.android.chrome", 782701313L))
+ }
+
+ // endregion
+
+ // region TRICHROME_BETA (package digit 40 → tens digit 4)
+
+ @Test
+ fun `TRICHROME_BETA on google webview returns Beta`() {
+ // TRICHROME_BETA=40 → tens digit 4, abi=3 → ...43
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.google.android.webview", 782701343L))
+ }
+
+ @Test
+ fun `TRICHROME_BETA on android chrome returns Beta`() {
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.android.chrome", 782701343L))
+ }
+
+ // endregion
+
+ // region suffix-based classification
+
+ @Test
+ fun `beta suffix on google webview returns Beta`() {
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.google.android.webview.beta", 0L))
+ }
+
+ @Test
+ fun `beta suffix on chrome returns Beta`() {
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.chrome.beta", 0L))
+ }
+
+ @Test
+ fun `dev suffix on google webview returns Dev`() {
+ assertEquals(WebViewChannel.Dev, classifyWebViewChannel("com.google.android.webview.dev", 0L))
+ }
+
+ @Test
+ fun `dev suffix on chrome returns Dev`() {
+ assertEquals(WebViewChannel.Dev, classifyWebViewChannel("com.chrome.dev", 0L))
+ }
+
+ @Test
+ fun `canary suffix on google webview returns Canary`() {
+ assertEquals(WebViewChannel.Canary, classifyWebViewChannel("com.google.android.webview.canary", 0L))
+ }
+
+ @Test
+ fun `canary suffix on chrome returns Canary`() {
+ assertEquals(WebViewChannel.Canary, classifyWebViewChannel("com.chrome.canary", 0L))
+ }
+
+ // endregion
+
+ // region suffix takes precedence over versionCode
+
+ @Test
+ fun `beta suffix with WEBVIEW_BETA digit still returns Beta`() {
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.chrome.beta", 782701313L))
+ }
+
+ @Test
+ fun `beta suffix with TRICHROME_BETA digit still returns Beta`() {
+ assertEquals(WebViewChannel.Beta, classifyWebViewChannel("com.chrome.beta", 782701343L))
+ }
+
+ // endregion
+
+ // region unknown vendors (fail-open)
+
+ @Test
+ fun `samsung webview returns null`() {
+ assertNull(classifyWebViewChannel("com.samsung.android.webview", 0L))
+ }
+
+ @Test
+ fun `huawei webview returns null`() {
+ assertNull(classifyWebViewChannel("com.huawei.webview", 0L))
+ }
+
+ @Test
+ fun `WEBVIEW_BETA digit on unknown vendor returns null`() {
+ assertNull(classifyWebViewChannel("com.samsung.android.webview", 782701313L))
+ }
+
+ @Test
+ fun `TRICHROME_BETA digit on unknown vendor returns null`() {
+ assertNull(classifyWebViewChannel("com.samsung.android.webview", 782701343L))
+ }
+
+ // endregion
+
+ // region false-positive guard
+
+ @Test
+ fun `random app with beta suffix returns null`() {
+ assertNull(classifyWebViewChannel("com.some.random.app.beta", 0L))
+ }
+
+ // endregion
+}