diff --git a/AGENTS.md b/AGENTS.md index ff9ba9a..9f9ce74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ app ──▶ feature:timer ──▶ core:service ──▶ core:data - **`app/`** — `@HiltAndroidApp` (`SleepTimerApp`), `MainActivity` (edge-to-edge Compose host), `SleepTimerNavHost` (type-safe Navigation-Compose routes in `navigation/Routes.kt`), `receiver/SleepTimerDeviceAdminReceiver` for the hard-lock path, and `di/AppModule` (provides the `@DeviceAdminComponent` `ComponentName` so lower modules receive the receiver by injection instead of hard-coding its class). The `ShizukuProvider` is declared here in `AndroidManifest.xml`; `shizuku-provider` is only on `:app`'s classpath so lint can resolve it. - **`feature:timer/`** — all Compose UI: `timer/` (dial, starfield, `TimerViewModel`, `AppOrientationController`), `settings/`, `theme/` (light/dark + six palettes in `AppThemes.kt`, animated transitions in `AnimatedAppTheme.kt`, and `ProvideAppTheme` which mirrors the active palette into a Material `ColorScheme` and keeps system-bar icon contrast in sync), `about/`. ViewModels use `@HiltViewModel` and dispatch to the service via `Intent`s (see below). -- **`core:service/`** — the `SleepTimerService` foreground service (the runtime "source of truth" while a timer is active), `TimerNotificationManager` (notification with +/−/cancel actions), `MediaVolumeController` (fade-out / fade-in), `ScreenLockHelper` (Device-Admin `lockNow`), and `shizuku/` (Shizuku state machine, the `ShellUserService` shell-exec bridge, and Wi-Fi, Bluetooth, soft screen-off controllers). +- **`core:service/`** — the `SleepTimerService` foreground service (the runtime "source of truth" while a timer is active), `TimerNotificationManager` (notification with +/−/cancel actions), `MediaVolumeController` (fade-out / fade-in), `screen/` (`ScreenLockHelper` for Device-Admin `lockNow`; `LockAccessibilityService` + `AccessibilityLockHelper` for the accessibility soft lock via `GLOBAL_ACTION_LOCK_SCREEN`, API 28+ — the service is declared in `:app`'s manifest and must stay Hilt-free because the system instantiates it), and `shizuku/` (Shizuku state machine, the `ShellUserService` shell-exec bridge, and Wi-Fi, Bluetooth, soft screen-off controllers). Which lock path runs at expiry is selected by `UserSettings.screenLockMethod` (`DeviceAdmin` / `Accessibility` / `Shizuku`). - **`core:data/`** — `UserSettings` + `TimerState`/`TimerPhase` + `ThemeId` models, `SettingsRepository` (Jetpack DataStore Preferences, single `settings` file), `TimerRepository` (in-process `StateFlow` — process state, **not** persisted), and the Hilt data module. Repositories are bound as `@Singleton` via `@Binds`. All modules apply the Kotlin compiler flag `-Xannotation-default-target=param-property` (needed for Hilt/Compose annotation targeting under Kotlin 2.x). @@ -55,7 +55,7 @@ Two non-obvious behaviors worth preserving when editing the service: ## Shizuku integration -Shizuku is optional. `ShizukuManager` models a four-state machine (`NotInstalled` / `NotRunning` / `PermissionRequired` / `Ready`) driven by `Shizuku.OnBinderReceivedListener` etc. Use `awaitInitialState(timeoutMs)` (not `isReady()`) for the initial startup check — a cold `pingBinder()` race otherwise reports `NotRunning` even when Shizuku is up; on the runtime path `ShizukuShell.exec` gates on `isReady()` directly. All three opt-in features (Wi-Fi off, Bluetooth off, soft screen-off) go through Shizuku because modern Android blocks direct toggling; the hard-lock path uses Device Admin and is independent of Shizuku. The `` block in `app/src/main/AndroidManifest.xml` is required on targetSdk 30+ to see the Shizuku package. +Shizuku is optional. `ShizukuManager` models a four-state machine (`NotInstalled` / `NotRunning` / `PermissionRequired` / `Ready`) driven by `Shizuku.OnBinderReceivedListener` etc. Use `awaitInitialState(timeoutMs)` (not `isReady()`) for the initial startup check — a cold `pingBinder()` race otherwise reports `NotRunning` even when Shizuku is up; on the runtime path `ShizukuShell.exec` gates on `isReady()` directly. All three opt-in features (Wi-Fi off, Bluetooth off, soft screen-off) go through Shizuku because modern Android blocks direct toggling; the hard-lock (Device Admin) and accessibility-lock paths are independent of Shizuku. The `` block in `app/src/main/AndroidManifest.xml` is required on targetSdk 30+ to see the Shizuku package. Shell commands run through a **bound Shizuku UserService**, not the private `newProcess` AIDL (which rikka has announced for removal). `ShizukuShell` binds `ShellUserService` — an `IShellUserService.Stub` (interface in `shizuku/IShellUserService.aidl`) that Shizuku spawns in a separate shell-uid (2000) process and that stays bound for the app's lifetime (`daemon(false)` ties the spawned process to ours, so it dies with the app). Three things follow from this that are easy to break: diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12199ac..f5316a8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,22 @@ android:exported="false" android:foregroundServiceType="mediaPlayback" /> + + + + + + + + Erforderlich, um den Bildschirm zu sperren, wenn der Schlaf-Timer endet. + + SleepTimer Bildschirmsperre + Wird nur verwendet, um den Bildschirm zu sperren, wenn der Schlaf-Timer endet. SleepTimer liest oder beobachtet keine Bildschirminhalte. + Startet den Schlaf-Timer mit nur einem Tipp. %d Min diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0676a1..1996fd7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,10 @@ Required to lock the screen when the sleep timer ends. + + SleepTimer screen lock + Used only to lock the screen when the sleep timer ends. SleepTimer does not read or observe any screen content. + Start the sleep timer with a single tap. %d min diff --git a/app/src/main/res/xml/accessibility_lock_service.xml b/app/src/main/res/xml/accessibility_lock_service.xml new file mode 100644 index 0000000..6b5ce64 --- /dev/null +++ b/app/src/main/res/xml/accessibility_lock_service.xml @@ -0,0 +1,7 @@ + + + diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/ScreenLockMethod.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/ScreenLockMethod.kt new file mode 100644 index 0000000..25930e2 --- /dev/null +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/ScreenLockMethod.kt @@ -0,0 +1,24 @@ +package dev.xitee.sleeptimer.core.data.model + +/** + * How the screen is turned off when the timer expires (only relevant while + * [UserSettings.screenOff] is enabled). + * + * - [DeviceAdmin] — `DevicePolicyManager.lockNow()`; requires credential on next unlock. + * - [Accessibility] — `GLOBAL_ACTION_LOCK_SCREEN` via the app's accessibility service; + * behaves like the power button, biometric unlock stays valid. API 28+. + * - [Shizuku] — simulated power-key press through Shizuku; biometric unlock stays valid. + */ +enum class ScreenLockMethod { + DeviceAdmin, + Accessibility, + Shizuku, + ; + + companion object { + val Default: ScreenLockMethod = DeviceAdmin + + fun fromStorage(value: String?): ScreenLockMethod = + entries.firstOrNull { it.name == value } ?: Default + } +} diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt index 6749eff..0444e4a 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/model/UserSettings.kt @@ -4,7 +4,7 @@ data class UserSettings( val stopMediaPlayback: Boolean = true, val fadeOutDurationSeconds: Int = 30, val screenOff: Boolean = false, - val softScreenOff: Boolean = false, + val screenLockMethod: ScreenLockMethod = ScreenLockMethod.Default, val turnOffWifi: Boolean = false, val turnOffBluetooth: Boolean = false, val hapticFeedbackEnabled: Boolean = true, diff --git a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt index a6f7492..98a33b4 100644 --- a/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt +++ b/core/data/src/main/kotlin/dev/xitee/sleeptimer/core/data/repository/SettingsRepository.kt @@ -1,6 +1,7 @@ package dev.xitee.sleeptimer.core.data.repository import dev.xitee.sleeptimer.core.data.model.AutoRotateMode +import dev.xitee.sleeptimer.core.data.model.ScreenLockMethod import dev.xitee.sleeptimer.core.data.model.ThemeId import dev.xitee.sleeptimer.core.data.model.UserSettings import kotlinx.coroutines.flow.Flow @@ -10,7 +11,7 @@ interface SettingsRepository { suspend fun updateStopMediaPlayback(enabled: Boolean) suspend fun updateFadeOutDuration(seconds: Int) suspend fun updateScreenOff(enabled: Boolean) - suspend fun updateSoftScreenOff(enabled: Boolean) + suspend fun updateScreenLockMethod(method: ScreenLockMethod) suspend fun updateTurnOffWifi(enabled: Boolean) suspend fun updateTurnOffBluetooth(enabled: Boolean) suspend fun updateHapticFeedback(enabled: Boolean) 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 ae0ec26..e7a0bfc 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 @@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import dagger.hilt.android.qualifiers.ApplicationContext 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.ScreenLockMethod import dev.xitee.sleeptimer.core.data.model.ThemeId import dev.xitee.sleeptimer.core.data.model.UserSettings import dev.xitee.sleeptimer.core.data.util.isSystemReduceMotionEnabled @@ -36,6 +37,10 @@ class SettingsRepositoryImpl @Inject constructor( val STOP_MEDIA = booleanPreferencesKey("stop_media_playback") val FADE_OUT_DURATION = intPreferencesKey("fade_out_duration_seconds") val SCREEN_OFF = booleanPreferencesKey("screen_off") + val SCREEN_LOCK_METHOD = stringPreferencesKey("screen_lock_method") + + // Legacy key from before ScreenLockMethod existed (true = Shizuku soft + // screen-off, false = device admin). Only read for migration, never written. val SOFT_SCREEN_OFF = booleanPreferencesKey("soft_screen_off") val TURN_OFF_WIFI = booleanPreferencesKey("turn_off_wifi") val TURN_OFF_BLUETOOTH = booleanPreferencesKey("turn_off_bluetooth") @@ -89,7 +94,8 @@ class SettingsRepositoryImpl @Inject constructor( stopMediaPlayback = prefs[STOP_MEDIA] ?: d.stopMediaPlayback, fadeOutDurationSeconds = prefs[FADE_OUT_DURATION] ?: d.fadeOutDurationSeconds, screenOff = prefs[SCREEN_OFF] ?: d.screenOff, - softScreenOff = prefs[SOFT_SCREEN_OFF] ?: d.softScreenOff, + screenLockMethod = prefs[SCREEN_LOCK_METHOD]?.let { ScreenLockMethod.fromStorage(it) } + ?: if (prefs[SOFT_SCREEN_OFF] == true) ScreenLockMethod.Shizuku else d.screenLockMethod, turnOffWifi = prefs[TURN_OFF_WIFI] ?: d.turnOffWifi, turnOffBluetooth = prefs[TURN_OFF_BLUETOOTH] ?: d.turnOffBluetooth, hapticFeedbackEnabled = prefs[HAPTIC_FEEDBACK] ?: d.hapticFeedbackEnabled, @@ -116,8 +122,8 @@ class SettingsRepositoryImpl @Inject constructor( dataStore.edit { it[SCREEN_OFF] = enabled } } - override suspend fun updateSoftScreenOff(enabled: Boolean) { - dataStore.edit { it[SOFT_SCREEN_OFF] = enabled } + override suspend fun updateScreenLockMethod(method: ScreenLockMethod) { + dataStore.edit { it[SCREEN_LOCK_METHOD] = method.name } } override suspend fun updateTurnOffWifi(enabled: Boolean) { 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 477e41b..170e0c0 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 @@ -10,6 +10,7 @@ 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.ScreenLockMethod import dev.xitee.sleeptimer.core.data.model.TimerPhase import dev.xitee.sleeptimer.core.data.model.TimerState import dev.xitee.sleeptimer.core.data.model.UserSettings @@ -18,6 +19,7 @@ import dev.xitee.sleeptimer.core.data.repository.TimerRepositoryImpl import dev.xitee.sleeptimer.core.data.util.remainingMillisToDisplayMinutes import dev.xitee.sleeptimer.core.service.media.MediaVolumeController import dev.xitee.sleeptimer.core.service.notification.TimerNotificationManager +import dev.xitee.sleeptimer.core.service.screen.AccessibilityLockHelper import dev.xitee.sleeptimer.core.service.screen.ScreenLockHelper import dev.xitee.sleeptimer.core.service.shizuku.ShizukuBluetoothController import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager @@ -45,6 +47,7 @@ class SleepTimerService : Service() { @Inject lateinit var notificationManager: TimerNotificationManager @Inject lateinit var mediaVolumeController: MediaVolumeController @Inject lateinit var screenLockHelper: ScreenLockHelper + @Inject lateinit var accessibilityLockHelper: AccessibilityLockHelper @Inject lateinit var shizukuManager: ShizukuManager @Inject lateinit var shizukuScreenOffHelper: ShizukuScreenOffHelper @Inject lateinit var shizukuWifiController: ShizukuWifiController @@ -308,19 +311,35 @@ class SleepTimerService : Service() { } if (settings.screenOff) { - 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()) { + when (settings.screenLockMethod) { + ScreenLockMethod.Shizuku -> { + if (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 { + // Shizuku chosen but unavailable — fall back to the hard lock. + screenLockHelper.lockScreen() + } + } + ScreenLockMethod.Accessibility -> { + // GLOBAL_ACTION_LOCK_SCREEN locks like the power button and is + // idempotent on an already-locked device — no isInteractive gate + // needed. Fall back to the hard lock if the accessibility service + // was disabled (or the device runs API < 28). + if (!accessibilityLockHelper.lockScreen()) { + screenLockHelper.lockScreen() + } + } + ScreenLockMethod.DeviceAdmin -> { + // Hard-lock: forces credential on next unlock. screenLockHelper.lockScreen() } - } else { - // Hard-lock: also the path when softScreenOff is on but Shizuku is - // unavailable. Forces credential on next unlock. - screenLockHelper.lockScreen() } } diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/AccessibilityLockHelper.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/AccessibilityLockHelper.kt new file mode 100644 index 0000000..fc05871 --- /dev/null +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/AccessibilityLockHelper.kt @@ -0,0 +1,49 @@ +package dev.xitee.sleeptimer.core.service.screen + +import android.accessibilityservice.AccessibilityService +import android.content.ComponentName +import android.content.Context +import android.os.Build +import android.provider.Settings +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Screen lock via [LockAccessibilityService] (`GLOBAL_ACTION_LOCK_SCREEN`). + * Behaves like a power-button lock — biometric unlock stays valid — without + * needing Shizuku. Requires API 28+ and the user enabling the service in the + * system accessibility settings. + */ +@Singleton +class AccessibilityLockHelper @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val serviceComponent = ComponentName(context, LockAccessibilityService::class.java) + + /** Whether the user has enabled the lock service in the accessibility settings. */ + fun isServiceEnabled(): Boolean { + if (!isSupported) return false + // Settings.Secure instead of LockAccessibilityService.instance: the instance + // only proves the service is currently bound, while the settings row is the + // authoritative grant — and it is queryable even right after the user returns + // from the settings screen, before the system finished binding. + val enabledServices = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) ?: return false + return enabledServices.split(':') + .any { ComponentName.unflattenFromString(it) == serviceComponent } + } + + fun lockScreen(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false + return LockAccessibilityService.instance + ?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN) == true + } + + companion object { + /** GLOBAL_ACTION_LOCK_SCREEN exists since API 28 (minSdk is 26). */ + val isSupported: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + } +} diff --git a/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/LockAccessibilityService.kt b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/LockAccessibilityService.kt new file mode 100644 index 0000000..0a0d3b2 --- /dev/null +++ b/core/service/src/main/kotlin/dev/xitee/sleeptimer/core/service/screen/LockAccessibilityService.kt @@ -0,0 +1,37 @@ +package dev.xitee.sleeptimer.core.service.screen + +import android.accessibilityservice.AccessibilityService +import android.view.accessibility.AccessibilityEvent + +/** + * Accessibility service whose only purpose is performing + * [AccessibilityService.GLOBAL_ACTION_LOCK_SCREEN] when the timer expires. It + * requests no event types and no window-content access (see + * `res/xml/accessibility_lock_service.xml` in the app module). The system + * instantiates and binds it while the user has it enabled in the accessibility + * settings; [AccessibilityLockHelper] reaches the live instance via [instance]. + * + * Must stay Hilt-free: the system creates it through the no-arg constructor. + */ +class LockAccessibilityService : AccessibilityService() { + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) = Unit + + override fun onInterrupt() = Unit + + override fun onDestroy() { + if (instance === this) instance = null + super.onDestroy() + } + + companion object { + @Volatile + var instance: LockAccessibilityService? = null + private set + } +} 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 57e3ed4..6c45c6a 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 @@ -3,6 +3,7 @@ package dev.xitee.sleeptimer.feature.timer.settings import android.app.Activity import android.app.admin.DevicePolicyManager import android.content.Intent +import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -47,6 +48,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -55,7 +57,9 @@ import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager import dev.xitee.sleeptimer.core.data.model.AutoRotateMode +import dev.xitee.sleeptimer.core.data.model.ScreenLockMethod import dev.xitee.sleeptimer.feature.timer.R +import dev.xitee.sleeptimer.feature.timer.settings.components.AccessibilityRequiredDialog import dev.xitee.sleeptimer.feature.timer.settings.components.AutoRotateModeDialog import dev.xitee.sleeptimer.feature.timer.settings.components.FadeOutSlider import dev.xitee.sleeptimer.feature.timer.settings.components.ScreenLockMethodDialog @@ -107,18 +111,24 @@ private fun SettingsContent( val shizukuWifiExplanation = stringResource(R.string.shizuku_feature_wifi) val shizukuBluetoothExplanation = stringResource(R.string.shizuku_feature_bluetooth) + val context = LocalContext.current + val deviceAdminLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult(), ) { result -> if (result.resultCode == Activity.RESULT_OK) { viewModel.updateScreenOff(true) - viewModel.updateSoftScreenOff(false) + viewModel.updateScreenLockMethod(ScreenLockMethod.DeviceAdmin) } } var shizukuDialogExplanation by remember { mutableStateOf(null) } var pendingShizukuToggle by remember { mutableStateOf<(() -> Unit)?>(null) } var showMethodDialog by remember { mutableStateOf(false) } + var showAccessibilityDialog by remember { mutableStateOf(false) } + // Set when the user was sent to the accessibility settings to enable the lock + // service; resolved on ON_RESUME once we can re-query the grant. + var pendingAccessibilityEnable by remember { mutableStateOf(false) } var showAutoRotateDialog by remember { mutableStateOf(false) } fun requestWithShizuku(explanation: String, enableAction: () -> Unit) { @@ -144,10 +154,10 @@ private fun SettingsContent( if (showMethodDialog) { ScreenLockMethodDialog( - onHardLockSelected = { + onDeviceAdminSelected = { if (viewModel.isDeviceAdminActive()) { viewModel.updateScreenOff(true) - viewModel.updateSoftScreenOff(false) + viewModel.updateScreenLockMethod(ScreenLockMethod.DeviceAdmin) } else { val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply { putExtra( @@ -162,16 +172,36 @@ private fun SettingsContent( deviceAdminLauncher.launch(intent) } }, - onSoftLockSelected = { + onAccessibilitySelected = { + if (viewModel.isAccessibilityLockEnabled()) { + viewModel.updateScreenOff(true) + viewModel.updateScreenLockMethod(ScreenLockMethod.Accessibility) + } else { + showAccessibilityDialog = true + } + }, + onShizukuSelected = { requestWithShizuku(shizukuSoftScreenOffExplanation) { viewModel.updateScreenOff(true) - viewModel.updateSoftScreenOff(true) + viewModel.updateScreenLockMethod(ScreenLockMethod.Shizuku) } }, onDismiss = { showMethodDialog = false }, ) } + if (showAccessibilityDialog) { + AccessibilityRequiredDialog( + onOpenSettings = { + // No activity result to wait for — the grant is re-checked on ON_RESUME. + pendingAccessibilityEnable = true + showAccessibilityDialog = false + context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) + }, + onDismiss = { showAccessibilityDialog = false }, + ) + } + if (showAutoRotateDialog) { AutoRotateModeDialog( selected = uiState.settings.autoRotateMode, @@ -193,11 +223,20 @@ private fun SettingsContent( viewModel.refreshShizuku() } - // Device admin can be revoked from system Settings without any broadcast we're - // subscribed to — re-query on resume so the "Screen" row description reflects - // the current admin state if the user came back from Settings → Device admin. + // Device admin and the accessibility service can be revoked from system Settings + // without any broadcast we're subscribed to — re-query on resume so the "Screen" + // row description reflects the current state when the user comes back. This is + // also where a pending accessibility-method selection completes: if the user + // enabled the service while away, commit the choice they made in the dialog. LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { - viewModel.refreshDeviceAdminState() + viewModel.refreshPermissionState() + if (pendingAccessibilityEnable) { + pendingAccessibilityEnable = false + if (viewModel.isAccessibilityLockEnabled()) { + viewModel.updateScreenOff(true) + viewModel.updateScreenLockMethod(ScreenLockMethod.Accessibility) + } + } } TimerBackground( @@ -275,7 +314,17 @@ private fun SettingsContent( title = stringResource(R.string.screen_title), description = when { !uiState.settings.screenOff -> stringResource(R.string.screen_description) - uiState.settings.softScreenOff -> stringResource(R.string.screen_method_active_soft) + uiState.settings.screenLockMethod == ScreenLockMethod.Shizuku -> + stringResource(R.string.screen_method_active_soft) + uiState.settings.screenLockMethod == ScreenLockMethod.Accessibility -> + if (uiState.isAccessibilityLockEnabled) { + stringResource(R.string.screen_method_active_accessibility) + } else { + // Service was disabled in system settings after being + // chosen — locking falls back to the hard lock (if + // granted) until re-enabled. + stringResource(R.string.screen_method_accessibility_revoked) + } 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. 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 b2aacbf..29025c0 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 @@ -7,4 +7,5 @@ data class SettingsUiState( val settings: UserSettings = UserSettings(), val shizukuState: ShizukuManager.State = ShizukuManager.State.NotInstalled, val isDeviceAdminActive: Boolean = false, + val isAccessibilityLockEnabled: 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 c4fec38..4b2a871 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 @@ -5,8 +5,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.xitee.sleeptimer.core.data.model.AutoRotateMode +import dev.xitee.sleeptimer.core.data.model.ScreenLockMethod import dev.xitee.sleeptimer.core.data.model.ThemeId import dev.xitee.sleeptimer.core.data.repository.SettingsRepository +import dev.xitee.sleeptimer.core.service.screen.AccessibilityLockHelper import dev.xitee.sleeptimer.core.service.screen.ScreenLockHelper import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager import kotlinx.coroutines.flow.MutableStateFlow @@ -22,36 +24,40 @@ class SettingsViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val shizukuManager: ShizukuManager, private val screenLockHelper: ScreenLockHelper, + private val accessibilityLockHelper: AccessibilityLockHelper, ) : ViewModel() { - // 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 - // reflects the current state. - private val adminRefreshTicker = MutableStateFlow(0) + // Tick to re-query isAdminActive / isServiceEnabled. Both 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 the system + // settings reflects the current state. + private val permissionRefreshTicker = MutableStateFlow(0) val uiState: StateFlow = combine( settingsRepository.settings, shizukuManager.state, - adminRefreshTicker, + permissionRefreshTicker, ) { settings, shizukuState, _ -> SettingsUiState( settings = settings, shizukuState = shizukuState, isDeviceAdminActive = screenLockHelper.isAdminActive(), + isAccessibilityLockEnabled = accessibilityLockHelper.isServiceEnabled(), ) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) fun isDeviceAdminActive(): Boolean = screenLockHelper.isAdminActive() + fun isAccessibilityLockEnabled(): Boolean = accessibilityLockHelper.isServiceEnabled() + fun getAdminComponent(): ComponentName = screenLockHelper.adminComponent fun refreshShizuku() = shizukuManager.refresh() - /** Triggers a re-read of the device-admin active flag. */ - fun refreshDeviceAdminState() { - adminRefreshTicker.value = adminRefreshTicker.value + 1 + /** Triggers a re-read of the device-admin and accessibility-service grants. */ + fun refreshPermissionState() { + permissionRefreshTicker.value = permissionRefreshTicker.value + 1 } fun requestShizukuPermission() = shizukuManager.requestPermission() fun isShizukuReady(): Boolean = shizukuManager.isReady() @@ -68,8 +74,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { settingsRepository.updateScreenOff(enabled) } } - fun updateSoftScreenOff(enabled: Boolean) { - viewModelScope.launch { settingsRepository.updateSoftScreenOff(enabled) } + fun updateScreenLockMethod(method: ScreenLockMethod) { + viewModelScope.launch { settingsRepository.updateScreenLockMethod(method) } } fun updateTurnOffWifi(enabled: Boolean) { diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/AccessibilityRequiredDialog.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/AccessibilityRequiredDialog.kt new file mode 100644 index 0000000..5963bbd --- /dev/null +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/AccessibilityRequiredDialog.kt @@ -0,0 +1,36 @@ +package dev.xitee.sleeptimer.feature.timer.settings.components + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Accessibility +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import dev.xitee.sleeptimer.feature.timer.R + +@Composable +fun AccessibilityRequiredDialog( + onOpenSettings: () -> Unit, + onDismiss: () -> Unit, + @StringRes dismissLabelRes: Int = R.string.shizuku_action_cancel, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(Icons.Default.Accessibility, contentDescription = null) }, + title = { Text(stringResource(R.string.accessibility_dialog_title)) }, + text = { Text(stringResource(R.string.accessibility_body_required)) }, + confirmButton = { + TextButton(onClick = onOpenSettings) { + Text(stringResource(R.string.accessibility_action_open_settings)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(dismissLabelRes)) + } + }, + ) +} diff --git a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/ScreenLockMethodDialog.kt b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/ScreenLockMethodDialog.kt index c7c70fc..9d6d3b9 100644 --- a/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/ScreenLockMethodDialog.kt +++ b/feature/timer/src/main/kotlin/dev/xitee/sleeptimer/feature/timer/settings/components/ScreenLockMethodDialog.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Accessibility import androidx.compose.material.icons.filled.AdminPanelSettings import androidx.compose.material.icons.filled.Nightlight import androidx.compose.material.icons.filled.PhoneAndroid @@ -24,12 +25,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import dev.xitee.sleeptimer.core.service.screen.AccessibilityLockHelper import dev.xitee.sleeptimer.feature.timer.R @Composable fun ScreenLockMethodDialog( - onHardLockSelected: () -> Unit, - onSoftLockSelected: () -> Unit, + onDeviceAdminSelected: () -> Unit, + onAccessibilitySelected: () -> Unit, + onShizukuSelected: () -> Unit, onDismiss: () -> Unit, ) { AlertDialog( @@ -42,14 +45,24 @@ fun ScreenLockMethodDialog( icon = Icons.Default.AdminPanelSettings, title = stringResource(R.string.screen_method_hard_title), description = stringResource(R.string.screen_method_hard_description), - onClick = { onHardLockSelected(); onDismiss() }, + onClick = { onDeviceAdminSelected(); onDismiss() }, ) + // GLOBAL_ACTION_LOCK_SCREEN needs API 28 — hide the option below that. + if (AccessibilityLockHelper.isSupported) { + Spacer(Modifier.height(8.dp)) + MethodOption( + icon = Icons.Default.Accessibility, + title = stringResource(R.string.screen_method_accessibility_title), + description = stringResource(R.string.screen_method_accessibility_description), + onClick = { onAccessibilitySelected(); onDismiss() }, + ) + } Spacer(Modifier.height(8.dp)) MethodOption( icon = Icons.Default.Nightlight, title = stringResource(R.string.screen_method_soft_title), description = stringResource(R.string.screen_method_soft_description), - onClick = { onSoftLockSelected(); onDismiss() }, + onClick = { onShizukuSelected(); onDismiss() }, ) } }, 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 46a0502..fcfa3ef 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 @@ -66,6 +66,7 @@ import dev.xitee.sleeptimer.core.data.util.isSystemReduceMotionEnabled 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.AccessibilityRequiredDialog 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 @@ -132,6 +133,7 @@ private fun TimerContent( } var showAdminStartupDialog by remember { mutableStateOf(false) } + var showAccessibilityStartupDialog by remember { mutableStateOf(false) } var shizukuStartupFeatures by remember { mutableStateOf>(emptyList()) } val shizukuState by viewModel.shizukuState.collectAsStateWithLifecycle() @@ -144,6 +146,7 @@ private fun TimerContent( LaunchedEffect(Unit) { viewModel.computeStartupPermissionCheck()?.let { check -> showAdminStartupDialog = check.adminMissing + showAccessibilityStartupDialog = check.accessibilityMissing shizukuStartupFeatures = check.shizukuMissingFeatures } } @@ -174,6 +177,19 @@ private fun TimerContent( ) } + if (showAccessibilityStartupDialog) { + AccessibilityRequiredDialog( + onOpenSettings = { + showAccessibilityStartupDialog = false + context.startActivity( + Intent(android.provider.Settings.ACTION_ACCESSIBILITY_SETTINGS), + ) + }, + onDismiss = { showAccessibilityStartupDialog = false }, + dismissLabelRes = R.string.dialog_action_ignore, + ) + } + if (shizukuStartupFeatures.isNotEmpty()) { val explanations = shizukuStartupFeatures.map { feature -> when (feature) { 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 e1ec991..6bb0d28 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 @@ -8,12 +8,14 @@ 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.ScreenLockMethod import dev.xitee.sleeptimer.core.data.model.TimerPhase import dev.xitee.sleeptimer.core.data.model.UserSettings import dev.xitee.sleeptimer.core.data.repository.SettingsRepository import dev.xitee.sleeptimer.core.data.repository.TimerRepository import dev.xitee.sleeptimer.core.data.util.remainingMillisToDisplayMinutes import dev.xitee.sleeptimer.core.service.SleepTimerService +import dev.xitee.sleeptimer.core.service.screen.AccessibilityLockHelper import dev.xitee.sleeptimer.core.service.screen.ScreenLockHelper import dev.xitee.sleeptimer.core.service.shizuku.ShizukuManager import kotlinx.coroutines.flow.MutableStateFlow @@ -31,6 +33,7 @@ class TimerViewModel @Inject constructor( private val settingsRepository: SettingsRepository, private val shizukuManager: ShizukuManager, private val screenLockHelper: ScreenLockHelper, + private val accessibilityLockHelper: AccessibilityLockHelper, @ApplicationContext private val context: Context, ) : ViewModel() { @@ -92,14 +95,19 @@ class TimerViewModel @Inject constructor( val shizukuState = shizukuManager.awaitInitialState() val s = settingsRepository.settings.first() val shizukuReady = shizukuState == ShizukuManager.State.Ready - val adminActive = screenLockHelper.isAdminActive() - val adminMissing = s.screenOff && !s.softScreenOff && !adminActive + val lockMethod = s.screenLockMethod + val adminMissing = s.screenOff && lockMethod == ScreenLockMethod.DeviceAdmin && + !screenLockHelper.isAdminActive() + val accessibilityMissing = s.screenOff && lockMethod == ScreenLockMethod.Accessibility && + !accessibilityLockHelper.isServiceEnabled() val shizukuFeatures = buildList { - if (s.screenOff && s.softScreenOff && !shizukuReady) add(ShizukuFeature.SCREEN_OFF) + if (s.screenOff && lockMethod == ScreenLockMethod.Shizuku && !shizukuReady) { + add(ShizukuFeature.SCREEN_OFF) + } if (s.turnOffWifi && !shizukuReady) add(ShizukuFeature.WIFI) if (s.turnOffBluetooth && !shizukuReady) add(ShizukuFeature.BLUETOOTH) } - return StartupPermissionCheck(adminMissing, shizukuFeatures) + return StartupPermissionCheck(adminMissing, accessibilityMissing, shizukuFeatures) } /** @@ -175,6 +183,7 @@ class TimerViewModel @Inject constructor( data class StartupPermissionCheck( val adminMissing: Boolean, + val accessibilityMissing: Boolean, val shizukuMissingFeatures: List, ) diff --git a/feature/timer/src/main/res/values-de/strings.xml b/feature/timer/src/main/res/values-de/strings.xml index 515b847..3dea740 100644 --- a/feature/timer/src/main/res/values-de/strings.xml +++ b/feature/timer/src/main/res/values-de/strings.xml @@ -102,9 +102,18 @@ Standard-Android-Sperre. Code zum Entsperren nötig. Shizuku (sanft) Simuliert Power-Taste. Biometrische Entsperrung bleibt aktiv. + Bedienungshilfe (sanft) + Sperrt wie die Power-Taste über einen Bedienungshilfen-Dienst. Biometrische Entsperrung bleibt aktiv, kein Shizuku nötig. Display ausschalten - Geräteadministrator Display ausschalten - Shizuku + Display ausschalten - Bedienungshilfe Display ausschalten - Geräteadministrator-Berechtigung fehlt + Display ausschalten - Bedienungshilfen-Dienst deaktiviert + + + Bedienungshilfen-Dienst erforderlich + Um den Bildschirm auf diese Weise zu sperren, aktiviere „SleepTimer Bildschirmsperre“ in den Bedienungshilfen-Einstellungen. SleepTimer nutzt den Dienst nur zum Sperren des Bildschirms und liest keine Bildschirminhalte. + Einstellungen öffnen Über diff --git a/feature/timer/src/main/res/values/strings.xml b/feature/timer/src/main/res/values/strings.xml index 4a9d2e8..3fd10ac 100644 --- a/feature/timer/src/main/res/values/strings.xml +++ b/feature/timer/src/main/res/values/strings.xml @@ -102,9 +102,18 @@ Standard Android lock. Requires credential to unlock. Shizuku (soft) Simulates power button. Biometric unlock stays active. + Accessibility (soft) + Locks like the power button via an accessibility service. Biometric unlock stays active, no Shizuku needed. Turn off display - Device admin Turn off display - Shizuku + Turn off display - Accessibility Turn off display - Device admin permission missing + Turn off display - Accessibility service disabled + + + Accessibility service required + To lock the screen this way, enable "SleepTimer screen lock" in the system accessibility settings. SleepTimer only uses it to lock the screen and does not read any screen content. + Open settings About