fix(android): auto-recover from transient WebView init failures

- refactor(android): unify automatic and manual WebView relaunch paths
- fix(android): add retry action to WebView init-failure screen (#7840)
- refactor(android): extract shared WebView recovery helper
- fix(android): auto-recover from transient WebView init failures
This commit is contained in:
Johannes Millan 2026-05-29 13:27:04 +02:00
parent 109f249011
commit 3c01d585ca
10 changed files with 295 additions and 16 deletions

View file

@ -12,6 +12,25 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--
Android 11+ package visibility: without these the PackageManager fallback in
WebViewCompatibilityChecker can't read the WebView provider version, so a
transient getCurrentWebViewPackage() miss leaves the block screen with no
detected version. Listing the providers explicitly keeps us off
QUERY_ALL_PACKAGES. → issue #7518.
Keep this list in sync with KNOWN_WEBVIEW_PACKAGES in WebViewCompatibilityChecker.kt.
-->
<queries>
<package android:name="com.google.android.webview" />
<package android:name="com.android.webview" />
<package android:name="com.android.chrome" />
<package android:name="com.chrome.beta" />
<package android:name="com.chrome.dev" />
<package android:name="com.chrome.canary" />
<package android:name="com.sec.android.app.sbrowser" />
<package android:name="com.huawei.webview" />
</queries>
<application
android:name=".App"
android:allowBackup="true"

View file

@ -29,6 +29,7 @@ import com.superproductivity.superproductivity.webview.JavaScriptInterface
import com.superproductivity.superproductivity.webview.WebHelper
import com.superproductivity.superproductivity.webview.WebViewBlockActivity
import com.superproductivity.superproductivity.webview.WebViewCompatibilityChecker
import com.superproductivity.superproductivity.webview.WebViewRecovery
import com.superproductivity.superproductivity.widget.ShareIntentQueue
import com.superproductivity.superproductivity.widget.StartupOverlayManager
import com.superproductivity.plugins.webdavhttp.WebDavHttpPlugin
@ -41,6 +42,7 @@ class CapacitorMainActivity : BridgeActivity() {
private lateinit var javaScriptInterface: JavaScriptInterface
private var webViewCompatibility: WebViewCompatibilityChecker.Result? = null
private var webViewBlocked = false
private var webViewRecoveryScheduled = false
private var pendingShareIntent: JSONObject? = null
private var isFrontendReady = false
private var startupOverlayManager: StartupOverlayManager? = null
@ -108,7 +110,9 @@ class CapacitorMainActivity : BridgeActivity() {
showWebViewInitFailureOrThrow("BridgeActivity.onCreate() failed to initialize WebView", e)
return
}
if (webViewBlocked) {
// A recovery relaunch was scheduled during super.onCreate()/load(); don't
// fall through to the (now-doomed) bridge.webView null check and re-handle it.
if (webViewBlocked || webViewRecoveryScheduled) {
return
}
@ -236,10 +240,36 @@ class CapacitorMainActivity : BridgeActivity() {
if (!WebViewCompatibilityChecker.isLikelyWebViewInitFailure(error)) {
throw error
}
showWebViewInitFailure(message, error)
recoverOrShowWebViewInitFailure(message, error)
}
private fun showWebViewInitFailure(message: String, error: Throwable? = null) {
recoverOrShowWebViewInitFailure(message, error)
}
/**
* WebView init failures are usually transient, and the user's own workaround is
* to relaunch the app so attempt that automatically once (via [WebViewRecovery])
* before surfacing the terminal block screen. [WebViewCompatibilityChecker] caps
* this at one relaunch per window so a genuinely broken provider still reaches
* the block screen instead of boot-looping. The [webViewRecoveryScheduled] flag
* stops Capacitor's multiple onCreate/load() failure checkpoints from each
* scheduling a relaunch. issue #7518.
*/
private fun recoverOrShowWebViewInitFailure(message: String, error: Throwable?) {
if (webViewBlocked || webViewRecoveryScheduled) {
return
}
if (WebViewCompatibilityChecker.canRetryInitFailure(this)) {
webViewRecoveryScheduled = true
Log.w("CapacitorMainActivity", "$message - scheduling one-shot WebView recovery relaunch")
WebViewRecovery.scheduleRelaunch(this)
return
}
blockForWebViewInitFailure(message, error)
}
private fun blockForWebViewInitFailure(message: String, error: Throwable?) {
if (error == null) {
Log.e("CapacitorMainActivity", "$message - finishing activity")
} else {

View file

@ -33,6 +33,7 @@ import com.superproductivity.superproductivity.webview.JavaScriptInterface
import com.superproductivity.superproductivity.webview.WebHelper
import com.superproductivity.superproductivity.webview.WebViewBlockActivity
import com.superproductivity.superproductivity.webview.WebViewCompatibilityChecker
import com.superproductivity.superproductivity.webview.WebViewRecovery
import com.superproductivity.superproductivity.webview.WebViewRequestHandler
import org.json.JSONObject
@ -101,8 +102,9 @@ class FullscreenActivity : AppCompatActivity() {
}
if (!initWebView()) {
showWebViewInitFailure(
recoverOrShowWebViewInitFailure(
message = "Failed to instantiate WebView",
error = null,
compatibility = compatibility,
)
return
@ -326,7 +328,30 @@ class FullscreenActivity : AppCompatActivity() {
if (!WebViewCompatibilityChecker.isLikelyWebViewInitFailure(error)) {
throw error
}
showWebViewInitFailure(message, error)
recoverOrShowWebViewInitFailure(message, error, null)
}
/**
* WebView init failures are usually transient, and the user's own workaround is
* to relaunch the app so attempt that automatically once (via [WebViewRecovery])
* before surfacing the terminal block screen. [WebViewCompatibilityChecker]
* caps this at one relaunch per window so a genuinely broken provider still
* reaches the block screen instead of boot-looping. issue #7518.
*
* No re-entry guard is needed here (unlike CapacitorMainActivity): every call
* site returns immediately, so this fires at most once per onCreate pass.
*/
private fun recoverOrShowWebViewInitFailure(
message: String,
error: Throwable?,
compatibility: WebViewCompatibilityChecker.Result?,
) {
if (WebViewCompatibilityChecker.canRetryInitFailure(this)) {
Log.w("SP-WebView", "$message - scheduling one-shot WebView recovery relaunch")
WebViewRecovery.scheduleRelaunch(this)
return
}
showWebViewInitFailure(message, error, compatibility)
}
private fun showWebViewInitFailure(

View file

@ -3,12 +3,13 @@ package com.superproductivity.superproductivity.webview
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.content.res.ColorStateList
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 androidx.core.content.ContextCompat
import com.superproductivity.superproductivity.R
class WebViewBlockActivity : AppCompatActivity() {
@ -64,9 +65,22 @@ class WebViewBlockActivity : AppCompatActivity() {
}.trim()
findViewById<TextView>(R.id.webview_block_details).text = details
val retryButton = findViewById<Button>(R.id.webview_block_retry)
if (config.showRetry) {
retryButton.visibility = View.VISIBLE
retryButton.setOnClickListener { relaunchApp() }
} else {
retryButton.visibility = View.GONE
}
val updateButton = findViewById<Button>(R.id.webview_block_update)
if (config.action == WebViewCompatibilityChecker.BlockScreenAction.OPEN_WEBVIEW_SETTINGS_WITH_WARNING) {
updateButton.setText(R.string.webview_block_open_settings)
// This branch is only reached for init failures, where Retry is the
// primary recovery, so render the settings action as secondary to
// keep a single clear primary action.
updateButton.backgroundTintList =
ColorStateList.valueOf(ContextCompat.getColor(this, R.color.webview_block_secondary))
updateButton.setOnClickListener {
showOpenWebViewConfirmation(provider, useUpdatePage = false)
}
@ -129,15 +143,7 @@ class WebViewBlockActivity : AppCompatActivity() {
}
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()
WebViewRecovery.relaunchNow(this)
}
companion object {

View file

@ -21,8 +21,17 @@ object WebViewCompatibilityChecker {
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"
private const val KEY_INIT_RETRY_AT = "init_failure_retry_at"
private const val DEFAULT_WEBVIEW_PACKAGE = "com.google.android.webview"
// A WebView init failure is frequently transient: the OS WebView provider can be
// mid-update or not yet resolved at the instant the activity starts (this is the
// INIT_FAILURE the user sees, with no readable version). We allow exactly ONE
// automatic recovery relaunch per this window; a failure that persists past it
// falls through to the block screen instead of boot-looping. Reset on the next
// successful load. → issue #7518.
private const val INIT_FAILURE_RETRY_WINDOW_MS = 60_000L
const val MIN_CHROMIUM_VERSION = 107
const val RECOMMENDED_CHROMIUM_VERSION = 110
@ -30,6 +39,9 @@ object WebViewCompatibilityChecker {
// 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.
// NOTE: keep this list in sync with the <queries> block in AndroidManifest.xml —
// on Android 11+ a package missing there is invisible to getPackageInfo() and the
// fallback silently can't read its version. → issue #7518.
private val KNOWN_WEBVIEW_PACKAGES = listOf(
"com.google.android.webview",
"com.android.webview",
@ -69,6 +81,11 @@ object WebViewCompatibilityChecker {
val action: BlockScreenAction,
val showTryAnyway: Boolean,
val showSource: Boolean,
// Offered only for INIT_FAILURE: these failures are frequently transient
// (e.g. the WebView provider not being ready on the first launch after a
// cold boot), so a relaunch usually succeeds. Harmless for a genuinely
// broken provider — it just returns to this screen.
val showRetry: Boolean,
)
data class Result(
@ -159,8 +176,13 @@ object WebViewCompatibilityChecker {
* 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)
// A successful load proves any prior transient init failure is resolved, so
// clear the recovery guard regardless of whether the version is readable.
if (prefs.getLong(KEY_INIT_RETRY_AT, 0L) != 0L) {
prefs.edit().remove(KEY_INIT_RETRY_AT).apply()
}
if (majorVersion == null || majorVersion <= 0) return
val needsVersionUpdate = majorVersion > prefs.getInt(KEY_LAST_GOOD_VERSION, -1)
val needsOverrideClear = majorVersion >= MIN_CHROMIUM_VERSION &&
prefs.getBoolean(KEY_BLOCK_OVERRIDE, false)
@ -181,6 +203,42 @@ object WebViewCompatibilityChecker {
preferences(context).edit().putBoolean(KEY_BLOCK_OVERRIDE, enabled).commit()
}
/**
* Whether the single automatic recovery relaunch allowed per transient WebView
* init failure is still available. Read-only: returns false once a relaunch was
* recorded within [INIT_FAILURE_RETRY_WINDOW_MS], so the caller shows the block
* screen instead of looping. The budget is spent separately by
* [recordInitFailureRetry] (only when a relaunch actually happens) and reset by
* [recordSuccessfulLoad] on the next healthy load.
*/
fun canRetryInitFailure(context: Context, now: Long = System.currentTimeMillis()): Boolean =
shouldRetryInitFailure(
preferences(context).getLong(KEY_INIT_RETRY_AT, 0L),
now,
INIT_FAILURE_RETRY_WINDOW_MS,
)
/**
* Spends the recovery-relaunch budget. Called at the moment of an actual
* relaunch (not when merely deciding to schedule one) so a user who kills the
* app during the settle delay doesn't burn the one shot.
*
* Uses [SharedPreferences.Editor.commit] (sync) because the caller relaunches
* immediately an async write could be lost to the relaunch and re-arm the loop.
*/
fun recordInitFailureRetry(context: Context, now: Long = System.currentTimeMillis()) {
preferences(context).edit().putLong(KEY_INIT_RETRY_AT, now).commit()
}
@VisibleForTesting
internal fun shouldRetryInitFailure(lastRetryAt: Long, now: Long, windowMs: Long): Boolean {
if (lastRetryAt <= 0L) return true
val elapsed = now - lastRetryAt
// elapsed < 0 means the wall clock moved backwards since the last attempt;
// treat it as stale and allow a retry rather than wedging the user.
return elapsed < 0L || elapsed >= windowMs
}
@VisibleForTesting
internal fun sourceFromName(sourceName: String?): VersionSource {
if (sourceName.isNullOrBlank()) return VersionSource.UNKNOWN
@ -224,6 +282,7 @@ object WebViewCompatibilityChecker {
},
showTryAnyway = canBypassBlock(source),
showSource = source != VersionSource.UNKNOWN,
showRetry = isInitFailure,
)
}

View file

@ -0,0 +1,76 @@
package com.superproductivity.superproductivity.webview
import android.app.Activity
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.superproductivity.superproductivity.FullscreenActivity
/**
* Recovers from a *transient* WebView init failure (#7518) by relaunching the app
* once, after a short settle delay, before the caller surfaces the terminal block
* screen. The OS WebView provider is frequently just mid-update / not-yet-resolved
* at the instant the activity starts; the user's own workaround is to fully close
* and reopen the app, and this does that automatically.
*
* Scope/limitation: this is a *same-process* relaunch (it routes through the
* MAIN/LAUNCHER activity with a fresh task, mirroring [WebViewBlockActivity]'s
* existing relaunch). It recovers the "provider not yet resolved" race but NOT a
* process whose native WebView load has already hard-failed and cached that
* would need a true fresh process (e.g. a ProcessPhoenix-style trampoline), which
* we deliberately avoid for now to keep this dependency-free and within Android's
* background-activity-launch rules. A genuinely broken provider therefore still
* reaches the block screen (no regression).
*
* Loop prevention lives in [WebViewCompatibilityChecker]: the retry budget is only
* spent at the moment we actually relaunch ([WebViewCompatibilityChecker.recordInitFailureRetry]),
* and a relaunch that re-fails within the window is denied by
* [WebViewCompatibilityChecker.canRetryInitFailure], falling through to the block screen.
*/
object WebViewRecovery {
private const val TAG = "WebViewRecovery"
// Delay before the relaunch, giving a mid-update / not-yet-resolved provider a
// moment to settle. ~2.5s roughly matches the "close the app and reopen"
// interval that already works for users. → issue #7518.
private const val RELAUNCH_DELAY_MS = 2500L
/**
* Schedules the one-shot *automatic* recovery relaunch (after a settle delay).
* The caller must have already confirmed
* [WebViewCompatibilityChecker.canRetryInitFailure] and must return immediately
* afterwards without continuing to use the (doomed) WebView.
*/
fun scheduleRelaunch(activity: Activity) {
Handler(Looper.getMainLooper()).postDelayed({
if (activity.isFinishing || activity.isDestroyed) {
return@postDelayed
}
// Spend the retry budget only now that we're actually relaunching, so a
// user who kills the app during the delay doesn't waste the one shot and
// gets a fresh auto-retry on their next manual launch.
WebViewCompatibilityChecker.recordInitFailureRetry(activity)
Log.w(TAG, "Auto-relaunching to recover from transient WebView init failure")
relaunchNow(activity)
}, RELAUNCH_DELAY_MS)
}
/**
* Relaunches the app immediately from the MAIN/LAUNCHER activity. Used for
* user-initiated recovery (the block screen's "Retry" / "Try anyway"), where no
* settle delay or retry-budget accounting applies.
*
* Always targets [FullscreenActivity] explicitly rather than
* PackageManager.getLaunchIntentForPackage, which can return null in stripped
* Android variants and would leave the user on an empty screen. FullscreenActivity
* itself routes via LaunchDecider to CapacitorMainActivity when appropriate.
*/
fun relaunchNow(activity: Activity) {
activity.startActivity(
Intent(activity, FullscreenActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
)
activity.finish()
}
}

View file

@ -29,6 +29,17 @@
android:textColor="#cccccc"
android:textSize="14sp" />
<Button
android:id="@+id/webview_block_retry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:backgroundTint="#4285F4"
android:text="@string/webview_block_retry"
android:textAllCaps="false"
android:textColor="#FFFFFF"
android:visibility="gone" />
<Button
android:id="@+id/webview_block_update"
android:layout_width="match_parent"
@ -44,7 +55,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:backgroundTint="#424242"
android:backgroundTint="@color/webview_block_secondary"
android:text="@string/webview_block_close"
android:textAllCaps="false"
android:textColor="#FFFFFF" />

View file

@ -6,5 +6,6 @@
<color name="statusBar">#f8f8f7</color>
<color name="navigationBar">#f8f8f7</color>
<color name="windowBackground">#f8f8f7</color>
<color name="webview_block_secondary">#424242</color>
</resources>

View file

@ -10,6 +10,7 @@
<string name="webview_block_provider">Provider package: %1$s</string>
<string name="webview_block_source">Detection source: %1$s</string>
<string name="webview_block_update">Update WebView</string>
<string name="webview_block_retry">Retry</string>
<string name="webview_block_open_settings">Open WebView settings</string>
<string name="webview_block_close">Close app</string>
<string name="webview_block_try_anyway">Try anyway (not recommended)</string>

View file

@ -244,6 +244,7 @@ class WebViewCompatibilityCheckerTest {
assertEquals(BlockScreenAction.OPEN_WEBVIEW_SETTINGS_WITH_WARNING, config.action)
assertFalse(config.showTryAnyway)
assertTrue(config.showSource)
assertTrue(config.showRetry)
}
@Test
@ -255,6 +256,7 @@ class WebViewCompatibilityCheckerTest {
assertEquals(R.string.webview_init_failure_details_without_provider, config.detailsIntroResId)
assertFalse(config.showTryAnyway)
assertTrue(config.showRetry)
}
@Test
@ -269,6 +271,7 @@ class WebViewCompatibilityCheckerTest {
assertEquals(BlockScreenAction.UPDATE_WEBVIEW, config.action)
assertTrue(config.showTryAnyway)
assertFalse(config.showSource)
assertFalse(config.showRetry)
}
// Intent helper data ------------------------------------------------------
@ -404,6 +407,54 @@ class WebViewCompatibilityCheckerTest {
assertFalse(WebViewCompatibilityChecker.isLikelyWebViewInitFailure(error))
}
// init-failure recovery guard --------------------------------------------
@Test
fun `shouldRetryInitFailure allows the first recovery attempt`() {
assertTrue(
WebViewCompatibilityChecker.shouldRetryInitFailure(
lastRetryAt = 0L,
now = 1_000L,
windowMs = 60_000L,
),
)
}
@Test
fun `shouldRetryInitFailure blocks a second attempt within the window`() {
// The relaunch we just triggered failed again immediately → don't boot-loop.
assertFalse(
WebViewCompatibilityChecker.shouldRetryInitFailure(
lastRetryAt = 1_000L,
now = 1_000L + 59_000L,
windowMs = 60_000L,
),
)
}
@Test
fun `shouldRetryInitFailure allows a fresh attempt once the window elapses`() {
assertTrue(
WebViewCompatibilityChecker.shouldRetryInitFailure(
lastRetryAt = 1_000L,
now = 1_000L + 60_000L,
windowMs = 60_000L,
),
)
}
@Test
fun `shouldRetryInitFailure allows retry when the wall clock moves backwards`() {
// A clock change must not wedge the user on the block screen forever.
assertTrue(
WebViewCompatibilityChecker.shouldRetryInitFailure(
lastRetryAt = 10_000L,
now = 5_000L,
windowMs = 60_000L,
),
)
}
// parseMajorVersion ------------------------------------------------------
@Test