Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
016c3ea
Update Gradle wrapper and dependency versions
projectdelta6 Jul 31, 2026
5c4f1dd
Split coroutineContext into dispatcher + injectable scope, inject Dat…
projectdelta6 Jul 31, 2026
1ea588f
Remove long-deprecated *Bool methods
projectdelta6 Jul 31, 2026
e7b5fe1
Remove the coroutine redesign spec doc now it's implemented
projectdelta6 Jul 31, 2026
76c9875
Tighten the 2.0 migration guide
projectdelta6 Jul 31, 2026
bd31460
Align jitpack.yml with this project
projectdelta6 Jul 31, 2026
9a58987
Address PR #10 review feedback
projectdelta6 Jul 31, 2026
cf903c5
Make produceFile idempotent in the DataStore test example
projectdelta6 Jul 31, 2026
7d448a8
README housekeeping
projectdelta6 Jul 31, 2026
8ad89bf
Give both helpers the same set of supported types
projectdelta6 Jul 31, 2026
7f221cb
Add Float, Set<String>, ByteArray, Instant and Set<Enum> to both helpers
projectdelta6 Jul 31, 2026
504b790
Align null/absent semantics across both helpers, and add prefs versio…
projectdelta6 Jul 31, 2026
3996e11
Replace the sample's getInstance() singleton with a Koin reference setup
projectdelta6 Jul 31, 2026
c0ed8c6
Fix empty Set<Enum> being mistaken for an absent key on BasePrefsHelper
projectdelta6 Jul 31, 2026
52e57fb
Fix clearPrefs wiping the migration stamp, and align Enum null semantics
projectdelta6 Jul 31, 2026
0c7996c
Verify the R8 claim instead of asserting it
projectdelta6 Jul 31, 2026
90ef67a
Polish from Grok's fourth review, and settle the strict-full-mode que…
projectdelta6 Jul 31, 2026
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
45 changes: 36 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ LocalDateTime, LocalDate, LocalTime, and Enums.
# Build the library
./gradlew :PrefsHelper:build

# Run all tests (both test classes live in :app/src/test and run on the JVM —
# Run all tests (all three test classes live in :app/src/test and run on the JVM —
# BaseDataStoreHelperTest runs under Robolectric, so no device/emulator is needed)
./gradlew :app:testDebugUnitTest

Expand All @@ -27,8 +27,16 @@ LocalDateTime, LocalDate, LocalTime, and Enums.
./gradlew :app:koverHtmlReportDebug # HTML -> app/build/reports/kover/htmlDebug/index.html
./gradlew :app:koverVerifyDebug # fails below the coverage floor (minBound in app/build.gradle.kts)

# Generate documentation
./gradlew :PrefsHelper:dokkaHtml
# R8 verification. The sample's release build is minified on purpose, so this proves the
# library needs no consumer keep rules (no missing_rules.txt = nothing needed keeping).
./gradlew :app:assembleRelease

# Behavioural R8 check — MANUAL, needs a connected device, deliberately not in CI.
# Points instrumented tests at the minified release build instead of debug.
./gradlew :app:connectedAndroidTest -PminifiedTests

# Generate documentation (Dokka 2.x V2 task; the old `dokkaHtml` V1 task no longer exists)
./gradlew :PrefsHelper:dokkaGenerateHtml

# Clean build
./gradlew clean
Expand All @@ -43,25 +51,44 @@ The library provides two abstract base classes that consumers extend:
- Wraps Android `SharedPreferences` with type-safe getters/setters
- Synchronous API for reads, async writes via `edit {}`
- Subclasses must provide `sharedPreferences` instance
- Uses coroutine context (defaults to `Dispatchers.IO + SupervisorJob`) for `clearPrefs()`
- Preferred usage is the `*Pref` property delegate factories (e.g. `var flag by booleanPref(KEY, defaultValue = false)`). Each type has a non-nullable overload `(key, defaultValue)` and a nullable overload `(key)`. Temporal types (`Date`, `LocalDateTime`, `LocalDate`, `LocalTime`) are nullable-only and preserve the existing -1L sentinel.
- Takes a `dispatcher: CoroutineDispatcher = Dispatchers.IO` (no `Job`), used by `clearPrefs()`. Because the dispatcher carries no Job, `clearPrefs()` is cancelled when its caller is cancelled — this is deliberate, see the 2.0 migration note in the README.
- Preferred usage is the `*Pref` property delegate factories (e.g. `var flag by booleanPref(KEY, defaultValue = false)`). Most types have a non-nullable overload `(key, defaultValue)` and a nullable overload `(key)`; the temporal types (`Date`, `Instant`, `LocalDateTime`, `LocalDate`, `LocalTime`) and `ByteArray` are nullable-only here, and `enumSetPref` is non-null-with-default only. The `-1L` sentinel those temporal types used is **gone as of 2.0** — null removes the key, see the Migrations section below.

### BaseDataStoreHelper

- Wraps Jetpack `DataStore<Preferences>` with type-safe methods
- Returns `Flow<T>` for reactive reads, plus blocking `readXxxValue()` methods (2s timeout)
- Both suspend and async write methods. The `writeXxxAsync` methods launch on `scope` and **return the `Job`**, so callers (and tests) can `.join()` to await completion instead of guessing with a delay. Delegate setters discard the `Job` (property setters return `Unit`), so they remain genuinely fire-and-forget.
- Subclasses pass `Context` and preference name to constructor
- **Two constructors.** Primary takes `(dataStore: DataStore<Preferences>, dispatcher, scope)` — the injectable path, used by tests. Secondary convenience constructor takes `(context, preferenceName, dispatcher, scope)` and builds the store via `PreferenceDataStoreFactory.create(produceFile = { context.applicationContext.preferencesDataStoreFile(name) })`. Subclasses written against the old `(context, name)` signature are unchanged.
- `dispatcher` (no `Job`) is where `suspend` functions work; `scope` is where the `*Async` writes launch and is injectable so consumers can supply an app-lifetime scope with a `CoroutineExceptionHandler`. The default `scope` is per-instance — there is no longer a shared companion `SupervisorJob`.
- As with DataStore itself, only one live instance per backing file per process — keep subclasses singletons.
- Null values remove the key from storage
- Preferred usage is the `*Pref` delegate + `*PrefFlow` alias pair (e.g. `var userId by intPref(KEY, defaultValue = -1)` paired with `val userIdFlow = intPrefFlow(KEY, defaultValue = -1)`). Delegate setters route through the existing `*Async` writes so callers don't need to build their own `CoroutineScope`.

Both classes support: `String`, `Int`, `Long`, `Boolean`, `LocalDateTime`, `LocalDate`, `LocalTime`, `Enum<*>`. `BasePrefsHelper` additionally supports `Date`; `BaseDataStoreHelper` additionally supports `Double`.
Both classes support the same types as of 2.0: `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`, `ByteArray`, `Set<String>`, `Date`, `Instant`, `LocalDateTime`, `LocalDate`, `LocalTime`, `Enum<*>`, `Set<Enum>`.

Storage conventions still differ by backend, and deliberately so:
- **`Double` on `BasePrefsHelper`** is stored as raw IEEE-754 bits via `putLong`/`Double.fromBits`, because `SharedPreferences` has no double primitive. Reading that key with `getLong` returns the bit pattern, not the number.
- **`ByteArray` on `BasePrefsHelper`** is Base64 (`NO_WRAP`) in a String, since SharedPreferences has no binary type. Undecodable data logs and returns null rather than throwing.
- **`Set<String>` on `BasePrefsHelper`** copies on both read and write. `SharedPreferences.getStringSet` documents its result as one callers must not modify, and the platform keeps a reference to the set it is handed — both directions are trapped, so both are copied.
- **Null semantics are identical on both helpers** as of 2.0: assigning null removes the key, an absent key reads as null. The `-1L` temporal sentinel `BasePrefsHelper` used before 2.0 is gone — it made `LocalDate` 1969-12-31 (epoch day -1) unstorable. `migrateLegacyTemporalSentinels(vararg keys)` sweeps stale sentinels left by 1.x; `getLocalTime` also treats out-of-range values as null, since `LocalTime.ofSecondOfDay(-1)` throws.
- **`Set<Enum>`** is stored as a set of `Enum.name` on both. Unknown names are dropped on read so deleting an enum constant doesn't break existing installs.

## Migrations

`BasePrefsHelper.migrateIfNeeded(currentVersion) { from -> }` runs a block at most once per version, stamped under `KEY_HELPER_VERSION` in the consumer's own prefs file. An unstamped file is treated as `VERSION_LEGACY` (1) if it holds anything, or as a fresh install if empty — that's how pre-versioning installs are told from new ones. Downgrades don't rewind the stamp. The stamp is written with `commit`, so the first call does a small synchronous write.

`BaseDataStoreHelper` has **no** equivalent, deliberately: DataStore's own `DataMigration` already tracks whether it has run and is applied atomically before the first read, which is strictly better than stamping a version afterwards. The convenience constructor takes a `migrations` list that is passed straight to `PreferenceDataStoreFactory`. Don't add a parallel version-key scheme here.

## Gotchas

- **Inline reified factories that access `protected` members**: an `inline fun <reified T>` that returns an anonymous object (e.g. a `ReadWriteProperty`) calling `protected` methods on `BaseDataStoreHelper` throws `IllegalAccessError` at runtime — the anonymous class is emitted inside the *subclass* at inline time, losing JVM-level protected access. Fix: split into a thin `inline` + `reified` wrapper that forwards to a non-inline `@PublishedApi internal` helper which owns the anonymous object. See `enumPref` / `enumPrefInternal` in `BaseDataStoreHelper.kt`. `BasePrefsHelper` is unaffected because its get/set accessors are `public`.
- **Inline reified factories that access `protected` members**: an `inline fun <reified T>` that returns an anonymous object (e.g. a `ReadWriteProperty`) calling `protected` methods on `BaseDataStoreHelper` throws `IllegalAccessError` at runtime — the anonymous class is emitted inside the *subclass* at inline time, losing JVM-level protected access. Fix: split into a thin `inline` + `reified` wrapper that forwards to a non-inline `@PublishedApi internal` helper which owns the anonymous object. See `enumPref` / `enumPrefInternal` and `enumSetPref` / `enumSetPrefInternal` in `BaseDataStoreHelper.kt`. `BasePrefsHelper` is unaffected because its get/set accessors are `public`.

This only reproduces when the delegate is used **from a subclass in another module**, which is why `BaseDataStoreHelperTest`'s `TestDataStoreHelper` (in `:app`) declares `delegate`-prefixed properties for both enum delegates. A test that calls `readEnumSetValue` directly will not catch a regression here.

- **R8 verification is manual and needs a device**: `app/src/androidTest/R8SurvivalTest.kt` is the only test that can check the README's "no consumer ProGuard rules" claim, since unit tests never run R8 and Robolectric runs unminified code. It only means anything with `-PminifiedTests`, which flips `testBuildType` to the minified release build; its first test asserts at runtime that classes really were renamed so a debug run can't pass vacuously. Harness keeps live in `proguard-rules-instrumentation.pro`, applied **only** under that flag — keep them out of `proguard-rules.pro`, or a plain `assembleRelease` stops being evidence that consumers need no rules.

- **Tests are JVM-only (Robolectric), not instrumented**: both test classes live in `app/src/test`. `BaseDataStoreHelperTest` uses a real `DataStore` but runs on the JVM via Robolectric (`@RunWith(AndroidJUnit4::class)` delegates to `RobolectricTestRunner` off-device). This is deliberate: **Kover cannot instrument on-device tests**, so DataStore coverage would read ~0% if the tests were instrumented — running them under Robolectric makes the `koverVerifyDebug` floor meaningful across both helpers. Robolectric's SDK is pinned to 36 in `app/src/test/resources/robolectric.properties` because `targetSdk = 37` has no Robolectric image yet.
- **Tests are JVM-only (Robolectric), not instrumented**: all *unit* test classes live in `app/src/test` (`BasePrefsHelperTest`, `BaseDataStoreHelperTest`, and `BaseDataStoreHelperInjectionTest`). `BaseDataStoreHelperTest` uses a real `DataStore` but runs on the JVM via Robolectric (`@RunWith(AndroidJUnit4::class)` delegates to `RobolectricTestRunner` off-device). This is deliberate: **Kover cannot instrument on-device tests**, so DataStore coverage would read ~0% if the tests were instrumented — running them under Robolectric makes the `koverVerifyDebug` floor meaningful across both helpers. Robolectric's SDK is pinned to 36 in `app/src/test/resources/robolectric.properties` because `targetSdk = 37` has no Robolectric image yet.

## Project Structure

Expand Down
Loading
Loading