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