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,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"
|
||||
}
|
||||
+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/")));
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user