Skip to content

Commit 1891606

Browse files
Merge pull request #586 from appdevforall/fix/K2GO-419-webview-render-crash-containment
K2GO-419 fix(webview): contain render-process death so the app survives
2 parents b3b6c6f + e6fcf85 commit 1891606

5 files changed

Lines changed: 153 additions & 11 deletions

File tree

‎controller/app/src/main/java/org/appdevforall/k2go/PortalActivity.java‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import android.os.Bundle;
1414
import android.util.Log;
1515
import android.webkit.WebView;
16-
import android.webkit.WebViewClient;
1716

1817
import androidx.appcompat.app.AppCompatActivity;
1918
import androidx.lifecycle.ViewModelProvider;
@@ -37,6 +36,7 @@
3736
import org.appdevforall.k2go.portal.domain.WebViewVersion;
3837
import org.appdevforall.k2go.portal.data.PdfViewerCatalog;
3938
import org.appdevforall.k2go.util.AppExecutors;
39+
import org.appdevforall.k2go.util.ResilientWebViewClient;
4040
import java.util.Collections;
4141
import java.util.List;
4242
import org.appdevforall.k2go.portal.presentation.GestureWebView;
@@ -216,7 +216,24 @@ protected void onCreate(Bundle savedInstanceState) {
216216
resetTimer.run();
217217
});
218218

219-
webView.setWebViewClient(new WebViewClient() {
219+
webView.setWebViewClient(new ResilientWebViewClient() {
220+
@Override
221+
protected void onRendererGone(boolean crashed) {
222+
// K2GO-419: the content WebView (Kiwix/Kolibri/maps/Forgejo) is the highest-exposure
223+
// surface. Rebuild the screen and reload the same page. The anti-loop guard lives in
224+
// the ViewModel (it outlives recreate()), so a page whose renderer keeps dying does
225+
// not spin an endless recreate: after a repeat, inform the user and leave to Home.
226+
webView = null; // the base destroyed it; drop the dangling reference so a pending
227+
// main-thread callback (onResume cache-clear) bails on its null check.
228+
if (vm.allowRendererAutoRecovery()) {
229+
recreate();
230+
} else {
231+
Toast.makeText(PortalActivity.this, R.string.k2go_portal_error_body,
232+
Toast.LENGTH_LONG).show();
233+
finish();
234+
}
235+
}
236+
220237
@Override
221238
public boolean shouldOverrideUrlLoading(WebView view, android.webkit.WebResourceRequest request) {
222239
String url = request.getUrl().toString();

‎controller/app/src/main/java/org/appdevforall/k2go/help/TooltipManager.java‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,13 @@
3131
import android.webkit.WebResourceError;
3232
import android.webkit.WebResourceRequest;
3333
import android.webkit.WebView;
34-
import android.webkit.WebViewClient;
3534
import android.widget.Button;
3635
import android.widget.LinearLayout;
3736
import android.widget.PopupWindow;
3837
import android.widget.TextView;
3938

4039
import org.appdevforall.k2go.ui.dialog.BrandDialog;
40+
import org.appdevforall.k2go.util.ResilientWebViewClient;
4141

4242
import org.appdevforall.k2go.R;
4343

@@ -222,6 +222,12 @@ private static void showPopup(final Context context, final View anchor, final in
222222

223223
View root = LayoutInflater.from(context).inflate(R.layout.tooltip_window, null);
224224
WebView web = root.findViewById(R.id.help_webview);
225+
web.setWebViewClient(new ResilientWebViewClient() {
226+
@Override
227+
protected void onRendererGone(boolean crashed) {
228+
dismissActive(); // K2GO-419: the tooltip's renderer died; drop the popup, keep the app
229+
}
230+
});
225231
TextView seeMore = root.findViewById(R.id.help_see_more);
226232
LinearLayout linksBox = root.findViewById(R.id.help_links);
227233

@@ -321,7 +327,18 @@ public static void dismissActive() {
321327
public static void openHelpPage(final Context context, String url, String label) {
322328
try {
323329
final WebView web = new WebView(context);
324-
web.setWebViewClient(new WebViewClient() {
330+
final BrandDialog.Handle handle = new BrandDialog(context)
331+
.setTitle(label)
332+
.setContentView(web)
333+
.setPositive(android.R.string.ok, null)
334+
.show();
335+
web.setWebViewClient(new ResilientWebViewClient() {
336+
@Override
337+
protected void onRendererGone(boolean crashed) {
338+
// K2GO-419: the help page's renderer died; drop the dialog, keep the app.
339+
try { handle.dismiss(); } catch (Exception ignored) {}
340+
}
341+
325342
@Override
326343
public void onReceivedError(WebView view, WebResourceRequest request,
327344
WebResourceError error) {
@@ -331,11 +348,6 @@ public void onReceivedError(WebView view, WebResourceRequest request,
331348
+ "</body></html>", "text/html", "utf-8");
332349
}
333350
});
334-
new BrandDialog(context)
335-
.setTitle(label)
336-
.setContentView(web)
337-
.setPositive(android.R.string.ok, null)
338-
.show();
339351
web.loadUrl(url);
340352
} catch (Exception e) {
341353
Log.e(TAG, "openHelpPage failed: " + e.getMessage());

‎controller/app/src/main/java/org/appdevforall/k2go/portal/presentation/PortalViewModel.java‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,26 @@
88
*/
99
package org.appdevforall.k2go.portal.presentation;
1010

11+
import android.os.SystemClock;
12+
1113
import androidx.lifecycle.LiveData;
1214
import androidx.lifecycle.MutableLiveData;
1315
import androidx.lifecycle.ViewModel;
1416

1517
import org.appdevforall.k2go.portal.domain.PortalUrlResolver;
18+
import org.appdevforall.k2go.util.ResilientWebViewClient;
1619

1720
/** Survives rotation: keeps the resolved target URL and the page-load state. */
1821
public class PortalViewModel extends ViewModel {
1922

2023
private final MutableLiveData<PortalUiState> state = new MutableLiveData<>(PortalUiState.idle());
2124
private String targetUrl;
2225

26+
// K2GO-419: renderer-crash recovery bookkeeping. The ViewModel outlives Activity.recreate(), so
27+
// this is the one place that remembers "we just recovered" across the rebuild, so a page whose
28+
// renderer keeps dying is not reloaded in an endless recreate loop. Window source: ResilientWebViewClient.
29+
private long lastRendererRecoveryMs = 0L;
30+
2331
public LiveData<PortalUiState> state() { return state; }
2432

2533
public boolean isLoading() {
@@ -38,4 +46,19 @@ public String targetUrl(String rawUrl) {
3846
}
3947
return targetUrl;
4048
}
49+
50+
/**
51+
* K2GO-419: true when the portal may auto-recover (rebuild + reload) after a renderer death.
52+
* False when a second death lands within {@link ResilientWebViewClient#RECOVERY_WINDOW_MS}, so the caller
53+
* breaks the loop (inform the user and leave) instead of reloading a page that keeps killing the
54+
* renderer. Records this attempt's time. Uses the monotonic clock so a wall-clock change cannot
55+
* skew the window.
56+
*/
57+
public boolean allowRendererAutoRecovery() {
58+
long now = SystemClock.elapsedRealtime();
59+
boolean recentlyRecovered = lastRendererRecoveryMs != 0L
60+
&& (now - lastRendererRecoveryMs) < ResilientWebViewClient.RECOVERY_WINDOW_MS;
61+
lastRendererRecoveryMs = now;
62+
return !recentlyRecovered;
63+
}
4164
}

‎controller/app/src/main/java/org/appdevforall/k2go/redesign/HelpViewerActivity.java‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import android.webkit.WebResourceRequest;
2222
import android.webkit.WebResourceResponse;
2323
import android.webkit.WebView;
24-
import android.webkit.WebViewClient;
2524
import android.widget.Toast;
2625

2726
import androidx.annotation.Nullable;
@@ -43,6 +42,7 @@
4342
import org.appdevforall.k2go.portal.domain.PdfViewerUrl;
4443
import org.appdevforall.k2go.portal.domain.WebViewVersion;
4544
import org.appdevforall.k2go.util.AppExecutors;
45+
import org.appdevforall.k2go.util.ResilientWebViewClient;
4646

4747
import java.util.Collections;
4848
import java.util.List;
@@ -98,7 +98,24 @@ protected void onCreate(@Nullable Bundle savedInstanceState) {
9898
// dual-build routing as PortalActivity). Offline-safe: returns empty when no box is up.
9999
AppExecutors.get().io().execute(() -> pdfViewerBuilds = PdfViewerCatalog.fetch());
100100

101-
webView.setWebViewClient(new WebViewClient() {
101+
webView.setWebViewClient(new ResilientWebViewClient() {
102+
@Override
103+
protected void onRendererGone(boolean crashed) {
104+
// K2GO-419: reload the same topic (from the intent + bundled assets) on a renderer
105+
// death, but do not loop: if a second death lands within the recovery window, close
106+
// the viewer instead of reloading a topic that keeps killing the renderer. The window
107+
// marker rides the intent, which recreate() preserves. Same window as PortalActivity.
108+
final String extraLastRecovery = "k2go_help_last_recovery";
109+
long now = android.os.SystemClock.elapsedRealtime();
110+
long last = getIntent() != null ? getIntent().getLongExtra(extraLastRecovery, 0L) : 0L;
111+
if (last != 0L && now - last < ResilientWebViewClient.RECOVERY_WINDOW_MS) {
112+
finish();
113+
return;
114+
}
115+
if (getIntent() != null) getIntent().putExtra(extraLastRecovery, now);
116+
recreate();
117+
}
118+
102119
@Override
103120
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
104121
return assetLoader.shouldInterceptRequest(request.getUrl());
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* ============================================================================
3+
* Name : ResilientWebViewClient.java
4+
* Author : AppDevForAll
5+
* Copyright : Copyright (c) 2026 AppDevForAll
6+
* Description : K2GO-419. One containment line for WebView render-process death.
7+
* ============================================================================
8+
*/
9+
package org.appdevforall.k2go.util;
10+
11+
import android.os.Build;
12+
import android.util.Log;
13+
import android.view.ViewGroup;
14+
import android.view.ViewParent;
15+
import android.webkit.RenderProcessGoneDetail;
16+
import android.webkit.WebView;
17+
import android.webkit.WebViewClient;
18+
19+
import androidx.annotation.RequiresApi;
20+
21+
/**
22+
* Base WebViewClient that keeps the app alive when a WebView render process dies.
23+
*
24+
* <p>A render process can die for reasons outside the app: system memory pressure, or a Chromium
25+
* internal abort (K2GO-419 saw the renderer crash inside libwebviewchromium.so, no app frames). If
26+
* no WebViewClient handles that death, the framework crashes the whole host app. See
27+
* https://developer.android.com/reference/android/webkit/WebViewClient#onRenderProcessGone(android.webkit.WebView,%20android.webkit.RenderProcessGoneDetail)
28+
*
29+
* <p>This client handles it in one place: it detaches and destroys the dead WebView (the object is
30+
* unusable after a renderer death) and returns true, so the app survives, then hands the screen its
31+
* own recovery through {@link #onRendererGone(boolean)}. Subclass this instead of WebViewClient.
32+
*
33+
* <p>onRenderProcessGone exists since API 26. On API 24-25 the framework never calls it and the app
34+
* still dies there, which cannot be helped from the app side.
35+
*/
36+
public abstract class ResilientWebViewClient extends WebViewClient {
37+
38+
private static final String TAG = "ResilientWebView";
39+
40+
/**
41+
* Shared anti-loop window. When a second renderer death lands within this of a recovery, the
42+
* recovering screen must stop reloading and bail (leave/close), so a page that reliably kills the
43+
* renderer cannot spin an endless reload. One source for the value, used by every recovery path.
44+
*/
45+
public static final long RECOVERY_WINDOW_MS = 15_000L;
46+
47+
@RequiresApi(api = Build.VERSION_CODES.O)
48+
@Override
49+
public final boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) {
50+
// didCrash()==false means the system reclaimed the renderer under memory pressure, not a bug.
51+
boolean crashed = detail != null && detail.didCrash();
52+
Log.w(TAG, "WebView renderer gone (didCrash=" + crashed + "); containing to keep the app alive");
53+
ViewParent parent = view.getParent();
54+
if (parent instanceof ViewGroup) {
55+
((ViewGroup) parent).removeView(view);
56+
}
57+
view.destroy();
58+
try {
59+
onRendererGone(crashed);
60+
} catch (Throwable t) {
61+
Log.e(TAG, "renderer recovery failed", t);
62+
}
63+
return true; // handled: the framework must NOT crash the host app
64+
}
65+
66+
/**
67+
* Recover the screen after the dead WebView has been detached and destroyed. Typical actions:
68+
* rebuild the screen and reload the same page, or dismiss a transient popup or dialog. The
69+
* crashed flag is false when the system evicted the renderer under memory pressure rather than a
70+
* real crash. Called on the main thread.
71+
*/
72+
protected abstract void onRendererGone(boolean crashed);
73+
}

0 commit comments

Comments
 (0)