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,39 @@
# Hobbs' Family Organizer App for NVIDIA Shield
Android TV wrapper for the private Hobbs family organizer dashboard. Secrets are never stored in source or the APK; configuration is provisioned into app-private files over ADB.
## Dashboard configuration
The wrapper supports both the hosted Site and a self-hosted server:
- No `files/base_url`: use `https://dayline-tv-jeremy.tautfoal1.chatgpt.site/` with the legacy `files/service_token` and `OAI-Sites-Authorization` header.
- `files/base_url` present: use that exact origin. An optional `files/access_token` is sent as `Authorization: Bearer ...`.
- HTTP additionally requires `files/allow_insecure_http` and is restricted to private LAN IPs or `.local`, `.lan`, and `.home.arpa` names.
Every navigation, resource request, and redirect is restricted to the configured scheme, host, and effective port. TLS errors are always rejected. The network policy trusts system certificates and user-installed CAs so an internal CA can be used without disabling certificate validation.
Use `scripts/configure-shield.ps1` to write local settings. The script streams tokens through ADB standard input so they never appear in command arguments or shell history:
```powershell
.\scripts\configure-shield.ps1 `
-Device 192.168.86.179:5555 `
-BaseUrl https://organizer.home.arpa/ `
-PromptForToken `
-AdbPath C:\path\to\adb.exe
```
For an unauthenticated HTTP service on a trusted LAN, omit `-PromptForToken` and add `-AllowInsecureHttp`. Run `scripts/restore-hosted-site.ps1` to remove only the local settings and return to the preserved hosted token.
The daily 6:30 AM wake-and-launch schedule is enabled per device by creating the app-private `files/daily_wake_0630_enabled` flag. When enabled, the alarm is restored after app updates, device restarts, and wall-clock or timezone changes. It can wake a sleeping Shield, but not one that is fully powered off or disconnected from power. HDMI-CEC power control must be enabled on the Shield and television for the TV to wake with it.
## Included APK identity
- File: `Hobbs-Family-Organizer-Local-v1.4.apk`
- Package: `com.dayline.tv`
- Version: `1.4` (`versionCode 5`)
- APK SHA-256: `A52B91E3ED169CAD985E6F70644BD13F6DD88FFFAAA8D9F1CA47928418495412`
- Signing certificate SHA-256: `27a386f6529b25831ed4139859d308a0ea4351b16dfc92a5ffd159a4227ccafe`
The signing certificate matches the installed v1.3 build, so `adb install -r` performs an in-place upgrade. The private signing key is not included.
This internal wrapper is a debuggable build because its provisioning and rollback scripts use Android's `run-as` command to write app-private configuration without exposing tokens in ADB arguments. Disable network debugging on the Shield after provisioning and re-enable it only for controlled maintenance.
@@ -0,0 +1,227 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Device,
[Parameter(Mandatory = $true)]
[string]$BaseUrl,
[string]$AdbPath = "adb",
[switch]$AllowInsecureHttp,
[switch]$PromptForToken,
[string]$TokenFile,
[switch]$NoLaunch
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ($PromptForToken -and $PSBoundParameters.ContainsKey("TokenFile")) {
throw "Use either -PromptForToken or -TokenFile, not both."
}
function Resolve-AdbExecutable {
param([string]$RequestedPath)
if (Test-Path -LiteralPath $RequestedPath -PathType Leaf) {
return (Resolve-Path -LiteralPath $RequestedPath).Path
}
$command = Get-Command -Name $RequestedPath -CommandType Application -ErrorAction SilentlyContinue
if ($null -eq $command) {
throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe."
}
return $command.Source
}
function Invoke-Adb {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[AllowNull()]
[string]$StandardInput = $null
)
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $script:ResolvedAdbPath
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardInput = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) {
[void]$startInfo.ArgumentList.Add($argument)
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "ADB could not be started."
}
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
if ($null -ne $StandardInput) {
$process.StandardInput.Write($StandardInput)
}
$process.StandardInput.Close()
$process.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
if ($process.ExitCode -ne 0) {
$detail = $stderr.Trim()
if ([string]::IsNullOrWhiteSpace($detail)) {
$detail = $stdout.Trim()
}
throw "ADB failed with exit code $($process.ExitCode): $detail"
}
return $stdout.Trim()
}
function Set-AppPrivateFile {
param(
[Parameter(Mandatory = $true)]
[ValidateSet("base_url", "access_token", "allow_insecure_http")]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Value
)
$remoteCommand = "run-as com.dayline.tv sh -c 'umask 077; cat > files/.$Name.new && chmod 600 files/.$Name.new && mv files/.$Name.new files/$Name'"
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand) -StandardInput $Value)
}
function Remove-AppPrivateFile {
param(
[Parameter(Mandatory = $true)]
[ValidateSet("access_token", "allow_insecure_http")]
[string]$Name
)
$remoteCommand = "run-as com.dayline.tv sh -c 'rm -f files/$Name files/.$Name.new'"
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand))
}
function Test-PrivateLanHost {
param([string]$HostName)
$normalized = $HostName.Trim().Trim("[", "]").ToLowerInvariant()
if ($normalized -eq "localhost" -or
$normalized.EndsWith(".local") -or
$normalized.EndsWith(".lan") -or
$normalized.EndsWith(".home.arpa")) {
return $true
}
$address = $null
if (-not [System.Net.IPAddress]::TryParse($normalized, [ref]$address)) {
return $false
}
$bytes = $address.GetAddressBytes()
if ($address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) {
return $bytes[0] -eq 10 -or
$bytes[0] -eq 127 -or
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or
($bytes[0] -eq 169 -and $bytes[1] -eq 254)
}
if ($address.Equals([System.Net.IPAddress]::IPv6Loopback)) {
return $true
}
return (($bytes[0] -band 0xFE) -eq 0xFC) -or
($bytes[0] -eq 0xFE -and ($bytes[1] -band 0xC0) -eq 0x80)
}
$script:ResolvedAdbPath = Resolve-AdbExecutable -RequestedPath $AdbPath
$dashboardUri = $null
if (-not [Uri]::TryCreate($BaseUrl.Trim(), [UriKind]::Absolute, [ref]$dashboardUri)) {
throw "BaseUrl is not a valid absolute URL."
}
if ($dashboardUri.Scheme -notin @("http", "https")) {
throw "BaseUrl must use http or https."
}
if (-not [string]::IsNullOrEmpty($dashboardUri.UserInfo) -or
-not [string]::IsNullOrEmpty($dashboardUri.Query) -or
-not [string]::IsNullOrEmpty($dashboardUri.Fragment)) {
throw "BaseUrl cannot contain credentials, a query, or a fragment."
}
if ($dashboardUri.AbsolutePath -notin @("", "/")) {
throw "The organizer must be served from the URL root path."
}
if ($dashboardUri.Scheme -eq "http") {
if (-not $AllowInsecureHttp) {
throw "HTTP requires the explicit -AllowInsecureHttp switch. HTTPS is recommended."
}
if (-not (Test-PrivateLanHost -HostName $dashboardUri.Host)) {
throw "HTTP is restricted to private LAN IP addresses or .local, .lan, and .home.arpa hosts."
}
}
$normalizedBaseUrl = $dashboardUri.GetLeftPart([UriPartial]::Authority) + "/"
$plainToken = $null
try {
if ($PSBoundParameters.ContainsKey("TokenFile")) {
if (-not (Test-Path -LiteralPath $TokenFile -PathType Leaf)) {
throw "TokenFile was not found."
}
$plainToken = (Get-Content -LiteralPath $TokenFile -Raw).Trim()
} elseif ($PromptForToken) {
$secureToken = Read-Host "Local bearer token" -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
try {
$plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
if ($null -ne $plainToken -and $plainToken -notmatch "^[A-Za-z0-9._~-]{32,512}$") {
throw "The bearer token must contain 32 to 512 URL-safe characters."
}
[void](Invoke-Adb -Arguments @("connect", $Device))
$state = Invoke-Adb -Arguments @("-s", $Device, "get-state")
if ($state -ne "device") {
throw "The Shield is not connected and authorized."
}
$packagePath = Invoke-Adb -Arguments @("-s", $Device, "shell", "pm path com.dayline.tv")
if (-not $packagePath.StartsWith("package:")) {
throw "Hobbs' Family Organizer App is not installed on the selected Shield."
}
Set-AppPrivateFile -Name "base_url" -Value $normalizedBaseUrl
if ($null -ne $plainToken) {
Set-AppPrivateFile -Name "access_token" -Value $plainToken
} else {
Remove-AppPrivateFile -Name "access_token"
}
if ($dashboardUri.Scheme -eq "http") {
Set-AppPrivateFile -Name "allow_insecure_http" -Value "enabled"
} else {
Remove-AppPrivateFile -Name "allow_insecure_http"
}
if (-not $NoLaunch) {
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am force-stop com.dayline.tv"))
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am start -W -n com.dayline.tv/.MainActivity"))
}
Write-Host "Configured Hobbs' Family Organizer App on $Device"
Write-Host "Dashboard: $normalizedBaseUrl"
Write-Host "Bearer token: $(if ($null -eq $plainToken) { 'not configured' } else { 'configured' })"
Write-Host "Insecure HTTP: $(if ($dashboardUri.Scheme -eq 'http') { 'enabled for this private LAN origin' } else { 'disabled' })"
Write-Host "The legacy hosted service_token was preserved for rollback."
} finally {
$plainToken = $null
}
@@ -0,0 +1,37 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Device,
[string]$AdbPath = "adb"
)
$ErrorActionPreference = "Stop"
if (Test-Path -LiteralPath $AdbPath -PathType Leaf) {
$resolvedAdb = (Resolve-Path -LiteralPath $AdbPath).Path
} else {
$command = Get-Command -Name $AdbPath -CommandType Application -ErrorAction SilentlyContinue
if ($null -eq $command) {
throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe."
}
$resolvedAdb = $command.Source
}
& $resolvedAdb connect $Device | Out-Null
if ((& $resolvedAdb -s $Device get-state) -ne "device") {
throw "The Shield is not connected and authorized."
}
& $resolvedAdb -s $Device shell "run-as com.dayline.tv sh -c 'rm -f files/base_url files/.base_url.new files/access_token files/.access_token.new files/allow_insecure_http files/.allow_insecure_http.new'"
if ($LASTEXITCODE -ne 0) {
throw "The local configuration could not be removed."
}
& $resolvedAdb -s $Device shell am force-stop com.dayline.tv
& $resolvedAdb -s $Device shell am start -W -n com.dayline.tv/.MainActivity
if ($LASTEXITCODE -ne 0) {
throw "The organizer could not be relaunched."
}
Write-Host "Restored the hosted Site configuration on $Device. The legacy service_token was not changed."
@@ -0,0 +1,6 @@
.gradle/
.idea/
build/
app/build/
local.properties
*.iml
@@ -0,0 +1,13 @@
# Building the Android wrapper
The source targets Android SDK 35, Java 17, Android Gradle Plugin 8.7.3, and Gradle 8.9.
From this directory, with the Android SDK and Gradle installed:
```text
gradle testDebugUnitTest lintDebug assembleDebug
```
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`.
Android requires the same signing certificate for upgrades. The release bundle intentionally excludes the private key used for the prebuilt APK. Use the included signed APK for existing Shields unless you have retained the original key separately.
@@ -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/")));
}
}
@@ -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 = "HobbsFamilyOrganizer"
include(":app")