feat(hobbs-organizer): v1.4.0 per-TV morning routine (admin TVs tab) + photo app 1.2.1

- Admin TVs tab: wake time/days, YouTube Music playlist, Shield volume per TV,
  pushed via 5-min TV check-ins (/api/tv/config, /api/tv/report, LAN-only)
- com.dayline.photos 1.2.1: MorningActivity (screen on, volume, playlist),
  calendar alarm at wake time, Doze-safe chained check-ins
- Both TVs configured for 06:30 daily with the family playlist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 22:48:50 -06:00
parent 2d16ded0c1
commit 6e94a0c998
22 changed files with 1083 additions and 18 deletions
@@ -16,8 +16,8 @@ android {
applicationId "com.dayline.photos"
minSdk 26
targetSdk 35
versionCode 2
versionName "1.1"
versionCode 4
versionName "1.2.1"
buildConfigField "String", "DEFAULT_URL", "\"http://192.168.86.100:8340/photo\""
}
@@ -2,6 +2,18 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Morning routine (TvAgent): exact wake alarms, re-arm after reboot, start other apps
from the background (SYSTEM_ALERT_WINDOW is granted over ADB: appops set ... allow). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Android 11 package visibility: the apps the morning routine opens. -->
<queries>
<package android:name="com.dayline.tv" />
<package android:name="com.google.android.youtube.tvmusic" />
</queries>
<uses-feature
android:name="android.software.leanback"
@@ -48,6 +60,29 @@
android:name="android.service.dream"
android:resource="@xml/dream_info" />
</service>
<!-- Morning routine: wakes the screen, sets the volume, starts the music. -->
<activity
android:name=".MorningActivity"
android:excludeFromRecents="true"
android:exported="false"
android:launchMode="singleInstance"
android:noHistory="true"
android:screenOrientation="landscape"
android:showWhenLocked="true"
android:turnScreenOn="true" />
<receiver
android:name=".AgentReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
<action android:name="com.dayline.photos.action.CHECK_IN" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,23 @@
package com.dayline.photos;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/** Boot, app update, clock changes and the periodic check-in alarm all lead to a check-in. */
public final class AgentReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
Context app = context.getApplicationContext();
// Re-arm from the cached settings at once (the server may be unreachable right after boot).
TvAgent.schedule(app, TvAgent.cachedConfig(app));
PendingResult pending = goAsync();
new Thread(() -> {
try {
TvAgent.checkIn(app);
} finally {
pending.finish();
}
}, "tv-agent-check-in").start();
}
}
@@ -0,0 +1,116 @@
package com.dayline.photos;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.graphics.Color;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.WindowManager;
import android.widget.TextView;
import org.json.JSONObject;
/**
* Started by the morning alarm. Turns the screen on (the Shield then wakes the TV over HDMI-CEC),
* sets the volume and starts the playlist in YouTube Music. The calendar is opened at the wake
* time by a second alarm, or straight away from here when music is off.
*/
public final class MorningActivity extends Activity {
/** Give the TV a moment to power up and switch input before audio starts. */
private static final long SETTLE_MS = 5_000;
private final Handler handler = new Handler(Looper.getMainLooper());
@Override
@SuppressWarnings("deprecation")
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true);
setShowWhenLocked(true);
}
TextView text = new TextView(this);
text.setText("Good morning");
text.setTextColor(Color.argb(170, 255, 255, 255));
text.setTextSize(42);
text.setGravity(Gravity.CENTER);
text.setBackgroundColor(Color.BLACK);
setContentView(text);
long wakeAt = getIntent().getLongExtra(TvAgent.EXTRA_WAKE_AT, System.currentTimeMillis());
// Hold off re-arming until this morning's calendar alarm has fired.
TvAgent.prefs(this).edit().putLong(TvAgent.KEY_ROUTINE_UNTIL, Math.max(wakeAt, System.currentTimeMillis()) + 60_000).apply();
handler.postDelayed(() -> run(wakeAt), SETTLE_MS);
}
private void run(long wakeAt) {
JSONObject config = TvAgent.cachedConfig(this);
StringBuilder detail = new StringBuilder();
boolean ok = true;
JSONObject volume = config.optJSONObject("volume");
if (volume != null && volume.optBoolean("enabled", false)) {
AudioManager audio = (AudioManager) getSystemService(AUDIO_SERVICE);
if (audio != null) {
int max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC);
int level = Math.round(Math.max(0, Math.min(100, volume.optInt("level", 40))) / 100f * max);
try {
audio.setStreamVolume(AudioManager.STREAM_MUSIC, level, 0);
detail.append("volume ").append(level).append('/').append(max).append("; ");
} catch (SecurityException denied) {
ok = false;
detail.append("volume refused; ");
}
}
}
String calendarPackage = config.optString("calendarPackage", "com.dayline.tv");
JSONObject music = config.optJSONObject("music");
String url = music != null && music.optBoolean("enabled", false) ? music.optString("url", "") : "";
if (!url.isEmpty()) {
Intent play = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
.setPackage(TvAgent.MUSIC_PACKAGE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try {
startActivity(play);
detail.append("music started");
} catch (ActivityNotFoundException missing) {
ok = false;
detail.append("YouTube Music not installed");
openCalendar(calendarPackage, detail);
}
} else {
openCalendar(calendarPackage, detail);
}
TvAgent.report(this, ok, detail.toString());
// Re-arm for the next day once this morning's calendar alarm is past.
long delay = Math.max(0, wakeAt + 60_000 - System.currentTimeMillis());
android.content.Context app = getApplicationContext();
handler.postDelayed(() -> {
TvAgent.prefs(app).edit().remove(TvAgent.KEY_ROUTINE_UNTIL).apply();
TvAgent.checkInAsync(app);
}, delay);
finish();
}
private void openCalendar(String calendarPackage, StringBuilder detail) {
Intent launch = TvAgent.calendarLaunchIntent(this, calendarPackage);
if (launch == null) {
detail.append("; calendar app not found");
return;
}
startActivity(launch);
detail.append(detail.length() > 0 ? "; " : "").append("calendar opened");
}
}
@@ -18,6 +18,7 @@ public final class PhotoActivity extends Activity {
hideSystemUi();
frame.view().requestFocus();
frame.load();
TvAgent.checkInAsync(this);
}
@Override
@@ -143,7 +143,7 @@ final class PhotoFrameView {
}
/** files/base_url (written over ADB run-as) overrides the built-in default. */
private static String configuredUrl(Context context) {
static String configuredUrl(Context context) {
File file = new File(context.getFilesDir(), BASE_URL_FILE);
if (file.isFile() && file.length() > 0 && file.length() < 2048) {
try (FileInputStream input = new FileInputStream(file)) {
@@ -0,0 +1,303 @@
package com.dayline.photos;
import android.annotation.SuppressLint;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.SystemClock;
import android.provider.Settings;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
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.Calendar;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Per-TV morning routine controlled from the organizer's admin page (TVs tab).
*
* Every few minutes the app checks in at GET /api/tv/config with this TV's Android ID and
* receives its settings: wake time + days, playlist, volume. It then arms two alarms:
* A) wake time minus the music lead: MorningActivity wakes the screen, sets the volume, starts music;
* B) the wake time itself: opens the calendar app (com.dayline.tv) on top.
* With music off only one alarm is used and MorningActivity opens the calendar itself.
*/
final class TvAgent {
static final String TAG = "HobbsTvAgent";
static final String ACTION_CHECK_IN = "com.dayline.photos.action.CHECK_IN";
static final String EXTRA_WAKE_AT = "wakeAt";
static final String MUSIC_PACKAGE = "com.google.android.youtube.tvmusic";
private static final String PREFS = "tv_agent";
private static final String KEY_CONFIG = "config";
private static final String KEY_VERSION = "version";
/** While a routine is running (music started, calendar alarm pending) do not re-arm. */
static final String KEY_ROUTINE_UNTIL = "routineUntil";
private static final int REQ_MORNING = 7001;
private static final int REQ_CALENDAR = 7002;
private static final int REQ_CHECK_IN = 7003;
private static final long DEFAULT_CHECK_IN_MINUTES = 5;
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
private TvAgent() {
}
// ---------- identity / server ----------
@SuppressLint("HardwareIds")
static String deviceId(Context context) {
String id = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
return id == null ? "" : id.toLowerCase();
}
static String origin(Context context) {
Uri parsed = Uri.parse(PhotoFrameView.configuredUrl(context));
return parsed.getScheme() + "://" + parsed.getAuthority();
}
private static String enc(String value) {
try {
return URLEncoder.encode(value == null ? "" : value, "UTF-8");
} catch (IOException impossible) {
return "";
}
}
private static String deviceName(Context context) {
String name = Settings.Global.getString(context.getContentResolver(), "device_name");
return name != null ? name : Build.MODEL;
}
// ---------- cached config ----------
static SharedPreferences prefs(Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE);
}
static JSONObject cachedConfig(Context context) {
try {
return new JSONObject(prefs(context).getString(KEY_CONFIG, "{}"));
} catch (JSONException broken) {
return new JSONObject();
}
}
static String appliedVersion(Context context) {
return prefs(context).getString(KEY_VERSION, "");
}
// ---------- check-in ----------
static void checkInAsync(Context context) {
Context app = context.getApplicationContext();
EXECUTOR.execute(() -> checkIn(app));
}
/** Blocking: fetch settings, re-arm alarms if they changed, confirm the applied version. */
static void checkIn(Context context) {
try {
JSONObject config = fetchConfig(context, appliedVersion(context));
String version = config.optString("version", "");
boolean changed = !version.equals(appliedVersion(context));
prefs(context).edit().putString(KEY_CONFIG, config.toString()).putString(KEY_VERSION, version).apply();
schedule(context, config);
scheduleCheckIns(context, config.optLong("checkInMinutes", DEFAULT_CHECK_IN_MINUTES));
// Tell the server at once that the new settings are in place (admin shows "on the TV").
if (changed) fetchConfig(context, version);
} catch (IOException | JSONException error) {
Log.w(TAG, "Check-in failed: " + error);
// Keep the last known schedule.
schedule(context, cachedConfig(context));
scheduleCheckIns(context, DEFAULT_CHECK_IN_MINUTES);
}
}
private static JSONObject fetchConfig(Context context, String applied) throws IOException, JSONException {
String url = origin(context) + "/api/tv/config?device=" + enc(deviceId(context))
+ "&name=" + enc(deviceName(context))
+ "&model=" + enc(Build.MODEL)
+ "&app=" + enc(BuildConfig.VERSION_NAME)
+ "&applied=" + enc(applied);
HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
connection.setConnectTimeout(10_000);
connection.setReadTimeout(10_000);
connection.setInstanceFollowRedirects(false);
try {
int code = connection.getResponseCode();
if (code != 200) throw new IOException("HTTP " + code);
return new JSONObject(readAll(connection.getInputStream()));
} finally {
connection.disconnect();
}
}
/** Fire-and-forget report of the morning routine's outcome (shown in admin). */
static void report(Context context, boolean ok, String detail) {
Context app = context.getApplicationContext();
EXECUTOR.execute(() -> {
HttpURLConnection connection = null;
try {
JSONObject body = new JSONObject()
.put("device", deviceId(app))
.put("kind", "morning")
.put("ok", ok)
.put("detail", detail)
.put("applied", appliedVersion(app))
.put("app", BuildConfig.VERSION_NAME);
connection = (HttpURLConnection) new URL(origin(app) + "/api/tv/report").openConnection();
connection.setConnectTimeout(10_000);
connection.setReadTimeout(10_000);
connection.setRequestMethod("POST");
connection.setDoOutput(true);
connection.setRequestProperty("Content-Type", "application/json");
try (OutputStream out = connection.getOutputStream()) {
out.write(body.toString().getBytes(StandardCharsets.UTF_8));
}
connection.getResponseCode();
} catch (IOException | JSONException error) {
Log.w(TAG, "Report failed: " + error);
} finally {
if (connection != null) connection.disconnect();
}
});
}
private static String readAll(InputStream input) throws IOException {
try (InputStream in = input; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[4096];
int read;
int total = 0;
while ((read = in.read(buffer)) != -1) {
total += read;
if (total > 64 * 1024) throw new IOException("Response too large");
out.write(buffer, 0, read);
}
return out.toString("UTF-8");
}
}
// ---------- alarms ----------
private static PendingIntent checkInIntent(Context context) {
Intent intent = new Intent(context, AgentReceiver.class).setAction(ACTION_CHECK_IN);
return PendingIntent.getBroadcast(context, REQ_CHECK_IN, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private static PendingIntent morningIntent(Context context, long wakeAt) {
Intent intent = new Intent(context, MorningActivity.class)
.putExtra(EXTRA_WAKE_AT, wakeAt)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context, REQ_MORNING, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
static Intent calendarLaunchIntent(Context context, String calendarPackage) {
Intent launch = context.getPackageManager().getLeanbackLaunchIntentForPackage(calendarPackage);
if (launch == null) launch = context.getPackageManager().getLaunchIntentForPackage(calendarPackage);
if (launch == null) return null;
return launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
}
private static PendingIntent calendarIntent(Context context, String calendarPackage) {
Intent launch = calendarLaunchIntent(context, calendarPackage);
if (launch == null) launch = new Intent().setComponent(new ComponentName(calendarPackage, calendarPackage + ".MainActivity"));
return PendingIntent.getActivity(context, REQ_CALENDAR, launch, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
static void scheduleCheckIns(Context context, long minutes) {
AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (alarms == null) return;
long interval = Math.max(1, minutes) * 60_000L;
// One-shot and chained (every check-in arms the next): "allow while idle" alarms still fire
// while the Shield sleeps in Doze, where repeating alarms would wait for a maintenance window.
alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, SystemClock.elapsedRealtime() + interval, checkInIntent(context));
}
/** Arms (or cancels) the morning alarms from a config object. */
static void schedule(Context context, JSONObject config) {
AlarmManager alarms = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (alarms == null) return;
if (System.currentTimeMillis() < prefs(context).getLong(KEY_ROUTINE_UNTIL, 0)) {
Log.i(TAG, "Routine in progress; re-arming after it ends");
return;
}
String calendarPackage = config.optString("calendarPackage", "com.dayline.tv");
alarms.cancel(morningIntent(context, 0));
alarms.cancel(calendarIntent(context, calendarPackage));
JSONObject wake = config.optJSONObject("wake");
if (wake == null || !wake.optBoolean("enabled", false)) {
Log.i(TAG, "Morning routine off");
return;
}
long wakeAt = nextWake(wake.optString("time", "06:30"), wake.optJSONArray("days"), System.currentTimeMillis());
if (wakeAt <= 0) return;
JSONObject music = config.optJSONObject("music");
boolean musicOn = music != null && music.optBoolean("enabled", false) && !music.optString("url", "").isEmpty();
long lead = musicOn ? config.optLong("musicLeadSeconds", 30) * 1000L : 0;
// setAlarmClock: exact, wakes the device from idle, and may start activities.
PendingIntent morning = morningIntent(context, wakeAt);
alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(wakeAt - lead, morning), morning);
if (musicOn) {
alarms.setAlarmClock(new AlarmManager.AlarmClockInfo(wakeAt, morning), calendarIntent(context, calendarPackage));
}
Log.i(TAG, "Morning routine armed for " + wakeAt + (musicOn ? " (music " + lead / 1000 + "s earlier)" : ""));
}
/** Next time at HH:MM (local) on one of the allowed days (0 = Sunday), strictly after now plus a minute. */
static long nextWake(String time, JSONArray days, long now) {
String[] parts = time.split(":");
int hour;
int minute;
try {
hour = Integer.parseInt(parts[0]);
minute = Integer.parseInt(parts[1]);
} catch (RuntimeException bad) {
return -1;
}
boolean[] allowed = new boolean[7];
boolean any = false;
if (days == null) {
java.util.Arrays.fill(allowed, true);
any = true;
} else {
for (int i = 0; i < days.length(); i++) {
int day = days.optInt(i, -1);
if (day >= 0 && day <= 6) {
allowed[day] = true;
any = true;
}
}
}
if (!any) return -1;
Calendar candidate = Calendar.getInstance();
candidate.setTimeInMillis(now);
candidate.set(Calendar.HOUR_OF_DAY, hour);
candidate.set(Calendar.MINUTE, minute);
candidate.set(Calendar.SECOND, 0);
candidate.set(Calendar.MILLISECOND, 0);
for (int i = 0; i < 8; i++) {
int day = candidate.get(Calendar.DAY_OF_WEEK) - 1; // Calendar.SUNDAY == 1
if (candidate.getTimeInMillis() > now + 60_000 && allowed[day]) return candidate.getTimeInMillis();
candidate.add(Calendar.DAY_OF_YEAR, 1);
}
return -1;
}
}