From 200247e8caa696bc6deb65fb11e5358215a42260 Mon Sep 17 00:00:00 2001 From: Patrick Baumgartner Date: Sun, 28 Jun 2026 14:54:36 +0200 Subject: [PATCH 1/2] feat: scheduled active hours with screen-off and power-button wake override Add an optional "Active Times" schedule that lets the frame turn its screen off and sleep the device during configured inactive hours, then wake itself back up at the next active period. Also add a manual power-button override so the user can briefly view the frame while it is asleep. Features - Per-weekday active schedule (multiple rules, multiple ranges per rule, overnight ranges supported) edited in a Compose-based editor. - During inactive hours the frame stops refreshing, dims to black, drops the keep-screen-on flags and (with device-admin permission) calls lockNow() to actually power the screen off and let the device sleep. - An AlarmManager RTC_WAKEUP alarm (setExactAndAllowWhileIdle, wakes from Doze) brings MainActivity to the foreground at the next active-start time and the screen powers back on. - Manual power-button override: pressing power while asleep shows the frame temporarily without FLAG_KEEP_SCREEN_ON, so the normal screen timeout still applies; when the screen turns off the schedule resumes and the wake alarm is re-armed. Implementation - Helpers: ActiveSchedule model + JSON (de)serialization, isActiveNow() and nextActiveStart() (empty schedule == always active so the screen never blacks out unconfigured). - MainActivity: tri-state isFrameInactive, setFrameActive(), screen on/off BroadcastReceiver for the override, wake alarm scheduling, lockDeviceIfPossible(), and onResume re-evaluation so an already-running instance woken via REORDER_TO_FRONT powers the screen on immediately. - ScheduleWakeReceiver: broadcast receiver that relaunches MainActivity. - FrameDeviceAdminReceiver + device_admin.xml (force-lock policy) for screen-off. - SettingsFragment: Active Times category (enable switch, schedule editor, screen-off permission toggle) with live summaries; Material dialogs. - ActiveScheduleActivity: Compose schedule editor. - Manifest: register the two receivers and the editor activity. --- app/src/main/AndroidManifest.xml | 25 ++ .../immichframe/ActiveScheduleActivity.kt | 267 ++++++++++++++++++ .../immichframe/FrameDeviceAdminReceiver.kt | 12 + .../com/immichframe/immichframe/Helpers.kt | 113 ++++++++ .../immichframe/immichframe/MainActivity.kt | 242 ++++++++++++++++ .../immichframe/ScheduleWakeReceiver.kt | 25 ++ .../immichframe/SettingsFragment.kt | 112 +++++++- app/src/main/res/layout/pref_close_button.xml | 27 ++ app/src/main/res/values/strings.xml | 3 + app/src/main/res/values/styles.xml | 10 +- app/src/main/res/xml/device_admin.xml | 6 + app/src/main/res/xml/settings_view.xml | 22 +- 12 files changed, 857 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt create mode 100644 app/src/main/java/com/immichframe/immichframe/FrameDeviceAdminReceiver.kt create mode 100644 app/src/main/java/com/immichframe/immichframe/ScheduleWakeReceiver.kt create mode 100644 app/src/main/res/layout/pref_close_button.xml create mode 100644 app/src/main/res/xml/device_admin.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 06e8c15..de573f5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,10 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt b/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt new file mode 100644 index 0000000..c16b4fa --- /dev/null +++ b/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt @@ -0,0 +1,267 @@ +package com.immichframe.immichframe + +import android.app.TimePickerDialog +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.preference.PreferenceManager +import com.immichframe.immichframe.ui.theme.ImmichFrameTheme +import java.text.DateFormatSymbols +import java.util.Locale + +// Calendar weekday constants (SUNDAY=1 .. SATURDAY=7) ordered Mon..Sun for display. +private val WEEKDAY_ORDER = listOf(2, 3, 4, 5, 6, 7, 1) +private val WORKDAYS = setOf(2, 3, 4, 5, 6) +private val WEEKEND = setOf(7, 1) +private val ALL_DAYS = setOf(1, 2, 3, 4, 5, 6, 7) + +// Locale-aware short weekday name for a Calendar day constant (follows the OS language). +private fun weekdayLabel(dayInt: Int): String { + val names = DateFormatSymbols(Locale.getDefault()).shortWeekdays + return names.getOrNull(dayInt)?.takeIf { it.isNotBlank() } ?: dayInt.toString() +} + +private val TIME_REGEX = Regex("^([01]?[0-9]|2[0-3]):([0-5][0-9])$") + +private fun isValidTime(time: String): Boolean = time.matches(TIME_REGEX) + +private class RuleState(days: Set, ranges: List) { + val days: SnapshotStateList = mutableStateListOf().apply { addAll(days) } + val ranges: SnapshotStateList> = + mutableStateListOf>().apply { addAll(ranges.map { it.start to it.end }) } +} + +class ActiveScheduleActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + val initial = Helpers.parseActiveSchedule(prefs.getString("activeSchedule", null)) + + setContent { + ImmichFrameTheme(darkTheme = true, dynamicColor = false) { + ScheduleEditorScreen( + initial = initial, + onSave = { schedule -> + prefs.edit() + .putString("activeSchedule", Helpers.serializeActiveSchedule(schedule)) + .apply() + finish() + }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScheduleEditorScreen( + initial: Helpers.ActiveSchedule, + onSave: (Helpers.ActiveSchedule) -> Unit, +) { + val context = LocalContext.current + val rules = remember { + mutableStateListOf().apply { + initial.rules.forEach { add(RuleState(it.days, it.ranges)) } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Active Schedule") }, + actions = { + TextButton(onClick = { + val cleaned = rules.mapNotNull { rule -> + val validRanges = rule.ranges + .filter { isValidTime(it.first) && isValidTime(it.second) } + .map { Helpers.ActiveRange(it.first, it.second) } + if (rule.days.isEmpty() || validRanges.isEmpty()) { + null + } else { + Helpers.ActiveRule(rule.days.toSortedSet(), validRanges) + } + } + onSave(Helpers.ActiveSchedule(cleaned)) + }) { + Text("Save") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (rules.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "No rules yet. Add a rule to define when the frame is active. " + + "Leaving this empty keeps the frame always on.", + textAlign = TextAlign.Center, + ) + } + } + } + + items(rules) { rule -> + RuleCard( + rule = rule, + onDelete = { rules.remove(rule) }, + onPickTime = { current, onPicked -> showTimePicker(context, current, onPicked) }, + ) + } + + item { + Button( + onClick = { rules.add(RuleState(emptySet(), listOf(Helpers.ActiveRange("07:00", "22:00")))) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Add rule") + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RuleCard( + rule: RuleState, + onDelete: () -> Unit, + onPickTime: (String, (String) -> Unit) -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + androidx.compose.foundation.layout.Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + androidx.compose.foundation.layout.Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Days", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDelete) { Text("Delete") } + } + + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WEEKDAY_ORDER.forEach { dayInt -> + val selected = rule.days.contains(dayInt) + FilterChip( + selected = selected, + onClick = { + if (selected) rule.days.remove(dayInt) else rule.days.add(dayInt) + }, + label = { Text(weekdayLabel(dayInt)) }, + ) + } + } + + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = { setDays(rule.days, WORKDAYS) }, label = { Text("Workdays") }) + AssistChip(onClick = { setDays(rule.days, WEEKEND) }, label = { Text("Weekends") }) + AssistChip(onClick = { setDays(rule.days, ALL_DAYS) }, label = { Text("Every day") }) + AssistChip(onClick = { rule.days.clear() }, label = { Text("Clear") }) + } + + HorizontalDivider() + + Text("Time ranges", style = MaterialTheme.typography.titleMedium) + + rule.ranges.forEachIndexed { index, range -> + androidx.compose.foundation.layout.Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { + onPickTime(range.first) { picked -> + rule.ranges[index] = picked to rule.ranges[index].second + } + }, + modifier = Modifier.weight(1f), + ) { Text(range.first, color = MaterialTheme.colorScheme.onSurface) } + Text("\u2013") + OutlinedButton( + onClick = { + onPickTime(range.second) { picked -> + rule.ranges[index] = rule.ranges[index].first to picked + } + }, + modifier = Modifier.weight(1f), + ) { Text(range.second, color = MaterialTheme.colorScheme.onSurface) } + TextButton(onClick = { rule.ranges.removeAt(index) }) { Text("\u2715") } + } + } + + TextButton(onClick = { rule.ranges.add("07:00" to "22:00") }) { + Text("Add time range") + } + } + } +} + +private fun setDays(target: SnapshotStateList, values: Set) { + target.clear() + target.addAll(values) +} + +private fun showTimePicker(context: Context, current: String, onPicked: (String) -> Unit) { + val parts = current.split(":") + val hour = parts.getOrNull(0)?.toIntOrNull()?.coerceIn(0, 23) ?: 8 + val minute = parts.getOrNull(1)?.toIntOrNull()?.coerceIn(0, 59) ?: 0 + TimePickerDialog( + context, + { _, pickedHour, pickedMinute -> + onPicked(String.format(Locale.US, "%02d:%02d", pickedHour, pickedMinute)) + }, + hour, + minute, + android.text.format.DateFormat.is24HourFormat(context), + ).show() +} diff --git a/app/src/main/java/com/immichframe/immichframe/FrameDeviceAdminReceiver.kt b/app/src/main/java/com/immichframe/immichframe/FrameDeviceAdminReceiver.kt new file mode 100644 index 0000000..f78ca70 --- /dev/null +++ b/app/src/main/java/com/immichframe/immichframe/FrameDeviceAdminReceiver.kt @@ -0,0 +1,12 @@ +package com.immichframe.immichframe + +import android.app.admin.DeviceAdminReceiver +import android.content.ComponentName +import android.content.Context + +class FrameDeviceAdminReceiver : DeviceAdminReceiver() { + companion object { + fun componentName(context: Context): ComponentName = + ComponentName(context.applicationContext, FrameDeviceAdminReceiver::class.java) + } +} diff --git a/app/src/main/java/com/immichframe/immichframe/Helpers.kt b/app/src/main/java/com/immichframe/immichframe/Helpers.kt index 1ff4786..0df3b38 100644 --- a/app/src/main/java/com/immichframe/immichframe/Helpers.kt +++ b/app/src/main/java/com/immichframe/immichframe/Helpers.kt @@ -11,8 +11,11 @@ import retrofit2.http.GET import androidx.core.graphics.scale import okhttp3.OkHttpClient import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import java.util.Calendar import java.util.concurrent.TimeUnit object Helpers { @@ -205,4 +208,114 @@ object Helpers { } } + // --- Active schedule --------------------------------------------------- + // Days use java.util.Calendar constants: SUNDAY=1 .. SATURDAY=7. + + data class ActiveRange(val start: String, val end: String) // "HH:mm" + data class ActiveRule(val days: Set, val ranges: List) + data class ActiveSchedule(val rules: List) + + private fun timeToMinutes(time: String): Int? { + val parts = time.split(":") + if (parts.size != 2) return null + val hour = parts[0].toIntOrNull() ?: return null + val minute = parts[1].toIntOrNull() ?: return null + if (hour !in 0..23 || minute !in 0..59) return null + return hour * 60 + minute + } + + fun parseActiveSchedule(json: String?): ActiveSchedule { + if (json.isNullOrBlank()) return ActiveSchedule(emptyList()) + return try { + val rulesArr = JSONObject(json).optJSONArray("rules") ?: JSONArray() + val rules = mutableListOf() + for (i in 0 until rulesArr.length()) { + val ruleObj = rulesArr.getJSONObject(i) + val daysArr = ruleObj.optJSONArray("days") ?: JSONArray() + val days = mutableSetOf() + for (j in 0 until daysArr.length()) days.add(daysArr.getInt(j)) + val rangesArr = ruleObj.optJSONArray("ranges") ?: JSONArray() + val ranges = mutableListOf() + for (j in 0 until rangesArr.length()) { + val rangeObj = rangesArr.getJSONObject(j) + ranges.add(ActiveRange(rangeObj.getString("start"), rangeObj.getString("end"))) + } + rules.add(ActiveRule(days, ranges)) + } + ActiveSchedule(rules) + } catch (_: Exception) { + ActiveSchedule(emptyList()) + } + } + + fun serializeActiveSchedule(schedule: ActiveSchedule): String { + val rulesArr = JSONArray() + for (rule in schedule.rules) { + val daysArr = JSONArray() + rule.days.sorted().forEach { daysArr.put(it) } + val rangesArr = JSONArray() + for (range in rule.ranges) { + rangesArr.put(JSONObject().put("start", range.start).put("end", range.end)) + } + rulesArr.put(JSONObject().put("days", daysArr).put("ranges", rangesArr)) + } + return JSONObject().put("rules", rulesArr).toString() + } + + /** + * Returns whether the frame should be active right now. + * An empty schedule means "always active" so enabling the feature without + * configuring it never blacks out the screen. + * Overnight ranges (start >= end) are anchored to the day they start on. + */ + fun isActiveNow(schedule: ActiveSchedule, now: Calendar): Boolean { + if (schedule.rules.isEmpty()) return true + val today = now.get(Calendar.DAY_OF_WEEK) + val yesterday = if (today == Calendar.SUNDAY) Calendar.SATURDAY else today - 1 + val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + + for (rule in schedule.rules) { + for (range in rule.ranges) { + val start = timeToMinutes(range.start) ?: continue + val end = timeToMinutes(range.end) ?: continue + when { + start < end -> { + // Same-day range + if (rule.days.contains(today) && nowMinutes in start until end) return true + } + start > end -> { + // Overnight range: part before midnight belongs to today's rule, + // part after midnight belongs to yesterday's rule. + if (rule.days.contains(today) && nowMinutes >= start) return true + if (rule.days.contains(yesterday) && nowMinutes < end) return true + } + else -> { + // start == end -> treat as active all day + if (rule.days.contains(today)) return true + } + } + } + } + return false + } + + /** + * Returns the next time (after [now]) at which the schedule becomes active, or null if the + * schedule has no rules (always active) or no active period is found within the next week. + * Scans minute-by-minute up to 8 days ahead. + */ + fun nextActiveStart(schedule: ActiveSchedule, now: Calendar): Calendar? { + if (schedule.rules.isEmpty()) return null + val cursor = now.clone() as Calendar + cursor.set(Calendar.SECOND, 0) + cursor.set(Calendar.MILLISECOND, 0) + cursor.add(Calendar.MINUTE, 1) + val maxSteps = 8 * 24 * 60 + repeat(maxSteps) { + if (isActiveNow(schedule, cursor)) return cursor + cursor.add(Calendar.MINUTE, 1) + } + return null + } + } \ No newline at end of file diff --git a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt index 4cde6f2..916130b 100644 --- a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt +++ b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt @@ -3,13 +3,21 @@ package com.immichframe.immichframe import android.animation.ObjectAnimator import android.animation.PropertyValuesHolder import android.annotation.SuppressLint +import android.app.AlarmManager +import android.app.KeyguardManager +import android.app.PendingIntent +import android.app.admin.DevicePolicyManager +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.graphics.Bitmap import android.graphics.Color import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.PowerManager import android.text.SpannableString import android.text.Spanned import android.text.style.RelativeSizeSpan @@ -96,6 +104,22 @@ class MainActivity : AppCompatActivity() { handler.postDelayed(this, 30000) } } + private val activeCheckRunnable = object : Runnable { + override fun run() { + checkActiveTime() + handler.postDelayed(this, 30000) + } + } + private var isFrameInactive: Boolean? = null + private var isManualOverride = false + private val screenStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_ON -> onScreenTurnedOn() + Intent.ACTION_SCREEN_OFF -> onScreenTurnedOff() + } + } + } private var isShowingFirst = true private var zoomAnimator: ObjectAnimator? = null @@ -165,6 +189,14 @@ class MainActivity : AppCompatActivity() { ) rcpServer.start() + registerReceiver( + screenStateReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }, + ) + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) val savedUrl = prefs.getString("webview_url", "") ?: "" @@ -497,6 +529,7 @@ class MainActivity : AppCompatActivity() { val authSecret = prefs.getString("authSecret", "") ?: "" val screenDim = prefs.getBoolean("screenDim", false) val settingsLock = prefs.getBoolean("settingsLock", false) + val activeTimes = prefs.getBoolean("activeTimes", false) webView.visibility = if (useWebView) View.VISIBLE else View.GONE imageView1.visibility = if (useWebView) View.GONE else View.VISIBLE @@ -518,6 +551,15 @@ class MainActivity : AppCompatActivity() { lp.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE window.attributes = lp } + if (activeTimes) { + handler.removeCallbacks(activeCheckRunnable) + handler.post(activeCheckRunnable) + } else { + handler.removeCallbacks(activeCheckRunnable) + if (isFrameInactive != false) { + setFrameActive(true) + } + } if (useWebView) { savedUrl = if (authSecret.isNotEmpty()) { savedUrl.toUri() @@ -832,6 +874,197 @@ class MainActivity : AppCompatActivity() { } } + private fun checkActiveTime() { + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val schedule = Helpers.parseActiveSchedule(prefs.getString("activeSchedule", null)) + val shouldBeActive = Helpers.isActiveNow(schedule, Calendar.getInstance()) + + if (shouldBeActive && isFrameInactive != false) { + setFrameActive(true) + } else if (!shouldBeActive && isFrameInactive != true) { + setFrameActive(false) + } + } + + private fun setFrameActive(active: Boolean) { + if (active) { + isFrameInactive = false + cancelWakeAlarm() + // Power the screen back on and show over the keyguard + wakeScreen() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + val lp = window.attributes + lp.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + window.attributes = lp + if (dimOverlay.isVisible) { + dimOverlay.animate() + .alpha(0f) + .setDuration(500L) + .withEndAction { + dimOverlay.visibility = View.GONE + loadSettings() + } + .start() + } else { + loadSettings() + } + } else { + isFrameInactive = true + // Stop refreshing content while inactive + stopImageTimer() + stopWeatherTimer() + if (useWebView) { + webView.loadUrl("about:blank") + } + dimOverlay.apply { + visibility = View.VISIBLE + alpha = 0.99f + } + val lp = window.attributes + lp.screenBrightness = 0f + window.attributes = lp + // Stop forcing the screen to stay on and allow it to power off + window.clearFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(false) + setTurnScreenOn(false) + } + // Wake the device back up at the next scheduled active time + scheduleWakeAlarm() + // Actively turn the screen off and sleep the device (requires device admin) + lockDeviceIfPossible() + } + } + + private fun onScreenTurnedOn() { + // Manual power-button override: when the schedule has put the frame to sleep and the + // user turns the screen on, temporarily show the frame without forcing it to stay on. + if (isFrameInactive == true && !isManualOverride) { + isManualOverride = true + showFrameTemporarily() + } + } + + private fun onScreenTurnedOff() { + // The manual override ended (screen timed out or was turned off); resume the schedule + // and re-arm the wake alarm for the next active rule. + if (isManualOverride) { + isManualOverride = false + setFrameActive(false) + } + } + + private fun showFrameTemporarily() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + keyguardManager.requestDismissKeyguard(this, null) + } + // Show over the keyguard but do NOT add FLAG_KEEP_SCREEN_ON, so the device's normal + // screen timeout still applies and the schedule resumes once the screen turns off. + window.addFlags( + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + or WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + or WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + ) + val lp = window.attributes + lp.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + window.attributes = lp + if (dimOverlay.isVisible) { + dimOverlay.animate() + .alpha(0f) + .setDuration(500L) + .withEndAction { + dimOverlay.visibility = View.GONE + loadSettings() + } + .start() + } else { + loadSettings() + } + } + + private fun wakeScreen() { + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + @Suppress("DEPRECATION") + val wakeLock = powerManager.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK + or PowerManager.ACQUIRE_CAUSES_WAKEUP + or PowerManager.ON_AFTER_RELEASE, + "immichframe:activeWake", + ) + // Briefly wake the screen, then auto-release; FLAG_KEEP_SCREEN_ON keeps it on afterwards + wakeLock.acquire(3000L) + } + + private fun lockDeviceIfPossible() { + val dpm = getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + if (dpm.isAdminActive(FrameDeviceAdminReceiver.componentName(this))) { + try { + dpm.lockNow() + } catch (e: SecurityException) { + Log.w("MainActivity", "Unable to lock device: ${e.message}") + } + } else { + Log.i( + "MainActivity", + "Device admin not enabled; screen will only dim. Enable it in Settings to fully sleep.", + ) + } + } + + private fun wakeAlarmPendingIntent(): PendingIntent { + val intent = Intent(this, ScheduleWakeReceiver::class.java).apply { + action = ScheduleWakeReceiver.ACTION_WAKE + } + return PendingIntent.getBroadcast( + this, + ScheduleWakeReceiver.REQUEST_CODE, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun scheduleWakeAlarm() { + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val schedule = Helpers.parseActiveSchedule(prefs.getString("activeSchedule", null)) + val next = Helpers.nextActiveStart(schedule, Calendar.getInstance()) ?: return + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val pendingIntent = wakeAlarmPendingIntent() + try { + // setExactAndAllowWhileIdle is available since API 23 (minSdk) and wakes from Doze + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + next.timeInMillis, + pendingIntent, + ) + } catch (e: SecurityException) { + // Exact alarms not permitted (API 31+); fall back to an inexact wake (may fire a bit late) + alarmManager.set(AlarmManager.RTC_WAKEUP, next.timeInMillis, pendingIntent) + Log.w("MainActivity", "Exact alarm denied, using inexact wake: ${e.message}") + } + } + + private fun cancelWakeAlarm() { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(wakeAlarmPendingIntent()) + } + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) { @@ -842,11 +1075,20 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() hideSystemUI() + // Re-evaluate the schedule whenever the activity returns to the foreground. This is + // essential when the wake alarm brings an already-running instance forward (which does + // not re-run onCreate), so the screen powers on immediately instead of waiting for the + // next periodic check. + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + if (!isManualOverride && prefs.getBoolean("activeTimes", false)) { + checkActiveTime() + } } override fun onDestroy() { super.onDestroy() rcpServer.stop() + unregisterReceiver(screenStateReceiver) handler.removeCallbacksAndMessages(null) } diff --git a/app/src/main/java/com/immichframe/immichframe/ScheduleWakeReceiver.kt b/app/src/main/java/com/immichframe/immichframe/ScheduleWakeReceiver.kt new file mode 100644 index 0000000..992cbd6 --- /dev/null +++ b/app/src/main/java/com/immichframe/immichframe/ScheduleWakeReceiver.kt @@ -0,0 +1,25 @@ +package com.immichframe.immichframe + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * Fired by AlarmManager (RTC_WAKEUP) at the next scheduled active-start time. Wakes the device by + * bringing MainActivity to the foreground; MainActivity then re-evaluates the schedule and powers + * the screen back on. Needed because the device may be in deep sleep, where the in-app Handler loop + * is suspended. + */ +class ScheduleWakeReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + val launch = Intent(context, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + } + context.startActivity(launch) + } + + companion object { + const val ACTION_WAKE = "com.immichframe.immichframe.ACTION_SCHEDULE_WAKE" + const val REQUEST_CODE = 4711 + } +} diff --git a/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt b/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt index d2f52ad..a765027 100644 --- a/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt +++ b/app/src/main/java/com/immichframe/immichframe/SettingsFragment.kt @@ -1,6 +1,8 @@ package com.immichframe.immichframe import android.app.Activity +import android.app.admin.DevicePolicyManager +import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle @@ -9,12 +11,14 @@ import android.os.Looper import android.provider.Settings import android.text.InputType import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.preference.EditTextPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager import androidx.preference.SwitchPreferenceCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import java.text.DateFormatSymbols +import java.util.Locale class SettingsFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { @@ -24,6 +28,9 @@ class SettingsFragment : PreferenceFragmentCompat() { val chkShowCurrentDate = findPreference("showCurrentDate") val chkScreenDim = findPreference("screenDim") val txtDimTime = findPreference("dim_time_range") + val chkActiveTimes = findPreference("activeTimes") + val editActiveSchedule = findPreference("active_schedule_edit") + val adminActiveSchedule = findPreference("active_schedule_admin") //obfuscate the authSecret @@ -38,6 +45,11 @@ class SettingsFragment : PreferenceFragmentCompat() { chkShowCurrentDate?.isVisible = !useWebView val screenDim = chkScreenDim?.isChecked ?: false txtDimTime?.isVisible = screenDim + val activeTimes = chkActiveTimes?.isChecked ?: false + editActiveSchedule?.isVisible = activeTimes + adminActiveSchedule?.isVisible = activeTimes + updateAdminSummary(adminActiveSchedule) + updateScheduleSummary(editActiveSchedule) // React to changes chkUseWebView?.setOnPreferenceChangeListener { _, newValue -> @@ -52,11 +64,50 @@ class SettingsFragment : PreferenceFragmentCompat() { txtDimTime?.isVisible = value true } + chkActiveTimes?.setOnPreferenceChangeListener { _, newValue -> + val value = newValue as Boolean + editActiveSchedule?.isVisible = value + adminActiveSchedule?.isVisible = value + true + } + editActiveSchedule?.setOnPreferenceClickListener { + startActivity(Intent(requireContext(), ActiveScheduleActivity::class.java)) + true + } + adminActiveSchedule?.setOnPreferenceClickListener { + val dpm = requireContext() + .getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val component = FrameDeviceAdminReceiver.componentName(requireContext()) + if (dpm.isAdminActive(component)) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Screen-Off Permission") + .setMessage("ImmichFrame can already turn the screen off. Disable this permission?") + .setPositiveButton("Disable") { _, _ -> + dpm.removeActiveAdmin(component) + // removeActiveAdmin applies asynchronously; refresh once it takes effect. + Handler(Looper.getMainLooper()).postDelayed({ + updateAdminSummary(adminActiveSchedule) + }, 500) + } + .setNegativeButton("Keep", null) + .show() + } else { + val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply { + putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, component) + putExtra( + DevicePolicyManager.EXTRA_ADD_EXPLANATION, + getString(R.string.device_admin_description), + ) + } + startActivity(intent) + } + true + } val chkSettingsLock = findPreference("settingsLock") chkSettingsLock?.setOnPreferenceChangeListener { _, newValue -> val enabling = newValue as Boolean if (enabling) { - AlertDialog.Builder(requireContext()) + MaterialAlertDialogBuilder(requireContext()) .setTitle("Confirm Action") .setMessage( "This will disable access to the settings screen, the only way back is via RPC commands (or uninstall/reinstall).\n" + @@ -138,4 +189,61 @@ class SettingsFragment : PreferenceFragmentCompat() { } } } + + override fun onResume() { + super.onResume() + updateAdminSummary(findPreference("active_schedule_admin")) + updateScheduleSummary(findPreference("active_schedule_edit")) + } + + private fun updateScheduleSummary(preference: Preference?) { + val pref = preference ?: return + val json = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString("activeSchedule", null) + pref.summary = summarizeSchedule(json) + } + + private fun summarizeSchedule(json: String?): String { + val schedule = Helpers.parseActiveSchedule(json) + if (schedule.rules.isEmpty()) return getString(R.string.active_schedule_always) + return schedule.rules.joinToString("\n") { rule -> + val days = summarizeDays(rule.days) + val times = rule.ranges.joinToString(", ") { "${it.start}–${it.end}" } + if (days.isEmpty()) times else "$days: $times" + } + } + + // Collapse a set of Calendar weekday constants into a compact label, e.g. "Mon–Fri, Sun". + private fun summarizeDays(days: Set): String { + val order = intArrayOf(2, 3, 4, 5, 6, 7, 1) // Mon..Sun + val indices = order.indices.filter { days.contains(order[it]) } + if (indices.isEmpty()) return "" + val names = DateFormatSymbols(Locale.getDefault()).shortWeekdays + fun name(dayInt: Int) = names.getOrNull(dayInt)?.takeIf { it.isNotBlank() } ?: dayInt.toString() + val parts = mutableListOf() + var start = 0 + while (start < indices.size) { + var end = start + while (end + 1 < indices.size && indices[end + 1] == indices[end] + 1) end++ + if (end - start >= 2) { + parts.add("${name(order[indices[start]])}–${name(order[indices[end]])}") + } else { + for (k in start..end) parts.add(name(order[indices[k]])) + } + start = end + 1 + } + return parts.joinToString(", ") + } + + private fun updateAdminSummary(preference: Preference?) { + val pref = preference ?: return + val dpm = requireContext() + .getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + val enabled = dpm.isAdminActive(FrameDeviceAdminReceiver.componentName(requireContext())) + pref.summary = if (enabled) { + "Enabled — the frame can turn off the screen and sleep the device. Tap to disable." + } else { + "Allow the frame to turn off the screen and sleep the device during inactive hours" + } + } } \ No newline at end of file diff --git a/app/src/main/res/layout/pref_close_button.xml b/app/src/main/res/layout/pref_close_button.xml new file mode 100644 index 0000000..841f27f --- /dev/null +++ b/app/src/main/res/layout/pref_close_button.xml @@ -0,0 +1,27 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0b35a48..956f966 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,4 +2,7 @@ Settings Quit immichFrame + Allows ImmichFrame to turn off the screen and let the + device sleep during inactive hours defined in the Active Schedule. + Always active — tap to add rules \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 4152493..8d28f78 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,4 +1,4 @@ - + - + \ No newline at end of file diff --git a/app/src/main/res/xml/device_admin.xml b/app/src/main/res/xml/device_admin.xml new file mode 100644 index 0000000..da4276f --- /dev/null +++ b/app/src/main/res/xml/device_admin.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/settings_view.xml b/app/src/main/res/xml/settings_view.xml index 8bf8299..d770cb0 100644 --- a/app/src/main/res/xml/settings_view.xml +++ b/app/src/main/res/xml/settings_view.xml @@ -44,6 +44,23 @@ android:title="Dim Time Range" /> + + + + + + - + android:layout="@layout/pref_close_button" /> + \ No newline at end of file From e272c8156fb429b2684c6042d5e4b9af2651266c Mon Sep 17 00:00:00 2001 From: Patrick Baumgartner Date: Sun, 28 Jun 2026 15:08:16 +0200 Subject: [PATCH 2/2] Address PR review feedback on Active Schedule - ActiveScheduleActivity: validate rules before saving; reject incomplete rules (no days / no ranges), malformed times, and zero-length ranges via a Toast instead of silently dropping them. Empty schedule still means always-active. - MainActivity.checkActiveTime: re-arm the wake alarm when already inactive so a changed schedule no longer leaves a stale wake time. - MainActivity.scheduleWakeAlarm: cancel any prior alarm before computing the next start so an empty/expired schedule clears the pending wake. - MainActivity.onScreenTurnedOff: re-evaluate the schedule (and the activeTimes toggle) before ending the manual override instead of always sleeping. - MainActivity.onResume: when Active Times is disabled while the frame is asleep, cancel the wake alarm and reactivate the frame so disabling the feature fully clears its side effects. --- .../immichframe/ActiveScheduleActivity.kt | 46 +++++++++++++++---- .../immichframe/immichframe/MainActivity.kt | 35 +++++++++++--- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt b/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt index c16b4fa..7053962 100644 --- a/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt +++ b/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt @@ -3,6 +3,7 @@ package com.immichframe.immichframe import android.app.TimePickerDialog import android.content.Context import android.os.Bundle +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement @@ -59,6 +60,32 @@ private val TIME_REGEX = Regex("^([01]?[0-9]|2[0-3]):([0-5][0-9])$") private fun isValidTime(time: String): Boolean = time.matches(TIME_REGEX) +// Validates the editor state before saving. Returns an error message for the first problem +// found, or null when the schedule is safe to persist. An empty rule list is allowed and means +// "always active". Every rule that does exist must be complete: at least one day, at least one +// range, all times well-formed, and no zero-length range (start == end would be treated as +// active all day by isActiveNow, which is almost never what the user intends). +private fun validateRules(rules: List): String? { + rules.forEachIndexed { index, rule -> + val position = index + 1 + if (rule.days.isEmpty()) { + return "Rule $position has no days selected." + } + if (rule.ranges.isEmpty()) { + return "Rule $position has no time ranges." + } + rule.ranges.forEach { (start, end) -> + if (!isValidTime(start) || !isValidTime(end)) { + return "Rule $position has an invalid time ($start–$end)." + } + if (start == end) { + return "Rule $position has a zero-length range ($start–$end)." + } + } + } + return null +} + private class RuleState(days: Set, ranges: List) { val days: SnapshotStateList = mutableStateListOf().apply { addAll(days) } val ranges: SnapshotStateList> = @@ -106,15 +133,16 @@ private fun ScheduleEditorScreen( title = { Text("Active Schedule") }, actions = { TextButton(onClick = { - val cleaned = rules.mapNotNull { rule -> - val validRanges = rule.ranges - .filter { isValidTime(it.first) && isValidTime(it.second) } - .map { Helpers.ActiveRange(it.first, it.second) } - if (rule.days.isEmpty() || validRanges.isEmpty()) { - null - } else { - Helpers.ActiveRule(rule.days.toSortedSet(), validRanges) - } + val error = validateRules(rules) + if (error != null) { + Toast.makeText(context, error, Toast.LENGTH_LONG).show() + return@TextButton + } + val cleaned = rules.map { rule -> + Helpers.ActiveRule( + rule.days.toSortedSet(), + rule.ranges.map { Helpers.ActiveRange(it.first, it.second) }, + ) } onSave(Helpers.ActiveSchedule(cleaned)) }) { diff --git a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt index 916130b..060011e 100644 --- a/app/src/main/java/com/immichframe/immichframe/MainActivity.kt +++ b/app/src/main/java/com/immichframe/immichframe/MainActivity.kt @@ -881,8 +881,14 @@ class MainActivity : AppCompatActivity() { if (shouldBeActive && isFrameInactive != false) { setFrameActive(true) - } else if (!shouldBeActive && isFrameInactive != true) { - setFrameActive(false) + } else if (!shouldBeActive) { + if (isFrameInactive != true) { + setFrameActive(false) + } else { + // Already inactive: re-arm in case the schedule changed and the + // existing wake alarm now points at a stale time. + scheduleWakeAlarm() + } } } @@ -960,11 +966,17 @@ class MainActivity : AppCompatActivity() { } private fun onScreenTurnedOff() { - // The manual override ended (screen timed out or was turned off); resume the schedule - // and re-arm the wake alarm for the next active rule. + // The manual override ended (screen timed out or was turned off); re-evaluate the + // current schedule before deciding whether to sleep again. The user may have disabled + // Active Times or a new active period may have started while they were viewing. if (isManualOverride) { isManualOverride = false - setFrameActive(false) + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + val activeTimes = prefs.getBoolean("activeTimes", false) + val schedule = Helpers.parseActiveSchedule(prefs.getString("activeSchedule", null)) + val shouldBeActive = + !activeTimes || Helpers.isActiveNow(schedule, Calendar.getInstance()) + setFrameActive(shouldBeActive) } } @@ -1043,9 +1055,12 @@ class MainActivity : AppCompatActivity() { private fun scheduleWakeAlarm() { val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) val schedule = Helpers.parseActiveSchedule(prefs.getString("activeSchedule", null)) - val next = Helpers.nextActiveStart(schedule, Calendar.getInstance()) ?: return val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager val pendingIntent = wakeAlarmPendingIntent() + // Always clear any previous alarm first so a stale wake time is dropped even when the + // schedule no longer has an upcoming active start. + alarmManager.cancel(pendingIntent) + val next = Helpers.nextActiveStart(schedule, Calendar.getInstance()) ?: return try { // setExactAndAllowWhileIdle is available since API 23 (minSdk) and wakes from Doze alarmManager.setExactAndAllowWhileIdle( @@ -1079,9 +1094,15 @@ class MainActivity : AppCompatActivity() { // essential when the wake alarm brings an already-running instance forward (which does // not re-run onCreate), so the screen powers on immediately instead of waiting for the // next periodic check. + if (isManualOverride) return val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) - if (!isManualOverride && prefs.getBoolean("activeTimes", false)) { + if (prefs.getBoolean("activeTimes", false)) { checkActiveTime() + } else if (isFrameInactive != false) { + // Active Times was turned off while the frame was asleep: restore it and drop any + // pending wake alarm so disabling the feature fully clears its side effects. + cancelWakeAlarm() + setFrameActive(true) } }