Enter the address you use to open Home Assistant. You sign in once; the app remembers it.
+
+
+
+
+
+
diff --git a/android/build.sh b/android/build.sh
new file mode 100755
index 0000000..75caf48
--- /dev/null
+++ b/android/build.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+# Builds 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 (Install as an app on an Android phone).
+# ./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/dashboard-studio.apk" "$OUT/app.aligned.apk"
+if [ -z "$DEBUG" ]; then
+ cp "$OUT/dashboard-studio.apk" ../dashboard_studio/web/dashboard-studio.apk
+ echo "Built and copied to dashboard_studio/web/dashboard-studio.apk"
+else
+ echo "Built $OUT/dashboard-studio.apk (debug)"
+fi
diff --git a/android/res/drawable-xxxhdpi/ic_fg.png b/android/res/drawable-xxxhdpi/ic_fg.png
new file mode 100644
index 0000000..276a6f2
Binary files /dev/null and b/android/res/drawable-xxxhdpi/ic_fg.png differ
diff --git a/android/res/drawable/ic_bg.xml b/android/res/drawable/ic_bg.xml
new file mode 100644
index 0000000..4ca414c
--- /dev/null
+++ b/android/res/drawable/ic_bg.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/res/mipmap-anydpi-v26/ic_launcher.xml b/android/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..d197ca3
--- /dev/null
+++ b/android/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/res/mipmap-hdpi/ic_launcher.png b/android/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..c5432e1
Binary files /dev/null and b/android/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/res/mipmap-mdpi/ic_launcher.png b/android/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..38890a0
Binary files /dev/null and b/android/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/res/mipmap-xhdpi/ic_launcher.png b/android/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..4525c57
Binary files /dev/null and b/android/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/res/mipmap-xxhdpi/ic_launcher.png b/android/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..99486d4
Binary files /dev/null and b/android/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/res/mipmap-xxxhdpi/ic_launcher.png b/android/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..fa80204
Binary files /dev/null and b/android/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/res/values/values.xml b/android/res/values/values.xml
new file mode 100644
index 0000000..dbd0745
--- /dev/null
+++ b/android/res/values/values.xml
@@ -0,0 +1,11 @@
+
+
+ Dashboards
+
+ #0f1526
+
diff --git a/android/src/nl/melvanveen/dashboardstudio/MainActivity.java b/android/src/nl/melvanveen/dashboardstudio/MainActivity.java
new file mode 100644
index 0000000..5f4a7f9
--- /dev/null
+++ b/android/src/nl/melvanveen/dashboardstudio/MainActivity.java
@@ -0,0 +1,299 @@
+package nl.melvanveen.dashboardstudio;
+
+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;
+
+/**
+ * Dashboard Studio as an Android app: a full-screen window on the Dashboard Studio app inside Home Assistant.
+ *
+ * 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 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 = DSApp.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 DSApp.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 DSApp.fail("noaddress", "");
+ url = location.origin + info.ingress_url;
+ }
+ const res = await hass.callApi("POST", "hassio/ingress/session");
+ const session = res?.data?.session || res?.session;
+ document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
+ DSApp.open(url);
+ } catch (e) {
+ DSApp.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.parseColor("#0f1526"));
+ 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() + " DashboardStudioApp/1.0");
+ CookieManager.getInstance().setAcceptCookie(true);
+ web.addJavascriptInterface(new Bridge(), "DSApp");
+ 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 the app" in Dashboard Studio: dashboardstudio://open?url=... */
+ private boolean fromLink(Intent intent) {
+ Uri data = intent == null ? null : intent.getData();
+ if (data == null || !"dashboardstudio".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/"); }
+
+ private void start() {
+ message = ""; detail = "";
+ if (ha().isEmpty()) { web.loadUrl(SETUP); return; }
+ // With the address known, try it straight away: a session may still be valid, or the page is cached for offline use.
+ web.loadUrl(app().isEmpty() ? ha() + "/" : app());
+ }
+
+ private void connect() {
+ connectedAt = System.currentTimeMillis();
+ web.loadUrl(ha() + "/");
+ }
+
+ private void showSetup(String why, String more) {
+ message = why; detail = more == null ? "" : more;
+ web.loadUrl(SETUP);
+ }
+
+ private class Client extends WebViewClient {
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ 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:")) 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 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.DSApp. */
+ 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: 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 */ }
+ });
+ }
+ }
+}
diff --git a/dashboard_studio/CHANGELOG.md b/dashboard_studio/CHANGELOG.md
index 84ef097..5e1d35f 100644
--- a/dashboard_studio/CHANGELOG.md
+++ b/dashboard_studio/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 1.14.1
+
+- **Android app:** Dashboard Studio as a real app for Android phones and tablets. Download it from **Install as an app**, sign in to Home Assistant once, and it opens full screen on your dashboard from then on, with its own icon. It works with plain http addresses too.
+- **Phone dashboards:** a round **⋯** button in the corner brings back the top bar with **Edit**, **Kiosk** and **Install as an app**, so you no longer have to find an empty spot to touch and hold.
+- **Install as an app** now says what works on your device: the app on Android, https for installing from the browser, and why it can't work from the home screen on iPhone.
+- Fixed: installing from the browser could fail with "can't be installed", because the browser fetched the app's details without the Home Assistant sign-in.
+
## 1.14.0
- **Seven new cards:** parcels on the way (PostNL, DHL), air quality per room with a hint to open a window, water and gas use per day, the solar forecast with the best time to run the washer, a garage and gate button that asks first, sports scores (TeamTracker, optionally only on match days) and Formula One (next race, sessions, standings and results).
diff --git a/dashboard_studio/DOCS.md b/dashboard_studio/DOCS.md
index 5c91510..b10dff8 100644
--- a/dashboard_studio/DOCS.md
+++ b/dashboard_studio/DOCS.md
@@ -214,12 +214,15 @@ In edit mode, hidden widgets stay visible but faded, with a **Hidden now** label
Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns.
-On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, touch and hold an empty spot for a moment; it hides again after 10 seconds. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other.
+On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, tap the round **⋯** button in the top right corner (or touch and hold an empty spot); it hides again after 15 seconds or when you tap **⋯** again. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other.
### On phones
-- **Install as an app:** **⋯ → Install as an app…** explains how. Home Assistant's own page can't be installed, so open Dashboard Studio in its own tab first (the dialog has a button for it). Installed, it opens full screen with its own icon and the dashboard's colors in the status bar. Dashboard Studio keeps Home Assistant's sign-in fresh in the background while it's open, using the login Home Assistant saved in that browser (with *Keep me logged in*). If it was closed for a long time and shows an error, open Home Assistant once.
-- **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. On Android, long-press the installed app's icon to run it straight away (up to four shortcuts; iPhones don't support this).
+- **Install as an app:** tap the phone button in the top bar (or **⋯ → Install as an app…** in edit mode). What it offers depends on the phone:
+ - **Android:** the Dashboard Studio app. Tap **Download the app**, open the downloaded file and allow installing apps from that source when Android asks (once). Then come back and tap **Open in the app**, or open the app and enter the address you use for Home Assistant. Sign in to Home Assistant once in the app; after that it opens straight on Dashboard Studio, full screen with its own icon and the dashboard's colors in the status bar, and keeps you signed in. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant, choose **Install as an app → Change address** in the app. The app finds Dashboard Studio by itself if you sign in as an administrator; other people can tap **Open in the app** on their phone instead.
+ - **From the browser** (Android and computers): the browser can also install Dashboard Studio as a web app, but only when Home Assistant is opened over **https** (for example Home Assistant Cloud or your own certificate). Home Assistant's own page can't be installed, so open Dashboard Studio in its own tab first (the dialog has a button for it).
+ - **iPhone and iPad:** apps on the home screen get their own storage there, without Home Assistant's sign-in, so they can't open an app that runs inside Home Assistant. Use Dashboard Studio from the sidebar in the Home Assistant app.
+- **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. When Dashboard Studio is installed from the browser on Android, long-press its icon to run one straight away (up to four shortcuts; the Android app and iPhones don't support this).
- **Offline:** each device keeps the last known states of its dashboard. When the connection drops, the dashboard keeps showing them with a small note saying from when, and catches up when it reconnects.
- **Swipe** left or right over the dashboard to go to the next or previous tab (not over cards that swipe or scroll themselves).
- **Pull down** at the top of the dashboard to reload every state.
diff --git a/dashboard_studio/config.yaml b/dashboard_studio/config.yaml
index 243b6cd..b28e56b 100644
--- a/dashboard_studio/config.yaml
+++ b/dashboard_studio/config.yaml
@@ -1,5 +1,5 @@
name: Dashboard Studio
-version: "1.14.0"
+version: "1.14.1"
slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
diff --git a/dashboard_studio/server/main.py b/dashboard_studio/server/main.py
index 6f425b0..0b53888 100644
--- a/dashboard_studio/server/main.py
+++ b/dashboard_studio/server/main.py
@@ -234,6 +234,13 @@ async def service_worker(request):
return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"})
+async def android_app(request):
+ """The Android app (built from android/ in the repository), to download on a phone."""
+ return web.FileResponse(WEB_DIR / "dashboard-studio.apk", headers={
+ "Content-Type": "application/vnd.android.package-archive",
+ "Content-Disposition": 'attachment; filename="Dashboard Studio.apk"', "Cache-Control": "no-store"})
+
+
async def get_dashboards(request):
return web.json_response(load_store())
@@ -372,6 +379,7 @@ def make_app():
app.router.add_get("/api/version", version)
app.router.add_get("/manifest.webmanifest", manifest)
app.router.add_get("/sw.js", service_worker)
+ app.router.add_get("/android.apk", android_app)
app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/users", users)
diff --git a/dashboard_studio/web/app.js b/dashboard_studio/web/app.js
index 1336b55..a7d33ce 100644
--- a/dashboard_studio/web/app.js
+++ b/dashboard_studio/web/app.js
@@ -246,21 +246,50 @@
let installPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
+ // The Android app (android/ in the repository) shows this page in its own window and adds window.DSApp.
+ const IN_APP = !!window.DSApp;
+ const APK_ID = "nl.melvanveen.dashboardstudio";
async function installDialog() {
let inFrame = false;
try { inFrame = window.top !== window; } catch { inFrame = true; }
- const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
- const how = inFrame
- ? tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there.")
- : ios ? tr("In Safari, tap the Share button, then Add to Home Screen.")
- : installPrompt ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.")
- : tr("In your browser’s menu, choose Install app or Add to Home screen.");
- const choice = await openModal(`
${tr("Install as an app")}
${esc(how)}
-
${tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon.")}
`;
+ let body, actions = ``;
+ if (IN_APP) {
+ body = p(tr("This is the Dashboard Studio app, connected to {0}.", location.host));
+ actions += ``;
+ } else if (android) {
+ // A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: the Android app always works.
+ body = p(tr("Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in."))
+ + `
${esc(tr("Download the app and open the file to install it. Android asks once to allow apps from this source."))}
+
${esc(tr("Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant."))}
`
+ + (browserInstall ? p(tr("Or install it from the browser, without the app."), "muted small") : "");
+ actions += (browserInstall ? `` : "")
+ + `${tr("Download the app")}`
+ + `${tr("Open in the app")}`;
+ } else if (ios) {
+ // Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant.
+ body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead."));
+ } else if (inFrame) {
+ body = p(tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there."));
+ actions += ``;
+ } else if (!window.isSecureContext) {
+ body = p(tr("Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again."));
+ } else {
+ body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen."));
+ if (browserInstall) actions += ``;
+ }
+ const choice = await openModal(`
${tr("Install as an app")}
${body}
+ ${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
+
${actions}
`,
+ (e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
+ if (choice === "tab") window.open(link, "_blank", "noopener");
if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; }
+ if (choice === "reset") window.DSApp.reset();
}
/** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so
@@ -770,7 +799,9 @@
const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)";
if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`);
s.setProperty("--d-radius", `${t.radius ?? 20}px`);
- document.querySelector('meta[name="theme-color"]')?.setAttribute("content", /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7");
+ const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7";
+ document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar);
+ window.DSApp?.setBarColor(bar);
s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`);
stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System");
s.setProperty("--d-blur", `${t.blur || 0}px`);
@@ -2450,6 +2481,7 @@
async function requestWakeLock() {
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; }
+ window.DSApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
}
const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " ");
@@ -2474,6 +2506,7 @@
closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null;
+ window.DSApp?.keepAwake(false);
if (document.fullscreenElement) document.exitFullscreen?.();
history.replaceState(null, "", location.pathname);
renderGrid();
@@ -2880,18 +2913,18 @@
canvasEl.addEventListener("pointerdown", (e) => {
if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return;
holdTimer2 = setTimeout(() => {
- document.body.classList.add("peek");
- clearTimeout(peekTimer);
- peekTimer = setTimeout(() => document.body.classList.remove("peek"), 10000);
+ peek(true);
navigator.vibrate?.(10);
}, 600);
});
for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); });
- function phoneHint() {
- if (!document.body.classList.contains("phone-ui") || lsGet("ds.peekHint")) return;
- lsSet("ds.peekHint", "1");
- toast(tr("Touch and hold an empty spot to show the menu."));
+ // The round button in the corner does the same, for dashboards without an empty spot.
+ function peek(on) {
+ document.body.classList.toggle("peek", on);
+ clearTimeout(peekTimer);
+ if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
}
+ $("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
// ------------------------------------------------------------ chrome wiring
function setCurrent(id) {
@@ -2954,6 +2987,8 @@
$("#btn-edit").onclick = () => setEdit(!editMode);
$("#btn-kiosk").onclick = () => enterKiosk();
+ $("#btn-app").onclick = () => installDialog();
+ $("#btn-app").hidden = IN_APP || standalone();
$("#btn-undo").onclick = undo;
$("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value);
@@ -3200,6 +3235,8 @@
$("#btn-redo").innerHTML = Icons.svg("redo");
$("#btn-menu").innerHTML = Icons.svg("more");
$("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk"));
+ $("#btn-app").innerHTML = Icons.svg("phone");
+ $("#phone-menu").innerHTML = Icons.svg("more");
document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
}
@@ -3272,7 +3309,6 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
- setTimeout(phoneHint, 1500);
const action = shared.get("ds_action");
if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// After an update, point at what changed (once per device).
diff --git a/dashboard_studio/web/dashboard-studio.apk b/dashboard_studio/web/dashboard-studio.apk
new file mode 100644
index 0000000..f744a4b
Binary files /dev/null and b/dashboard_studio/web/dashboard-studio.apk differ
diff --git a/dashboard_studio/web/i18n.js b/dashboard_studio/web/i18n.js
index 19809cd..90b529d 100644
--- a/dashboard_studio/web/i18n.js
+++ b/dashboard_studio/web/i18n.js
@@ -274,7 +274,6 @@
"Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek",
"Designed for phones": "Ontworpen voor telefoons",
"A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.": "Een indeling met 4 kolommen die je op telefoongrootte ontwerpt. Op een telefoon vult hij het scherm zonder de bovenbalk; houd een lege plek ingedrukt om de balk terug te halen.",
- "Touch and hold an empty spot to show the menu.": "Houd een lege plek ingedrukt om het menu te tonen.",
"Room": "Kamer", "Room name": "Naam van de kamer", "Temperature sensor": "Temperatuursensor", "Humidity sensor": "Vochtigheidssensor",
"Extra entities (or all of them, without a room)": "Extra entiteiten (of allemaal, zonder kamer)", "Show devices": "Toon apparaten",
"Background image URL (optional)": "URL van achtergrondafbeelding (optioneel)", "Automatic": "Automatisch",
@@ -614,6 +613,18 @@
"1 device isn’t on a dashboard": "1 apparaat staat niet op een dashboard",
"{0} devices aren’t on a dashboard": "{0} apparaten staan niet op een dashboard",
"Devices not on a dashboard…": "Apparaten die niet op een dashboard staan…",
+ "Menu": "Menu",
+ "This is the Dashboard Studio app, connected to {0}.": "Dit is de Dashboard Studio-app, verbonden met {0}.",
+ "Change address": "Adres wijzigen",
+ "Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in.": "Gebruik de Dashboard Studio-app voor Android. Hij opent schermvullend met een eigen pictogram en houdt je ingelogd.",
+ "Download the app and open the file to install it. Android asks once to allow apps from this source.": "Download de app en open het bestand om hem te installeren. Android vraagt één keer of apps uit deze bron zijn toegestaan.",
+ "Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant.": "Kom hier terug en tik op Openen in de app. Of open de app en vul het adres van Home Assistant in.",
+ "Or install it from the browser, without the app.": "Of installeer hem vanuit de browser, zonder de app.",
+ "Install from the browser": "Installeren vanuit de browser",
+ "Download the app": "App downloaden",
+ "Open in the app": "Openen in de app",
+ "On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead.": "Op iPhone en iPad kunnen apps op het beginscherm de inlog van Home Assistant niet gebruiken, dus daar kan Dashboard Studio niet los draaien. Open het in plaats daarvan via de zijbalk in de Home Assistant-app.",
+ "Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again.": "Browsers installeren alleen apps vanaf een beveiligd adres (https), en Home Assistant is hier via http geopend. Open Home Assistant via het https-adres, bijvoorbeeld Home Assistant Cloud, en probeer het opnieuw.",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
};
diff --git a/dashboard_studio/web/index.html b/dashboard_studio/web/index.html
index 1cb60fb..5d1075c 100644
--- a/dashboard_studio/web/index.html
+++ b/dashboard_studio/web/index.html
@@ -9,7 +9,8 @@
Dashboard Studio
-
+
+
@@ -37,6 +38,7 @@
+
@@ -82,6 +84,7 @@