feat(hobbs-organizer): Shield photo app (com.dayline.photos) + monitoring, links, .100 boot fix

- shield/photos-source: launcher tile + DreamService screensaver showing /photo
  (locked-down WebView, default URL baked in, files/base_url override)
- build-photos-apk.sh: containerized build on hf-pdocker-02 (JDK17, SDK 35);
  signing key kept off-repo on .100 + NAS
- Hobbs-Family-Photos-1.0.apk installed on .186 (canary) and set as its screensaver
- Prometheus probes for /api/health and /api/health/photos; Farm Manager links + group
- CLAUDE.md: .100 left the stale Swarm and its NFS mount waits for network (11-03 outage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 19:28:12 -06:00
parent c2e5c1d9dc
commit 0a3ec27774
20 changed files with 501 additions and 1 deletions
@@ -0,0 +1,7 @@
.gradle/
build/
app/build/
local.properties
keystore.properties
*.jks
*.keystore
@@ -0,0 +1,14 @@
# Hobbs Family Photos (Shield app)
Package `com.dayline.photos`. It has a launcher tile plus an Android TV screen saver (DreamService). Both show the
organizer's `/photo` page in a locked-down WebView.
- The default URL `http://192.168.86.100:8340/photo` is baked in through `BuildConfig.DEFAULT_URL`.
- To override it, write `files/base_url` with `adb shell run-as com.dayline.photos`. The release build is debuggable for exactly this.
- The signing key is **not in git**. Lose it and every future update needs an uninstall and reinstall on each TV.
- It lives at `/var/lib/hobbs-organizer/apk-signing/` on hf-pdocker-02, with a copy on the NAS at `/mnt/docker-data/configs/hobbs-organizer/apk-signing/`.
- `keystore.properties` sets `storeFile`, `storePassword`, `keyAlias` and `keyPassword`.
- Build: `compose/hf-pdocker-02/hobbs-organizer/build-photos-apk.sh` on hf-pdocker-02. It runs a JDK 17 container with the
Android SDK (platform 35, build-tools 35.0.0) and Gradle 8.9, cached in the Docker volume `hobbs-android-sdk`.
- Set it as the screen saver from the TV (Settings → Device Preferences → Screen saver), or over ADB:
`settings put secure screensaver_components com.dayline.photos/.PhotoDreamService`
@@ -0,0 +1,58 @@
plugins {
id "com.android.application"
}
// Signing key lives outside the repo (see BUILDING.md). keystore.properties provides
// storeFile, storePassword, keyAlias, keyPassword.
def keystoreProps = new Properties()
def keystoreFile = rootProject.file("keystore.properties")
if (keystoreFile.exists()) keystoreFile.withInputStream { keystoreProps.load(it) }
android {
namespace "com.dayline.photos"
compileSdk 35
defaultConfig {
applicationId "com.dayline.photos"
minSdk 26
targetSdk 35
versionCode 1
versionName "1.0"
buildConfigField "String", "DEFAULT_URL", "\"http://192.168.86.100:8340/photo\""
}
buildFeatures {
buildConfig true
}
signingConfigs {
release {
if (keystoreProps["storeFile"]) {
storeFile file(keystoreProps["storeFile"])
storePassword keystoreProps["storePassword"]
keyAlias keystoreProps["keyAlias"]
keyPassword keystoreProps["keyPassword"]
}
}
}
buildTypes {
release {
minifyEnabled false
// debuggable so `adb shell run-as com.dayline.photos` can write files/base_url,
// the same provisioning model as the dashboard app.
debuggable true
signingConfig signingConfigs.release
}
}
lint {
abortOnError false
checkReleaseBuilds false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="false"
android:banner="@drawable/tv_banner"
android:fullBackupContent="false"
android:hardwareAccelerated="true"
android:icon="@drawable/ic_photos"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.HobbsPhotos"
android:usesCleartextTraffic="true">
<!-- Launcher tile: opens the photo frame full screen. -->
<activity
android:name=".PhotoActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Screen saver: Settings > Device Preferences > Screen saver > Hobbs Family Photos. -->
<service
android:name=".PhotoDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/dream_info" />
</service>
</application>
</manifest>
@@ -0,0 +1,45 @@
package com.dayline.photos;
import android.app.Activity;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
/** Launcher tile: the photo frame, full screen, screen kept on. Back exits. */
public final class PhotoActivity extends Activity {
private PhotoFrameView frame;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
frame = new PhotoFrameView(this);
setContentView(frame.view());
hideSystemUi();
frame.load();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) hideSystemUi();
}
@SuppressWarnings("deprecation")
private void hideSystemUi() {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
}
@Override
protected void onDestroy() {
if (frame != null) frame.destroy();
frame = null;
super.onDestroy();
}
}
@@ -0,0 +1,37 @@
package com.dayline.photos;
import android.service.dreams.DreamService;
/** Android TV screen saver showing the photo frame. Any remote button wakes the TV as usual. */
public final class PhotoDreamService extends DreamService {
private PhotoFrameView frame;
@Override
public void onAttachedToWindow() {
super.onAttachedToWindow();
setInteractive(false);
setFullscreen(true);
setScreenBright(true);
frame = new PhotoFrameView(this);
setContentView(frame.view());
}
@Override
public void onDreamingStarted() {
super.onDreamingStarted();
if (frame != null) frame.load();
}
@Override
public void onDreamingStopped() {
if (frame != null) frame.view().stopLoading();
super.onDreamingStopped();
}
@Override
public void onDetachedFromWindow() {
if (frame != null) frame.destroy();
frame = null;
super.onDetachedFromWindow();
}
}
@@ -0,0 +1,135 @@
package com.dayline.photos;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Color;
import android.net.Uri;
import android.net.http.SslError;
import android.os.Handler;
import android.os.Looper;
import android.webkit.CookieManager;
import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* A locked-down full-screen WebView showing the organizer's /photo page.
* Shared by the launcher activity and the screen saver.
*/
final class PhotoFrameView {
private static final long RETRY_MS = 30_000;
private static final String BASE_URL_FILE = "base_url";
private final WebView webView;
private final Handler handler = new Handler(Looper.getMainLooper());
private final String url;
private final String origin;
private boolean destroyed;
@SuppressLint("SetJavaScriptEnabled")
PhotoFrameView(Context context) {
url = configuredUrl(context);
Uri parsed = Uri.parse(url);
origin = parsed.getScheme() + "://" + parsed.getAuthority();
WebView.setWebContentsDebuggingEnabled(false);
webView = new WebView(context);
webView.setBackgroundColor(Color.BLACK);
webView.setFocusable(false);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setJavaScriptCanOpenWindowsAutomatically(false);
settings.setSupportMultipleWindows(false);
settings.setSupportZoom(false);
settings.setUseWideViewPort(true);
settings.setLoadWithOverviewMode(true);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
settings.setMediaPlaybackRequiresUserGesture(true);
settings.setUserAgentString(settings.getUserAgentString() + " HobbsPhotoFrame/1.0");
CookieManager.getInstance().setAcceptCookie(false);
webView.setWebChromeClient(new WebChromeClient());
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
return !sameOrigin(request.getUrl());
}
@Override
public void onReceivedSslError(WebView view, SslErrorHandler sslHandler, SslError error) {
sslHandler.cancel();
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) {
showMessage("Waiting for the photo server…");
scheduleRetry();
}
}
});
}
WebView view() {
return webView;
}
void load() {
if (!destroyed) webView.loadUrl(url);
}
void destroy() {
destroyed = true;
handler.removeCallbacksAndMessages(null);
webView.stopLoading();
webView.setWebViewClient(new WebViewClient());
webView.destroy();
}
private boolean sameOrigin(Uri uri) {
return (uri.getScheme() + "://" + uri.getAuthority()).equalsIgnoreCase(origin);
}
private void scheduleRetry() {
handler.removeCallbacksAndMessages(null);
handler.postDelayed(this::load, RETRY_MS);
}
private void showMessage(String text) {
String html = "<html><body style=\"margin:0;height:100vh;display:flex;align-items:center;justify-content:center;"
+ "background:#000;color:#ffffff99;font:28px sans-serif\">" + text + "</body></html>";
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null);
}
/** files/base_url (written over ADB run-as) overrides the built-in default. */
private static String configuredUrl(Context context) {
File file = new File(context.getFilesDir(), BASE_URL_FILE);
if (file.isFile() && file.length() > 0 && file.length() < 2048) {
try (FileInputStream input = new FileInputStream(file)) {
byte[] data = new byte[(int) file.length()];
int read = input.read(data);
String value = new String(data, 0, Math.max(read, 0), StandardCharsets.UTF_8).trim();
Uri parsed = Uri.parse(value);
if (("http".equals(parsed.getScheme()) || "https".equals(parsed.getScheme())) && parsed.getHost() != null) {
return value;
}
} catch (IOException ignored) {
// fall back to the default
}
}
return BuildConfig.DEFAULT_URL;
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#07101D" android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
<path android:fillColor="#A5F3FC" android:pathData="M22,28 L86,28 L86,80 L22,80 Z" />
<path android:fillColor="#07101D" android:pathData="M27,33 L81,33 L81,75 L27,75 Z" />
<path android:fillColor="#34D399" android:pathData="M27,75 L45,52 L58,66 L66,58 L81,75 Z" />
<path android:fillColor="#FBBF24" android:pathData="M70,46 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path android:fillColor="#07101D" android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
<path android:fillColor="#0E2233" android:pathData="M0,0 L150,0 L62,180 L0,180 Z" />
<!-- picture frame -->
<path android:fillColor="#A5F3FC" android:pathData="M40,45 L150,45 L150,135 L40,135 Z" />
<path android:fillColor="#07101D" android:pathData="M48,53 L142,53 L142,127 L48,127 Z" />
<path android:fillColor="#34D399" android:pathData="M48,127 L80,86 L103,111 L117,97 L142,127 Z" />
<path android:fillColor="#FBBF24" android:pathData="M123,75 m-9,0 a9,9 0 1,0 18,0 a9,9 0 1,0 -18,0" />
<!-- "PHOTOS" accent bars -->
<path android:fillColor="#E2E8F0" android:pathData="M170,70 L290,70 L290,84 L170,84 Z" />
<path android:fillColor="#94A3B8" android:pathData="M170,96 L260,96 L260,106 L170,106 Z" />
<path android:fillColor="#67E8F9" android:pathData="M170,120 L290,120 L290,125 L170,125 Z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hobbs Family Photos</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HobbsPhotos" parent="android:style/Theme.Material.NoActionBar">
<item name="android:windowFullscreen">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">#000000</item>
<item name="android:navigationBarColor">#000000</item>
<item name="android:statusBarColor">#000000</item>
</style>
</resources>
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<dream xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,3 @@
plugins {
id "com.android.application" version "8.7.3" apply false
}
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=false
android.nonTransitiveRClass=true
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HobbsPhotoFrame"
include(":app")