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:
+58
@@ -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
|
||||
}
|
||||
}
|
||||
+53
@@ -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>
|
||||
+45
@@ -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();
|
||||
}
|
||||
}
|
||||
+37
@@ -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();
|
||||
}
|
||||
}
|
||||
+135
@@ -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;
|
||||
}
|
||||
}
|
||||
+12
@@ -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>
|
||||
+18
@@ -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>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hobbs Family Photos</string>
|
||||
</resources>
|
||||
+10
@@ -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>
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<dream xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
+9
@@ -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>
|
||||
Reference in New Issue
Block a user