Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TimerState>` — 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).
Expand All @@ -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 `<queries>` 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 `<queries>` 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:

Expand Down
16 changes: 16 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@
android:exported="false"
android:foregroundServiceType="mediaPlayback" />

<!-- Optional soft screen-lock via GLOBAL_ACTION_LOCK_SCREEN. exported="true"
is safe: only the system holds BIND_ACCESSIBILITY_SERVICE, and it only
binds after the user enables the service in accessibility settings. -->
<service
android:name="dev.xitee.sleeptimer.core.service.screen.LockAccessibilityService"
android:exported="true"
android:label="@string/accessibility_service_label"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_lock_service" />
</service>

<!-- Per-instance widget configuration, launched by the widget host on
placement (pre-Android 12) and via long-press -> edit (12+). Exported
because some launchers start it directly rather than through the
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
<!-- Device Admin -->
<string name="device_admin_description">Erforderlich, um den Bildschirm zu sperren, wenn der Schlaf-Timer endet.</string>

<!-- Accessibility lock service (shown in the system accessibility settings) -->
<string name="accessibility_service_label">SleepTimer Bildschirmsperre</string>
<string name="accessibility_service_description">Wird nur verwendet, um den Bildschirm zu sperren, wenn der Schlaf-Timer endet. SleepTimer liest oder beobachtet keine Bildschirminhalte.</string>

<!-- Home screen widget -->
<string name="widget_description">Startet den Schlaf-Timer mit nur einem Tipp.</string>
<string name="widget_minutes">%d Min</string>
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
<!-- Device Admin -->
<string name="device_admin_description">Required to lock the screen when the sleep timer ends.</string>

<!-- Accessibility lock service (shown in the system accessibility settings) -->
<string name="accessibility_service_label">SleepTimer screen lock</string>
<string name="accessibility_service_description">Used only to lock the screen when the sleep timer ends. SleepTimer does not read or observe any screen content.</string>

<!-- Home screen widget -->
<string name="widget_description">Start the sleep timer with a single tap.</string>
<string name="widget_minutes">%d min</string>
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/xml/accessibility_lock_service.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Deliberately minimal: no event types, no window-content access, no flags.
The service exists solely to perform GLOBAL_ACTION_LOCK_SCREEN when the
sleep timer expires. -->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityFeedbackType="feedbackGeneric"
android:description="@string/accessibility_service_description" />
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading