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 42a4259..cb6473c 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,13 +4,17 @@ 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.AutoRotateMode +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 @@ -34,7 +38,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() @@ -50,7 +60,9 @@ class SettingsRepositoryImpl @Inject constructor( starsEnabled = prefs[STARS_ENABLED] ?: d.starsEnabled, autoRotateMode = AutoRotateMode.fromStorage(prefs[AUTO_ROTATE_MODE]), 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), ) } @@ -99,6 +111,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..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 @@ -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,52 @@ 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 + // 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) updateTimerState(TimerPhase.RUNNING) - notificationManager.updateNotification( - remainingMillisToDisplayMinutes(remainingMillis), - stepMinutes, - ) + notifyRemainingIfChanged() } TimerPhase.FADING_OUT -> { // Replace countdownJob with the fade-in + restart so Cancel during @@ -185,16 +229,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 +247,37 @@ 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) + // 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) - 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 +290,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 +304,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..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 @@ -1,60 +1,145 @@ 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 + } + } + // 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) { + 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 b98ab50..69e2341 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 @@ -37,7 +37,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 @@ -66,7 +65,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 @@ -82,7 +81,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, @@ -269,7 +268,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 c6dcc63..877957f 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,15 +1,13 @@ 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.AutoRotateMode 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 @@ -23,16 +21,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 @@ -48,13 +39,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..75a031e --- /dev/null +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/theme/ProvideAppTheme.kt @@ -0,0 +1,80 @@ +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.graphics.luminance +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) { + // 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(), + 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 bbdd9c9..3e8bea0 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 176ba0c..6a3226a 100644 --- a/feature/timer/src/main/res/values-de/strings.xml +++ b/feature/timer/src/main/res/values-de/strings.xml @@ -95,6 +95,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 c83645a..7eed385 100644 --- a/feature/timer/src/main/res/values/strings.xml +++ b/feature/timer/src/main/res/values/strings.xml @@ -95,6 +95,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 b6a2402..02cdd41 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,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" }