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 +}