Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
3. Tag and push: `git tag v<version> && git push origin v<version>`. 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

Expand Down
94 changes: 78 additions & 16 deletions kiosk/app/src/main/java/co/rileysheehan/gingham/FrameActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -371,23 +383,73 @@ 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<String>() { @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<String>() { @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() {
ConnectivityManager cm = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE);
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; }
Expand Down
112 changes: 112 additions & 0 deletions kiosk/app/src/main/java/co/rileysheehan/gingham/LoadMath.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
7 changes: 7 additions & 0 deletions releases/v0.2.1.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading