feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340

- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

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 18:27:22 -06:00
parent 122ea392d1
commit e9c7e9df1a
68 changed files with 12831 additions and 0 deletions
@@ -0,0 +1,37 @@
plugins {
id "com.android.application"
}
android {
namespace "com.dayline.tv"
compileSdk 35
defaultConfig {
applicationId "com.dayline.tv"
minSdk 26
targetSdk 35
versionCode 5
versionName "1.4"
testInstrumentationRunner "android.test.InstrumentationTestRunner"
}
buildTypes {
debug {
debuggable true
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
testImplementation "junit:junit:4.13.2"
}
@@ -0,0 +1 @@
# Hobbs' Family Organizer App currently has no release-only shrinking rules.
@@ -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-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<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:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:hardwareAccelerated="true"
android:icon="@drawable/ic_dayline"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.DaylineTV"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
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>
<receiver
android:name=".ScheduleReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,245 @@
package com.dayline.tv;
import android.content.Context;
import android.net.Uri;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.IDN;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.regex.Pattern;
final class DashboardConfig {
private static final String LEGACY_SITE_URL = "https://dayline-tv-jeremy.tautfoal1.chatgpt.site/";
private static final String LEGACY_AUTH_HEADER = "OAI-Sites-Authorization";
private static final String LOCAL_AUTH_HEADER = "Authorization";
private static final String BASE_URL_FILE = "base_url";
private static final String ACCESS_TOKEN_FILE = "access_token";
private static final String LEGACY_TOKEN_FILE = "service_token";
private static final String ALLOW_HTTP_FILE = "allow_insecure_http";
private static final int MAX_URL_BYTES = 2048;
private static final int MAX_TOKEN_BYTES = 1024;
private static final Pattern TOKEN_PATTERN = Pattern.compile("[A-Za-z0-9._~-]{32,512}");
private final URI baseOrigin;
private final String baseUrl;
private final String authHeader;
private final String token;
private DashboardConfig(URI baseOrigin, String baseUrl, String authHeader, String token) {
this.baseOrigin = baseOrigin;
this.baseUrl = baseUrl;
this.authHeader = authHeader;
this.token = token;
}
static DashboardConfig load(Context context) throws ConfigException {
String configuredUrl = readOptionalText(context, BASE_URL_FILE, MAX_URL_BYTES);
boolean hostedFallback = configuredUrl == null;
String rawUrl = hostedFallback ? LEGACY_SITE_URL : configuredUrl;
URI origin = parseBaseUrl(rawUrl, context.getFileStreamPath(ALLOW_HTTP_FILE).isFile());
String token;
String authHeader;
if (hostedFallback) {
token = readOptionalText(context, LEGACY_TOKEN_FILE, MAX_TOKEN_BYTES);
if (!isValidToken(token)) {
throw new ConfigException(
"The hosted dashboard access token is missing. Provision files/service_token over ADB or configure files/base_url for a local server."
);
}
authHeader = LEGACY_AUTH_HEADER;
} else {
token = readOptionalText(context, ACCESS_TOKEN_FILE, MAX_TOKEN_BYTES);
if (token != null && !isValidToken(token)) {
throw new ConfigException("The local access token must be 32 to 512 URL-safe characters.");
}
authHeader = token == null ? null : LOCAL_AUTH_HEADER;
}
String normalizedUrl = normalizedRootUrl(origin);
try {
return new DashboardConfig(new URI(normalizedUrl), normalizedUrl, authHeader, token);
} catch (URISyntaxException error) {
throw new ConfigException("The dashboard URL could not be normalized.");
}
}
String baseUrl() {
return baseUrl;
}
boolean hasToken() {
return token != null;
}
String authHeader() {
return authHeader;
}
String authorizationValue() {
return token == null ? null : "Bearer " + token;
}
boolean isAllowed(Uri uri) {
if (uri == null) return false;
try {
return sameOrigin(baseOrigin, new URI(uri.toString()));
} catch (URISyntaxException error) {
return false;
}
}
private static URI parseBaseUrl(String value, boolean allowHttp) throws ConfigException {
URI uri;
try {
uri = new URI(value.trim());
} catch (URISyntaxException error) {
throw new ConfigException("The configured dashboard URL is invalid.");
}
String scheme = lower(uri.getScheme());
String host = normalizeHost(uri.getHost());
if (scheme == null || host == null || host.isBlank()) {
throw new ConfigException("The dashboard URL must include an http or https host.");
}
if (!scheme.equals("https") && !scheme.equals("http")) {
throw new ConfigException("The dashboard URL must use http or https.");
}
if (uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new ConfigException("The dashboard URL cannot contain credentials, a query, or a fragment.");
}
String path = uri.getRawPath();
if (path != null && !path.isEmpty() && !path.equals("/")) {
throw new ConfigException("The dashboard must be served from the URL root path.");
}
if (uri.getPort() == 0 || uri.getPort() < -1 || uri.getPort() > 65535) {
throw new ConfigException("The dashboard URL contains an invalid port.");
}
if (scheme.equals("http") && (!allowHttp || !isPrivateLanHost(host))) {
throw new ConfigException(
"HTTP requires files/allow_insecure_http and a private LAN address or .local, .lan, or .home.arpa host."
);
}
return uri;
}
static boolean sameOrigin(URI expected, URI candidate) {
if (expected == null || candidate == null || candidate.getUserInfo() != null) return false;
String expectedScheme = lower(expected.getScheme());
String candidateScheme = lower(candidate.getScheme());
if (!"http".equals(candidateScheme) && !"https".equals(candidateScheme)) return false;
return expectedScheme != null
&& expectedScheme.equals(candidateScheme)
&& normalizeHost(expected.getHost()).equals(normalizeHost(candidate.getHost()))
&& effectivePort(expected) == effectivePort(candidate);
}
static boolean isPrivateLanHost(String value) {
String host = normalizeHost(value);
if (host == null || host.isBlank()) return false;
if (host.equals("localhost") || host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) {
return true;
}
if (isPrivateIpv4(host)) return true;
String ipv6 = host.toLowerCase(Locale.ROOT);
if (ipv6.startsWith("[") && ipv6.endsWith("]")) ipv6 = ipv6.substring(1, ipv6.length() - 1);
return ipv6.equals("::1")
|| ipv6.startsWith("fc")
|| ipv6.startsWith("fd")
|| ipv6.startsWith("fe8")
|| ipv6.startsWith("fe9")
|| ipv6.startsWith("fea")
|| ipv6.startsWith("feb");
}
private static boolean isPrivateIpv4(String host) {
String[] parts = host.split("\\.", -1);
if (parts.length != 4) return false;
int[] octets = new int[4];
for (int index = 0; index < parts.length; index++) {
if (parts[index].isEmpty() || parts[index].length() > 3) return false;
try {
octets[index] = Integer.parseInt(parts[index]);
} catch (NumberFormatException error) {
return false;
}
if (octets[index] < 0 || octets[index] > 255) return false;
}
return octets[0] == 10
|| octets[0] == 127
|| octets[0] == 192 && octets[1] == 168
|| octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31
|| octets[0] == 169 && octets[1] == 254;
}
private static int effectivePort(URI uri) {
if (uri.getPort() != -1) return uri.getPort();
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
private static String normalizedRootUrl(URI uri) throws ConfigException {
String scheme = lower(uri.getScheme());
String host = normalizeHost(uri.getHost());
try {
return new URI(scheme, null, host, uri.getPort(), "/", null, null).toString();
} catch (URISyntaxException error) {
throw new ConfigException("The dashboard URL could not be normalized.");
}
}
private static String normalizeHost(String host) {
if (host == null) return null;
String normalized = host.trim().toLowerCase(Locale.ROOT);
if (normalized.startsWith("[") && normalized.endsWith("]")) {
return normalized.substring(1, normalized.length() - 1);
}
if (normalized.contains(":")) return normalized;
try {
return IDN.toASCII(normalized, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
} catch (IllegalArgumentException error) {
return "";
}
}
private static String lower(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
private static boolean isValidToken(String value) {
return value != null && TOKEN_PATTERN.matcher(value).matches();
}
private static String readOptionalText(Context context, String name, int maxBytes) throws ConfigException {
File file = context.getFileStreamPath(name);
if (!file.isFile()) return null;
if (file.length() == 0) return null;
if (file.length() > maxBytes) throw new ConfigException("Configuration file " + name + " is too large.");
try (InputStream input = new FileInputStream(file)) {
byte[] bytes = new byte[(int) file.length()];
int offset = 0;
while (offset < bytes.length) {
int read = input.read(bytes, offset, bytes.length - offset);
if (read < 0) break;
offset += read;
}
String value = new String(bytes, 0, offset, StandardCharsets.UTF_8).trim();
return value.isEmpty() ? null : value;
} catch (IOException error) {
throw new ConfigException("Configuration file " + name + " could not be read.");
}
}
static final class ConfigException extends Exception {
ConfigException(String message) {
super(message);
}
}
}
@@ -0,0 +1,80 @@
package com.dayline.tv;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
import java.util.Calendar;
final class DaylineScheduler {
static final String ACTION_DAILY_WAKE = "com.dayline.tv.action.DAILY_WAKE";
static final int WAKE_HOUR = 6;
static final int WAKE_MINUTE = 30;
private static final String TAG = "DaylineScheduler";
private static final int REQUEST_CODE = 630;
private static final String ENABLED_FLAG_FILE = "daily_wake_0630_enabled";
private DaylineScheduler() {
}
static void scheduleNext(Context context) {
Context appContext = context.getApplicationContext();
AlarmManager alarmManager = (AlarmManager) appContext.getSystemService(Context.ALARM_SERVICE);
if (alarmManager == null) {
Log.e(TAG, "AlarmManager is unavailable; daily launch was not scheduled");
return;
}
PendingIntent launchIntent = launchPendingIntent(appContext);
if (!appContext.getFileStreamPath(ENABLED_FLAG_FILE).isFile()) {
alarmManager.cancel(launchIntent);
Log.i(TAG, "Daily launch is disabled on this device");
return;
}
long triggerAtMillis = nextWakeMillis(System.currentTimeMillis());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, launchIntent);
Log.w(TAG, "Exact-alarm access is unavailable; scheduled an approximate 6:30 AM launch");
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, launchIntent);
Log.i(TAG, "Scheduled daily launch for " + triggerAtMillis);
}
}
static long nextWakeMillis(long nowMillis) {
Calendar next = Calendar.getInstance();
next.setTimeInMillis(nowMillis);
next.set(Calendar.HOUR_OF_DAY, WAKE_HOUR);
next.set(Calendar.MINUTE, WAKE_MINUTE);
next.set(Calendar.SECOND, 0);
next.set(Calendar.MILLISECOND, 0);
if (next.getTimeInMillis() <= nowMillis) {
next.add(Calendar.DAY_OF_YEAR, 1);
}
return next.getTimeInMillis();
}
private static PendingIntent launchPendingIntent(Context context) {
Intent intent = new Intent(context, MainActivity.class)
.setAction(ACTION_DAILY_WAKE)
.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP
);
return PendingIntent.getActivity(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
}
@@ -0,0 +1,399 @@
package com.dayline.tv;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.graphics.Color;
import android.net.Uri;
import android.net.http.SslError;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.View;
import android.view.WindowManager;
import android.webkit.CookieManager;
import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class MainActivity extends Activity {
private static final int MAX_REDIRECTS = 5;
private WebView webView;
private volatile DashboardConfig dashboardConfig;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureWakeBehavior();
DaylineScheduler.scheduleNext(this);
enterImmersiveMode();
configureWebView();
loadDashboard();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
configureWakeBehavior();
DaylineScheduler.scheduleNext(this);
}
@SuppressWarnings("deprecation")
private void configureWakeBehavior() {
getWindow().addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true);
setShowWhenLocked(true);
}
}
@SuppressLint("SetJavaScriptEnabled")
private void configureWebView() {
WebView.setWebContentsDebuggingEnabled(false);
webView = new WebView(this);
webView.setBackgroundColor(Color.rgb(7, 11, 18));
webView.setFocusable(true);
webView.setFocusableInTouchMode(true);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setJavaScriptCanOpenWindowsAutomatically(false);
settings.setSupportMultipleWindows(false);
settings.setBuiltInZoomControls(false);
settings.setDisplayZoomControls(false);
settings.setSupportZoom(false);
settings.setUseWideViewPort(true);
settings.setLoadWithOverviewMode(true);
settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
settings.setSafeBrowsingEnabled(true);
settings.setUserAgentString(settings.getUserAgentString() + " HobbsOrganizer/1.4");
webView.setInitialScale(0);
CookieManager cookies = CookieManager.getInstance();
cookies.setAcceptCookie(false);
cookies.setAcceptThirdPartyCookies(webView, false);
webView.setWebChromeClient(new WebChromeClient());
webView.setWebViewClient(new DaylineWebViewClient());
setContentView(webView);
webView.requestFocus(View.FOCUS_DOWN);
}
private void loadDashboard() {
try {
dashboardConfig = DashboardConfig.load(this);
} catch (DashboardConfig.ConfigException error) {
showLocalMessage(
"Setup required",
error.getMessage()
);
return;
}
Map<String, String> headers = new LinkedHashMap<>();
if (dashboardConfig.hasToken()) {
headers.put(dashboardConfig.authHeader(), dashboardConfig.authorizationValue());
}
webView.loadUrl(dashboardConfig.baseUrl(), headers);
}
private void showLocalMessage(String title, String detail) {
String html = "<!doctype html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<style>html,body{height:100%;margin:0;background:#070b12;color:#f8fafc;font-family:sans-serif}"
+ "body{display:grid;place-items:center}.card{max-width:760px;border:1px solid #ffffff1a;border-radius:28px;padding:52px;"
+ "background:#0c121d;text-align:center;box-shadow:0 30px 90px #0006}h1{font-size:44px;margin:0 0 16px;color:#a5f3fc}"
+ "p{font-size:24px;line-height:1.5;color:#94a3b8;margin:0}</style></head><body><div class=\"card\"><h1>"
+ escapeHtml(title) + "</h1><p>" + escapeHtml(detail) + "</p></div></body></html>";
webView.loadDataWithBaseURL("https://dayline.local/", html, "text/html", "UTF-8", null);
}
private static String escapeHtml(String value) {
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
}
@SuppressWarnings("deprecation")
private void enterImmersiveMode() {
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
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && webView != null && webView.canGoBack()) {
webView.goBack();
return true;
}
return super.onKeyDown(keyCode, event);
}
@Override
protected void onDestroy() {
if (webView != null) {
webView.stopLoading();
webView.setWebChromeClient(null);
webView.setWebViewClient(null);
webView.destroy();
webView = null;
}
dashboardConfig = null;
super.onDestroy();
}
private final class DaylineWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
DashboardConfig config = dashboardConfig;
return config == null || !config.isAllowed(request.getUrl());
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
DashboardConfig config = dashboardConfig;
return config == null || !config.isAllowed(Uri.parse(url));
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
DashboardConfig config = dashboardConfig;
if (config == null) return localError(503, "Unavailable", "Dashboard configuration is unavailable.");
if (!config.isAllowed(request.getUrl())) return localError(403, "Forbidden", "Blocked cross-origin request.");
if (!"GET".equalsIgnoreCase(request.getMethod())) return null;
return fetchRequest(request.getUrl(), request.getRequestHeaders(), config, request.isForMainFrame(), 0);
}
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
handler.cancel();
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) {
showLocalMessage("The organizer is offline", "Check the Shield network connection. The dashboard will be available after relaunching the app.");
}
}
}
private WebResourceResponse fetchRequest(
Uri uri,
Map<String, String> requestHeaders,
DashboardConfig config,
boolean mainFrame,
int redirectCount
) {
if (!config.isAllowed(uri)) return localError(403, "Forbidden", "Blocked cross-origin request.");
if (redirectCount > MAX_REDIRECTS) return localError(508, "Loop Detected", "Too many redirects.");
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(uri.toString()).openConnection();
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(10_000);
connection.setReadTimeout(20_000);
connection.setRequestMethod("GET");
if (config.hasToken()) {
connection.setRequestProperty(config.authHeader(), config.authorizationValue());
}
connection.setRequestProperty("Accept-Encoding", "identity");
copyRequestHeader(requestHeaders, connection, "Accept");
copyRequestHeader(requestHeaders, connection, "Accept-Language");
copyRequestHeader(requestHeaders, connection, "User-Agent");
int status = connection.getResponseCode();
if (status >= 300 && status < 400) {
String location = connection.getHeaderField("Location");
if (location == null || location.isBlank()) {
connection.disconnect();
return localError(502, "Bad Gateway", "Invalid redirect from the organizer.");
}
Uri redirect = Uri.parse(new URL(new URL(uri.toString()), location).toString());
connection.disconnect();
if (!config.isAllowed(redirect)) {
return localError(401, "Unauthorized", "Organizer access needs to be provisioned again.");
}
return fetchRequest(redirect, requestHeaders, config, mainFrame, redirectCount + 1);
}
String contentType = connection.getContentType();
String mimeType = mimeType(contentType, uri);
String encoding = charset(contentType, mimeType);
InputStream raw = status >= 400 ? connection.getErrorStream() : connection.getInputStream();
if (raw == null) raw = new ByteArrayInputStream(new byte[0]);
Map<String, String> responseHeaders = responseHeaders(connection.getHeaderFields());
String reason = connection.getResponseMessage();
if (reason == null || reason.isBlank()) reason = status >= 400 ? "Request Failed" : "OK";
if (mainFrame && "text/html".equalsIgnoreCase(mimeType)) {
byte[] original = readLimited(raw, 2 * 1024 * 1024);
raw.close();
connection.disconnect();
String html = new String(original, StandardCharsets.UTF_8);
String viewport = "<meta name=\"viewport\" content=\"width=1920, initial-scale=1, user-scalable=no\">";
String rewritten = html.replaceFirst(
"(?i)<meta[^>]+name\\s*=\\s*[\\\"']viewport[\\\"'][^>]*>",
viewport
);
if (rewritten.equals(html)) {
rewritten = html.replaceFirst("(?i)<head[^>]*>", "$0" + viewport);
}
return new WebResourceResponse(
mimeType,
"UTF-8",
status,
reason,
responseHeaders,
new ByteArrayInputStream(rewritten.getBytes(StandardCharsets.UTF_8))
);
}
InputStream body = new DisconnectingInputStream(raw, connection);
return new WebResourceResponse(mimeType, encoding, status, reason, responseHeaders, body);
} catch (IOException error) {
if (connection != null) connection.disconnect();
return localError(502, "Bad Gateway", "The organizer could not be reached.");
}
}
private static void copyRequestHeader(Map<String, String> source, HttpURLConnection connection, String name) {
if (source == null) return;
for (Map.Entry<String, String> entry : source.entrySet()) {
if (name.equalsIgnoreCase(entry.getKey()) && entry.getValue() != null) {
connection.setRequestProperty(name, entry.getValue());
return;
}
}
}
private static Map<String, String> responseHeaders(Map<String, List<String>> source) {
if (source == null) return Collections.emptyMap();
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : source.entrySet()) {
String name = entry.getKey();
if (name == null || entry.getValue() == null) continue;
String lower = name.toLowerCase(Locale.ROOT);
if (lower.equals("content-length") || lower.equals("content-encoding")
|| lower.equals("transfer-encoding") || lower.equals("connection")
|| lower.equals("set-cookie")) continue;
result.put(name, String.join(", ", entry.getValue()));
}
return result;
}
private static String mimeType(String contentType, Uri uri) {
if (contentType != null && !contentType.isBlank()) {
return contentType.split(";", 2)[0].trim();
}
String path = uri.getPath() == null ? "" : uri.getPath().toLowerCase(Locale.ROOT);
if (path.endsWith(".js") || path.endsWith(".mjs")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
if (path.endsWith(".webp")) return "image/webp";
if (path.endsWith(".woff2")) return "font/woff2";
return "text/html";
}
private static String charset(String contentType, String mimeType) {
if (contentType != null) {
for (String part : contentType.split(";")) {
String trimmed = part.trim();
if (trimmed.toLowerCase(Locale.ROOT).startsWith("charset=")) {
return trimmed.substring(8).replace("\"", "");
}
}
}
return mimeType.startsWith("text/") || mimeType.contains("javascript") || mimeType.contains("json")
? "UTF-8"
: null;
}
private static byte[] readLimited(InputStream input, int limit) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024];
int total = 0;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > limit) throw new IOException("Response body too large");
output.write(buffer, 0, read);
}
return output.toByteArray();
}
private static WebResourceResponse localError(int status, String reason, String message) {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
return new WebResourceResponse(
"text/plain",
"UTF-8",
status,
reason,
Collections.singletonMap("Cache-Control", "no-store"),
new ByteArrayInputStream(body)
);
}
private static final class DisconnectingInputStream extends FilterInputStream {
private final HttpURLConnection connection;
DisconnectingInputStream(InputStream input, HttpURLConnection connection) {
super(input);
this.connection = connection;
}
@Override
public void close() throws IOException {
try {
super.close();
} finally {
connection.disconnect();
}
}
}
}
@@ -0,0 +1,19 @@
package com.dayline.tv;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public final class ScheduleReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent == null ? null : intent.getAction();
if (!Intent.ACTION_BOOT_COMPLETED.equals(action)
&& !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)
&& !Intent.ACTION_TIME_CHANGED.equals(action)
&& !Intent.ACTION_TIMEZONE_CHANGED.equals(action)) {
return;
}
DaylineScheduler.scheduleNext(context);
}
}
@@ -0,0 +1,11 @@
<?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="#08111E" android:pathData="M8,0 L100,0 Q108,0 108,8 L108,100 Q108,108 100,108 L8,108 Q0,108 0,100 L0,8 Q0,0 8,0" />
<path android:fillColor="#22D3EE" android:pathData="M24,62 L36,62 L36,82 L24,82 Z" />
<path android:fillColor="#A5F3FC" android:pathData="M48,26 L60,26 L60,82 L48,82 Z" />
<path android:fillColor="#67E8F9" android:pathData="M72,43 L84,43 L84,82 L72,82 Z" />
</vector>
@@ -0,0 +1,17 @@
<?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" />
<path android:fillColor="#22D3EE" android:pathData="M54,97 L70,97 L70,132 L54,132 Z" />
<path android:fillColor="#A5F3FC" android:pathData="M82,53 L98,53 L98,132 L82,132 Z" />
<path android:fillColor="#67E8F9" android:pathData="M110,74 L126,74 L126,132 L110,132 Z" />
<path android:fillColor="#E2E8F0" android:pathData="M150,64 L160,64 L160,86 L180,86 L180,64 L190,64 L190,120 L180,120 L180,97 L160,97 L160,120 L150,120 Z" />
<path android:fillColor="#E2E8F0" android:pathData="M203,64 L238,64 L238,75 L213,75 L213,87 L234,87 L234,98 L213,98 L213,120 L203,120 Z" />
<path android:fillColor="#E2E8F0" android:pathData="M250,64 L280,64 Q290,64 290,74 L290,110 Q290,120 280,120 L250,120 Q240,120 240,110 L240,74 Q240,64 250,64 Z" />
<path android:fillColor="#07101D" android:pathData="M254,75 L276,75 Q280,75 280,79 L280,105 Q280,109 276,109 L254,109 Q250,109 250,105 L250,79 Q250,75 254,75 Z" />
<path android:fillColor="#67E8F9" android:pathData="M150,132 L290,132 L290,137 L150,137 Z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hobbs\' Family Organizer App</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DaylineTV" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">#070B12</item>
<item name="android:colorAccent">#67E8F9</item>
<item name="android:navigationBarColor">#070B12</item>
<item name="android:statusBarColor">#070B12</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
@@ -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,42 @@
package com.dayline.tv;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.net.URI;
public final class DashboardConfigTest {
@Test
public void acceptsPrivateLanHosts() {
assertTrue(DashboardConfig.isPrivateLanHost("10.0.0.10"));
assertTrue(DashboardConfig.isPrivateLanHost("172.16.0.1"));
assertTrue(DashboardConfig.isPrivateLanHost("172.31.255.254"));
assertTrue(DashboardConfig.isPrivateLanHost("192.168.86.50"));
assertTrue(DashboardConfig.isPrivateLanHost("organizer.home.arpa"));
assertTrue(DashboardConfig.isPrivateLanHost("organizer.local"));
assertTrue(DashboardConfig.isPrivateLanHost("organizer.lan"));
assertTrue(DashboardConfig.isPrivateLanHost("fd12:3456::10"));
}
@Test
public void rejectsPublicOrDeceptiveHosts() {
assertFalse(DashboardConfig.isPrivateLanHost("8.8.8.8"));
assertFalse(DashboardConfig.isPrivateLanHost("172.32.0.1"));
assertFalse(DashboardConfig.isPrivateLanHost("192.168.86.999"));
assertFalse(DashboardConfig.isPrivateLanHost("organizer.lan.example.com"));
assertFalse(DashboardConfig.isPrivateLanHost("example.com"));
}
@Test
public void sameOriginRequiresMatchingSchemeHostAndEffectivePort() throws Exception {
URI expected = new URI("https://organizer.home.arpa/");
assertTrue(DashboardConfig.sameOrigin(expected, new URI("https://ORGANIZER.home.arpa/api/weather")));
assertTrue(DashboardConfig.sameOrigin(expected, new URI("https://organizer.home.arpa:443/assets/app.js")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("http://organizer.home.arpa/")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://organizer.home.arpa:8443/")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://other.home.arpa/")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://user@organizer.home.arpa/")));
}
}