fix(android): add WebView block recovery paths

Builds on the prior authoritative-vs-fallback fix with three layered
recovery mechanisms so users hit by a false BLOCK are never locked out
of their data:

- Last-known-good auto-recovery: persist the highest WebView version
  that has ever loaded the app on this device. A later transient
  mis-read that drops below MIN_CHROMIUM_VERSION is downgraded to WARN.
- "Try anyway" override: third button on the block screen opens an
  AlertDialog with an explicit risk acknowledgment (crashes, render
  failures, possible data loss). Confirming persists an override and
  relaunches the app. Hardened against tapjacking via
  filterTouchesWhenObscured on both the activity and dialog window.
- Override auto-clears once a healthy version is detected, so a future
  genuine block is not silently bypassed.

Also tightens the UA regex (drops the misleading Safari Version/X
fallback that always reads "4.0" and would falsely block) and adds
diagnostic logging gated by Log.isLoggable for field debugging.

Tests: 12 unit tests covering statusForVersion branches, all
applyOverrides paths, and parseMajorVersion edge cases.

Refs #7229
This commit is contained in:
Johannes Millan 2026-04-28 14:01:45 +02:00
parent 10257fc21a
commit 7c5b58ecf6
7 changed files with 285 additions and 27 deletions

View file

@ -115,6 +115,11 @@ class CapacitorMainActivity : BridgeActivity() {
}
}
// We made it past the pre-flight version check and the WebView is alive.
// Persist the detected version so a transient mis-read on a later launch
// can't lock the user out, and clear any prior user override if healthy.
WebViewCompatibilityChecker.recordSuccessfulLoad(this, webViewCompatibility?.majorVersion)
// Hide the action bar
supportActionBar?.hide()

View file

@ -86,6 +86,11 @@ class FullscreenActivity : AppCompatActivity() {
return
}
// We made it past the pre-flight version check and the WebView is alive.
// Persist the detected version so a transient mis-read on a later launch
// can't lock the user out, and clear any prior user override if healthy.
WebViewCompatibilityChecker.recordSuccessfulLoad(this, compatibility.majorVersion)
// FOR TESTING HTML INPUTS QUICKLY
//// webView = (application as App).wv
// webView = WebHelper().instanceView(this)

View file

@ -1,11 +1,14 @@
package com.superproductivity.superproductivity.webview
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Button
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import com.superproductivity.superproductivity.FullscreenActivity
import com.superproductivity.superproductivity.R
class WebViewBlockActivity : AppCompatActivity() {
@ -14,6 +17,11 @@ class WebViewBlockActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_webview_block)
// Defense-in-depth against tapjacking: the override below is the only path to
// permanently disable the WebView block, so we drop touches when the window
// is partially obscured by an overlay app.
findViewById<View>(android.R.id.content).filterTouchesWhenObscured = true
val minVersion = intent.getIntExtra(EXTRA_MIN_VERSION, WebViewCompatibilityChecker.MIN_CHROMIUM_VERSION)
val detectedVersion = intent.getIntExtra(EXTRA_VERSION_MAJOR, -1)
val versionName = intent.getStringExtra(EXTRA_VERSION_NAME)
@ -44,6 +52,36 @@ class WebViewBlockActivity : AppCompatActivity() {
findViewById<Button>(R.id.webview_block_close).setOnClickListener {
finishAffinity()
}
findViewById<Button>(R.id.webview_block_try_anyway).setOnClickListener {
showOverrideConfirmation(minVersion)
}
}
private fun showOverrideConfirmation(minVersion: Int) {
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.webview_override_warning_title)
.setMessage(getString(R.string.webview_override_warning_message, minVersion))
.setPositiveButton(R.string.webview_override_warning_continue) { _, _ ->
WebViewCompatibilityChecker.setBlockOverride(this, true)
relaunchApp()
}
.setNegativeButton(R.string.webview_override_warning_cancel, null)
.setCancelable(true)
.show()
dialog.window?.decorView?.filterTouchesWhenObscured = true
}
private fun relaunchApp() {
// Always launch FullscreenActivity (the manifest's MAIN/LAUNCHER) explicitly
// rather than via PackageManager.getLaunchIntentForPackage, which can return
// null in stripped Android variants and would leave the user with an empty
// screen after tapping confirm. FullscreenActivity itself routes via
// LaunchDecider to CapacitorMainActivity when appropriate.
val launchIntent = Intent(this, FullscreenActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(launchIntent)
finish()
}
companion object {

View file

@ -2,6 +2,7 @@ package com.superproductivity.superproductivity.webview
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.net.Uri
@ -11,22 +12,29 @@ import android.webkit.WebSettings
import android.webkit.WebView
import androidx.annotation.VisibleForTesting
import androidx.core.content.pm.PackageInfoCompat
import kotlin.math.max
object WebViewCompatibilityChecker {
private const val TAG = "WebViewCompat"
private const val PREFS_NAME = "webview_compatibility"
private const val KEY_LAST_GOOD_VERSION = "last_known_good_major_version"
private const val KEY_BLOCK_OVERRIDE = "block_override"
const val MIN_CHROMIUM_VERSION = 107
const val RECOMMENDED_CHROMIUM_VERSION = 110
// System WebView providers and Chromium-based browsers whose version is a
// reasonable proxy when WebView.getCurrentWebViewPackage() is unavailable.
// The match is treated as non-authoritative (canBlockBasedOnVersion = false),
// so a wrong match here can only ever produce WARN, never BLOCK.
private val KNOWN_WEBVIEW_PACKAGES = listOf(
"com.google.android.webview",
"com.android.webview",
"com.android.chrome",
"com.chrome.beta",
"com.chrome.dev",
"com.chrome.canary",
"com.sec.android.app.sbrowser",
"com.mi.globalbrowser",
"com.huawei.webview",
"org.mozilla.firefox",
)
enum class Status {
@ -59,41 +67,87 @@ object WebViewCompatibilityChecker {
packageInfo?.let { parseMajorVersion(it.versionName) ?: parseMajorVersion(it.longVersionCode) }
val userAgentMajor = resolveFromUserAgent(context)
if (userAgentMajor != null) {
return buildResult(
val raw = when {
userAgentMajor != null -> buildResult(
majorVersion = userAgentMajor,
packageInfo = packageInfo,
source = VersionSource.USER_AGENT,
canBlockBasedOnVersion = true,
)
}
if (packageMajor != null && resolvedPackageInfo.canBlockBasedOnVersion) {
return buildResult(
packageMajor != null && resolvedPackageInfo.canBlockBasedOnVersion -> buildResult(
majorVersion = packageMajor,
packageInfo = packageInfo,
source = VersionSource.PACKAGE,
canBlockBasedOnVersion = true,
)
}
if (packageMajor != null) {
return buildResult(
packageMajor != null -> buildResult(
majorVersion = packageMajor,
packageInfo = packageInfo,
source = VersionSource.PACKAGE,
canBlockBasedOnVersion = false,
)
else -> buildResult(
majorVersion = null,
packageInfo = packageInfo,
source = VersionSource.UNKNOWN,
canBlockBasedOnVersion = false,
)
}
return buildResult(
majorVersion = null,
packageInfo = packageInfo,
source = VersionSource.UNKNOWN,
canBlockBasedOnVersion = false,
)
val prefs = preferences(context)
val lastGood = prefs.getInt(KEY_LAST_GOOD_VERSION, -1).takeIf { it > 0 }
val override = prefs.getBoolean(KEY_BLOCK_OVERRIDE, false)
val finalStatus = applyOverrides(raw.status, lastGood, override)
if (Log.isLoggable(TAG, Log.DEBUG)) {
Log.d(
TAG,
"evaluate: pkg=${packageInfo?.packageName}/${packageInfo?.versionName} " +
"uaMajor=$userAgentMajor pkgMajor=$packageMajor " +
"canBlockFromPkg=${resolvedPackageInfo?.canBlockBasedOnVersion} " +
"lastGood=$lastGood override=$override " +
"raw=${raw.status} final=$finalStatus source=${raw.source}",
)
}
return raw.copy(status = finalStatus)
}
/**
* Records that the WebView successfully loaded with [majorVersion]. If the device ever
* reports a lower version in the future (e.g. flaky version detection), [evaluate]
* downgrades a BLOCK to WARN so the user is not locked out by a transient mis-read.
*
* Auto-clears any prior user "Try anyway" override once the device reaches a healthy
* version, so a future genuine block (e.g. WebView downgrade) is not silently bypassed.
*/
fun recordSuccessfulLoad(context: Context, majorVersion: Int?) {
if (majorVersion == null || majorVersion <= 0) return
val prefs = preferences(context)
val needsVersionUpdate = majorVersion > prefs.getInt(KEY_LAST_GOOD_VERSION, -1)
val needsOverrideClear = majorVersion >= MIN_CHROMIUM_VERSION &&
prefs.getBoolean(KEY_BLOCK_OVERRIDE, false)
if (!needsVersionUpdate && !needsOverrideClear) return
val edit = prefs.edit()
if (needsVersionUpdate) edit.putInt(KEY_LAST_GOOD_VERSION, majorVersion)
if (needsOverrideClear) edit.putBoolean(KEY_BLOCK_OVERRIDE, false)
edit.apply()
}
/**
* Persists the user's decision to bypass the version-based block screen. Uses
* [SharedPreferences.Editor.commit] (sync) because the call site immediately
* relaunches the app an async write could be lost if the process is killed
* during the relaunch and the user would loop back to the block screen.
*/
fun setBlockOverride(context: Context, enabled: Boolean) {
preferences(context).edit().putBoolean(KEY_BLOCK_OVERRIDE, enabled).commit()
}
private fun preferences(context: Context): SharedPreferences =
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private fun buildResult(
majorVersion: Int?,
packageInfo: PackageInfo?,
@ -139,6 +193,18 @@ object WebViewCompatibilityChecker {
}
}
@VisibleForTesting
internal fun applyOverrides(
rawStatus: Status,
lastKnownGoodVersion: Int?,
userOverride: Boolean,
): Status {
if (rawStatus != Status.BLOCK) return rawStatus
if (userOverride) return Status.WARN
if (lastKnownGoodVersion != null && lastKnownGoodVersion >= MIN_CHROMIUM_VERSION) return Status.WARN
return Status.BLOCK
}
private fun resolvePackageInfo(context: Context): ResolvedPackageInfo? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// Why: on devices with a broken WebView provider getCurrentWebViewPackage()
@ -189,13 +255,9 @@ object WebViewCompatibilityChecker {
return null
}
val chromeMatch = CHROME_REGEX.find(userAgent)?.groupValues?.getOrNull(1)?.toIntOrNull()
if (chromeMatch != null) {
return chromeMatch
}
val versionMatch = VERSION_REGEX.find(userAgent)?.groupValues?.getOrNull(1)?.toIntOrNull()
return versionMatch
// Only trust an explicit Chromium UA token. The "Version/X" Safari token is
// hard-coded to "Version/4.0" in WebView UAs and would falsely trigger BLOCK.
return CHROME_REGEX.find(userAgent)?.groupValues?.getOrNull(1)?.toIntOrNull()
}
private fun parseMajorVersion(versionName: String?): Int? {
@ -235,7 +297,6 @@ object WebViewCompatibilityChecker {
get() = PackageInfoCompat.getLongVersionCode(this)
private val CHROME_REGEX = Regex("Chrom(?:e|ium)/(\\d+)")
private val VERSION_REGEX = Regex("Version/(\\d+)")
private data class ResolvedPackageInfo(
val packageInfo: PackageInfo,

View file

@ -48,5 +48,16 @@
android:text="@string/webview_block_close"
android:textAllCaps="false"
android:textColor="#FFFFFF" />
<Button
android:id="@+id/webview_block_try_anyway"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:backgroundTint="#1a1a1a"
android:text="@string/webview_block_try_anyway"
android:textAllCaps="false"
android:textColor="#888888"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>

View file

@ -7,6 +7,11 @@
<string name="webview_block_provider">Provider package: %1$s</string>
<string name="webview_block_update">Update WebView</string>
<string name="webview_block_close">Close app</string>
<string name="webview_block_try_anyway">Try anyway (not recommended)</string>
<string name="webview_override_warning_title">Continue at your own risk</string>
<string name="webview_override_warning_message">Your Android System WebView appears to be older than version %1$d.\n\nIf the version reported above is correct, continuing may cause:\n• The app to crash or fail to load\n• Data to render incorrectly or not at all\n• Sync errors and possible data loss\n\nVersion detection is occasionally wrong on certain devices. If you are sure your WebView is up to date, you can continue — but please update Android System WebView from the Play Store first whenever possible.</string>
<string name="webview_override_warning_continue">I understand the risk, continue</string>
<string name="webview_override_warning_cancel">Cancel</string>
<!-- Share intent -->
<string name="share_received">Content received, creating task\u2026</string>

View file

@ -5,6 +5,9 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class WebViewCompatibilityCheckerTest {
// statusForVersion -------------------------------------------------------
@Test
fun `blocks old authoritative WebView version`() {
assertEquals(
@ -17,6 +20,20 @@ class WebViewCompatibilityCheckerTest {
)
}
@Test
fun `blocks when user agent reports old version with no provider package`() {
// Regression guard: USER_AGENT source has packageName=null, but is still
// authoritative. Must still block when below MIN.
assertEquals(
Status.BLOCK,
WebViewCompatibilityChecker.statusForVersion(
majorVersion = WebViewCompatibilityChecker.MIN_CHROMIUM_VERSION - 1,
packageName = null,
canBlockBasedOnVersion = true,
),
)
}
@Test
fun `warns for old package-manager fallback version`() {
assertEquals(
@ -40,4 +57,120 @@ class WebViewCompatibilityCheckerTest {
),
)
}
@Test
fun `warns when major version is unknown`() {
assertEquals(
Status.WARN,
WebViewCompatibilityChecker.statusForVersion(
majorVersion = null,
packageName = "com.google.android.webview",
canBlockBasedOnVersion = true,
),
)
}
@Test
fun `warns for third-party WebView with very low version even when authoritative`() {
// Third-party WebViews sometimes use non-Chromium versioning. We only warn,
// never block, when their reported major is suspiciously small.
assertEquals(
Status.WARN,
WebViewCompatibilityChecker.statusForVersion(
majorVersion = 30,
packageName = "com.example.someotherwebview",
canBlockBasedOnVersion = true,
),
)
}
@Test
fun `accepts recommended authoritative version`() {
assertEquals(
Status.OK,
WebViewCompatibilityChecker.statusForVersion(
majorVersion = WebViewCompatibilityChecker.RECOMMENDED_CHROMIUM_VERSION,
packageName = "com.google.android.webview",
canBlockBasedOnVersion = true,
),
)
}
// applyOverrides ---------------------------------------------------------
@Test
fun `applyOverrides downgrades BLOCK when user override is set`() {
assertEquals(
Status.WARN,
WebViewCompatibilityChecker.applyOverrides(
rawStatus = Status.BLOCK,
lastKnownGoodVersion = null,
userOverride = true,
),
)
}
@Test
fun `applyOverrides downgrades BLOCK when last known good version is acceptable`() {
assertEquals(
Status.WARN,
WebViewCompatibilityChecker.applyOverrides(
rawStatus = Status.BLOCK,
lastKnownGoodVersion = WebViewCompatibilityChecker.MIN_CHROMIUM_VERSION,
userOverride = false,
),
)
}
@Test
fun `applyOverrides keeps BLOCK when no override and no good history`() {
assertEquals(
Status.BLOCK,
WebViewCompatibilityChecker.applyOverrides(
rawStatus = Status.BLOCK,
lastKnownGoodVersion = null,
userOverride = false,
),
)
}
@Test
fun `applyOverrides keeps BLOCK when last good version is itself old`() {
assertEquals(
Status.BLOCK,
WebViewCompatibilityChecker.applyOverrides(
rawStatus = Status.BLOCK,
lastKnownGoodVersion = WebViewCompatibilityChecker.MIN_CHROMIUM_VERSION - 1,
userOverride = false,
),
)
}
@Test
fun `applyOverrides leaves non-BLOCK status untouched`() {
assertEquals(
Status.OK,
WebViewCompatibilityChecker.applyOverrides(
rawStatus = Status.OK,
lastKnownGoodVersion = null,
userOverride = true,
),
)
assertEquals(
Status.WARN,
WebViewCompatibilityChecker.applyOverrides(
rawStatus = Status.WARN,
lastKnownGoodVersion = null,
userOverride = true,
),
)
}
// parseMajorVersion ------------------------------------------------------
@Test
fun `parseMajorVersion ignores zero and negative version codes`() {
assertEquals(null, WebViewCompatibilityChecker.parseMajorVersion(0))
assertEquals(null, WebViewCompatibilityChecker.parseMajorVersion(-1))
}
}