diff --git a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java index 445cd5832..60f37f516 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java +++ b/client/android/app/src/main/java/com/liketrek/trek/MainActivity.java @@ -1,5 +1,6 @@ package com.liketrek.trek; +import android.content.Intent; import android.os.Bundle; import android.view.View; import com.getcapacitor.BridgeActivity; @@ -13,14 +14,37 @@ public class MainActivity extends BridgeActivity { // bundled address screen, so the UI arrives in the version the server // runs and on its own origin. BridgeActivity reads `config` while it // builds the bridge inside super.onCreate(). + boolean restarting = TrekServerStore.consumeRestart(this); String server = TrekServerStore.getUrl(this); - if (server != null) { - config = TrekServerStore.configFor(this, server); - } + boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this); + config = TrekServerStore.configFor(this, onServer ? server : null, restarting); super.onCreate(savedInstanceState); + if (bridge == null) return; // The UI asks for no overscroll effect (overscroll-behavior: none on html) // and Chrome honours that, but the WebView draws the stretch on its root // scroller natively. It scales the whole view, top bar and bottom dock included. - if (bridge != null) bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER); + bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER); + // Set before any WebView callback can run: they are posted to this thread. + if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen)); + } + + private boolean leavingServer; + + /** + * The server did not load: back to the address screen, which offers retry + * and change. A fresh task rather than recreate(), because this usually + * happens during launch, while the system splash is still up, and a + * recreated activity leaves that splash lying over the new page. + */ + private void showAddressScreen() { + // A failed page can report more than one error; restart only once. + if (leavingServer) return; + leavingServer = true; + TrekServerStore.showShellOnce(this); + TrekServerStore.markRestart(this); + runOnUiThread(() -> { + startActivity(new Intent(this, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK)); + overridePendingTransition(0, 0); + }); } } diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java index 9781015a0..49c4a5623 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekServerStore.java @@ -18,6 +18,8 @@ final class TrekServerStore { private static final String PREFS = "trek_shell"; private static final String KEY_URL = "server_url"; + private static final String KEY_SHELL_ONCE = "shell_once"; + private static final String KEY_RESTART = "restart"; private static final String CONFIG_DIR = "trek-server-config"; private TrekServerStore() {} @@ -36,21 +38,55 @@ final class TrekServerStore { editor.commit(); } + /** Start the next launch on the bundled address screen, even with a server remembered. */ + static void showShellOnce(Context context) { + prefs(context).edit().putBoolean(KEY_SHELL_ONCE, true).commit(); + } + + static boolean consumeShellOnce(Context context) { + return consume(context, KEY_SHELL_ONCE); + } + + /** The next onCreate is the app restarting itself, not a launch. */ + static void markRestart(Context context) { + prefs(context).edit().putBoolean(KEY_RESTART, true).commit(); + } + + static boolean consumeRestart(Context context) { + return consume(context, KEY_RESTART); + } + + private static boolean consume(Context context, String key) { + boolean set = prefs(context).getBoolean(key, false); + if (set) prefs(context).edit().remove(key).commit(); + return set; + } + /** - * The bundled capacitor.config.json with server.url set. Capacitor only - * takes the server URL from a config file, so this writes a copy into the - * app's private storage and loads that. Falls back to the bundled config, - * which shows the address screen, if the copy cannot be written. + * The bundled capacitor.config.json, adjusted for this start. Capacitor only + * takes these settings from a config file, so this writes a copy into the + * app's private storage and loads that; null means the bundled one as it is. + * + * With a server: server.url set, and server.errorPath dropped, because that + * page would have no bridge on the server's origin (TrekWebViewClient + * handles a failed load instead). + * + * On a restart: no launch splash. The splash plugin shows it again after + * recreate() but cannot take it down when the page hides it early, which + * left it covering a fully rendered page. A restart has no launch to cover. */ - static CapConfig configFor(Context context, String url) { + static CapConfig configFor(Context context, String url, boolean restarting) { + if (url == null && !restarting) return null; try { JSONObject json = new JSONObject(readAsset(context, "capacitor.config.json")); - JSONObject server = json.optJSONObject("server"); - if (server == null) { - server = new JSONObject(); - json.put("server", server); + if (url != null) { + JSONObject server = child(json, "server"); + server.put("url", url); + server.remove("errorPath"); + } + if (restarting) { + child(child(json, "plugins"), "SplashScreen").put("launchShowDuration", 0); } - server.put("url", url); File dir = new File(context.getFilesDir(), CONFIG_DIR); if (!dir.isDirectory() && !dir.mkdirs()) throw new IOException("Cannot create " + dir); @@ -59,11 +95,19 @@ final class TrekServerStore { } return CapConfig.loadFromFile(context, dir.getAbsolutePath()); } catch (IOException | JSONException ex) { - Logger.error("TrekShell", "Could not point the app at " + url, ex); - return CapConfig.loadDefault(context); + Logger.error("TrekShell", "Could not adjust the app config for " + url, ex); + return null; } } + private static JSONObject child(JSONObject parent, String key) throws JSONException { + JSONObject existing = parent.optJSONObject(key); + if (existing != null) return existing; + JSONObject created = new JSONObject(); + parent.put(key, created); + return created; + } + private static String readAsset(Context context, String name) throws IOException { try (InputStream in = context.getAssets().open(name); ByteArrayOutputStream out = new ByteArrayOutputStream()) { byte[] buffer = new byte[8192]; diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java index bf8e2e13a..d59271a03 100644 --- a/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekShellPlugin.java @@ -60,6 +60,7 @@ public class TrekShellPlugin extends Plugin { /** The server URL is part of the bridge's configuration, so it takes a new activity. */ private void restart() { + TrekServerStore.markRestart(getContext()); getActivity().runOnUiThread(() -> getActivity().recreate()); } diff --git a/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java new file mode 100644 index 000000000..389b971a9 --- /dev/null +++ b/client/android/app/src/main/java/com/liketrek/trek/TrekWebViewClient.java @@ -0,0 +1,49 @@ +package com.liketrek.trek; + +import android.webkit.WebResourceError; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; +import com.getcapacitor.Bridge; +import com.getcapacitor.BridgeWebViewClient; + +/** + * Used while the WebView shows the user's server. When that server cannot be + * loaded, Capacitor would show server.errorPath, but on Android it injects + * its bridge only into the server's origin, so that local page could neither + * retry nor change the server and stayed blank. Instead the app restarts on + * the bundled address screen, where the bridge is present and the screen + * offers exactly those two things. + */ +class TrekWebViewClient extends BridgeWebViewClient { + + private final Runnable onUnreachable; + + TrekWebViewClient(Bridge bridge, Runnable onUnreachable) { + super(bridge); + this.onUnreachable = onUnreachable; + } + + @Override + public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { + if (request.isForMainFrame()) { + onUnreachable.run(); + return; + } + super.onReceivedError(view, request, error); + } + + /** + * Only a 5xx on the page itself means the server is down (a proxy's 502 or + * 503 while TREK restarts). A 4xx page is left alone: the server answered, + * and TREK serves its UI for every other path. + */ + @Override + public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { + if (!request.isForMainFrame()) { + super.onReceivedHttpError(view, request, errorResponse); + } else if (errorResponse.getStatusCode() >= 500) { + onUnreachable.run(); + } + } +} diff --git a/client/capacitor.config.ts b/client/capacitor.config.ts index f08920fe5..17147a102 100644 --- a/client/capacitor.config.ts +++ b/client/capacitor.config.ts @@ -17,9 +17,11 @@ const config: CapacitorConfig = { // secure context. iOS keeps its default capacitor:// scheme: WKWebView // handles https itself and Capacitor silently drops an iosScheme of https. androidScheme: 'https', - // Shown, from the app bundle, whenever the server fails to load. The page - // knows a server is remembered and offers to retry or pick another one; - // on iOS it first tries the stored offline copy of the server's start page. + // iOS shows this page from the app bundle whenever the server fails to + // load. It knows a server is remembered, first tries the stored offline + // copy of the server's start page, then offers to retry or pick another + // one. Android restarts on the same page instead (TrekWebViewClient.java), + // because it injects no bridge into a local page while a server is set. errorPath: 'index.html', }, ios: {