Dashboard Studio 2.2.0: guest invites, personal looks, more themes, Haven widgets and tiles

- Vacuum card: the robot drawn beside its dock by state, last clean, maintenance
- Formula One card: the official F1 look with a user-supplied logo
- Nine more themes; per-person theme, text size and language on Admin, People
- Guest invites: an expiring link and QR code for one dashboard, served on a
  LAN-only guest port, with the Home Assistant connection filtered both ways
- Haven 2.2: home screen widgets, three quick settings tiles, several homes,
  and self-updates from the Haven that Dashboard Studio serves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 00:31:50 +01:00
parent f4c98e27af
commit a5823b4726
26 changed files with 1514 additions and 164 deletions
+38 -2
View File
@@ -1,13 +1,15 @@
<?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">
android:versionCode="2"
android:versionName="2.2.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" />
<!-- installing a newer Haven that Dashboard Studio offers -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="@string/app_name"
@@ -33,5 +35,39 @@
<data android:scheme="haven" android:host="open" />
</intent-filter>
</activity>
<!-- choosing what a widget or a quick settings tile controls (long-press a tile to choose again) -->
<activity
android:name=".PickActivity"
android:exported="true"
android:label="Choose"
android:theme="@android:style/Theme.Material.NoActionBar">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity>
<receiver android:name=".EntityWidget" android:exported="true" android:label="Haven">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widget_info" />
</receiver>
<service android:name=".HavenTile$One" android:exported="true" android:icon="@drawable/ic_tile" android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter><action android:name="android.service.quicksettings.action.QS_TILE" /></intent-filter>
</service>
<service android:name=".HavenTile$Two" android:exported="true" android:icon="@drawable/ic_tile" android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter><action android:name="android.service.quicksettings.action.QS_TILE" /></intent-filter>
</service>
<service android:name=".HavenTile$Three" android:exported="true" android:icon="@drawable/ic_tile" android:label="@string/tile_label"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter><action android:name="android.service.quicksettings.action.QS_TILE" /></intent-filter>
</service>
</application>
</manifest>
+26
View File
@@ -19,6 +19,15 @@
.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; }
.homes { display: flex; flex-direction: column; gap: 8px; margin: 0 0 22px; }
.homes:empty { display: none; }
.home { display: flex; align-items: center; gap: 8px; }
.home .go { flex: 1; text-align: left; background: rgba(255, 255, 255, .08); color: #f4eee4; font-weight: 600; padding: 12px 14px; display: flex; flex-direction: column; }
.home .go small { font-weight: 400; color: #a9b8c2; font-size: 13px; }
.home .go.on { outline: 2px solid #f4a531; }
.home .x { width: 44px; flex: none; background: transparent; color: #a9b8c2; font-size: 20px; padding: 12px 0; }
.add { background: transparent; color: #f4a531; border: 1.5px solid rgba(244, 165, 49, .5); margin-bottom: 22px; }
#f.hide { display: none; }
</style>
</head>
<body>
@@ -28,6 +37,8 @@
<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>
<div class="homes" id="homes"></div>
<button type="button" class="add" id="add" hidden data-t="add">Add another home</button>
<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>
@@ -42,6 +53,7 @@
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",
add: "Nog een huis toevoegen",
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.",
@@ -61,6 +73,20 @@
const addr = document.getElementById("addr");
addr.value = app ? app.address() : "";
document.getElementById("f").onsubmit = (e) => { e.preventDefault(); app.setup(addr.value.trim()); };
// Several homes (a holiday home, your parents'): open one, remove one, or add another.
const homes = app ? JSON.parse(app.homes()) : [];
const box = document.getElementById("homes"), form = document.getElementById("f"), add = document.getElementById("add");
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const removeLabel = T === NL ? "Verwijderen" : "Remove";
box.innerHTML = homes.map((h) => `<div class="home"><button type="button" class="go ${h.current ? "on" : ""}" data-id="${esc(h.id)}">${esc(h.name)}<small>${esc(h.ha)}</small></button>
<button type="button" class="x" data-x="${esc(h.id)}" aria-label="${removeLabel} ${esc(h.name)}">×</button></div>`).join("");
box.onclick = (e) => {
const go = e.target.closest("[data-id]"), x = e.target.closest("[data-x]");
if (go) app.switchHome(go.dataset.id);
if (x && confirm((T === NL ? "Verwijderen: " : "Remove ") + x.closest(".home").querySelector(".go").firstChild.textContent + "?")) app.removeHome(x.dataset.x);
};
if (homes.length && homes.some((h) => h.current) && !why) { form.classList.add("hide"); add.hidden = false; }
add.onclick = () => app.addHome();
</script>
</body>
</html>
+4
View File
@@ -32,6 +32,10 @@ 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
# Haven asks Dashboard Studio for this, to offer itself an update
CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml)
NAME=$(sed -n 's/.*android:versionName="\([^"]*\)".*/\1/p' AndroidManifest.xml)
printf '{"version": "%s", "code": %s}\n' "$NAME" "$CODE" > ../dashboard_studio/web/haven.json
echo "Built and copied to dashboard_studio/web/haven.apk"
else
echo "Built $OUT/haven.apk (debug)"
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Haven's house, as a single-colour mark for quick settings tiles -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#ffffff" android:pathData="M12,3.2 L20.6,10.4 L19.3,11.9 L18,10.8 L18,19 C18,19.6 17.6,20 17,20 L14.4,20 L14.4,15.4 C14.4,14.1 13.3,13 12,13 C10.7,13 9.6,14.1 9.6,15.4 L9.6,20 L7,20 C6.4,20 6,19.6 6,19 L6,10.8 L4.7,11.9 L3.4,10.4 Z" />
</vector>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#e6142a38" />
<corners android:radius="22dp" />
</shape>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#ffffd98a" />
<corners android:radius="22dp" />
</shape>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
<stroke android:width="1.5dp" android:color="#55f4eee4" />
</shape>
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/w_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/w_bg"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="10dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/w_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Haven"
android:textColor="#f4eee4"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/w_state"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="Choose what it controls"
android:textColor="#a9b8c2"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:id="@+id/w_steps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:visibility="gone">
<TextView
android:id="@+id/w_minus"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@drawable/w_step"
android:gravity="center"
android:text="−"
android:contentDescription="Lower"
android:textColor="#f4eee4"
android:textSize="20sp" />
<TextView
android:id="@+id/w_plus"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="6dp"
android:background="@drawable/w_step"
android:gravity="center"
android:text="+"
android:contentDescription="Higher"
android:textColor="#f4eee4"
android:textSize="20sp" />
</LinearLayout>
</LinearLayout>
+2
View File
@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Haven</string>
<string name="widget_desc">A light, scene or thermostat from Home Assistant</string>
<string name="tile_label">Haven</string>
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@drawable/loading</item>
<item name="android:statusBarColor">@color/bg</item>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:configure="nl.melvanveen.haven.PickActivity"
android:initialLayout="@layout/widget_entity"
android:minWidth="180dp"
android:minHeight="56dp"
android:minResizeWidth="110dp"
android:minResizeHeight="40dp"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="3"
android:targetCellHeight="1"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
android:description="@string/widget_desc" />
@@ -0,0 +1,123 @@
package nl.melvanveen.haven;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.view.View;
import android.widget.RemoteViews;
import org.json.JSONObject;
import java.util.Locale;
/**
* A light, switch, scene or thermostat on the home screen. A tap does what it does on the dashboard
* (toggle, run, press); a thermostat gets − and + for its target. States refresh after every tap,
* when the widget is added, and every half hour.
*/
public class EntityWidget extends AppWidgetProvider {
private static final String TAP = "nl.melvanveen.haven.TAP", STEP = "nl.melvanveen.haven.STEP";
@Override
public void onUpdate(Context c, AppWidgetManager mgr, int[] ids) {
for (int id : ids) refresh(c, mgr, id);
}
@Override
public void onDeleted(Context c, int[] ids) {
for (int id : ids) Hass.prefs(c).edit().remove("widget_" + id).apply();
}
@Override
public void onReceive(Context c, Intent intent) {
super.onReceive(c, intent);
String action = intent.getAction();
if (!TAP.equals(action) && !STEP.equals(action)) return;
int id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 0);
String[] cfg = config(c, id);
if (cfg == null) return;
PendingResult done = goAsync();
Hass.BG.execute(() -> {
try {
JSONObject home = Hass.home(c, cfg[0]);
JSONObject s = Hass.state(home, cfg[1]);
if (STEP.equals(action)) {
JSONObject a = s.getJSONObject("attributes");
double step = a.optDouble("target_temp_step", 0.5), target = a.optDouble("temperature", 20) + intent.getIntExtra("dir", 1) * step;
Hass.call(home, "climate", "set_temperature", new JSONObject().put("entity_id", cfg[1]).put("temperature", target));
} else Hass.act(home, cfg[1], s.optString("state"));
Thread.sleep(700); // let Home Assistant settle before reading the new state
} catch (Exception ignored) { /* the refresh below shows what is true */ }
draw(c, AppWidgetManager.getInstance(c), id);
done.finish();
});
}
/** {home id, entity, name}, or null for a widget that was never set up. */
static String[] config(Context c, int id) {
String v = Hass.prefs(c).getString("widget_" + id, null);
return v == null ? null : v.split("\\|", 3);
}
static void refresh(Context c, AppWidgetManager mgr, int id) { Hass.BG.execute(() -> draw(c, mgr, id)); }
/** Fetch the state and draw the widget. Runs off the main thread. */
static void draw(Context c, AppWidgetManager mgr, int id) {
String[] cfg = config(c, id);
RemoteViews v = new RemoteViews(c.getPackageName(), R.layout.widget_entity);
if (cfg == null) { mgr.updateAppWidget(id, v); return; }
v.setTextViewText(R.id.w_name, cfg[2]);
String state = "", line = "Can't reach Home Assistant";
boolean climate = cfg[1].startsWith("climate.");
try {
JSONObject s = Hass.state(Hass.home(c, cfg[0]), cfg[1]), a = s.getJSONObject("attributes");
state = s.optString("state");
if (climate) {
line = String.format(Locale.getDefault(), "%s · %s° → %s°", cap(state), num(a.opt("current_temperature")), num(a.opt("temperature")));
} else if (cfg[1].startsWith("light.") && "on".equals(state) && a.has("brightness") && !a.isNull("brightness")) {
line = "On · " + Math.round(a.optDouble("brightness") / 2.55) + "%";
} else if (cfg[1].matches("^(scene|script|button|input_button)\\..+")) {
line = "Tap to run";
} else line = cap(state);
} catch (Exception e) {
if ("signin".equals(e.getMessage())) line = "Open Haven to sign in";
}
boolean on = Hass.isOn(state) && !climate;
v.setTextViewText(R.id.w_state, line);
v.setInt(R.id.w_root, "setBackgroundResource", on ? R.drawable.w_bg_on : R.drawable.w_bg);
v.setTextColor(R.id.w_name, on ? 0xff2b1a02 : 0xfff4eee4);
v.setTextColor(R.id.w_state, on ? 0xff6b4a12 : 0xffa9b8c2);
v.setViewVisibility(R.id.w_steps, climate ? View.VISIBLE : View.GONE);
v.setOnClickPendingIntent(R.id.w_root, climate ? openApp(c) : pending(c, id, TAP, 0));
if (climate) {
v.setOnClickPendingIntent(R.id.w_minus, pending(c, id, STEP, -1));
v.setOnClickPendingIntent(R.id.w_plus, pending(c, id, STEP, 1));
}
mgr.updateAppWidget(id, v);
}
private static PendingIntent pending(Context c, int id, String action, int dir) {
Intent i = new Intent(c, EntityWidget.class).setAction(action).putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, id).putExtra("dir", dir);
return PendingIntent.getBroadcast(c, id * 4 + dir + 1, i, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private static PendingIntent openApp(Context c) {
return PendingIntent.getActivity(c, 0, new Intent(c, MainActivity.class), PendingIntent.FLAG_IMMUTABLE);
}
private static String cap(String s) { return s == null || s.isEmpty() ? "" : Character.toUpperCase(s.charAt(0)) + s.substring(1).replace('_', ' '); }
private static String num(Object o) {
if (!(o instanceof Number)) return "–";
double d = ((Number) o).doubleValue();
return d == Math.rint(d) ? String.valueOf((long) d) : String.format(Locale.getDefault(), "%.1f", d);
}
static void refreshAll(Context c) {
AppWidgetManager mgr = AppWidgetManager.getInstance(c);
for (int id : mgr.getAppWidgetIds(new ComponentName(c, EntityWidget.class))) refresh(c, mgr, id);
}
}
+180
View File
@@ -0,0 +1,180 @@
package nl.melvanveen.haven;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Haven's homes and its own line to Home Assistant, for the widgets and tiles that work without the app open.
*
* Each home keeps its address, Dashboard Studio's address and the sign-in Home Assistant's page made in the app
* (a refresh token). From that Haven gets a short-lived access token and uses Home Assistant's REST API, which
* everyone with a login may use to read states and control devices.
*/
final class Hass {
private Hass() {}
static final ExecutorService BG = Executors.newSingleThreadExecutor();
private static final Map<String, String[]> ACCESS = new HashMap<>(); // home id -> {token, expires at}
static SharedPreferences prefs(Context c) { return c.getSharedPreferences("app", Context.MODE_PRIVATE); }
// ---------------------------------------------------------------- homes
/** Every home, oldest first. Haven 2.0 kept one home as "ha" and "app": that becomes the first. */
static JSONArray homes(Context c) {
SharedPreferences p = prefs(c);
try {
if (!p.contains("homes")) {
JSONArray list = new JSONArray();
if (!p.getString("ha", "").isEmpty()) {
list.put(new JSONObject().put("id", "h1").put("name", Uri.parse(p.getString("ha", "")).getHost())
.put("ha", p.getString("ha", "")).put("app", p.getString("app", "")));
p.edit().putString("current", "h1").apply();
}
p.edit().putString("homes", list.toString()).apply();
return list;
}
return new JSONArray(p.getString("homes", "[]"));
} catch (Exception e) {
return new JSONArray();
}
}
static JSONObject home(Context c, String id) {
JSONArray list = homes(c);
if (id == null) id = prefs(c).getString("current", "");
for (int i = 0; i < list.length(); i++) {
JSONObject h = list.optJSONObject(i);
if (h != null && id.equals(h.optString("id"))) return h;
}
return null;
}
static JSONObject current(Context c) { return home(c, null); }
/** Add or change a home (matched by id, or else by address). */
static JSONObject save(Context c, JSONObject h) {
try {
JSONArray list = homes(c), out = new JSONArray();
boolean found = false;
for (int i = 0; i < list.length(); i++) {
JSONObject x = list.getJSONObject(i);
if (x.optString("id").equals(h.optString("id")) || (!h.has("id") && x.optString("ha").equalsIgnoreCase(h.optString("ha")))) {
for (java.util.Iterator<String> k = h.keys(); k.hasNext(); ) { String key = k.next(); x.put(key, h.get(key)); }
h = x; found = true;
}
out.put(x);
}
if (!found) {
if (!h.has("id")) h.put("id", "h" + System.currentTimeMillis());
if (!h.has("name")) h.put("name", Uri.parse(h.optString("ha")).getHost());
out.put(h);
}
prefs(c).edit().putString("homes", out.toString()).apply();
return h;
} catch (Exception e) {
return h;
}
}
static void remove(Context c, String id) {
JSONArray list = homes(c), out = new JSONArray();
for (int i = 0; i < list.length(); i++) if (!id.equals(list.optJSONObject(i).optString("id"))) out.put(list.optJSONObject(i));
SharedPreferences.Editor e = prefs(c).edit().putString("homes", out.toString());
if (id.equals(prefs(c).getString("current", ""))) e.putString("current", out.length() > 0 ? out.optJSONObject(0).optString("id") : "");
e.apply();
ACCESS.remove(id);
}
static void setCurrent(Context c, String id) { prefs(c).edit().putString("current", id).apply(); }
// ---------------------------------------------------------------- Home Assistant
/** An access token for the home, renewed with its refresh token when it runs out. Runs off the main thread. */
static String token(JSONObject home) throws Exception {
String[] cached = ACCESS.get(home.optString("id"));
if (cached != null && Long.parseLong(cached[1]) > System.currentTimeMillis() + 60_000) return cached[0];
String refresh = home.optString("refresh");
if (refresh.isEmpty()) throw new IllegalStateException("signin");
String body = "grant_type=refresh_token&refresh_token=" + URLEncoder.encode(refresh, "UTF-8")
+ "&client_id=" + URLEncoder.encode(home.optString("client", home.optString("ha") + "/"), "UTF-8");
JSONObject r = new JSONObject(http("POST", home.optString("ha") + "/auth/token", body, "application/x-www-form-urlencoded", null));
String access = r.getString("access_token");
ACCESS.put(home.optString("id"), new String[] { access, String.valueOf(System.currentTimeMillis() + r.optLong("expires_in", 1800) * 1000) });
return access;
}
static String api(JSONObject home, String method, String path, String json) throws Exception {
return http(method, home.optString("ha") + "/api/" + path, json, "application/json", token(home));
}
static JSONArray states(JSONObject home) throws Exception { return new JSONArray(api(home, "GET", "states", null)); }
static JSONObject state(JSONObject home, String entity) throws Exception { return new JSONObject(api(home, "GET", "states/" + entity, null)); }
static void call(JSONObject home, String domain, String service, JSONObject data) throws Exception {
api(home, "POST", "services/" + domain + "/" + service, data.toString());
}
static String domain(String entity) { return entity.substring(0, entity.indexOf('.')); }
/** What a tap does, as on the dashboard: toggle what can be toggled, run scenes and scripts, press buttons. */
static void act(JSONObject home, String entity, String stateNow) throws Exception {
String d = domain(entity);
JSONObject data = new JSONObject().put("entity_id", entity);
switch (d) {
case "scene": case "script": call(home, d, "turn_on", data); break;
case "button": case "input_button": call(home, d, "press", data); break;
case "lock": call(home, "lock", "locked".equals(stateNow) ? "unlock" : "lock", data); break;
case "cover": call(home, "cover", "toggle", data); break;
case "climate": call(home, "climate", "off".equals(stateNow) ? "turn_on" : "turn_off", data); break;
default: call(home, "homeassistant", "toggle", data);
}
}
static boolean isOn(String state) {
return state != null && (state.equals("on") || state.equals("open") || state.equals("unlocked") || state.equals("playing")
|| state.equals("heat") || state.equals("cool") || state.equals("auto") || state.equals("heat_cool"));
}
/** Entities a widget or tile can control. */
static boolean pickable(String entity) {
return entity.matches("^(light|switch|fan|input_boolean|scene|script|button|input_button|lock|cover|climate|media_player)\\..+");
}
static String http(String method, String url, String body, String type, String bearer) throws Exception {
HttpURLConnection con = (HttpURLConnection) new URL(url).openConnection();
con.setRequestMethod(method);
con.setConnectTimeout(8000);
con.setReadTimeout(12000);
if (bearer != null) con.setRequestProperty("Authorization", "Bearer " + bearer);
if (body != null) {
con.setDoOutput(true);
con.setRequestProperty("Content-Type", type);
try (OutputStream o = con.getOutputStream()) { o.write(body.getBytes(StandardCharsets.UTF_8)); }
}
int code = con.getResponseCode();
InputStream in = code < 400 ? con.getInputStream() : con.getErrorStream();
ByteArrayOutputStream out = new ByteArrayOutputStream();
if (in != null) { byte[] buf = new byte[8192]; int n; while ((n = in.read(buf)) > 0) out.write(buf, 0, n); in.close(); }
con.disconnect();
if (code >= 400) throw new IllegalStateException("HTTP " + code);
return out.toString("UTF-8");
}
}
@@ -0,0 +1,78 @@
package nl.melvanveen.haven;
import android.app.PendingIntent;
import android.content.Intent;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.service.quicksettings.Tile;
import android.service.quicksettings.TileService;
import org.json.JSONObject;
/**
* Favourite actions in Android's pull-down menu. Android shows a fixed set of tiles per app, so Haven offers
* three; each asks what it controls the first time it is tapped.
*/
public abstract class HavenTile extends TileService {
abstract String key();
public static class One extends HavenTile { String key() { return "1"; } }
public static class Two extends HavenTile { String key() { return "2"; } }
public static class Three extends HavenTile { String key() { return "3"; } }
static Class<?> forKey(String key) { return "1".equals(key) ? One.class : "2".equals(key) ? Two.class : Three.class; }
private String[] config() {
String v = Hass.prefs(this).getString("tile_" + key(), null);
return v == null ? null : v.split("\\|", 3);
}
@Override
public void onStartListening() {
String[] cfg = config();
Tile t = getQsTile();
if (t == null) return;
t.setIcon(Icon.createWithResource(this, R.drawable.ic_tile));
if (cfg == null) {
t.setLabel("Haven");
if (Build.VERSION.SDK_INT >= 29) t.setSubtitle("Tap to choose");
t.setState(Tile.STATE_INACTIVE);
t.updateTile();
return;
}
t.setLabel(cfg[2]);
t.updateTile();
Hass.BG.execute(() -> show(cfg));
}
private void show(String[] cfg) {
String state = "";
try { state = Hass.state(Hass.home(this, cfg[0]), cfg[1]).optString("state"); } catch (Exception ignored) { /* shown as unavailable */ }
Tile t = getQsTile();
if (t == null) return;
t.setLabel(cfg[2]);
boolean run = cfg[1].matches("^(scene|script|button|input_button)\\..+");
t.setState(state.isEmpty() ? Tile.STATE_UNAVAILABLE : run ? Tile.STATE_INACTIVE : Hass.isOn(state) ? Tile.STATE_ACTIVE : Tile.STATE_INACTIVE);
if (Build.VERSION.SDK_INT >= 29) t.setSubtitle(state.isEmpty() ? "Can't reach" : run ? "Tap to run" : Hass.isOn(state) ? "On" : "Off");
t.updateTile();
}
@Override
public void onClick() {
String[] cfg = config();
if (cfg == null) {
Intent pick = new Intent(this, PickActivity.class).putExtra("tile", key()).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
if (Build.VERSION.SDK_INT >= 34) startActivityAndCollapse(PendingIntent.getActivity(this, 10 + key().hashCode(), pick, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
else startActivityAndCollapse(pick);
return;
}
Hass.BG.execute(() -> {
try {
JSONObject home = Hass.home(this, cfg[0]);
Hass.act(home, cfg[1], Hass.state(home, cfg[1]).optString("state"));
Thread.sleep(600);
} catch (Exception ignored) { /* the tile shows what is true */ }
show(cfg);
});
}
}
+131 -6
View File
@@ -1,13 +1,21 @@
package nl.melvanveen.haven;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.graphics.Color;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.view.View;
import android.view.WindowManager;
import android.webkit.CookieManager;
@@ -21,6 +29,10 @@ import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
/**
* 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.
@@ -71,6 +83,7 @@ public class MainActivity extends Activity {
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.saveTokens(JSON.stringify(hass.auth?.data || {}), hass.config?.location_name || "");
HavenApp.open(url);
} catch (e) {
HavenApp.fail("error", String(e?.message || e?.body?.message || e?.code || e));
@@ -121,9 +134,17 @@ public class MainActivity extends Activity {
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(); }
// The home Haven shows now; the others wait in the list (see Hass).
private String ha() { JSONObject h = Hass.current(this); return h == null ? "" : h.optString("ha"); }
private String app() { JSONObject h = Hass.current(this); return h == null ? "" : h.optString("app"); }
private void save(String ha, String app) {
try {
JSONObject h = Hass.current(this);
if (h == null || !h.optString("ha").equalsIgnoreCase(ha)) h = new JSONObject().put("ha", ha);
h.put("app", app);
Hass.setCurrent(this, Hass.save(this, h).optString("id"));
} catch (JSONException ignored) { /* nothing to save */ }
}
private static String origin(Uri u) {
return u.getScheme() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
@@ -172,8 +193,12 @@ public class MainActivity extends Activity {
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);
if (isIngress(url) || (!page.isEmpty() && url.startsWith(page))) {
view.clearHistory(); // back leaves the app instead of going to Home Assistant
// keep the sign-in fresh for the widgets and tiles, and see whether Dashboard Studio has a newer Haven
view.evaluateJavascript("try { HavenApp.saveTokens(localStorage.getItem('hassTokens') || '', '') } catch (e) {}", null);
checkUpdate();
} else view.evaluateJavascript(CONNECT_JS, null);
}
@Override
@@ -244,7 +269,76 @@ public class MainActivity extends Activity {
protected void onPause() { super.onPause(); web.onPause(); CookieManager.getInstance().flush(); }
@Override
protected void onResume() { super.onResume(); web.onResume(); }
protected void onResume() {
super.onResume();
web.onResume();
if (pendingApk >= 0 && canInstall()) install(pendingApk);
}
// ---------------------------------------------------------------- updates
// Dashboard Studio serves the Haven that belongs to it (haven.json and haven.apk next to its page). When that
// is newer than this one, Haven offers to install it. At most twice a day.
private long pendingApk = -1;
private void checkUpdate() {
if (System.currentTimeMillis() - prefs.getLong("updateCheck", 0) < 12 * 3600_000L) return;
prefs.edit().putLong("updateCheck", System.currentTimeMillis()).apply();
String base = app().replaceAll("[?#].*", "").replaceAll("[^/]*$", "");
if (base.isEmpty()) return;
String cookie = CookieManager.getInstance().getCookie(base);
Hass.BG.execute(() -> {
try {
java.net.HttpURLConnection con = (java.net.HttpURLConnection) new java.net.URL(base + "haven.json").openConnection();
if (cookie != null) con.setRequestProperty("Cookie", cookie);
if (con.getResponseCode() != 200) return;
JSONObject v = new JSONObject(new String(con.getInputStream().readAllBytes(), "UTF-8"));
long mine = Build.VERSION.SDK_INT >= 28 ? getPackageManager().getPackageInfo(getPackageName(), 0).getLongVersionCode()
: getPackageManager().getPackageInfo(getPackageName(), 0).versionCode;
if (v.optLong("code") <= mine) return;
runOnUiThread(() -> new AlertDialog.Builder(this)
.setTitle("Haven " + v.optString("version") + " is ready")
.setMessage("A newer Haven came with the latest Dashboard Studio. Install it now?")
.setNegativeButton("Later", null)
.setPositiveButton("Install", (d, w) -> download(base + "haven.apk", cookie, v.optString("version")))
.show());
} catch (Exception ignored) { /* try again later */ }
});
}
private void download(String url, String cookie, String version) {
DownloadManager dm = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
DownloadManager.Request r = new DownloadManager.Request(Uri.parse(url))
.setTitle("Haven " + version).setMimeType("application/vnd.android.package-archive")
.setDestinationInExternalFilesDir(this, Environment.DIRECTORY_DOWNLOADS, "haven-" + version + ".apk");
if (cookie != null) r.addRequestHeader("Cookie", cookie);
long id = dm.enqueue(r);
BroadcastReceiver done = new BroadcastReceiver() {
@Override public void onReceive(Context c, Intent i) {
if (i.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != id) return;
unregisterReceiver(this);
if (canInstall()) install(id);
else { // Android asks once to allow installing apps from Haven
pendingApk = id;
startActivity(new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())));
}
}
};
IntentFilter f = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= 33) registerReceiver(done, f, Context.RECEIVER_EXPORTED);
else registerReceiver(done, f);
}
private boolean canInstall() { return Build.VERSION.SDK_INT < 26 || getPackageManager().canRequestPackageInstalls(); }
private void install(long id) {
pendingApk = -1;
Uri apk = ((DownloadManager) getSystemService(DOWNLOAD_SERVICE)).getUriForDownloadedFile(id);
if (apk == null) return;
try {
startActivity(new Intent(Intent.ACTION_VIEW).setDataAndType(apk, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK));
} catch (ActivityNotFoundException ignored) { /* no installer */ }
}
@Override
protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); }
@@ -288,6 +382,37 @@ public class MainActivity extends Activity {
/** Dashboard Studio couldn't renew its session: sign in again on Home Assistant's page. */
@JavascriptInterface public void reconnect() { runOnUiThread(MainActivity.this::connect); }
/** The sign-in Home Assistant's page made, kept for the widgets and tiles; and the home's own name. */
@JavascriptInterface public void saveTokens(String json, String name) {
try {
JSONObject t = new JSONObject(json == null || json.isEmpty() ? "{}" : json), h = Hass.current(MainActivity.this);
if (h == null) return;
if (!t.optString("refresh_token").isEmpty()) h.put("refresh", t.optString("refresh_token")).put("client", t.optString("clientId", h.optString("ha") + "/"));
if (name != null && !name.isEmpty()) h.put("name", name);
Hass.save(MainActivity.this, h);
EntityWidget.refreshAll(MainActivity.this);
} catch (JSONException ignored) { /* not a sign-in */ }
}
/** The homes for the setup page: id, name, address, and which one is open. No sign-ins. */
@JavascriptInterface public String homes() {
JSONArray out = new JSONArray(), list = Hass.homes(MainActivity.this);
String cur = Hass.prefs(MainActivity.this).getString("current", "");
try {
for (int i = 0; i < list.length(); i++) {
JSONObject h = list.getJSONObject(i);
out.put(new JSONObject().put("id", h.optString("id")).put("name", h.optString("name")).put("ha", h.optString("ha")).put("current", cur.equals(h.optString("id"))));
}
} catch (JSONException ignored) { /* an empty list */ }
return out.toString();
}
@JavascriptInterface public void switchHome(String id) { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, id); start(); }); }
@JavascriptInterface public void addHome() { runOnUiThread(() -> { Hass.setCurrent(MainActivity.this, ""); showSetup("", ""); }); }
@JavascriptInterface public void removeHome(String id) { runOnUiThread(() -> { Hass.remove(MainActivity.this, id); showSetup("", ""); }); }
/** Dashboard Studio, in Haven: Install as an app, Change address. */
@JavascriptInterface public void reset() { runOnUiThread(() -> { message = ""; detail = ""; web.loadUrl(SETUP); }); }
@@ -0,0 +1,139 @@
package nl.melvanveen.haven;
import android.app.Activity;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.service.quicksettings.TileService;
import android.text.Editable;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.List;
/** Choose what a home screen widget or a quick settings tile controls: a light, scene, thermostat and so on. */
public class PickActivity extends Activity {
private final List<String[]> all = new ArrayList<>(); // {entity, name}
private final List<String[]> shown = new ArrayList<>();
private ArrayAdapter<String> adapter;
private TextView note;
private int widgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
private String tile;
private JSONObject home;
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
setResult(RESULT_CANCELED); // backing out leaves no empty widget behind
widgetId = getIntent().getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
tile = getIntent().getStringExtra("tile");
// a long press on a tile: choose again for that tile
ComponentName from = getIntent().getParcelableExtra(Intent.EXTRA_COMPONENT_NAME);
if (tile == null && from != null && from.getClassName().contains("HavenTile$")) {
String n = from.getClassName();
tile = n.endsWith("One") ? "1" : n.endsWith("Two") ? "2" : "3";
}
home = Hass.current(this);
int pad = dp(20);
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setPadding(pad, pad, pad, 0);
root.setBackgroundColor(Color.parseColor("#0d1d29"));
TextView title = text(tile != null ? "Choose a quick setting" : "Choose what this widget controls", 22, "#f4eee4");
root.addView(title);
note = text(home == null ? "" : home.optString("name"), 14, "#a9b8c2");
note.setPadding(0, dp(4), 0, dp(12));
root.addView(note);
EditText search = new EditText(this);
search.setHint("Search");
search.setTextColor(Color.parseColor("#f4eee4"));
search.setHintTextColor(Color.parseColor("#7f909b"));
search.setSingleLine();
root.addView(search);
ListView list = new ListView(this);
adapter = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_2, android.R.id.text1, new ArrayList<>()) {
@Override public View getView(int pos, View convert, ViewGroup parent) {
View v = super.getView(pos, convert, parent);
TextView a = v.findViewById(android.R.id.text1), b = v.findViewById(android.R.id.text2);
a.setText(shown.get(pos)[1]); a.setTextColor(Color.parseColor("#f4eee4"));
b.setText(shown.get(pos)[0]); b.setTextColor(Color.parseColor("#a9b8c2"));
return v;
}
};
list.setAdapter(adapter);
list.setOnItemClickListener((p, v, pos, id) -> choose(shown.get(pos)));
root.addView(list, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
setContentView(root);
search.addTextChangedListener(new TextWatcher() {
public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
public void onTextChanged(CharSequence s, int a, int b, int c) { filter(s.toString()); }
public void afterTextChanged(Editable s) {}
});
if (home == null || home.optString("refresh").isEmpty()) { note.setText("Open Haven and sign in first, then add this again."); return; }
Hass.BG.execute(() -> {
try {
JSONArray states = Hass.states(home);
for (int i = 0; i < states.length(); i++) {
JSONObject s = states.getJSONObject(i);
String e = s.getString("entity_id");
if (Hass.pickable(e)) all.add(new String[] { e, s.getJSONObject("attributes").optString("friendly_name", e) });
}
all.sort((x, y) -> x[1].compareToIgnoreCase(y[1]));
runOnUiThread(() -> filter(""));
} catch (Exception err) {
runOnUiThread(() -> note.setText("Home Assistant can't be reached. Check your connection and try again."));
}
});
}
private void filter(String q) {
String f = q.trim().toLowerCase();
shown.clear();
for (String[] x : all) if (f.isEmpty() || x[1].toLowerCase().contains(f) || x[0].contains(f)) shown.add(x);
adapter.clear();
for (String[] x : shown) adapter.add(x[1]);
adapter.notifyDataSetChanged();
}
private void choose(String[] pick) {
String value = home.optString("id") + "|" + pick[0] + "|" + pick[1];
if (tile != null) {
Hass.prefs(this).edit().putString("tile_" + tile, value).apply();
TileService.requestListeningState(this, new ComponentName(this, HavenTile.forKey(tile)));
finish();
return;
}
if (widgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { finish(); return; }
Hass.prefs(this).edit().putString("widget_" + widgetId, value).apply();
EntityWidget.refresh(this, AppWidgetManager.getInstance(this), widgetId);
setResult(RESULT_OK, new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId));
finish();
}
private TextView text(String s, int sp, String color) {
TextView t = new TextView(this);
t.setText(s);
t.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp);
t.setTextColor(Color.parseColor(color));
t.setGravity(Gravity.START);
return t;
}
private int dp(int v) { return Math.round(v * getResources().getDisplayMetrics().density); }
}