This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Toolchain: JDK 17, Android SDK platform 36 (compileSdk/targetSdk), minSdk = 26, AGP 9.1.1, Kotlin 2.3.20, Compose BoM 2026.03.01. Hilt + KSP for DI.
./gradlew assembleDebug— build the debug APK (what CI uses to verify)./gradlew assembleRelease— build release; signs with thereleaseconfig only whenSIGNING_KEYSTORE_PATHis set in the environment (withSIGNING_KEYSTORE_PASSWORD/SIGNING_KEY_ALIAS/SIGNING_KEY_PASSWORD), else falls back to debug signing so local builds succeed without secrets./gradlew lint— Android lint across all modules (required to pass in CI)./gradlew testDebugUnitTest— unit tests (currently no test sources exist; CI runs the task anyway to catch new ones)- Single-module variants:
./gradlew :feature:timer:lintDebug,./gradlew :core:service:testDebugUnitTest, etc.
Versioning is dynamic via the axion-release plugin (applied at the root). versionName is derived from the latest v* git tag (project.version); versionCode is computed from that same tag version with the schema major*100_000 + minor*1_000 + patch*10 (tag v1.0.1 → 100010), keeping it monotonic with releases published before dynamic versioning. Tags must be plain SemVer after the v prefix — axion-release fails the build on anything else (e.g. v1.0.1.1), so a hotfix is just the next patch tag. Don't hand-edit either field in app/build.gradle.kts. To cut a release, push a new v<x.y.z> tag — .github/workflows/release.yml builds a signed APK and publishes the GitHub Release. CI checkouts must use fetch-depth: 0 so tags and full history are visible.
Release checklist (do this in the commit you will tag, before pushing the tag):
- Bump
app/version.propertiesto the newversionName/versionCode. This file is not read by Gradle — axion-release still derives the real build values from the tag — it exists only so F-Droid'scheckupdatescan read the version statically (seeUpdateCheckDatain the fdroiddata recipe). It must match the tag or the release build fails. - Hand-write the changelog for the new
versionCodein both locales:fastlane/metadata/android/en-US/changelogs/<versionCode>.txtand.../de-DE/changelogs/<versionCode>.txt, each ≤500 chars, user-facing tone. F-Droid displays only the current version's file and reads it from the tagged tree.
The release.yml workflow enforces both (a Verify release metadata matches tag step) and fails the tag build if version.properties disagrees with the tag or a changelog is missing/oversized. If it fails, fix on main, then delete and re-push the tag. F-Droid builds the app itself from source (unsigned via -PdisableSigning); the GitHub release APK is unaffected.
Four Gradle modules with a strict one-way dependency flow:
app ──▶ feature:timer ──▶ core:service ──▶ core:data
└───────────────────▶ core:data
app/—@HiltAndroidApp(SleepTimerApp),MainActivity(edge-to-edge Compose host),SleepTimerNavHost(type-safe Navigation-Compose routes innavigation/Routes.kt),receiver/SleepTimerDeviceAdminReceiverfor the hard-lock path, anddi/AppModule(provides the@DeviceAdminComponentComponentNameso lower modules receive the receiver by injection instead of hard-coding its class). TheShizukuProvideris declared here inAndroidManifest.xml;shizuku-provideris 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 inAppThemes.kt, animated transitions inAnimatedAppTheme.kt, andProvideAppThemewhich mirrors the active palette into a MaterialColorSchemeand keeps system-bar icon contrast in sync),about/. ViewModels use@HiltViewModeland dispatch to the service viaIntents (see below).core:service/— theSleepTimerServiceforeground service (the runtime "source of truth" while a timer is active),TimerNotificationManager(notification with +/−/cancel actions),MediaVolumeController(fade-out / fade-in),screen/(ScreenLockHelperfor Device-AdminlockNow;LockAccessibilityService+AccessibilityLockHelperfor the accessibility soft lock viaGLOBAL_ACTION_LOCK_SCREEN, API 28+ — the service is declared in:app's manifest and must stay Hilt-free because the system instantiates it), andshizuku/(Shizuku state machine, theShellUserServiceshell-exec bridge, and Wi-Fi, Bluetooth, soft screen-off controllers). Which lock path runs at expiry is selected byUserSettings.screenLockMethod(DeviceAdmin/Accessibility/Shizuku).core:data/—UserSettings+TimerState/TimerPhase+ThemeIdmodels,SettingsRepository(Jetpack DataStore Preferences, singlesettingsfile),TimerRepository(in-processStateFlow<TimerState>— process state, not persisted), and the Hilt data module. Repositories are bound as@Singletonvia@Binds.
All modules apply the Kotlin compiler flag -Xannotation-default-target=param-property (needed for Hilt/Compose annotation targeting under Kotlin 2.x).
UI never mutates timer state directly — it sends intents to SleepTimerService. The action names in SleepTimerService.Companion are the public contract:
ACTION_START+EXTRA_DURATION_MILLIS→ begins countdown, callsstartForegroundwithFOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACKACTION_ADD_MINUTES/ACTION_SUBTRACT_MINUTES→ use the currentstepMinutesfrom settingsACTION_SET_MINUTES+EXTRA_MINUTES→ absolute set (dial-commit while running)ACTION_CANCEL→ cancels countdown + in-flight fade, restores volume, stops the service
The service writes to TimerRepositoryImpl on every tick; the UI observes timerRepository.timerState to render. UserSettings.stepMinutes is primed synchronously in onCreate via runBlocking so the very first notification uses the persisted step value, not the UserSettings() default.
Two non-obvious behaviors worth preserving when editing the service:
- Restart-resilience: if
onStartCommandis invoked by the OS (or by a stalePendingIntentfrom a surviving notification) with any action other thanACTION_STARTwhile no countdown is active (countdownJob?.isActive != true), the service callsstopSelfbefore returning. Skipping this branch will crash withForegroundServiceDidNotStartInTimeExceptionbecausestartForegroundis never called within the 5-second window. - Add-during-fade: when the user taps "+" while the timer is in
FADING_OUT, the new countdown job wrapsoldJob.cancelAndJoin()so a subsequent Cancel can interrupt the fade-in + restart sequence. The countdown and fade-in run in parallel — the clock starts from the tap, not from the end of the fade-in.
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:
core:serviceenablesbuildFeatures { aidl = true }to compile the interface.ShellUserServiceis instantiated by Shizuku in its own process via the no-arg constructor — keep it Hilt-free with no constructor params, and don't reference app infrastructure from it.- Bump
ShizukuShell.USER_SERVICE_VERSIONwhenever the AIDL or the service's behavior changes; Shizuku only replaces an already-running user service when the version differs.
- No
INTERNETpermission. The app must never make a network call. Do not add analytics, crash reporting, Firebase, Play Services, ads, or any third-party SDK that opens a socket. - All settings live in DataStore Preferences. Global settings go in
UserSettings: add one by extendingUserSettings, mapping a newPreferences.KeyinSettingsRepositoryImpl, and exposing anupdateXmethod. Per-instance or otherwise dynamic config that doesn't fit the one-value-per-setting model may use its own repository over the same DataStore file (seeWidgetConfigRepository, keyed byappWidgetIdand pruned in the provider'sonDeleted) — but never introduce a second storage mechanism. - Strings are localized — any user-visible string added to
values/strings.xmlmust also be translated invalues-de/strings.xml.
docs/plans/ holds dated design notes for in-flight or completed features (e.g. Shizuku integration, in-app rotation). Read the relevant plan before making large changes in those areas; the reasoning often isn't repeated in code comments.