mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
10257fc21a
commit
7c5b58ecf6
7 changed files with 285 additions and 27 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue