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:
+2
-2
@@ -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\""
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -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>
|
||||
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
+116
@@ -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");
|
||||
}
|
||||
}
|
||||
+1
@@ -18,6 +18,7 @@ public final class PhotoActivity extends Activity {
|
||||
hideSystemUi();
|
||||
frame.view().requestFocus();
|
||||
frame.load();
|
||||
TvAgent.checkInAsync(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -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)) {
|
||||
|
||||
+303
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user