From 4c957826d23944e58811af9f44fe0f65733d23e0 Mon Sep 17 00:00:00 2001 From: Riley Sheehan Date: Tue, 20 Oct 2026 10:34:36 -0500 Subject: [PATCH] Gingham 0.2.1: after a power cut, the app keeps trying until the wall is back --- RELEASING.md | 6 + .../rileysheehan/gingham/FrameActivity.java | 94 ++++++++++--- .../co/rileysheehan/gingham/LoadMath.java | 112 +++++++++++++++ package.json | 2 +- releases/v0.2.1.md | 7 + test/load-java.test.js | 130 ++++++++++++++++++ 6 files changed, 334 insertions(+), 17 deletions(-) create mode 100644 kiosk/app/src/main/java/co/rileysheehan/gingham/LoadMath.java create mode 100644 releases/v0.2.1.md create mode 100644 test/load-java.test.js diff --git a/RELEASING.md b/RELEASING.md index 69679c0..9bcd390 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -9,6 +9,12 @@ 3. Tag and push: `git tag v && git push origin v`. The workflow builds the three signed APKs, `SHA256SUMS` and the container image, and publishes the release with those notes (or, with no notes file, GitHub's list of changes). +4. The bump to the next patch rides in the next PR to `main`, whatever that PR is, with no notes yet: a PR of its + own would be a second review and a second go for one line. Any build that is not a release calls itself its + version's dev build (`0.2.1-dev.70`), which sorts before that release. One made after the release while + `package.json` still names it would be offered the release it came after: an older app, which Android refuses to + install over it. So **no dev build goes onto a frame while `package.json` names a version that is already + released.** ## What a release promises diff --git a/kiosk/app/src/main/java/co/rileysheehan/gingham/FrameActivity.java b/kiosk/app/src/main/java/co/rileysheehan/gingham/FrameActivity.java index c40d2ae..cdf87e4 100644 --- a/kiosk/app/src/main/java/co/rileysheehan/gingham/FrameActivity.java +++ b/kiosk/app/src/main/java/co/rileysheehan/gingham/FrameActivity.java @@ -10,6 +10,7 @@ import android.net.ConnectivityManager; import android.net.Network; import android.net.Uri; +import android.net.http.SslError; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -26,6 +27,8 @@ import android.webkit.CookieManager; import android.webkit.JavascriptInterface; import android.webkit.RenderProcessGoneDetail; +import android.webkit.SslErrorHandler; +import android.webkit.ValueCallback; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; @@ -55,13 +58,14 @@ */ public class FrameActivity extends Activity { private static final String PREFS = "frame", KEY_URL = "url"; - private static final long RETRY_MS = 20_000, CORNER_HOLD_MS = 4_000; + private static final long RETRY_MS = 20_000, PEEK_MS = 2_000, CORNER_HOLD_MS = 4_000; private final Handler handler = new Handler(Looper.getMainLooper()); private FrameLayout root; private WebView web; private TextView notice; - private boolean failed; + /** Whether the wall is on screen, and what to do while it is not (LoadMath). A new one for each WebView. */ + private LoadMath.Wall load = new LoadMath.Wall(); /** The wall's address as last loaded; read from the page's bridge thread, so volatile. */ private volatile String wallUrl; /** While the play page is up: its address, and how long without a touch before the wall comes back. */ @@ -150,13 +154,20 @@ private void showFrame(final String url) { web.setWebViewClient(new WebViewClient() { @Override public void onPageFinished(WebView view, String finished) { CookieManager.getInstance().flush(); - if (!failed) hideNotice(); + // Finished is not loaded: Chromium's error page finishes too. The page says which it is. + if (view == web && load.ask()) askPage(); } @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { - if (request.isForMainFrame()) retryLater(); + if (view == web && request.isForMainFrame()) act(load.error()); } @Override public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse response) { - if (request.isForMainFrame() && response.getStatusCode() >= 500) retryLater(); + if (view == web && request.isForMainFrame() && response.getStatusCode() >= 500) act(load.error()); + } + // A certificate the WebView will not take, most often because the frame's clock is not set yet. Refused, as + // it would be anyway, and counted here as a failed load: a WebView may report it nowhere else. + @Override public void onReceivedSslError(WebView view, SslErrorHandler ssl, SslError error) { + ssl.cancel(); + if (view == web && error.getUrl() != null && sameHost(url, Uri.parse(error.getUrl()))) act(load.error()); } @Override public boolean onRenderProcessGone(WebView view, RenderProcessGoneDetail detail) { handler.post(new Runnable() { @Override public void run() { start(); } }); // rebuild the WebView rather than crash @@ -168,14 +179,15 @@ private void showFrame(final String url) { }); root.addView(web, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); notice = new TextView(this); + notice.setText("Can’t reach the frame’s server\nTrying again every few seconds"); notice.setTextColor(Color.WHITE); notice.setTextSize(TypedValue.COMPLEX_UNIT_SP, 22); notice.setGravity(Gravity.CENTER); notice.setBackgroundColor(Color.BLACK); notice.setVisibility(View.GONE); root.addView(notice, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); - failed = false; - web.loadUrl(url); + load = new LoadMath.Wall(); + loadWall(); watchNetwork(); handler.removeCallbacks(saveCookies); handler.postDelayed(saveCookies, 30_000); @@ -254,7 +266,7 @@ private static String[] fetchPlayPage(String settings, String cookies) { private void showPlayPage(final String address, int minutes) { CookieManager.getInstance().flush(); handler.removeCallbacks(retry); - failed = false; + handler.removeCallbacks(peek); hideNotice(); if (web != null) { root.removeView(web); web.destroy(); } playUrl = address; @@ -371,15 +383,64 @@ private static boolean sameHost(String home, Uri other) { return host != null && host.equals(other.getHost()); } - /** The page did not load. Say so plainly and keep trying; a frame must never need someone to press reload. */ - private void retryLater() { - failed = true; - notice.setText("Can’t reach the frame’s server\nTrying again every few seconds"); - notice.setVisibility(View.VISIBLE); + /* + * The wall loads, and goes on being loaded until it is the wall: a frame must never need someone to press reload. + * LoadMath has the rules and why they are what they are. Every load is the wall's own address asked for afresh + * (never reload(), which asks again for whatever is on screen, and that may be Chromium's error page), and a wait + * is always running until the page on screen has said a server answered it. Until then the notice stays up. + */ + private void loadWall() { + if (web == null || playUrl != null) return; + load.begin(); + web.loadUrl(wallUrl); + retryIn(RETRY_MS); + handler.removeCallbacks(peek); + handler.postDelayed(peek, PEEK_MS); + } + + private void retryIn(long ms) { handler.removeCallbacks(retry); - handler.postDelayed(retry, RETRY_MS); + handler.postDelayed(retry, ms); + } + + /** The wait ran out. A load nothing has spoken against is asked once where it is; any other is started again. */ + private final Runnable retry = new Runnable() { @Override public void run() { + if (web == null || playUrl != null || load.up()) return; + if (load.tick()) loadWall(); else { retryIn(RETRY_MS); askPage(); } + } }; + + /** + * A look every few seconds while the load is in its first wait: Chromium's error page can be on screen long before + * the WebView says anything, if it ever does, and the notice should not wait for the wait (LoadMath.Wall.peek). + */ + private final Runnable peek = new Runnable() { @Override public void run() { + if (web == null || playUrl != null || !load.peeking()) return; + final WebView page = web; + final LoadMath.Wall wall = load; + final int attempt = wall.attempt(); + page.evaluateJavascript("location.href", new ValueCallback() { @Override public void onReceiveValue(String href) { + if (page == web && wall == load && playUrl == null && wall.peek(attempt, href) && notice != null) notice.setVisibility(View.VISIBLE); + } }); + handler.postDelayed(this, PEEK_MS); + } }; + + /** Ask the page on screen for its own address, which is the one thing Chromium's error page cannot pass off. */ + private void askPage() { + final WebView page = web; + final LoadMath.Wall wall = load; + final int attempt = wall.attempt(); + page.evaluateJavascript("location.href", new ValueCallback() { @Override public void onReceiveValue(String href) { + if (page == web && wall == load && playUrl == null) act(wall.answer(attempt, href)); + } }); + } + + /** What LoadMath answered: the wall is up, or it is not and the notice says so plainly while the app keeps trying. */ + private void act(int next) { + if (next == LoadMath.Wall.NOTHING || playUrl != null) return; + if (next == LoadMath.Wall.UP) { handler.removeCallbacks(retry); handler.removeCallbacks(peek); hideNotice(); return; } + if (notice != null) notice.setVisibility(View.VISIBLE); + if (next == LoadMath.Wall.AGAIN) loadWall(); else retryIn(RETRY_MS); } - private final Runnable retry = new Runnable() { @Override public void run() { if (web != null) { failed = false; web.reload(); } } }; private void hideNotice() { if (notice != null) notice.setVisibility(View.GONE); } private void watchNetwork() { @@ -387,7 +448,8 @@ private void watchNetwork() { if (cm == null || networkCallback != null) return; networkCallback = new ConnectivityManager.NetworkCallback() { @Override public void onAvailable(Network network) { - handler.post(new Runnable() { @Override public void run() { if (failed) { handler.removeCallbacks(retry); retry.run(); } } }); + // The network is back while the wall is failing: do not sit out the rest of the wait. + handler.post(new Runnable() { @Override public void run() { if (load.failing()) loadWall(); } }); } }; try { cm.registerDefaultNetworkCallback(networkCallback); } catch (RuntimeException e) { networkCallback = null; } diff --git a/kiosk/app/src/main/java/co/rileysheehan/gingham/LoadMath.java b/kiosk/app/src/main/java/co/rileysheehan/gingham/LoadMath.java new file mode 100644 index 0000000..050b4b8 --- /dev/null +++ b/kiosk/app/src/main/java/co/rileysheehan/gingham/LoadMath.java @@ -0,0 +1,112 @@ +package co.rileysheehan.gingham; + +import java.util.Locale; + +/** + * Whether the wall is really on screen, in plain Java so it is tested with a JDK (test/load-java.test.js) rather than + * on a frame. FrameActivity does the Android half: loading the page, asking it where it is, showing the notice and + * keeping the wait. + * + * A WebView does not say that a load failed in one place. A frame with no clock battery wakes from a power cut years + * in the past, and a load made in the moment between the Wi-Fi coming up and the network setting the time is refused + * for its certificate, which a WebView reports apart from every other failure before it ends on Chromium's own blank + * page. A word about one load can also arrive after the next has begun. An app that takes "finished, and nobody + * complained" for the wall then sits on that blank page until someone comes to restart it. + * + * So nothing counts as the wall until the page on screen says a server answered it. Until then the notice a failure put + * up stays up, and there is always another try waiting. + */ +final class LoadMath { + private LoadMath() {} + + /** + * Whether a page's own address, `location.href` as evaluateJavascript hands it back (a JSON string), is one a + * server answered. Chromium's error page calls itself chrome-error://chromewebdata/ (data:text/html,chromewebdata + * in old WebViews) whatever address the WebView gives for it, and a WebView that loaded nothing is at about:blank. + */ + static boolean served(String href) { + if (href == null) return false; + String h = href.trim().toLowerCase(Locale.ROOT); + if (h.startsWith("\"")) h = h.substring(1); + return h.startsWith("https://") || h.startsWith("http://"); + } + + /** Whether a page's own address, as served() reads it, is Chromium's error page: never the wall, in any load. */ + static boolean errorPage(String href) { + if (href == null) return false; + String h = href.trim().toLowerCase(Locale.ROOT); + if (h.startsWith("\"")) h = h.substring(1); + return h.startsWith("chrome-error:") || h.startsWith("data:text/html,chromewebdata"); + } + + /** + * One wall's loads. FrameActivity tells it what the WebView said and does what it answers: + * UP, the wall is on screen: take the notice down and stop waiting. + * WAIT, it is not: put the notice up and try again after the usual wait. + * AGAIN, it is not, and the wait has already run out: put the notice up and load now. + * NOTHING, for a word that came too late to mean anything. + */ + static final class Wall { + static final int NOTHING = 0, UP = 1, WAIT = 2, AGAIN = 3; + + private int attempt; + private boolean up, failing, errored, blank, ticked; + + /** A load begins. Its number goes out with the question to the page and comes back with the answer. */ + int begin() { up = false; errored = false; blank = false; ticked = false; return ++attempt; } + int attempt() { return attempt; } + + /** The wall is on screen. */ + boolean up() { return up; } + /** A load has failed and the wall has not been seen since: the notice is up. */ + boolean failing() { return failing; } + + /** + * The WebView said the page itself did not load: no answer, a certificate it would not take, or the server's + * own 5xx. Whatever finishes after that in the same load is not the wall, even a page a server answered. + */ + int error() { up = false; failing = true; errored = true; return WAIT; } + + /** A page finished: whether to ask it where it is. After an error there is nothing to learn. */ + boolean ask() { return !errored; } + + /** What the page on screen said of itself during load number `n`. */ + int answer(int n, String href) { + if (n != attempt || errored) return NOTHING; + if (served(href)) { up = true; failing = false; blank = false; ticked = false; return UP; } + up = false; failing = true; blank = true; + return ticked ? AGAIN : WAIT; + } + + /** + * Whether a look at the page could still tell something the WebView has not: the load is in its first wait, + * nothing has said a word for or against it, and the notice is down. + */ + boolean peeking() { return !up && !failing && !errored && !blank && !ticked; } + + /** + * A look at the page every few seconds of a load's first wait (MER-1214). A WebView whose page a service worker + * keeps can end a failed load on Chromium's error page without a word about it: on the emulators the error page + * was up at 4 s and the notice not until the wait ran out at 20 s. Only that page counts here. Early in a load + * the page on screen may still be the one before it, so a look never takes a page for the wall; that is + * answer()'s, once the WebView says a page finished or the wait runs out. True: the notice goes up now, and the + * wait already running loads the wall again when it runs out. + */ + boolean peek(int n, String href) { + if (n != attempt || !peeking() || !errorPage(href)) return false; + failing = true; blank = true; + return true; + } + + /** + * The wait ran out and the wall is still not up. True: load it again. False, once in each load and only when + * nothing has yet said this load failed: ask the page first, because a slow load that has already arrived is + * not one to start over. If that goes unanswered too, the next time is true. + */ + boolean tick() { + if (errored || blank || ticked) return true; + ticked = true; + return false; + } + } +} diff --git a/package.json b/package.json index 8f3bbe1..803940f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gingham", - "version": "0.2.0", + "version": "0.2.1", "private": true, "license": "AGPL-3.0-only", "description": "A family wall display for any Android tablet or photo frame: calendar, lists, weather and photos, self-hosted.", diff --git a/releases/v0.2.1.md b/releases/v0.2.1.md new file mode 100644 index 0000000..5dd6936 --- /dev/null +++ b/releases/v0.2.1.md @@ -0,0 +1,7 @@ +- After a power cut, the app keeps trying until the wall is back, and says so on screen while it waits. +- A calendar can be hidden from the wall in Settings → Calendars without removing it, so showing it again is one tap. +- A list with no description no longer shows "Tap to check off" beside its name. +- A checked-off task strikes through its words only, and a person's initial lines up with the title when a task wraps. + +On a frame that runs the app, install this one from Settings → Updates: it updates in place and keeps the frame +paired. Calendars, lists, photos and settings from 0.2.0 carry over as they are. diff --git a/test/load-java.test.js b/test/load-java.test.js new file mode 100644 index 0000000..7b78551 --- /dev/null +++ b/test/load-java.test.js @@ -0,0 +1,130 @@ +// The Android app's rules for loading the wall (kiosk/.../LoadMath.java), compiled and run with a plain JDK: which page +// counts as the wall, and what the app does about every word a WebView gives it, in the order a WebView gives them. The +// first cases are a night a frame spent on a blank page: nothing in them may end without the wall up or a try waiting. +// +// Skipped, saying so, where there is no JDK (javac on PATH or under JAVA_HOME). `npm test` runs it wherever there is one. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const {execFileSync, spawnSync} = require('node:child_process'); + +const SOURCE = path.join(__dirname, '..', 'kiosk', 'app', 'src', 'main', 'java', 'co', 'rileysheehan', 'gingham', 'LoadMath.java'); +function jdk() { + for (const bin of [process.env.JAVA_HOME && path.join(process.env.JAVA_HOME, 'bin'), ''].filter(b => b !== undefined && b !== null)) { + const javac = bin ? path.join(bin, 'javac') : 'javac', java = bin ? path.join(bin, 'java') : 'java'; + const probe = spawnSync(javac, ['-version'], {encoding: 'utf8'}); + if (probe.status === 0) return {javac, java}; + } + return null; +} +const tools = jdk(); + +// Reads one case per line, "\t", and prints one answer per line in the same order. +const DRIVER = `package co.rileysheehan.gingham; +import java.nio.file.*; +public class LoadDriver { + public static void main(String[] a) throws Exception { + for (String line : new String(Files.readAllBytes(Paths.get(a[0])), "UTF-8").split("\\n", -1)) { + if (line.isEmpty()) continue; + String[] c = line.split("\\t", -1); + String href = c[1].equals("") ? null : c[1]; + System.out.println(c[0].equals("served") ? LoadMath.served(href) + "," + LoadMath.errorPage(href) : wall(c[1])); + } + } + static final String[] NEXT = {"nothing", "up", "wait", "again"}; + // One wall, played through "b" (a load begins), "e" (the WebView reports an error), "f" (a page finished: ask it?), + // "a n href" (the page's answer in load n, "." for the load under way), "p n href" (what a look during the first + // wait saw: "show" the notice or "-"), "k" (is a look still worth making?), "t" (the wait ran out) and "s" (where + // are we?), answering everything but the "b"s in order. + static String wall(String script) { + LoadMath.Wall w = new LoadMath.Wall(); + StringBuilder out = new StringBuilder(); + for (String op : script.split(";")) { + String[] p = op.trim().split(" "); + String said = null; + switch (p[0]) { + case "b": w.begin(); break; + case "e": said = NEXT[w.error()]; break; + case "f": said = w.ask() ? "ask" : "skip"; break; + case "a": said = NEXT[w.answer(p[1].equals(".") ? w.attempt() : Integer.parseInt(p[1]), p[2])]; break; + case "p": said = w.peek(p[1].equals(".") ? w.attempt() : Integer.parseInt(p[1]), p[2]) ? "show" : "-"; break; + case "k": said = w.peeking() ? "look" : "no"; break; + case "t": said = w.tick() ? "load" : "ask"; break; + case "s": said = w.up() ? "up" : w.failing() ? "failing" : "-"; break; + } + if (said != null) out.append(out.length() > 0 ? "," : "").append(said); + } + return out.toString(); + } +} +`; + +// location.href as evaluateJavascript hands it back: JSON, so a string comes in its quotes. +const WALL = '"https://frame.example.com/"', ERROR_PAGE = '"chrome-error://chromewebdata/"'; +// Each with whether a server answered it, and whether it is Chromium's error page (which a look in a load's first wait +// may act on; about:blank and the rest may be the page before the load and are left to the wait). +const SERVED = [[WALL, true], ['"http://gingham.local/"', true], ['"http://127.0.0.1:18080/"', true], ['"HTTPS://FRAME.EXAMPLE.COM/"', true], + [' "https://frame.example.com/?k=secret" ', true], ['https://frame.example.com/', true], + [ERROR_PAGE, false, true], ['"data:text/html,chromewebdata"', false, true], ['"about:blank"', false], ['"file:///android_asset/index.html"', false], + ['"javascript:void(0)"', false], ['"httpx://frame.example.com/"', false], ['"chrome-error://chromewebdata/?https://frame.example.com/"', false, true], + ['"CHROME-ERROR://chromewebdata/"', false, true], ['null', false], ['""', false], ['', false], ['', false]]; +const WALLS = [ + [`b; f; a . ${WALL}; s`, 'ask,up,up', 'a load that works: the page says where it is, and the wall is up'], + // No Wi-Fi yet; then Wi-Fi with the clock still years back, so the certificate is refused; then the clock is right. + [`b; e; f; s; t; b; e; f; s; t; b; f; a . ${WALL}; s`, 'wait,skip,failing,load,wait,skip,failing,load,ask,up,up', + 'a power cut: each failure waits and loads again, and the notice stays until the wall is seen'], + [`b; e; b; f; a . ${ERROR_PAGE}; s; t; b; f; a . ${WALL}; s`, 'wait,ask,wait,failing,load,ask,up,up', + 'the same night when the WebView reports no error for the second load: Chromium’s error page finishing is not the wall'], + [`b; e; b; f; a . ${ERROR_PAGE}; f; a . ${WALL}; s`, 'wait,ask,wait,ask,up,up', 'the first load’s error page finishes after the second has begun: the second can still be the wall'], + [`b; f; b; a 1 ${WALL}; s`, 'ask,nothing,-', 'an answer to a question asked of an earlier load says nothing about this one'], + [`b; e; b; s; f; s`, 'wait,failing,ask,failing', 'trying again does not take the notice down: only the wall does'], + [`b; e; f; a . ${WALL}; s; t`, 'wait,skip,nothing,failing,load', 'the server’s own 5xx page is at the wall’s address and is still not the wall'], + [`b; t; a . "about:blank"; s`, 'ask,again,failing', 'a load the WebView never says a word about: asked when the wait runs out, and loaded again at once'], + [`b; t; t`, 'ask,load', 'and if the page does not answer either, the next wait loads it again'], + [`b; t; a . ${WALL}; s`, 'ask,up,up', 'a slow load that has arrived by the time the wait runs out is not started over'], + [`b; e; b; t; a . ${WALL}; s`, 'wait,ask,up,up', 'nor is a slow one that follows a failure'], + [`b; f; a . ${WALL}; e; s; t`, 'ask,up,wait,failing,load', 'the wall reloads itself after an update and that fails: back to trying'], + [`b; f; a . ${WALL}; f; a . ${ERROR_PAGE}; s; t`, 'ask,up,ask,wait,failing,load', 'the same, when the WebView reports no error for it'], + [`b; f; a . ${WALL}; f; a . ${WALL}; s`, 'ask,up,ask,up,up', 'and when the wall’s own reload works, nothing changes'], + // A look every few seconds of the first wait (MER-1214): the page a service worker keeps can end on the error page + // with no word from the WebView, which left it blank until the wait ran out. + [`b; k; p . "about:blank"; k; p . ${ERROR_PAGE}; s; k; t`, 'look,-,look,show,failing,no,load', + 'the error page seen early puts the notice up, and the wait loads the wall again when it runs out'], + [`b; p . ${WALL}; s; k; f; a . ${WALL}; s`, '-,-,look,ask,up,up', 'a look never takes a page for the wall: that is the WebView’s word and the wait’s'], + [`b; p . ${ERROR_PAGE}; f; a . ${WALL}; s; k`, 'show,ask,up,up,no', 'a look at the page before this load that was the error page: the wall still takes the notice down'], + [`b; f; a . ${WALL}; k; p . ${ERROR_PAGE}; s`, 'ask,up,no,-,up', 'once the wall is up, no look'], + [`b; e; k; p . ${ERROR_PAGE}; t`, 'wait,no,-,load', 'nor once the WebView has said the load failed: the notice is up already'], + [`b; t; k; p . ${ERROR_PAGE}; s`, 'ask,no,-,-', 'nor after the first wait: it is answer()’s again'], + [`b; b; p 1 ${ERROR_PAGE}; s; k`, '-,-,look', 'a look made for an earlier load says nothing of this one'] +]; + +test('The app’s rules for loading the wall: which page counts, and that a failed load always leaves the notice up and another try waiting', {skip: !tools && 'no JDK here (set JAVA_HOME or put javac on PATH)'}, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'load-java-')); + try { + const pkg = path.join(dir, 'src', 'co', 'rileysheehan', 'gingham'); + fs.mkdirSync(pkg, {recursive: true}); + fs.copyFileSync(SOURCE, path.join(pkg, 'LoadMath.java')); + fs.writeFileSync(path.join(pkg, 'LoadDriver.java'), DRIVER); + execFileSync(tools.javac, ['-d', path.join(dir, 'classes'), path.join(pkg, 'LoadMath.java'), path.join(pkg, 'LoadDriver.java')], {stdio: 'pipe'}); + fs.writeFileSync(path.join(dir, 'cases'), [...SERVED.map(([href]) => 'served\t' + href), ...WALLS.map(([script]) => 'wall\t' + script)].join('\n') + '\n'); + const out = execFileSync(tools.java, ['-cp', path.join(dir, 'classes'), 'co.rileysheehan.gingham.LoadDriver', path.join(dir, 'cases')], {encoding: 'utf8'}).split('\n'); + let i = 0; + for (const [href, served, error = false] of SERVED) assert.equal(out[i++], served + ',' + error, 'served, error page: ' + JSON.stringify(href)); + for (const [script, said, why] of WALLS) assert.equal(out[i++], said, why + ' (' + script + ')'); + } finally { fs.rmSync(dir, {recursive: true, force: true}); } +}); + +// The activity's half, which no JDK can run: read from the source, so the mistake that left a frame dark cannot come back +// quietly. The wall is asked for by its address and never reloaded, and a refused certificate is counted as a failure. +test('The app loads the wall by its address, never reload(), and counts a refused certificate as a failed load', () => { + const activity = fs.readFileSync(path.join(path.dirname(SOURCE), 'FrameActivity.java'), 'utf8').replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, ''); + assert.doesNotMatch(activity, /\.reload\s*\(/, 'reload() asks again for whatever is on screen, which may be Chromium’s error page'); + assert.match(activity, /web\.loadUrl\(wallUrl\)/); + const ssl = activity.match(/onReceivedSslError\([^)]*\)\s*\{([^}]*)\}/); + assert.ok(ssl, 'the wall’s WebView listens for certificate errors'); + assert.match(ssl[1], /\.cancel\(\)/); + assert.doesNotMatch(activity, /\.proceed\s*\(/, 'a certificate error is never waved through'); + assert.match(ssl[1], /load\.error\(\)/); +});