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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

on:
pull_request:
branches: [ master ]
push:
branches: [ master ]

# Cancel superseded runs on the same branch/PR.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
name: Tests & coverage gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21' # Runs Gradle/AGP; library still compiles to JVM 11.

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

# All tests run on the JVM — BaseDataStoreHelperTest runs under Robolectric via
# the AndroidJUnit4 delegating runner — so no emulator is needed. koverVerifyDebug
# enforces the coverage floor over com.duck.prefshelper.* (both helpers).
- name: Run tests and verify coverage
run: ./gradlew :app:testDebugUnitTest :app:koverXmlReportDebug :app:koverVerifyDebug

- name: Upload Kover coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: kover-report
path: app/build/reports/kover/
if-no-files-found: ignore
19 changes: 12 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ LocalDateTime, LocalDate, LocalTime, and Enums.
# Build the library
./gradlew :PrefsHelper:build

# Run unit tests
./gradlew :PrefsHelper:test
# Run all tests (both 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

# Run instrumented tests (requires connected device/emulator)
./gradlew :PrefsHelper:connectedAndroidTest
# Run a single test class
./gradlew :app:testDebugUnitTest --tests "com.duck.app.BaseDataStoreHelperTest"

# Run a single instrumented test class (--tests is rejected by connectedAndroidTest)
./gradlew :app:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.duck.app.BaseDataStoreHelperTest
# Coverage (Kover; aggregates :PrefsHelper into :app since the tests live in :app).
# Android module -> variant-suffixed tasks. No device needed.
./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
Expand All @@ -47,7 +50,7 @@ The library provides two abstract base classes that consumers extend:

- Wraps Jetpack `DataStore<Preferences>` with type-safe methods
- Returns `Flow<T>` for reactive reads, plus blocking `readXxxValue()` methods (2s timeout)
- Both suspend and async (fire-and-forget via `scope.launch`) write methods
- 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
- 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`.
Expand All @@ -58,6 +61,8 @@ Both classes support: `String`, `Int`, `Long`, `Boolean`, `LocalDateTime`, `Loca

- **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`.

- **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.

## Project Structure

- `PrefsHelper/` - Library module (published to JitPack)
Expand Down
15 changes: 9 additions & 6 deletions PrefsHelper/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import com.android.build.api.dsl.LibraryExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.dokka)
alias(libs.plugins.kover)
`maven-publish`
}

group = "com.github.projectdelta6"

android {
configure<LibraryExtension> {
namespace = "com.duck.prefshelper"
compileSdk = libs.versions.compileSdk.get().toInt()

Expand All @@ -30,11 +32,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}

publishing {
singleVariant("release") {
Expand All @@ -44,6 +41,12 @@ android {
}
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}

afterEvaluate {
publishing {
publications {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import androidx.datastore.preferences.preferencesDataStore
import com.duck.prefshelper.BaseDataStoreHelper.Companion.supervisorJob
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
Expand All @@ -24,6 +25,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.Duration.Companion.seconds
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
Expand Down Expand Up @@ -121,8 +123,9 @@ abstract class BaseDataStoreHelper(
* Generic method to delete value from the data store asynchronously.
*
* @param key The key to delete the value for
* @return The [Job] for the launched removal — call [Job.join] to await completion.
*/
protected inline fun <reified T> removeKeyAsync(key: Preferences.Key<T>) =
protected inline fun <reified T> removeKeyAsync(key: Preferences.Key<T>): Job =
scope.launch { removeKey(key) }

/**
Expand All @@ -139,8 +142,9 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
protected inline fun <reified T> writeValueAsync(key: Preferences.Key<T>, value: T) =
protected inline fun <reified T> writeValueAsync(key: Preferences.Key<T>, value: T): Job =
scope.launch { writeValue(key, value) }

/**
Expand All @@ -163,9 +167,10 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store or null to delete
* @return The [Job] for the launched write/removal — call [Job.join] to await completion.
*/
@JvmName("writeNullableValueAsync")
protected inline fun <reified T> writeValueAsync(key: Preferences.Key<T>, value: T?) =
protected inline fun <reified T> writeValueAsync(key: Preferences.Key<T>, value: T?): Job =
if (value == null) removeKeyAsync(key) else writeValueAsync(key, value)

/**
Expand Down Expand Up @@ -205,7 +210,7 @@ abstract class BaseDataStoreHelper(
*/
protected inline fun <reified T> readValueBlocking(key: Preferences.Key<T>): T? =
runBlocking(coroutineContext) {
withTimeoutOrNull(2000) {
withTimeoutOrNull(2.seconds) {
dataStore.data.first()[key]
}
}
Expand Down Expand Up @@ -257,8 +262,9 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
protected fun writeIntAsync(key: String, value: Int?) =
protected fun writeIntAsync(key: String, value: Int?): Job =
writeValueAsync(intPreferencesKey(key), value)

/**
Expand Down Expand Up @@ -308,8 +314,9 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
protected fun writeDoubleAsync(key: String, value: Double?) =
protected fun writeDoubleAsync(key: String, value: Double?): Job =
writeValueAsync(doublePreferencesKey(key), value)

/**
Expand Down Expand Up @@ -368,8 +375,9 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
protected fun writeStringAsync(key: String, value: String?) =
protected fun writeStringAsync(key: String, value: String?): Job =
writeValueAsync(stringPreferencesKey(key), value)

/**
Expand Down Expand Up @@ -428,8 +436,9 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
protected fun writeLongAsync(key: String, value: Long?) =
protected fun writeLongAsync(key: String, value: Long?): Job =
writeValueAsync(longPreferencesKey(key), value)

/**
Expand Down Expand Up @@ -488,8 +497,9 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
protected fun writeBooleanAsync(key: String, value: Boolean?) =
protected fun writeBooleanAsync(key: String, value: Boolean?): Job =
writeValueAsync(booleanPreferencesKey(key), value)

/**
Expand Down Expand Up @@ -547,9 +557,10 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
@RequiresApi(Build.VERSION_CODES.O)
protected fun writeLocalDateTimeAsync(key: String, value: LocalDateTime?) =
protected fun writeLocalDateTimeAsync(key: String, value: LocalDateTime?): Job =
writeValueAsync(stringPreferencesKey(key), value?.toString())

/**
Expand Down Expand Up @@ -607,9 +618,10 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
@RequiresApi(Build.VERSION_CODES.O)
protected fun writeLocalDateAsync(key: String, value: LocalDate?) =
protected fun writeLocalDateAsync(key: String, value: LocalDate?): Job =
writeValueAsync(stringPreferencesKey(key), value?.toString())

/**
Expand Down Expand Up @@ -667,9 +679,10 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
@RequiresApi(Build.VERSION_CODES.O)
protected fun writeLocalTimeAsync(key: String, value: LocalTime?) =
protected fun writeLocalTimeAsync(key: String, value: LocalTime?): Job =
writeValueAsync(stringPreferencesKey(key), value?.toString())

/**
Expand Down Expand Up @@ -726,8 +739,9 @@ abstract class BaseDataStoreHelper(
*
* @param key The key to store the value under
* @param value The value to store
* @return The [Job] for the launched write — call [Job.join] to await completion.
*/
protected fun writeEnumAsync(key: String, value: Enum<*>?) =
protected fun writeEnumAsync(key: String, value: Enum<*>?): Job =
writeValueAsync(stringPreferencesKey(key), value?.name)

/**
Expand Down
60 changes: 49 additions & 11 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import com.android.build.api.dsl.ApplicationExtension
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kover)
}

android {
configure<ApplicationExtension> {
namespace = "com.duck.app"
compileSdk = libs.versions.compileSdk.get().toInt()

Expand All @@ -30,15 +32,46 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}

buildFeatures {
compose = true
}

testOptions {
unitTests {
// Required for Robolectric: BaseDataStoreHelperTest runs on the JVM and
// needs the merged manifest + resources on the unit-test classpath.
isIncludeAndroidResources = true
}
}
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}

kover {
reports {
// Coverage gate: `./gradlew :app:koverVerifyDebug` fails below this floor.
// Aggregated line coverage across com.duck.prefshelper.* (both helpers, now
// all JVM-tested via Robolectric). Set below current to catch regressions
// without tripping on minor refactors; raise as coverage improves.
verify {
rule {
minBound(70)
}
}
filters {
includes {
// Measure only the published library. The sample app (incl. the
// consumer com.duck.app.data.prefs.* classes, which the tests don't
// instantiate) is scaffolding, not the code under test.
classes("com.duck.prefshelper.*")
}
}
}
}

dependencies {
Expand All @@ -54,13 +87,18 @@ dependencies {
// implementation(libs.androidx.dataStore)
implementation(project(":PrefsHelper"))

// Aggregate the library's coverage into this module's Kover report,
// since the tests that exercise PrefsHelper live here in :app.
kover(project(":PrefsHelper"))

// Unit tests run on the JVM. BaseDataStoreHelperTest runs under Robolectric via the
// AndroidJUnit4 delegating runner, so DataStore coverage is visible to Kover (which
// cannot instrument on-device tests). No emulator required.
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.robolectric)

debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Loading
Loading