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:
@@ -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"
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
# Hobbs' Family Organizer App currently has no release-only shrinking rules.
|
||||
+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-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>
|
||||
+245
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+399
@@ -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("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
@@ -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>
|
||||
+17
@@ -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>
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Hobbs\' Family Organizer App</string>
|
||||
</resources>
|
||||
+13
@@ -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>
|
||||
+9
@@ -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>
|
||||
+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>
|
||||
+42
@@ -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/")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user