From cb57aa55a9ab16b867d1fc35a44b7143efd5280b Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:17:53 +0200 Subject: [PATCH 1/2] fix: timer runtime correctness, theming, and Shizuku UserService migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a batch of bugs and improvements found in a full-project review. Timer runtime (core:service) - Anchor the countdown to a SystemClock.elapsedRealtime() deadline instead of accumulating delay() ticks, which pause during CPU deep-sleep and drift — a no-media timer no longer silently overruns. - Soft screen-off only injects KEYCODE_POWER while the screen is interactive, so "turn off display" can no longer wake an already-off device. - Harden service races: a generation counter stops a cancel teardown from killing a freshly started timer; ACTION_START over a running fade joins the old job and restores volume; the expiry teardown checks job identity so a "+ during fade" successor survives. - Post the notification only when the displayed minute changes (skip ~59 redundant re-posts/min); refresh +/- labels immediately when step size is edited mid-timer. Data (core:data) - ReplaceFileCorruptionHandler + IOException fallback so a corrupted settings file degrades to defaults instead of crashing the app at service start. - Single MAX_TIMER_MINUTES (240) source of truth; clamp persisted presets on read. Theming (feature:timer) - ProvideAppTheme derives a Material ColorScheme from the active AppTheme so dialogs/sliders/text-buttons follow the selected theme (incl. Light), and syncs system-bar icon contrast to the app theme rather than the system dark-mode flag. Dial (feature:timer) - Read time-varying values through rememberUpdatedState inside the pointerInput gesture lambdas, fixing stale captures when dragging a running timer. - Dial, hour-dots, and +/- caps all derive from MAX_TIMER_MINUTES (4h). Shizuku (core:service) - Migrate off the private, removal-announced Shizuku.newProcess to the official UserService API (IShellUserService AIDL + ShellUserService). ShizukuShell keeps its exec(vararg String): Boolean contract; command timeout is now enforced inside the user service. Proguard keep rules updated accordingly. Cleanups - Provide the device-admin ComponentName from the app module via a Hilt qualifier and inject it, removing three duplicated class-name string literals. - Delete the dead TimerPhase.FINISHED case; drop the unused lifecycle-service dep; ThemeId.values() -> entries; compareAndSet preset load to avoid a startup race. Verified: assembleDebug, lint, assembleRelease (R8 keeps verified in mapping), testDebugUnitTest all pass. Shizuku privileged actions need on-device verification. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/proguard-rules.pro | 13 +- .../dev/xitee/sleeptimer/di/AppModule.kt | 26 +++ .../sleeptimer/core/data/di/DataModule.kt | 8 +- .../sleeptimer/core/data/model/ThemeId.kt | 2 +- .../sleeptimer/core/data/model/TimerState.kt | 4 +- .../data/repository/SettingsRepositoryImpl.kt | 18 ++- core/service/build.gradle.kts | 6 +- .../service/shizuku/IShellUserService.aidl | 12 ++ .../core/service/SleepTimerService.kt | 149 ++++++++++++------ .../core/service/screen/ScreenLockHelper.kt | 16 +- .../core/service/shizuku/ShellUserService.kt | 37 +++++ .../core/service/shizuku/ShizukuShell.kt | 116 +++++++++++--- .../feature/timer/about/AboutScreen.kt | 5 +- .../feature/timer/settings/SettingsScreen.kt | 10 +- .../feature/timer/settings/SettingsUiState.kt | 1 + .../timer/settings/SettingsViewModel.kt | 19 +-- .../timer/settings/ThemePickerScreen.kt | 3 +- .../feature/timer/theme/ProvideAppTheme.kt | 74 +++++++++ .../feature/timer/timer/TimerScreen.kt | 18 ++- .../feature/timer/timer/TimerViewModel.kt | 23 ++- .../timer/timer/components/CircularDial.kt | 39 +++-- .../timer/components/CircularDialState.kt | 3 +- .../timer/src/main/res/values-de/strings.xml | 1 + feature/timer/src/main/res/values/strings.xml | 1 + gradle/libs.versions.toml | 1 - 25 files changed, 465 insertions(+), 140 deletions(-) create mode 100644 app/src/main/kotlin/dev/xitee/sleeptimer/di/AppModule.kt create mode 100644 core/service/src/main/aidl/dev/xitee/sleeptimer/core/service/shizuku/IShellUserService.aidl create mode 100644 core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShellUserService.kt create mode 100644 feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 3249a18..bd04b61 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -14,10 +14,9 @@ } -keep,includedescriptorclasses class dev.xitee.sleeptimer.navigation.**$$serializer { *; } -# Shizuku AIDL + provider: the AAR ships consumer-proguard rules, but we reference -# rikka.shizuku.ShizukuProvider by fully-qualified name in AndroidManifest.xml. -# The manifest reference keeps the class; this keeps the IShizukuService AIDL stub -# we use in ShizukuShell. --keep class moe.shizuku.server.IShizukuService { *; } --keep class moe.shizuku.server.IShizukuService$Stub { *; } --keep class moe.shizuku.server.IRemoteProcess { *; } +# Shizuku user service: ShellUserService is instantiated by name inside the +# Shizuku-spawned shell process, and the AIDL stub crosses the binder — neither +# may be renamed or stripped. +-keep class dev.xitee.sleeptimer.core.service.shizuku.ShellUserService { *; } +-keep class dev.xitee.sleeptimer.core.service.shizuku.IShellUserService { *; } +-keep class dev.xitee.sleeptimer.core.service.shizuku.IShellUserService$Stub { *; } diff --git a/app/src/main/kotlin/dev/xitee/sleeptimer/di/AppModule.kt b/app/src/main/kotlin/dev/xitee/sleeptimer/di/AppModule.kt new file mode 100644 index 0000000..040185e --- /dev/null +++ b/app/src/main/kotlin/dev/xitee/sleeptimer/di/AppModule.kt @@ -0,0 +1,26 @@ +package dev.xitee.sleeptimer.di + +import android.content.ComponentName +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dev.xitee.sleeptimer.core.service.screen.DeviceAdminComponent +import dev.xitee.sleeptimer.receiver.SleepTimerDeviceAdminReceiver + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + + /** + * Single source of truth for the device-admin receiver's ComponentName. The + * receiver class lives in this module, so lower modules must receive the + * component via injection instead of hard-coding the class name. + */ + @Provides + @DeviceAdminComponent + fun provideDeviceAdminComponent(@ApplicationContext context: Context): ComponentName = + ComponentName(context, SleepTimerDeviceAdminReceiver::class.java) +} diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/di/DataModule.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/di/DataModule.kt index ecda707..4c73337 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/di/DataModule.kt @@ -2,7 +2,9 @@ package dev.xitee.sleeptimer.core.data.di import android.content.Context import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStore import dagger.Binds import dagger.Module @@ -16,7 +18,11 @@ import dev.xitee.sleeptimer.core.data.repository.TimerRepository import dev.xitee.sleeptimer.core.data.repository.TimerRepositoryImpl import javax.inject.Singleton -private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") +private val Context.dataStore: DataStore by preferencesDataStore( + name = "settings", + // A corrupted settings file must degrade to defaults, not crash every collector. + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, +) @Module @InstallIn(SingletonComponent::class) diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/ThemeId.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/ThemeId.kt index 50c0ed1..e3bb893 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/ThemeId.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/ThemeId.kt @@ -13,6 +13,6 @@ enum class ThemeId { val Default: ThemeId = Midnight fun fromStorage(value: String?): ThemeId = - values().firstOrNull { it.name == value } ?: Default + entries.firstOrNull { it.name == value } ?: Default } } diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/TimerState.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/TimerState.kt index a3c9fc5..287570d 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/TimerState.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/TimerState.kt @@ -1,5 +1,8 @@ package dev.xitee.sleeptimer.core.data.model +/** Upper bound for any timer duration — shared by the dial UI, the service, and the persisted preset. */ +const val MAX_TIMER_MINUTES = 240 + data class TimerState( val phase: TimerPhase = TimerPhase.IDLE, val totalDurationMillis: Long = 0L, @@ -10,5 +13,4 @@ enum class TimerPhase { IDLE, RUNNING, FADING_OUT, - FINISHED, } diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt index 80dca3f..da81640 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepositoryImpl.kt @@ -4,12 +4,16 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES import dev.xitee.sleeptimer.core.data.model.ThemeId import dev.xitee.sleeptimer.core.data.model.UserSettings import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -32,7 +36,13 @@ class SettingsRepositoryImpl @Inject constructor( val PRESET_MINUTES = intPreferencesKey("preset_minutes") } - override val settings: Flow = dataStore.data.map { prefs -> + override val settings: Flow = dataStore.data + .catch { error -> + // A failed read must not crash collectors — SleepTimerService reads this + // flow with runBlocking in onCreate. Fall back to defaults. + if (error is IOException) emit(emptyPreferences()) else throw error + } + .map { prefs -> // Single source of truth: defaults come from UserSettings(), so adding a new // field only requires updating the data class. val d = UserSettings() @@ -47,7 +57,9 @@ class SettingsRepositoryImpl @Inject constructor( theme = ThemeId.fromStorage(prefs[THEME]), starsEnabled = prefs[STARS_ENABLED] ?: d.starsEnabled, stepMinutes = prefs[STEP_MINUTES] ?: d.stepMinutes, - presetMinutes = prefs[PRESET_MINUTES] ?: d.presetMinutes, + // Clamp on read too: values persisted before the cap changed must not + // leak an out-of-range preset into the dial. + presetMinutes = (prefs[PRESET_MINUTES] ?: d.presetMinutes).coerceIn(1, MAX_TIMER_MINUTES), ) } @@ -92,6 +104,6 @@ class SettingsRepositoryImpl @Inject constructor( } override suspend fun updatePresetMinutes(minutes: Int) { - dataStore.edit { it[PRESET_MINUTES] = minutes.coerceIn(1, 300) } + dataStore.edit { it[PRESET_MINUTES] = minutes.coerceIn(1, MAX_TIMER_MINUTES) } } } diff --git a/core/service/build.gradle.kts b/core/service/build.gradle.kts index 75e2a31..2a3900f 100644 --- a/core/service/build.gradle.kts +++ b/core/service/build.gradle.kts @@ -12,6 +12,11 @@ android { minSdk = 26 } + buildFeatures { + // For the Shizuku user-service interface (IShellUserService.aidl). + aidl = true + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -31,7 +36,6 @@ dependencies { ksp(libs.hilt.android.compiler) implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.service) implementation(libs.shizuku.api) } diff --git a/core/service/src/main/aidl/dev/xitee/sleeptimer/core/service/shizuku/IShellUserService.aidl b/core/service/src/main/aidl/dev/xitee/sleeptimer/core/service/shizuku/IShellUserService.aidl new file mode 100644 index 0000000..33872db --- /dev/null +++ b/core/service/src/main/aidl/dev/xitee/sleeptimer/core/service/shizuku/IShellUserService.aidl @@ -0,0 +1,12 @@ +// Interface of the Shizuku user service that executes shell commands with +// shell-uid privileges. See ShellUserService for the implementation. +package dev.xitee.sleeptimer.core.service.shizuku; + +interface IShellUserService { + + void destroy() = 16777114; // Destroy method defined by the Shizuku server + + // Runs the command and returns its exit code; negative values signal + // timeout (-2) or failure to launch (-1). + int exec(in String[] command, long timeoutMillis) = 1; +} diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt index b052cd7..f4743a5 100644 --- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt @@ -5,8 +5,11 @@ import android.content.Intent import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder +import android.os.PowerManager +import android.os.SystemClock import androidx.core.app.ServiceCompat import dagger.hilt.android.AndroidEntryPoint +import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES import dev.xitee.sleeptimer.core.data.model.TimerPhase import dev.xitee.sleeptimer.core.data.model.TimerState import dev.xitee.sleeptimer.core.data.model.UserSettings @@ -32,6 +35,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import javax.inject.Inject +import kotlin.coroutines.coroutineContext @AndroidEntryPoint class SleepTimerService : Service() { @@ -48,10 +52,25 @@ class SleepTimerService : Service() { private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private var countdownJob: Job? = null + + // Deadline on the elapsedRealtime clock. delay() ticks are uptime-based and pause + // while the CPU deep-sleeps, so the remaining time must be re-derived from a clock + // that keeps counting through sleep — otherwise the countdown silently extends. + private var deadlineElapsed: Long = 0L private var remainingMillis: Long = 0L private var totalDurationMillis: Long = 0L private var stepMinutes: Int = 0 + // Bumped by startTimer so an in-flight cancel teardown can tell whether a newer + // timer took over while it was joining the old countdown job (see cancelTimer). + private var timerGeneration: Int = 0 + + // Last minutes value shown in the notification; notify() is skipped while the + // displayed text would not change. Int.MIN_VALUE forces the next update through. + private var lastNotifiedMinutes: Int = Int.MIN_VALUE + + private val powerManager by lazy { getSystemService(POWER_SERVICE) as PowerManager } + companion object { const val ACTION_START = "dev.xitee.sleeptimer.action.START" const val ACTION_CANCEL = "dev.xitee.sleeptimer.action.CANCEL" @@ -61,6 +80,7 @@ class SleepTimerService : Service() { const val EXTRA_DURATION_MILLIS = "dev.xitee.sleeptimer.extra.DURATION_MILLIS" const val EXTRA_MINUTES = "dev.xitee.sleeptimer.extra.MINUTES" private const val FADE_IN_SECONDS = 2 + private const val MAX_TIMER_MILLIS = MAX_TIMER_MINUTES * 60_000L } override fun onCreate() { @@ -71,7 +91,14 @@ class SleepTimerService : Service() { serviceScope.launch { settingsRepository.settings .map { it.stepMinutes } - .collect { stepMinutes = it } + .collect { newStep -> + val changed = newStep != stepMinutes + stepMinutes = newStep + // Keep the +/− action labels current if the step is edited mid-timer. + if (changed && countdownJob?.isActive == true && lastNotifiedMinutes >= 0) { + notificationManager.updateNotification(lastNotifiedMinutes, stepMinutes) + } + } } } @@ -84,7 +111,7 @@ class SleepTimerService : Service() { // there is no countdown to modify. Skip straight to stopSelf — otherwise we // would never call startForeground within the 5-second window and crash with // ForegroundServiceDidNotStartInTimeException. - if (action != ACTION_START && countdownJob == null) { + if (action != ACTION_START && countdownJob?.isActive != true) { stopSelf(startId) return START_NOT_STICKY } @@ -92,7 +119,11 @@ class SleepTimerService : Service() { ACTION_START -> { val durationMillis = intent.getLongExtra(EXTRA_DURATION_MILLIS, 0L) if (durationMillis > 0) { - startTimer(durationMillis) + startTimer(durationMillis.coerceAtMost(MAX_TIMER_MILLIS)) + } else if (countdownJob?.isActive != true) { + // Invalid start with nothing running — same 5-second-window + // consideration as the stale-intent guard above. + stopSelf(startId) } } ACTION_ADD_MINUTES -> { @@ -113,16 +144,17 @@ class SleepTimerService : Service() { } private fun startTimer(durationMillis: Long) { - // Cancel any existing countdown - countdownJob?.cancel() + timerGeneration++ + val previousJob = countdownJob totalDurationMillis = durationMillis - remainingMillis = durationMillis + setRemaining(durationMillis) // Create notification channel and start foreground notificationManager.createNotificationChannel() + lastNotifiedMinutes = remainingMillisToDisplayMinutes(remainingMillis) val notification = notificationManager.buildNotification( - remainingMinutes = remainingMillisToDisplayMinutes(remainingMillis), + remainingMinutes = lastNotifiedMinutes, stepMinutes = stepMinutes, ) @@ -143,40 +175,48 @@ class SleepTimerService : Service() { // Start countdown. onTimerExpired runs inside this job so that cancelling // the job also cancels the fade-out — lets + and Cancel interrupt the fade. countdownJob = serviceScope.launch { + // If the previous countdown was mid-fade, stop it and undo its volume + // ramp before this timer takes over — otherwise media would stay quiet + // and the faded level would be captured as the new "original" volume. + previousJob?.cancelAndJoin() + mediaVolumeController.restoreVolume() runCountdownAndExpire() } } - private suspend fun runCountdownAndExpire() { - while (remainingMillis > 0) { - delay(1000L) - remainingMillis -= 1000L - - if (remainingMillis <= 0) { - remainingMillis = 0 - } + /** Anchors [remainingMillis] to a fresh elapsedRealtime deadline. */ + private fun setRemaining(millis: Long) { + remainingMillis = millis.coerceAtLeast(0L) + deadlineElapsed = SystemClock.elapsedRealtime() + remainingMillis + } + private suspend fun runCountdownAndExpire() { + while (true) { + remainingMillis = (deadlineElapsed - SystemClock.elapsedRealtime()).coerceAtLeast(0L) updateTimerState(TimerPhase.RUNNING) - - val remainingMinutes = remainingMillisToDisplayMinutes(remainingMillis) - notificationManager.updateNotification(remainingMinutes, stepMinutes) + notifyRemainingIfChanged() + if (remainingMillis <= 0L) break + delay(minOf(1_000L, remainingMillis)) } - onTimerExpired() } + private fun notifyRemainingIfChanged() { + val minutes = remainingMillisToDisplayMinutes(remainingMillis) + if (minutes == lastNotifiedMinutes) return + lastNotifiedMinutes = minutes + notificationManager.updateNotification(minutes, stepMinutes) + } + private fun addStep() { when (timerRepository.timerState.value.phase) { TimerPhase.RUNNING -> { if (countdownJob?.isActive != true) return - val stepMillis = stepMinutes * 60 * 1000L - remainingMillis += stepMillis - totalDurationMillis += stepMillis + val stepMillis = stepMinutes * 60_000L + setRemaining((remainingMillis + stepMillis).coerceAtMost(MAX_TIMER_MILLIS)) + totalDurationMillis = (totalDurationMillis + stepMillis).coerceAtMost(MAX_TIMER_MILLIS) updateTimerState(TimerPhase.RUNNING) - notificationManager.updateNotification( - remainingMillisToDisplayMinutes(remainingMillis), - stepMinutes, - ) + notifyRemainingIfChanged() } TimerPhase.FADING_OUT -> { // Replace countdownJob with the fade-in + restart so Cancel during @@ -185,16 +225,13 @@ class SleepTimerService : Service() { // parallel — the clock ticks from the moment the user presses +, // not after the fade-in completes. val oldJob = countdownJob ?: return - val stepMillis = stepMinutes * 60 * 1000L + val stepMillis = (stepMinutes * 60_000L).coerceAtMost(MAX_TIMER_MILLIS) countdownJob = serviceScope.launch { oldJob.cancelAndJoin() totalDurationMillis = stepMillis - remainingMillis = stepMillis + setRemaining(stepMillis) updateTimerState(TimerPhase.RUNNING) - notificationManager.updateNotification( - remainingMillisToDisplayMinutes(remainingMillis), - stepMinutes, - ) + notifyRemainingIfChanged() launch { mediaVolumeController.fadeInToOriginal(FADE_IN_SECONDS) } runCountdownAndExpire() } @@ -206,38 +243,35 @@ class SleepTimerService : Service() { private fun setRemainingMinutes(minutes: Int) { if (timerRepository.timerState.value.phase != TimerPhase.RUNNING) return if (countdownJob?.isActive != true) return - val newRemaining = minutes * 60 * 1000L - remainingMillis = newRemaining - totalDurationMillis = maxOf(totalDurationMillis, newRemaining) + setRemaining(minutes.coerceIn(1, MAX_TIMER_MINUTES) * 60_000L) + totalDurationMillis = maxOf(totalDurationMillis, remainingMillis) updateTimerState(TimerPhase.RUNNING) - notificationManager.updateNotification( - remainingMillisToDisplayMinutes(remainingMillis), - stepMinutes, - ) + notifyRemainingIfChanged() } private fun subtractStep() { if (timerRepository.timerState.value.phase != TimerPhase.RUNNING) return if (countdownJob?.isActive != true) return - val stepMillis = stepMinutes * 60 * 1000L + val stepMillis = stepMinutes * 60_000L if (remainingMillis <= stepMillis) return - remainingMillis -= stepMillis + setRemaining(remainingMillis - stepMillis) totalDurationMillis = (totalDurationMillis - stepMillis).coerceAtLeast(remainingMillis) updateTimerState(TimerPhase.RUNNING) - notificationManager.updateNotification( - remainingMillisToDisplayMinutes(remainingMillis), - stepMinutes, - ) + notifyRemainingIfChanged() } private fun cancelTimer() { val job = countdownJob countdownJob = null + val generation = timerGeneration serviceScope.launch { // Join the fade-out before restoring volume, otherwise the still-running // fade coroutine would overwrite the restored volume at its next step. job?.cancelAndJoin() mediaVolumeController.restoreVolume() + // A newer timer may have started while the join was in flight — its + // foreground session must survive this teardown. + if (timerGeneration != generation) return@launch updateTimerState(TimerPhase.IDLE) notificationManager.cancelNotification() stopForeground(STOP_FOREGROUND_REMOVE) @@ -250,6 +284,7 @@ class SleepTimerService : Service() { if (settings.stopMediaPlayback) { updateTimerState(TimerPhase.FADING_OUT) + lastNotifiedMinutes = Int.MIN_VALUE notificationManager.updateNotification(0, stepMinutes, TimerPhase.FADING_OUT) mediaVolumeController.fadeOutAndPause(settings.fadeOutDurationSeconds) } @@ -263,18 +298,28 @@ class SleepTimerService : Service() { } if (settings.screenOff) { - val usedShizuku = if (settings.softScreenOff && shizukuManager.isReady()) { - shizukuScreenOffHelper.turnOffScreen() + if (settings.softScreenOff && shizukuManager.isReady()) { + // KEYCODE_POWER is a toggle: only send it while the screen is on, + // otherwise "turn off the screen" would wake a sleeping device. If + // the screen is already off there is nothing to do — falling back + // to the hard lock here would defeat the user's choice to keep + // biometric unlock working. + if (powerManager.isInteractive && !shizukuScreenOffHelper.turnOffScreen()) { + screenLockHelper.lockScreen() + } } else { - false - } - if (!usedShizuku) { - // Hard-lock fallback: also the path when softScreenOff is off, or - // when the Shizuku attempt failed. Forces credential on next unlock. + // Hard-lock: also the path when softScreenOff is on but Shizuku is + // unavailable. Forces credential on next unlock. screenLockHelper.lockScreen() } } + // "+" during the fade replaces countdownJob with a successor that restarts + // the countdown; if that happened, this coroutine must not tear down the + // foreground session the successor is still using. + if (countdownJob !== coroutineContext[Job]) return + countdownJob = null + // Reset state before stopping foreground to avoid race with onDestroy timerRepository.updateState(TimerState()) stopForeground(STOP_FOREGROUND_REMOVE) diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/ScreenLockHelper.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/ScreenLockHelper.kt index 6dac4c8..5b43f30 100644 --- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/ScreenLockHelper.kt +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/ScreenLockHelper.kt @@ -5,20 +5,26 @@ import android.content.ComponentName import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject +import javax.inject.Qualifier import javax.inject.Singleton +/** + * Marks the [ComponentName] of the app's DeviceAdminReceiver. Provided by the app + * module — the only module that can reference the receiver class directly, so a + * rename there can't silently break the string-based lookups here. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DeviceAdminComponent + @Singleton class ScreenLockHelper @Inject constructor( @ApplicationContext private val context: Context, + @DeviceAdminComponent val adminComponent: ComponentName, ) { private val devicePolicyManager = context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - val adminComponent: ComponentName = ComponentName( - context, - "dev.xitee.sleeptimer.receiver.SleepTimerDeviceAdminReceiver", - ) - fun isAdminActive(): Boolean = devicePolicyManager.isAdminActive(adminComponent) fun lockScreen(): Boolean { diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShellUserService.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShellUserService.kt new file mode 100644 index 0000000..8b4caa0 --- /dev/null +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShellUserService.kt @@ -0,0 +1,37 @@ +package dev.xitee.sleeptimer.core.service.shizuku + +import android.util.Log +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess + +/** + * Runs inside the process Shizuku spawns with shell (uid 2000) privileges. + * Instantiated there by name via its no-arg constructor — don't add constructor + * parameters or reference Hilt/app infrastructure from this class. + */ +class ShellUserService : IShellUserService.Stub() { + + override fun destroy() { + exitProcess(0) + } + + override fun exec(command: Array, timeoutMillis: Long): Int = try { + val process = ProcessBuilder(*command).redirectErrorStream(true).start() + if (process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS)) { + process.exitValue() + } else { + process.destroyForcibly() + Log.w(TAG, "cmd=${command.joinToString(" ")} timed out after ${timeoutMillis}ms") + EXIT_TIMEOUT + } + } catch (t: Throwable) { + Log.e(TAG, "exec failed: ${command.joinToString(" ")}", t) + EXIT_ERROR + } + + private companion object { + const val TAG = "ShellUserService" + const val EXIT_ERROR = -1 + const val EXIT_TIMEOUT = -2 + } +} diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt index 5e142de..7da02c3 100644 --- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt @@ -1,60 +1,138 @@ package dev.xitee.sleeptimer.core.service.shizuku +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.os.IBinder import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.TimeoutCancellationException -import moe.shizuku.server.IShizukuService import rikka.shizuku.Shizuku import javax.inject.Inject import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +/** + * Executes shell commands with shell-uid privileges through Shizuku's UserService + * API — the supported replacement for the private `newProcess` AIDL, which rikka + * has announced for removal. [ShellUserService] is spawned by Shizuku on first use + * and stays bound for the app's lifetime; `daemon(false)` ties the spawned process + * to ours, so it dies with the app. + */ @Singleton class ShizukuShell @Inject constructor( + @ApplicationContext context: Context, private val shizukuManager: ShizukuManager, ) { + private val userServiceArgs = Shizuku.UserServiceArgs( + ComponentName(context.packageName, ShellUserService::class.java.name), + ) + .daemon(false) + .processNameSuffix("shell") + .version(USER_SERVICE_VERSION) + + private val bindMutex = Mutex() + + @Volatile + private var boundService: IShellUserService? = null + /** * Runs a shell command via Shizuku. Returns true on exit code 0. * Safe to call when Shizuku is not ready — returns false silently. - * Bounded by [EXEC_TIMEOUT_MS]; a wedged Shizuku binder can't block cancel forever. + * The command is bounded by [EXEC_TIMEOUT_MS] inside the user service, so a + * wedged command can't block timer teardown forever. */ suspend fun exec(vararg args: String): Boolean = withContext(Dispatchers.IO) { if (!shizukuManager.isReady()) { Log.w(TAG, "exec called but Shizuku not ready: state=${shizukuManager.state.value}") return@withContext false } - try { - val binder = Shizuku.getBinder() ?: return@withContext false - val service = IShizukuService.Stub.asInterface(binder) - val remote = service.newProcess(args, null, null) - try { - val exit = withTimeout(EXEC_TIMEOUT_MS) { remote.waitFor() } - if (exit != 0) { - Log.w(TAG, "cmd=${args.joinToString(" ")} exit=$exit") - } - exit == 0 - } finally { - try { remote.destroy() } catch (_: Exception) { /* best-effort cleanup */ } - } + val service = try { + withTimeout(BIND_TIMEOUT_MS) { obtainService() } } catch (ce: CancellationException) { - // Also covers TimeoutCancellationException (a CancellationException subclass): - // treat a timeout as a silent failure rather than propagating cancellation. if (ce is TimeoutCancellationException) { - Log.w(TAG, "cmd=${args.joinToString(" ")} timed out after ${EXEC_TIMEOUT_MS}ms") + Log.w(TAG, "binding shell user service timed out after ${BIND_TIMEOUT_MS}ms") return@withContext false } throw ce } catch (t: Exception) { + Log.e(TAG, "binding shell user service failed", t) + return@withContext false + } + try { + val exit = service.exec(args, EXEC_TIMEOUT_MS) + if (exit != 0) { + Log.w(TAG, "cmd=${args.joinToString(" ")} exit=$exit") + } + exit == 0 + } catch (t: Exception) { + // The binder likely died with the user service process — drop the + // cache so the next call rebinds. + boundService = null Log.e(TAG, "exec failed: ${args.joinToString(" ")}", t) false } } + private suspend fun obtainService(): IShellUserService { + boundService?.let { if (it.asBinder().isBinderAlive) return it } + return bindMutex.withLock { + val cached = boundService + if (cached != null && cached.asBinder().isBinderAlive) { + cached + } else { + bindUserService().also { boundService = it } + } + } + } + + private suspend fun bindUserService(): IShellUserService = + suspendCancellableCoroutine { continuation -> + val connection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + if (binder == null || !binder.pingBinder()) { + if (continuation.isActive) { + continuation.resumeWithException( + IllegalStateException("invalid binder received for $name"), + ) + } + return + } + val service = IShellUserService.Stub.asInterface(binder) + if (continuation.isActive) continuation.resume(service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + boundService = null + } + } + try { + Shizuku.bindUserService(userServiceArgs, connection) + } catch (t: Throwable) { + if (continuation.isActive) continuation.resumeWithException(t) + } + } + private companion object { const val TAG = "ShizukuShell" + + // Bump when ShellUserService's AIDL or behavior changes — Shizuku replaces + // a running user service whose version differs. + const val USER_SERVICE_VERSION = 1 + + // The first bind spawns a new process via Shizuku; give it more headroom + // than a plain command. + const val BIND_TIMEOUT_MS = 10_000L + // `svc wifi disable` etc. complete in milliseconds in practice; 5s is a generous // ceiling that's still short enough not to delay timer cancel perceptibly. const val EXEC_TIMEOUT_MS = 5_000L diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/about/AboutScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/about/AboutScreen.kt index 158fe90..701471c 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/about/AboutScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/about/AboutScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -49,7 +48,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.xitee.sleeptimer.feature.timer.R import dev.xitee.sleeptimer.feature.timer.theme.AppThemes -import dev.xitee.sleeptimer.feature.timer.theme.LocalAppTheme +import dev.xitee.sleeptimer.feature.timer.theme.ProvideAppTheme import dev.xitee.sleeptimer.feature.timer.theme.appTheme import dev.xitee.sleeptimer.feature.timer.timer.components.TimerBackground @@ -63,7 +62,7 @@ fun AboutScreen( ) { val settings by viewModel.settings.collectAsStateWithLifecycle() - CompositionLocalProvider(LocalAppTheme provides AppThemes.byId(settings.theme)) { + ProvideAppTheme(AppThemes.byId(settings.theme)) { AboutContent( starsEnabled = settings.starsEnabled, onBack = onBack, diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt index b62ae20..ba07efc 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -62,7 +61,7 @@ import dev.xitee.sleeptimer.feature.timer.settings.components.ShizukuRequiredDia import dev.xitee.sleeptimer.feature.timer.settings.components.StepMinutesSlider import dev.xitee.sleeptimer.feature.timer.settings.components.ThemeRow import dev.xitee.sleeptimer.feature.timer.theme.AppThemes -import dev.xitee.sleeptimer.feature.timer.theme.LocalAppTheme +import dev.xitee.sleeptimer.feature.timer.theme.ProvideAppTheme import dev.xitee.sleeptimer.feature.timer.theme.appTheme import dev.xitee.sleeptimer.feature.timer.theme.rememberAnimatedAppTheme import dev.xitee.sleeptimer.feature.timer.timer.components.TimerBackground @@ -78,7 +77,7 @@ fun SettingsScreen( val ready = uiState ?: return val animatedTheme = rememberAnimatedAppTheme(AppThemes.byId(ready.settings.theme)) - CompositionLocalProvider(LocalAppTheme provides animatedTheme) { + ProvideAppTheme(animatedTheme) { SettingsContent( uiState = ready, onBack = onBack, @@ -250,7 +249,10 @@ private fun SettingsContent( description = when { !uiState.settings.screenOff -> stringResource(R.string.screen_description) uiState.settings.softScreenOff -> stringResource(R.string.screen_method_active_soft) - else -> stringResource(R.string.screen_method_active_hard) + uiState.isDeviceAdminActive -> stringResource(R.string.screen_method_active_hard) + // Hard-lock is configured but the admin grant was revoked in + // system settings — locking will silently no-op until re-granted. + else -> stringResource(R.string.screen_method_hard_revoked) }, checked = uiState.settings.screenOff, onCheckedChange = { enabled -> diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsUiState.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsUiState.kt index b3f5f59..b2aacbf 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsUiState.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsUiState.kt @@ -6,4 +6,5 @@ import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager data class SettingsUiState( val settings: UserSettings = UserSettings(), val shizukuState: ShizukuManager.State = ShizukuManager.State.NotInstalled, + val isDeviceAdminActive: Boolean = false, ) diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt index 9529424..32e1107 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/SettingsViewModel.kt @@ -1,14 +1,12 @@ package dev.xitee.sleeptimer.feature.timer.settings -import android.app.admin.DevicePolicyManager import android.content.ComponentName -import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import dev.xitee.sleeptimer.core.data.model.ThemeId import dev.xitee.sleeptimer.core.data.repository.SettingsRepository +import dev.xitee.sleeptimer.core.service.screen.ScreenLockHelper import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -22,16 +20,9 @@ import javax.inject.Inject class SettingsViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val shizukuManager: ShizukuManager, - @ApplicationContext private val context: Context, + private val screenLockHelper: ScreenLockHelper, ) : ViewModel() { - private val devicePolicyManager = - context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager - private val adminComponent = ComponentName( - context, - "dev.xitee.sleeptimer.receiver.SleepTimerDeviceAdminReceiver", - ) - // Tick to re-query isAdminActive. Admin grants can be revoked from system Settings // without any callback into the app, so nothing else drives a refresh. Bumped from // SettingsScreen on ON_RESUME so returning from Settings → Security → Device admin @@ -47,13 +38,13 @@ class SettingsViewModel @Inject constructor( SettingsUiState( settings = settings, shizukuState = shizukuState, + isDeviceAdminActive = screenLockHelper.isAdminActive(), ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - fun isDeviceAdminActive(): Boolean = - devicePolicyManager.isAdminActive(adminComponent) + fun isDeviceAdminActive(): Boolean = screenLockHelper.isAdminActive() - fun getAdminComponent(): ComponentName = adminComponent + fun getAdminComponent(): ComponentName = screenLockHelper.adminComponent fun refreshShizuku() = shizukuManager.refresh() diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/ThemePickerScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/ThemePickerScreen.kt index 8b017a1..22275ee 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/ThemePickerScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/ThemePickerScreen.kt @@ -36,6 +36,7 @@ import dev.xitee.sleeptimer.feature.timer.settings.components.ThemePreviewIcon import dev.xitee.sleeptimer.feature.timer.theme.AppTheme import dev.xitee.sleeptimer.feature.timer.theme.AppThemes import dev.xitee.sleeptimer.feature.timer.theme.LocalAppTheme +import dev.xitee.sleeptimer.feature.timer.theme.ProvideAppTheme import dev.xitee.sleeptimer.feature.timer.theme.rememberAnimatedAppTheme import dev.xitee.sleeptimer.feature.timer.timer.components.TimerBackground @@ -48,7 +49,7 @@ fun ThemePickerScreen( val ready = uiState ?: return val animatedTheme = rememberAnimatedAppTheme(AppThemes.byId(ready.settings.theme)) - CompositionLocalProvider(LocalAppTheme provides animatedTheme) { + ProvideAppTheme(animatedTheme) { ThemePickerContent( selected = ready.settings.theme, starsEnabled = ready.settings.starsEnabled, diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt new file mode 100644 index 0000000..df1f34e --- /dev/null +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt @@ -0,0 +1,74 @@ +package dev.xitee.sleeptimer.feature.timer.theme + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +/** + * Provides [LocalAppTheme] and mirrors the theme into a Material color scheme so + * Material components (dialogs, text buttons, sliders) match the selected app theme + * instead of the static dark scheme from the app module. Also keeps the system bar + * icon contrast in sync with the app theme rather than the system dark-mode setting. + */ +@Composable +fun ProvideAppTheme(theme: AppTheme, content: @Composable () -> Unit) { + SyncSystemBarAppearance(isDark = theme.isDark) + CompositionLocalProvider(LocalAppTheme provides theme) { + MaterialTheme( + colorScheme = theme.toMaterialColorScheme(), + typography = MaterialTheme.typography, + content = content, + ) + } +} + +private fun AppTheme.toMaterialColorScheme(): ColorScheme { + val base = if (isDark) darkColorScheme() else lightColorScheme() + // AlertDialogs and menus draw on the surfaceContainer roles; derive a subtly + // elevated container from the theme background instead of the Material baseline. + val container = if (isDark) surface2.compositeOver(bgSolid) else Color(0xFFFAF8F3) + return base.copy( + primary = accent, + onPrimary = accentInk, + secondary = accent, + onSecondary = accentInk, + background = bgSolid, + onBackground = textPrimary, + surface = bgSolid, + onSurface = textPrimary, + surfaceVariant = container, + onSurfaceVariant = textDim, + outline = strokeStrong, + surfaceTint = accent, + surfaceContainerHigh = container, + surfaceContainerHighest = container, + ) +} + +@Composable +private fun SyncSystemBarAppearance(isDark: Boolean) { + val view = LocalView.current + LaunchedEffect(view, isDark) { + val window = view.context.findActivity()?.window ?: return@LaunchedEffect + val insetsController = WindowCompat.getInsetsController(window, view) + insetsController.isAppearanceLightStatusBars = !isDark + insetsController.isAppearanceLightNavigationBars = !isDark + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt index b402ec8..b25ac32 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -57,13 +56,14 @@ import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES import dev.xitee.sleeptimer.core.data.util.remainingMillisToDisplayMinutes import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager import dev.xitee.sleeptimer.feature.timer.R import dev.xitee.sleeptimer.feature.timer.settings.components.DeviceAdminRequiredDialog import dev.xitee.sleeptimer.feature.timer.settings.components.ShizukuRequiredDialog import dev.xitee.sleeptimer.feature.timer.theme.AppThemes -import dev.xitee.sleeptimer.feature.timer.theme.LocalAppTheme +import dev.xitee.sleeptimer.feature.timer.theme.ProvideAppTheme import dev.xitee.sleeptimer.feature.timer.theme.appTheme import dev.xitee.sleeptimer.feature.timer.theme.rememberAnimatedAppTheme import dev.xitee.sleeptimer.feature.timer.timer.components.CircularDial @@ -82,7 +82,7 @@ fun TimerScreen( ) { val settings by viewModel.settings.collectAsStateWithLifecycle() val animatedTheme = rememberAnimatedAppTheme(AppThemes.byId(settings.theme)) - CompositionLocalProvider(LocalAppTheme provides animatedTheme) { + ProvideAppTheme(animatedTheme) { TimerContent( onNavigateToSettings = onNavigateToSettings, viewModel = viewModel, @@ -368,7 +368,7 @@ private fun TimerContent( val step = settings.stepMinutes val current = dialState.totalMinutes val next = (current / step + 1) * step - viewModel.commitMinutes(next.coerceAtMost(300)) + viewModel.commitMinutes(next.coerceAtMost(MAX_TIMER_MINUTES)) } }, isMinusEnabled = if (isRunning) { @@ -376,8 +376,11 @@ private fun TimerContent( } else { dialState.totalMinutes > 1 }, - isPlusEnabled = !isRunning && dialState.totalMinutes < 300, - plusStepVisibleWhileRunning = true, + isPlusEnabled = if (isRunning) { + runningRemainingSeconds < MAX_TIMER_MINUTES * 60 + } else { + dialState.totalMinutes < MAX_TIMER_MINUTES + }, ) Spacer(modifier = Modifier.height(32.dp)) @@ -489,7 +492,6 @@ private fun ActionRow( onPlusStep: () -> Unit, isMinusEnabled: Boolean, isPlusEnabled: Boolean, - plusStepVisibleWhileRunning: Boolean, ) { androidx.compose.foundation.layout.Row( modifier = Modifier @@ -517,7 +519,7 @@ private fun ActionRow( contentDescription = stringResource(R.string.cd_step_plus), onClick = onPlusStep, hapticEnabled = hapticEnabled, - enabled = if (isRunning) plusStepVisibleWhileRunning else isPlusEnabled, + enabled = isPlusEnabled, iconRotation = iconRotation, ) } diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt index 9a6477b..e1ec991 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/TimerViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES import dev.xitee.sleeptimer.core.data.model.TimerPhase import dev.xitee.sleeptimer.core.data.model.UserSettings import dev.xitee.sleeptimer.core.data.repository.SettingsRepository @@ -33,7 +34,7 @@ class TimerViewModel @Inject constructor( @ApplicationContext private val context: Context, ) : ViewModel() { - private val _selectedMinutes = MutableStateFlow(15) + private val _selectedMinutes = MutableStateFlow(DEFAULT_SELECTED_MINUTES) val settings: StateFlow = settingsRepository.settings .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UserSettings()) @@ -46,7 +47,10 @@ class TimerViewModel @Inject constructor( init { viewModelScope.launch { - _selectedMinutes.value = settingsRepository.settings.first().presetMinutes + val preset = settingsRepository.settings.first().presetMinutes + // compareAndSet: don't stomp a value the user already dialed in the + // brief window before the preset finished loading. + _selectedMinutes.compareAndSet(DEFAULT_SELECTED_MINUTES, preset) } } @@ -55,7 +59,7 @@ class TimerViewModel @Inject constructor( _selectedMinutes, ) { timerState, selectedMin -> when (timerState.phase) { - TimerPhase.IDLE, TimerPhase.FINISHED -> { + TimerPhase.IDLE -> { TimerUiState.Idle(selectedMinutes = selectedMin) } TimerPhase.RUNNING -> { @@ -106,9 +110,8 @@ class TimerViewModel @Inject constructor( * once the running session ends. */ fun setMinutes(minutes: Int) { - val phase = timerRepository.timerState.value.phase - if (phase != TimerPhase.IDLE && phase != TimerPhase.FINISHED) return - _selectedMinutes.value = minutes.coerceIn(1, 300) + if (timerRepository.timerState.value.phase != TimerPhase.IDLE) return + _selectedMinutes.value = minutes.coerceIn(1, MAX_TIMER_MINUTES) } /** @@ -116,7 +119,7 @@ class TimerViewModel @Inject constructor( * timer is running, dispatches to the service to adjust remaining time. */ fun commitMinutes(minutes: Int) { - val coerced = minutes.coerceIn(1, 300) + val coerced = minutes.coerceIn(1, MAX_TIMER_MINUTES) val state = timerRepository.timerState.value when (state.phase) { TimerPhase.RUNNING -> { @@ -162,6 +165,12 @@ class TimerViewModel @Inject constructor( action = actionName setClassName(context, SleepTimerService::class.java.name) } + + private companion object { + // Must match UserSettings().presetMinutes so the compareAndSet in init only + // yields when the user hasn't touched the dial yet. + const val DEFAULT_SELECTED_MINUTES = 15 + } } data class StartupPermissionCheck( diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt index f7c1b32..b6f16ac 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDial.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,6 +30,7 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp +import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES import dev.xitee.sleeptimer.feature.timer.theme.AppTheme import dev.xitee.sleeptimer.feature.timer.theme.appTheme import kotlin.math.cos @@ -55,6 +57,15 @@ fun CircularDial( } var lastReportedMinutes by remember { mutableStateOf(state.totalMinutes) } + // pointerInput(Unit) never restarts, so plain parameter captures inside the + // gesture lambdas freeze at the value they had when the pointer coroutine first + // launched. Read everything that changes over time through rememberUpdatedState. + val currentIsRunning by rememberUpdatedState(isRunning) + val currentRunningMinutes by rememberUpdatedState(runningMinutes) + val currentHapticEnabled by rememberUpdatedState(hapticEnabled) + val currentOnMinutesChanged by rememberUpdatedState(onMinutesChanged) + val currentOnMinutesCommitted by rememberUpdatedState(onMinutesCommitted) + val targetMinutes: Float = if (isRunning && !state.isDragging) { runningMinutes } else { @@ -77,7 +88,7 @@ fun CircularDial( val displayMinutes: Float = animatedMinutes.value val minuteInRing = ((displayMinutes % 60f) + 60f) % 60f val ringFraction = minuteInRing / 60f - val hoursComplete = (displayMinutes / 60f).toInt().coerceIn(0, 5) + val hoursComplete = (displayMinutes / 60f).toInt().coerceIn(0, state.maxRevolutions) Box( modifier = modifier.aspectRatio(1f), @@ -92,9 +103,9 @@ fun CircularDial( // While running, dialState tracks the last idle preset, not // the ticking remaining time. Seed it to the currently-shown // minutes so the drag continues from the right position. - if (isRunning) { - val seed = kotlin.math.ceil(runningMinutes.coerceAtLeast(0f)) - .toInt().coerceIn(1, 300) + if (currentIsRunning) { + val seed = kotlin.math.ceil(currentRunningMinutes.coerceAtLeast(0f)) + .toInt().coerceIn(1, MAX_TIMER_MINUTES) state.setMinutes(seed) } state.onDragStart( @@ -113,18 +124,18 @@ fun CircularDial( y = change.position.y, ) if (state.totalMinutes != lastReportedMinutes) { - if (hapticEnabled) { + if (currentHapticEnabled) { view.performHapticFeedback(tickHapticType) } lastReportedMinutes = state.totalMinutes - onMinutesChanged(state.totalMinutes) + currentOnMinutesChanged(state.totalMinutes) } }, onDragEnd = { state.onDragEnd() // Persist only at drag end to avoid DataStore write flood // during the gesture (30+ writes/sec otherwise). - onMinutesCommitted(state.totalMinutes) + currentOnMinutesCommitted(state.totalMinutes) }, ) }, @@ -191,7 +202,7 @@ fun CircularDial( ) } - drawHourDots(center, radius, hoursComplete, theme) + drawHourDots(center, radius, hoursComplete, state.maxRevolutions, theme) drawKnob( center = center, @@ -323,11 +334,17 @@ private fun DrawScope.drawProgressArc( } } -private fun DrawScope.drawHourDots(center: Offset, radius: Float, hoursComplete: Int, theme: AppTheme) { +private fun DrawScope.drawHourDots( + center: Offset, + radius: Float, + hoursComplete: Int, + totalDots: Int, + theme: AppTheme, +) { val rDot = radius - 42.dp.toPx() - for (i in 0 until 5) { + for (i in 0 until totalDots) { val active = i < hoursComplete - val angle = (i / 5f) * (2f * Math.PI.toFloat()) - (Math.PI.toFloat() / 2f) + val angle = (i / totalDots.toFloat()) * (2f * Math.PI.toFloat()) - (Math.PI.toFloat() / 2f) val x = center.x + rDot * cos(angle) val y = center.y + rDot * sin(angle) drawCircle( diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDialState.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDialState.kt index 74c449b..58ade11 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDialState.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/timer/components/CircularDialState.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import dev.xitee.sleeptimer.core.data.model.MAX_TIMER_MINUTES import kotlin.math.atan2 import kotlin.math.roundToInt @@ -23,7 +24,7 @@ class CircularDialState { private var previousAngle = 90f private var cumulativeDegrees = 0f - val maxRevolutions = 5 // 5 hours + val maxRevolutions = MAX_TIMER_MINUTES / 60 private val minMinutes = 1 private val maxMinutes = maxRevolutions * 60 private val minDegrees = (minMinutes / 60f) * 360f diff --git a/feature/timer/src/main/res/values-de/strings.xml b/feature/timer/src/main/res/values-de/strings.xml index 75de465..9558252 100644 --- a/feature/timer/src/main/res/values-de/strings.xml +++ b/feature/timer/src/main/res/values-de/strings.xml @@ -87,6 +87,7 @@ Simuliert Power-Taste. Biometrische Entsperrung bleibt aktiv. Display ausschalten - Geräteadministrator Display ausschalten - Shizuku + Display ausschalten - Geräteadministrator-Berechtigung fehlt Über diff --git a/feature/timer/src/main/res/values/strings.xml b/feature/timer/src/main/res/values/strings.xml index 3ed966c..28ec237 100644 --- a/feature/timer/src/main/res/values/strings.xml +++ b/feature/timer/src/main/res/values/strings.xml @@ -87,6 +87,7 @@ Simulates power button. Biometric unlock stays active. Turn off display - Device admin Turn off display - Shizuku + Turn off display - Device admin permission missing About diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6eee6f9..7f72805 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,6 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivityCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } -androidx-lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", version.ref = "androidxLifecycle" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" } From 8467f7065133f1e0aa1a658ce3505775710b79f8 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:52:36 +0200 Subject: [PATCH 2/2] fix: address review findings from PR #39 final pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShizukuShell: unbind the pending ServiceConnection on bind timeout/cancel (invokeOnCancellation). A timed-out bind previously left the connection registered, so a late onServiceConnected orphaned a live binder — never cached, never unbound — for the app's lifetime. - SleepTimerService: ignore ACTION_ADD/SET that land in the post-expiry teardown window. With stopMediaPlayback off, onTimerExpired keeps the phase RUNNING through the Wi-Fi/BT/screen calls; a "+"/dial-commit there set a deadline no loop reads and was then wiped to IDLE. Guard on remainingMillis>0 (subtractStep was already covered by its <= stepMillis return). - ProvideAppTheme: key system-bar icon contrast off the rendered background luminance instead of the instant isDark flag, so the icons flip at the theme cross-fade midpoint rather than snapping washed-out at t=0. Equivalent at rest — every palette has bgSolid.luminance() < 0.5 exactly when isDark. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../xitee/sleeptimer/core/service/SleepTimerService.kt | 6 ++++++ .../xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt | 7 +++++++ .../sleeptimer/feature/timer/theme/ProvideAppTheme.kt | 8 +++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt index f4743a5..dfeab06 100644 --- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/SleepTimerService.kt @@ -212,6 +212,10 @@ class SleepTimerService : Service() { when (timerRepository.timerState.value.phase) { TimerPhase.RUNNING -> { if (countdownJob?.isActive != true) return + // Already at 0 and the job is mid-teardown in onTimerExpired (no fade, + // phase still RUNNING): adding here would set a deadline no loop reads, + // and the teardown then resets to IDLE — the tap would be silently lost. + if (remainingMillis <= 0L) return val stepMillis = stepMinutes * 60_000L setRemaining((remainingMillis + stepMillis).coerceAtMost(MAX_TIMER_MILLIS)) totalDurationMillis = (totalDurationMillis + stepMillis).coerceAtMost(MAX_TIMER_MILLIS) @@ -243,6 +247,8 @@ class SleepTimerService : Service() { private fun setRemainingMinutes(minutes: Int) { if (timerRepository.timerState.value.phase != TimerPhase.RUNNING) return if (countdownJob?.isActive != true) return + // Ignore a dial-commit that lands in the post-expiry teardown window (see addStep). + if (remainingMillis <= 0L) return setRemaining(minutes.coerceIn(1, MAX_TIMER_MINUTES) * 60_000L) totalDurationMillis = maxOf(totalDurationMillis, remainingMillis) updateTimerState(TimerPhase.RUNNING) diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt index 7da02c3..cb3de85 100644 --- a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/shizuku/ShizukuShell.kt @@ -115,6 +115,13 @@ class ShizukuShell @Inject constructor( boundService = null } } + // If the caller times out (BIND_TIMEOUT_MS) or is cancelled before the + // service connects, detach the pending connection. Otherwise Shizuku keeps + // it bound and a late onServiceConnected orphans a live binder — which is + // never cached (continuation is dead) nor unbound — for the app's lifetime. + continuation.invokeOnCancellation { + runCatching { Shizuku.unbindUserService(userServiceArgs, connection, true) } + } try { Shizuku.bindUserService(userServiceArgs, connection) } catch (t: Throwable) { diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt index df1f34e..75a031e 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat @@ -23,7 +24,12 @@ import androidx.core.view.WindowCompat */ @Composable fun ProvideAppTheme(theme: AppTheme, content: @Composable () -> Unit) { - SyncSystemBarAppearance(isDark = theme.isDark) + // Key the bar-icon contrast off the actually-rendered background luminance, not the + // theme's isDark flag. During a theme cross-fade bgSolid animates while isDark would + // snap at t=0, washing the icons out over the still-transitioning background for the + // whole 350ms; luminance flips them at the visual midpoint instead. Equivalent at + // rest — every palette has bgSolid.luminance() < 0.5 exactly when isDark is true. + SyncSystemBarAppearance(isDark = theme.bgSolid.luminance() < 0.5f) CompositionLocalProvider(LocalAppTheme provides theme) { MaterialTheme( colorScheme = theme.toMaterialColorScheme(),