Skip to content

Code-review follow-up: release hardening, correctness and cleanup#21

Merged
Xitee1 merged 12 commits into
mainfrom
fix/code-review-improvements
Apr 18, 2026
Merged

Code-review follow-up: release hardening, correctness and cleanup#21
Xitee1 merged 12 commits into
mainfrom
fix/code-review-improvements

Conversation

@Xitee1
Copy link
Copy Markdown
Owner

@Xitee1 Xitee1 commented Apr 18, 2026

Summary

Follow-up to a code review of the vibe-coded initial pass. Addresses correctness, release-build robustness, and minor cleanup. No user-visible behavior changes except the device-admin row now updates on resume.

Split into 12 focused commits. Each was individually verified to compile; final commit verified with `./gradlew assembleRelease` (R8 + resource shrinking).

Notable fixes

  • Release blockers:

    • `core:service` `ic_timer.xml` referenced `?attr/colorControlNormal` without AppCompat — failed aapt verification on release. Tint is ignored for notification icons anyway.
    • Added ProGuard keeps for `@Serializable` nav-route `$serializer` companions and the Shizuku AIDL stub.
    • Replaced string-literal class refs to `SleepTimerService` with `::class.java.name`.
  • Service robustness:

    • `onStartCommand` now bails out early with `stopSelf()` when a non-START action arrives without an active countdown (prevents `ForegroundServiceDidNotStartInTimeException` from stale notification PendingIntents after process death).
    • `TimerRepository` interface is now read-only; `updateState` lives on the concrete impl and is only injected into the service — ViewModels can no longer desync timer state.
    • `ShizukuShell.exec` bounded with a 5s `withTimeout` so a wedged binder can't block timer cancel.
  • Compose correctness/perf:

    • `TimerBackground` no longer writes snapshot state from inside a `Canvas` draw scope.
    • `LocalAppTheme` switched from `staticCompositionLocalOf` to `compositionLocalOf` so theme transitions don't invalidate the whole subtree each frame.
  • UX:

    • Dial-drag preset persistence debounced to `onDragEnd` (was ~30 DataStore writes per drag).
    • Device-admin state re-queried on `ON_RESUME` in Settings so revoking admin from system Settings reflects immediately.
  • Manifest:

    • `` entry for `moe.shizuku.privileged.api` (required under targetSdk 30+ package visibility).
    • `dataExtractionRules` excluding the settings DataStore from cloud-backup only (device-transfer still allowed, so local migration keeps prefs; a cold cloud restore to a different device starts clean since Shizuku/device-admin grants are device-local anyway).
  • Cleanup:

    • Deleted empty `ServiceModule`.
    • Removed duplicate notification strings from `app/res` (they already live in `core:service/res`).
    • Consolidated `SettingsRepositoryImpl` defaults to a single `UserSettings()` instance.

Test plan

  • `./gradlew assembleDebug` passes
  • `./gradlew assembleRelease` passes (R8 + resource shrinking enabled)
  • Manual: start a timer, verify notification shows correct step minutes and countdown
  • Manual: drag the dial, confirm preset persists only on release (not during drag)
  • Manual: +/- buttons in idle still update and persist the preset
  • Manual: enable screen-lock feature, revoke admin from system Settings, return to app — "Screen" row shows "Device admin permission required"
  • Manual: grant Shizuku permission, verify Wi-Fi / Bluetooth toggles work at timer end
  • Manual: tap +/- / cancel notification action after process death — app should not crash

🤖 Generated with Claude Code

Xitee1 and others added 12 commits April 18, 2026 18:58
The core:service module doesn't depend on AppCompat/Material, so
?attr/colorControlNormal failed aapt verification in release builds.
Notifications re-tint the small icon with the system accent anyway —
the attribute was a no-op at runtime.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
staticCompositionLocalOf invalidates every reader on any change.
rememberAnimatedAppTheme produces a new AppTheme instance every
frame during the 350ms theme transition (24 animateColorAsState
values), so with the static local the whole subtree recomposed
~21 times per theme swap. compositionLocalOf limits invalidation
to readers of the changed fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
StarField was assigning dpToPx from inside a Canvas draw lambda while
a LaunchedEffect read the same value — the "state-write-from-draw"
anti-pattern that can cause invalidation loops. Read LocalDensity
once in the composable body and capture the pixel value as a plain val.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IRemoteProcess.waitFor() is a blocking binder call — if the Shizuku
server wedges, the coroutine would block indefinitely. Wrap in
withTimeout(5_000) and treat TimeoutCancellationException as a
silent false so timer cancel can't hang on a stuck binder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Device admin can be revoked from system Settings without any
broadcast the app is subscribed to — the "Screen" row stayed at
the old state until another unrelated emission happened. Add an
adminRefreshTicker StateFlow to the VM's combine source and bump
it from a LifecycleEventEffect(ON_RESUME) in SettingsScreen so
returning from Settings → Security → Device admin reflects reality.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Defaults were duplicated between the UserSettings data class and
the per-key ?: fallbacks in the repository. Single source of truth:
instantiate UserSettings() once and read its fields as the ?: side.
Adding a new preference now only requires updating the data class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Notification strings lived in both app/res and core/service/res
(with the service copy as <plurals>, app as <string>). The service
reads from its own R class, so the app copies were just dead weight.

ServiceModule had no @Provides/@BINDS methods — all dependencies
are @Inject constructable @singletons. Dropping the empty module.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- <queries> entry for moe.shizuku.privileged.api so
  PackageManager.getPackageInfo() can see Shizuku under
  targetSdk 30+ package visibility rules.

- dataExtractionRules excluding the settings DataStore from
  cloud-backup only. Device-transfer is still allowed so prefs
  follow the user on local migration, but a cold cloud restore
  onto a different account/device won't carry over toggles whose
  backing grants (device admin, Shizuku) are device-local.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
updateState was exposed on the TimerRepository interface, so any
ViewModel could overwrite service-owned timer state. Move mutation
to TimerRepositoryImpl as a non-interface public method and inject
the concrete impl only where mutation is legitimate (currently
SleepTimerService). ViewModels keep their read-only interface
binding via Hilt and can no longer desync the foreground service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
If the OS recreates the service from a stale notification action
PendingIntent after process death (ACTION_ADD_MINUTES / SUBTRACT /
CANCEL), the old code never called startForeground and crashed
with ForegroundServiceDidNotStartInTimeException inside the 5s
foreground-start window. Bail out early with stopSelf() when a
non-START action arrives without an active countdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two problems under minified release builds:

1. Navigation route objects (@serializable data object) rely on
   generated $serializer companions that R8 stripped without
   explicit keep rules. Added targeted rules for
   dev.xitee.sleeptimer.navigation.** plus a keep for
   IShizukuService AIDL stub used by ShizukuShell.

2. TimerViewModel and TimerNotificationManager built Intents for
   SleepTimerService from hardcoded string literals. Services
   declared in the manifest are kept by default Android rules,
   so this didn't actually break — but it's fragile and a future
   class rename would silently fail. Replaced with
   SleepTimerService::class.java.name, consolidated Intent
   construction into a small serviceIntent() helper, and dropped
   the duplicated action constants in TimerViewModel's companion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dial called onMinutesChanged on every drag tick, and the VM
persisted to DataStore on every call — 30+ writes per drag. Split
setMinutes (UI-only, for drag ticks) from commitMinutes (UI +
DataStore write, for +/- step buttons and dial onDragEnd). One
persist per gesture instead of tens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Xitee1 Xitee1 merged commit 0bfd50b into main Apr 18, 2026
1 check passed
@Xitee1 Xitee1 deleted the fix/code-review-improvements branch April 18, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant