Dashboard Studio 2.0.0: Admin, Builder and Dashboard screens, roles, Haven
- Home Assistant administrators are admins: Dashboard, Builder and Admin screens - Everyone else only sees the dashboards an admin chose, always in kiosk mode; the server enforces it (saving, people, devices, history are admin-only, and the websocket only allows reading states and controlling devices) - Admin screen: dashboards, people and what each sees (with preview), devices, messages to the screens, the Haven app, activity, about - Haven: new Android app (haven/) that only shows dashboards; replaces the Dashboard Studio Android app and signs in through Home Assistant's page - Screens pick up dashboard and access changes within a minute - Version history records who made each change; panel visible to all users Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -0,0 +1 @@
|
||||
build/
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="nl.melvanveen.haven"
|
||||
android:versionCode="1"
|
||||
android:versionName="2.0.0">
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:allowBackup="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- "Open in Haven" in Dashboard Studio: haven://open?url=<the address of Dashboard Studio> -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="haven" android:host="open" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,66 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; min-height: 100vh; background: linear-gradient(180deg, #17384a 0%, #0d1d29 60%); color: #f4eee4; font: 16px/1.45 system-ui, sans-serif; display: flex; align-items: center; justify-content: center; }
|
||||
main { width: 100%; max-width: 420px; padding: 28px 22px; box-sizing: border-box; }
|
||||
img { width: 80px; height: 80px; border-radius: 20px; display: block; margin-bottom: 18px; box-shadow: 0 10px 30px rgba(0, 0, 0, .35); }
|
||||
.name { font-size: 15px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #f4a531; margin: 0 0 4px; }
|
||||
h1 { font-size: 26px; margin: 0 0 8px; }
|
||||
p { margin: 0 0 18px; color: #a9b8c2; }
|
||||
.msg { background: rgba(255, 69, 58, .15); color: #ffb4ae; padding: 10px 14px; border-radius: 12px; }
|
||||
.msg:empty { display: none; }
|
||||
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
|
||||
input { width: 100%; box-sizing: border-box; font: inherit; color: inherit; background: rgba(255, 255, 255, .07); border: 1px solid rgba(255, 255, 255, .14); border-radius: 12px; padding: 12px 14px; }
|
||||
input:focus { outline: 2px solid #f4a531; border-color: transparent; }
|
||||
.hint { font-size: 13px; margin: 8px 0 22px; }
|
||||
button { width: 100%; font: inherit; font-weight: 700; color: #2b1a02; background: #f4a531; border: 0; border-radius: 12px; padding: 13px; }
|
||||
button:active { opacity: .85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img src="icon.png" alt="">
|
||||
<p class="name">Haven</p>
|
||||
<h1 data-t="title">Your home’s dashboards</h1>
|
||||
<p data-t="intro">Enter the address you use to open Home Assistant, then sign in with your own login. You only do this once.</p>
|
||||
<p class="msg" id="msg"></p>
|
||||
<form id="f">
|
||||
<label for="addr" data-t="label">Home Assistant address</label>
|
||||
<input id="addr" type="text" inputmode="url" autocomplete="url" autocapitalize="off" spellcheck="false" placeholder="http://homeassistant.local:8123" required>
|
||||
<p class="hint" data-t="hint">Ask whoever set up Home Assistant: it’s on the Admin screen of Dashboard Studio, under Haven app.</p>
|
||||
<button type="submit" data-t="go">Connect</button>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
const NL = {
|
||||
title: "De dashboards van je huis",
|
||||
intro: "Vul het adres in waarmee je Home Assistant opent en log in met je eigen account. Dat doe je maar één keer.",
|
||||
label: "Adres van Home Assistant",
|
||||
hint: "Vraag het aan wie Home Assistant heeft ingesteld: het staat in Dashboard Studio op het beheerscherm, onder Haven-app.",
|
||||
go: "Verbinden",
|
||||
offline: "Home Assistant is niet bereikbaar op dit adres. Controleer het adres en je verbinding.",
|
||||
notfound: "Dashboard Studio staat niet in Home Assistant. Vraag wie Home Assistant beheert om het te installeren.",
|
||||
noaddress: "Dashboard Studio is gevonden, maar het adres kon niet worden opgevraagd. Tik in Dashboard Studio op Openen in Haven, of vraag wie Home Assistant beheert.",
|
||||
error: "Verbinden lukte niet: {0}",
|
||||
};
|
||||
const EN = {
|
||||
offline: "Home Assistant can't be reached at this address. Check the address and your connection.",
|
||||
notfound: "Dashboard Studio isn't installed in Home Assistant. Ask whoever manages Home Assistant to install it.",
|
||||
noaddress: "Dashboard Studio was found, but its address couldn't be looked up. Tap Open in Haven in Dashboard Studio, or ask whoever manages Home Assistant.",
|
||||
error: "Couldn't connect: {0}",
|
||||
};
|
||||
const T = navigator.language.startsWith("nl") ? NL : EN;
|
||||
if (T === NL) document.querySelectorAll("[data-t]").forEach((el) => { el.textContent = NL[el.dataset.t] || el.textContent; });
|
||||
const app = window.HavenApp;
|
||||
const why = app ? app.message() : "", detail = app ? app.detail() : "";
|
||||
if (why) document.getElementById("msg").textContent = (T[why] || T.error).replace("{0}", detail || why);
|
||||
const addr = document.getElementById("addr");
|
||||
addr.value = app ? app.address() : "";
|
||||
document.getElementById("f").onsubmit = (e) => { e.preventDefault(); app.setup(addr.value.trim()); };
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
# Builds Haven, the Android app, without Gradle, with the Android SDK's own tools, and copies it into the
|
||||
# Home Assistant app, which offers it for download (Admin, Haven app; and Install as an app on Android).
|
||||
# ./build.sh the release build, signed with ~/.android/dashboard-studio.jks (made the first time)
|
||||
# ./build.sh debug a debuggable build, for testing in the emulator (Chrome can inspect its pages)
|
||||
# Keep the key: Android only installs an update that is signed with the same key as the installed app.
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
|
||||
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
|
||||
KEY="${DS_KEYSTORE:-$HOME/.android/dashboard-studio.jks}"
|
||||
OUT=build
|
||||
rm -rf "$OUT" && mkdir -p "$OUT/res" "$OUT/classes" "$OUT/gen"
|
||||
|
||||
DEBUG=""
|
||||
[ "$1" = "debug" ] && DEBUG="--debug-mode"
|
||||
"$BT/aapt2" compile --dir res -o "$OUT/res"
|
||||
"$BT/aapt2" link -o "$OUT/app.unsigned.apk" -I "$JAR" --manifest AndroidManifest.xml -A assets \
|
||||
--java "$OUT/gen" $DEBUG "$OUT"/res/*.flat
|
||||
javac -nowarn --release 17 -classpath "$JAR" -d "$OUT/classes" $(find src "$OUT/gen" -name '*.java')
|
||||
"$BT/d8" --release --min-api 24 --lib "$JAR" --output "$OUT" $(find "$OUT/classes" -name '*.class')
|
||||
(cd "$OUT" && zip -q app.unsigned.apk classes.dex)
|
||||
"$BT/zipalign" -f -p 4 "$OUT/app.unsigned.apk" "$OUT/app.aligned.apk"
|
||||
|
||||
if [ ! -f "$KEY" ]; then
|
||||
mkdir -p "$(dirname "$KEY")"
|
||||
keytool -genkeypair -keystore "$KEY" -storepass dashboard-studio -keypass dashboard-studio -alias app \
|
||||
-keyalg RSA -keysize 4096 -validity 36500 -dname "CN=Dashboard Studio" >/dev/null
|
||||
echo "Made a new signing key: $KEY (keep it, updates need the same key)"
|
||||
fi
|
||||
"$BT/apksigner" sign --ks "$KEY" --ks-pass pass:dashboard-studio --ks-key-alias app --out "$OUT/haven.apk" "$OUT/app.aligned.apk"
|
||||
if [ -z "$DEBUG" ]; then
|
||||
cp "$OUT/haven.apk" ../dashboard_studio/web/haven.apk
|
||||
echo "Built and copied to dashboard_studio/web/haven.apk"
|
||||
else
|
||||
echo "Built $OUT/haven.apk (debug)"
|
||||
fi
|
||||
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient android:angle="270" android:startColor="#245268" android:endColor="#0d1d29" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/ic_bg" />
|
||||
<item android:gravity="center" android:width="160dp" android:height="160dp" android:drawable="@drawable/ic_fg" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_bg" />
|
||||
<foreground android:drawable="@drawable/ic_fg" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Haven</string>
|
||||
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/loading</item>
|
||||
<item name="android:statusBarColor">@color/bg</item>
|
||||
<item name="android:navigationBarColor">@color/bg</item>
|
||||
<item name="android:colorAccent">#f4a531</item>
|
||||
</style>
|
||||
<color name="bg">#0d1d29</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,328 @@
|
||||
package nl.melvanveen.haven;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.graphics.Color;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.CookieManager;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.ValueCallback;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Haven: the dashboards of Dashboard Studio as an Android app, for everyone in the home. Full screen, and only
|
||||
* the dashboards: Dashboard Studio leaves out its builder and admin screen when window.HavenApp is there.
|
||||
*
|
||||
* Home Assistant only lets a signed-in browser into an app's page (its "ingress"). So the app first opens
|
||||
* Home Assistant itself, where you sign in once; then, on Home Assistant's page, it asks for an ingress
|
||||
* session the way Home Assistant's own page does, and opens Dashboard Studio. From then on Dashboard Studio
|
||||
* renews that session itself, with the login Home Assistant kept in this app.
|
||||
*/
|
||||
public class MainActivity extends Activity {
|
||||
private static final String SETUP = "file:///android_asset/setup.html";
|
||||
private static final int PICK_FILE = 1;
|
||||
|
||||
private WebView web;
|
||||
private SharedPreferences prefs;
|
||||
private String message = "", detail = "";
|
||||
private long connectedAt = 0;
|
||||
private ValueCallback<Uri[]> fileCallback;
|
||||
|
||||
/** Runs on Home Assistant's own page: wait until it's signed in, open an ingress session, then go to Dashboard Studio. */
|
||||
private static final String CONNECT_JS = """
|
||||
(function () {
|
||||
if (window.__dsConnect) return;
|
||||
window.__dsConnect = true;
|
||||
const known = HavenApp.appUrl();
|
||||
let tries = 0;
|
||||
const tick = async () => {
|
||||
const hass = document.querySelector("home-assistant")?.hass;
|
||||
if (!hass?.connected || !hass.panels || !Object.keys(hass.panels).length) {
|
||||
if (++tries < 1200) setTimeout(tick, 250);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// keep the login (as "Keep me logged in" does), so Dashboard Studio can renew its session later
|
||||
if (!localStorage.getItem("hassTokens") && hass.auth?.data) localStorage.setItem("hassTokens", JSON.stringify(hass.auth.data));
|
||||
let url = known;
|
||||
if (!url) {
|
||||
const panel = Object.values(hass.panels).find((p) => /(^|_)dashboard_studio$/.test(p.config?.ingress || ""));
|
||||
if (!panel) return HavenApp.fail("notfound", "");
|
||||
let info;
|
||||
try { info = await hass.callWS({ type: "supervisor/api", endpoint: `/addons/${panel.config.ingress}/info`, method: "get" }); }
|
||||
catch { info = (await hass.callApi("GET", `hassio/addons/${panel.config.ingress}/info`).catch(() => null))?.data; }
|
||||
if (!info?.ingress_url) return HavenApp.fail("noaddress", "");
|
||||
url = location.origin + info.ingress_url;
|
||||
}
|
||||
// The websocket gives everyone a session; the REST address only works for administrators.
|
||||
let session;
|
||||
try { session = (await hass.callWS({ type: "supervisor/api", endpoint: "/ingress/session", method: "post" }))?.session; }
|
||||
catch { const res = await hass.callApi("POST", "hassio/ingress/session"); session = res?.data?.session || res?.session; }
|
||||
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
|
||||
HavenApp.open(url);
|
||||
} catch (e) {
|
||||
HavenApp.fail("error", String(e?.message || e?.body?.message || e?.code || e));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
})();
|
||||
""";
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
prefs = getSharedPreferences("app", MODE_PRIVATE);
|
||||
if ((getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) WebView.setWebContentsDebuggingEnabled(true);
|
||||
|
||||
web = new WebView(this);
|
||||
web.setBackgroundColor(Color.TRANSPARENT); // the loading screen shows through until a page is ready
|
||||
setContentView(web);
|
||||
WebSettings s = web.getSettings();
|
||||
s.setJavaScriptEnabled(true);
|
||||
s.setDomStorageEnabled(true);
|
||||
s.setDatabaseEnabled(true);
|
||||
s.setMediaPlaybackRequiresUserGesture(false); // camera streams and the doorbell sound
|
||||
s.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
|
||||
s.setUserAgentString(s.getUserAgentString() + " HavenApp/2.0");
|
||||
CookieManager.getInstance().setAcceptCookie(true);
|
||||
web.addJavascriptInterface(new Bridge(), "HavenApp");
|
||||
web.setWebViewClient(new Client());
|
||||
web.setWebChromeClient(new Chrome());
|
||||
|
||||
if (!fromLink(getIntent()) && state == null) start();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
fromLink(intent);
|
||||
}
|
||||
|
||||
/** "Open in Haven" in Dashboard Studio: haven://open?url=... */
|
||||
private boolean fromLink(Intent intent) {
|
||||
Uri data = intent == null ? null : intent.getData();
|
||||
if (data == null || !"haven".equals(data.getScheme())) return false;
|
||||
String url = data.getQueryParameter("url");
|
||||
if (url == null || !url.matches("(?i)https?://.+")) return false;
|
||||
save(origin(Uri.parse(url)), url);
|
||||
start();
|
||||
return true;
|
||||
}
|
||||
|
||||
private String ha() { return prefs.getString("ha", ""); }
|
||||
private String app() { return prefs.getString("app", ""); }
|
||||
private void save(String ha, String app) { prefs.edit().putString("ha", ha).putString("app", app).apply(); }
|
||||
|
||||
private static String origin(Uri u) {
|
||||
return u.getScheme() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
|
||||
}
|
||||
private static boolean isIngress(String url) { return url != null && url.contains("/api/hassio_ingress/"); }
|
||||
|
||||
/** Always through Home Assistant's own page: it gets a fresh session first, so Dashboard Studio's page
|
||||
* never opens signed out (its offline copy can't help then, and the WebView wouldn't say it failed). */
|
||||
private void start() {
|
||||
message = ""; detail = "";
|
||||
offlineTried = false;
|
||||
if (ha().isEmpty()) { web.loadUrl(SETUP); return; }
|
||||
connect();
|
||||
}
|
||||
|
||||
private void connect() {
|
||||
connectedAt = System.currentTimeMillis();
|
||||
web.loadUrl(ha() + "/");
|
||||
}
|
||||
|
||||
/** While Home Assistant's page does its work, the loading screen (the window background) shows instead. */
|
||||
private void hidePage() {
|
||||
web.setAlpha(0f);
|
||||
reveal.removeCallbacks(showPage);
|
||||
reveal.postDelayed(showPage, 6000); // still there after a while: probably something to read or answer
|
||||
}
|
||||
private final android.os.Handler reveal = new android.os.Handler(android.os.Looper.getMainLooper());
|
||||
private final Runnable showPage = () -> web.animate().alpha(1f).setDuration(150);
|
||||
private boolean offlineTried = false;
|
||||
|
||||
private void showSetup(String why, String more) {
|
||||
message = why; detail = more == null ? "" : more;
|
||||
web.loadUrl(SETUP);
|
||||
}
|
||||
|
||||
private class Client extends WebViewClient {
|
||||
@Override
|
||||
public void onPageStarted(WebView view, String url, android.graphics.Bitmap icon) {
|
||||
boolean haPage = url != null && !ha().isEmpty() && url.startsWith(ha()) && !isIngress(url) && !url.equals(app().isEmpty() ? "-" : app());
|
||||
if (haPage && !url.contains("/auth/")) hidePage();
|
||||
else if (url != null && url.contains("/auth/")) { reveal.removeCallbacks(showPage); web.setAlpha(1f); } // signing in
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
if (url != null && (isIngress(url) || url.startsWith("file:"))) { reveal.removeCallbacks(showPage); web.animate().alpha(1f).setDuration(150); }
|
||||
if (url == null || ha().isEmpty() || !url.startsWith(ha())) return;
|
||||
String page = app().replaceAll("[?#].*", "");
|
||||
if (isIngress(url) || (!page.isEmpty() && url.startsWith(page))) view.clearHistory(); // back leaves the app instead of going to Home Assistant
|
||||
else view.evaluateJavascript(CONNECT_JS, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedHttpError(WebView view, WebResourceRequest req, WebResourceResponse res) {
|
||||
if (!req.isForMainFrame() || !isIngress(req.getUrl().toString())) return;
|
||||
int code = res.getStatusCode();
|
||||
if (code != 401 && code != 403) return;
|
||||
// The ingress session ran out: get a new one from Home Assistant's page, unless that just failed.
|
||||
if (System.currentTimeMillis() - connectedAt < 20000) showSetup("error", "Home Assistant refused the session (" + code + ")");
|
||||
else connect();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceivedError(WebView view, WebResourceRequest req, WebResourceError err) {
|
||||
if (!req.isForMainFrame() || req.getUrl().toString().startsWith("file:")) return;
|
||||
// Home Assistant can't be reached: Dashboard Studio's offline copy may still open, with the last known states.
|
||||
if (!isIngress(req.getUrl().toString()) && !app().isEmpty() && !offlineTried) { offlineTried = true; web.loadUrl(app()); }
|
||||
else { reveal.removeCallbacks(showPage); web.setAlpha(1f); showSetup("offline", ""); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest req) {
|
||||
Uri u = req.getUrl();
|
||||
String scheme = u.getScheme() == null ? "" : u.getScheme();
|
||||
if ((scheme.equals("http") || scheme.equals("https")) && !ha().isEmpty() && origin(u).equalsIgnoreCase(ha())) return false;
|
||||
if (scheme.equals("file")) return false;
|
||||
// other sites and apps: the browser or the app they belong to
|
||||
try {
|
||||
Intent i = scheme.equals("intent") ? Intent.parseUri(u.toString(), Intent.URI_INTENT_SCHEME) : new Intent(Intent.ACTION_VIEW, u);
|
||||
i.addCategory(Intent.CATEGORY_BROWSABLE);
|
||||
i.setComponent(null);
|
||||
startActivity(i);
|
||||
} catch (Exception ignored) { /* nothing to open it with */ }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private class Chrome extends WebChromeClient {
|
||||
@Override
|
||||
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> callback, FileChooserParams params) {
|
||||
if (fileCallback != null) fileCallback.onReceiveValue(null);
|
||||
fileCallback = callback;
|
||||
try {
|
||||
startActivityForResult(params.createIntent(), PICK_FILE);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
fileCallback = null;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int request, int result, Intent data) {
|
||||
if (request == PICK_FILE && fileCallback != null) {
|
||||
fileCallback.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(result, data));
|
||||
fileCallback = null;
|
||||
} else super.onActivityResult(request, result, data);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (web.canGoBack()) web.goBack();
|
||||
else super.onBackPressed();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() { super.onPause(); web.onPause(); CookieManager.getInstance().flush(); }
|
||||
|
||||
@Override
|
||||
protected void onResume() { super.onResume(); web.onResume(); }
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); }
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(Bundle state) {
|
||||
super.onRestoreInstanceState(state);
|
||||
if (web.restoreState(state) == null) start();
|
||||
}
|
||||
|
||||
/** What the pages can call, as window.HavenApp. */
|
||||
private class Bridge {
|
||||
@JavascriptInterface public String address() { return ha(); }
|
||||
@JavascriptInterface public String appUrl() { return app(); }
|
||||
@JavascriptInterface public String message() { return message; }
|
||||
@JavascriptInterface public String detail() { return detail; }
|
||||
|
||||
/** The setup page: a Home Assistant address, or the link from Dashboard Studio. */
|
||||
@JavascriptInterface public void setup(String input) {
|
||||
runOnUiThread(() -> {
|
||||
String text = input == null ? "" : input.trim();
|
||||
if (text.isEmpty()) return;
|
||||
if (!text.matches("(?i)https?://.*")) text = "http://" + text;
|
||||
Uri u = Uri.parse(text);
|
||||
if (u.getHost() == null || u.getHost().isEmpty()) { showSetup("offline", ""); return; }
|
||||
save(origin(u), isIngress(text) ? text : "");
|
||||
start();
|
||||
});
|
||||
}
|
||||
|
||||
@JavascriptInterface public void open(String url) {
|
||||
runOnUiThread(() -> {
|
||||
if (url == null || !url.startsWith(ha())) return;
|
||||
save(ha(), url);
|
||||
web.loadUrl(url);
|
||||
});
|
||||
}
|
||||
|
||||
@JavascriptInterface public void fail(String why, String more) { runOnUiThread(() -> showSetup(why, more)); }
|
||||
|
||||
/** Dashboard Studio couldn't renew its session: sign in again on Home Assistant's page. */
|
||||
@JavascriptInterface public void reconnect() { runOnUiThread(MainActivity.this::connect); }
|
||||
|
||||
/** Dashboard Studio, in Haven: Install as an app, Change address. */
|
||||
@JavascriptInterface public void reset() { runOnUiThread(() -> { message = ""; detail = ""; web.loadUrl(SETUP); }); }
|
||||
|
||||
/** Kiosk mode: keep the screen on and hide the system bars. */
|
||||
@JavascriptInterface public void keepAwake(boolean on) {
|
||||
runOnUiThread(() -> {
|
||||
View decor = getWindow().getDecorView();
|
||||
if (on) {
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
|
||||
} else {
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
decor.setSystemUiVisibility(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The status and navigation bars take the dashboard's background color. */
|
||||
@JavascriptInterface public void setBarColor(String css) {
|
||||
runOnUiThread(() -> {
|
||||
try {
|
||||
String c = css.trim();
|
||||
if (c.matches("#[0-9a-fA-F]{3}")) c = "#" + c.charAt(1) + c.charAt(1) + c.charAt(2) + c.charAt(2) + c.charAt(3) + c.charAt(3);
|
||||
if (c.matches("#[0-9a-fA-F]{8}")) c = "#" + c.substring(7) + c.substring(1, 7); // CSS #rrggbbaa, Android #aarrggbb
|
||||
int color = Color.parseColor(c) | 0xff000000;
|
||||
getWindow().setStatusBarColor(color);
|
||||
getWindow().setNavigationBarColor(color);
|
||||
double light = 0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color);
|
||||
View decor = getWindow().getDecorView();
|
||||
int flags = decor.getSystemUiVisibility() & ~(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
|
||||
if (light > 150) flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
|
||||
decor.setSystemUiVisibility(flags);
|
||||
} catch (IllegalArgumentException ignored) { /* not a color Android knows */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||