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..7053962
--- /dev/null
+++ b/app/src/main/java/com/immichframe/immichframe/ActiveScheduleActivity.kt
@@ -0,0 +1,295 @@
+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
+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)
+
+// 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> =
+ 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 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))
+ }) {
+ 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..060011e 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,212 @@ 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) {
+ 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()
+ }
+ }
+ }
+
+ 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); 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
+ 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)
+ }
+ }
+
+ 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 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(
+ 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 +1090,26 @@ 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.
+ if (isManualOverride) return
+ val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
+ 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)
+ }
}
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