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:
@@ -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>
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in New Issue
Block a user