Skip to content

Coroutine API redesign: dispatcher + injectable scope + injectable DataStore (2.0) - #10

Merged
projectdelta6 merged 17 commits into
masterfrom
feature/coroutine-api-redesign
Jul 31, 2026
Merged

Coroutine API redesign: dispatcher + injectable scope + injectable DataStore (2.0)#10
projectdelta6 merged 17 commits into
masterfrom
feature/coroutine-api-redesign

Conversation

@projectdelta6

@projectdelta6 projectdelta6 commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Implements the coroutine API redesign proposal (the spec doc is removed in this branch now that it has shipped — readable at git show 5c4f1dd:COROUTINE-API-REDESIGN.md), plus the Gradle/dependency bump and a deprecation clear-out that a major version is the right moment for.

This is a breaking change and wants a major version tag.

Why

One constructor parameter was doing two unrelated jobs — choosing the dispatcher for suspend functions and owning fire-and-forget launches. That conflation caused three problems:

  1. withContext(coroutineContext) reparented work onto a foreign Job, so clearPrefs() and the suspending writes behaved like NonCancellable — a suspend function that its caller cannot cancel.
  2. The default SupervisorJob was a companion val: one Job shared by every helper instance in the process. Cancel it once and every prefs helper in the app silently stops doing async work, forever.
  3. There was no way to inject a scope, which is what prompted the whole thing — fire-and-forget write failures had nowhere to go but the default handler.

What changed

dispatcher (no Job) decides where suspend functions work; scope owns the *Async launches and is injectable. The default scope is now per-instance.

BaseDataStoreHelper gains a second constructor. The primary takes the DataStore<Preferences> directly — that's what makes subclasses unit-testable; the convenience constructor keeps the old (context, preferenceName) signature and builds the store via PreferenceDataStoreFactory.

Also: readValueBlocking drops its runBlocking context (DataStore is already main-safe, so the IO hop bought nothing and the Job made the read cancellable process-wide by accident), and the four long-deprecated *Bool methods are gone.

Compatibility

Most subclasses need no change. The existing 1,621-line test suite passes completely unchanged — the convenience constructor is source-compatible. Only call sites that explicitly passed coroutineContext need editing.

Full migration guide in the README under ## Migrating to 2.0.

The one that needs care

Making suspend functions properly caller-cancellable is a genuine behaviour change, not a refactor. A logout-style sequence running on a screen-scoped coroutine can now be interrupted halfway, leaving SharedPreferences cleared and DataStore not. Anything that relied on a prefs write surviving its caller's cancellation needs an application-lifetime scope or withContext(NonCancellable).

Verification

  • :app:testDebugUnitTest — green, including the untouched existing suite
  • :app:koverVerifyDebug — coverage floor holds
  • :PrefsHelper:build — green

New BaseDataStoreHelperInjectionTest (7 tests, 3.7s, no sleeps or polling) covers the injected DataStore, the injected scope, per-instance scope isolation, and caller-cancellable clearPrefs on both helpers.

The two cancellation tests were checked against a mutated build: reverting clearPrefs to a foreign-Job withContext makes exactly those two fail and nothing else, so they're real regression guards rather than tests that pass either way.

Notes for the reviewer

  • Dokka was already broken on masterdokkaHtml fails with "Cannot run Dokka V1 tasks when V2 mode is enabled", since Dokka 2.2.0 (already pinned before this branch) removed the V1 task. Corrected the command in CLAUDE.md to dokkaGenerateHtml, which builds cleanly. Worth checking nothing in CI still calls the old one.
  • The removed *Bool methods were protected, so only subclasses can break — grep the consuming apps for them alongside the coroutineContext / supervisorJob sweep.

Not in this PR

  • The AIM app's logout call site — different repo, but should ship in the same release
  • The version bump / JitPack tag, left as your call

🤖 Generated with Claude Code

projectdelta6 and others added 4 commits July 31, 2026 09:59
- Gradle 9.5.0 -> 9.6.1 (wrapper scripts regenerated)
- Gradle plugin 9.2.1 -> 9.3.1
- Kotlin 2.4.0 -> 2.4.10
- Kover 0.9.8 -> 0.9.9
- lifecycle-runtime 2.10.0 -> 2.11.0
- Compose BOM 2026.05.01 -> 2026.06.01

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aStore

Implements COROUTINE-API-REDESIGN.md, taking the wider of the two options
so the testability goal is actually delivered rather than deferred.

One constructor parameter was doing two unrelated jobs: choosing the
dispatcher for suspend functions AND owning fire-and-forget launches.
Splitting them fixes three problems:

- suspend functions no longer reparent onto a foreign Job, so clearPrefs()
  and the suspending writes are cancelled by their caller as they should be
- the default SupervisorJob is now per-instance; the process-wide companion
  vals on both classes are deleted
- consumers can inject a scope, so async write failures can reach a
  CoroutineExceptionHandler instead of the default handler

BaseDataStoreHelper now has two constructors. The primary takes the
DataStore directly, which is what makes subclasses unit-testable; the
convenience constructor keeps the old (context, preferenceName) signature
and builds the store via PreferenceDataStoreFactory. Existing subclasses
compile unchanged — the 1,621-line existing suite passes untouched.

readValueBlocking drops its runBlocking context: DataStore is already
main-safe, so the IO hop bought nothing and the Job it carried made the
read cancellable process-wide by accident.

Adds BaseDataStoreHelperInjectionTest covering the injected DataStore,
the injected scope, per-instance scope isolation, and caller-cancellable
clearPrefs on both helpers. The two cancellation tests were verified
against a mutated build: both fail under the old foreign-Job behaviour.

BREAKING CHANGE: coroutineContext -> dispatcher (+ optional scope), and
the companion supervisorJob properties are gone. See "Migrating to 2.0"
in the README — in particular, any logout-style sequence that relied on
prefs writes surviving its caller's cancellation now needs an
application-lifetime scope or withContext(NonCancellable).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The four protected boolean helpers on BaseDataStoreHelper have carried
@deprecated annotations and a "remove in a future release" TODO for
several versions. A major bump is the release to do it in.

  writeBool(key, Boolean)   -> writeBoolean(key, value)
  writeBool(key, Boolean?)  -> writeBoolean(key, value)
  readNullableBool(key)     -> readBoolean(key)
  readBool(key)             -> readBoolean(key, true)

Nothing in this repo referenced them. Documented in the README migration
note, including the trap in the last one: readBool defaulted to true, and
readBoolean(key) alone is the nullable overload, so a mechanical
replace-with that drops the argument changes behaviour silently.

Also fixes the documentation command in CLAUDE.md. Dokka 2.2.0 removed
the V1 dokkaHtml task, so the documented command has been failing since
the Dokka upgrade; dokkaGenerateHtml is the replacement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The proposal has shipped and its content now lives where consumers will
actually look for it: the migration guide in the README and the class
KDoc on both helpers.

The design record isn't lost — the doc, including the Outcome section
recording where the implementation deviated from the proposal and why,
is retrievable from history:

  git show 5c4f1dd:COROUTINE-API-REDESIGN.md

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Fix a misleading line: it said subclasses were fine if they didn't
  "pass a context", but BaseDataStoreHelper(context, "name") does pass a
  context. The thing that matters is not passing a coroutine context.
- Call out that item 2 (caller-cancellable suspend functions) is the only
  change that can alter behaviour without a compile error. The rest are
  removed or retyped symbols that fail the build immediately, so that's
  where a reader's attention should go.
- Add the 2.0.0-beta01 coordinate for testing ahead of the release.
- Note the injectable DataStore as a new capability, since a reader
  skimming only the migration guide would otherwise miss it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@projectdelta6
projectdelta6 changed the base branch from develop to master July 31, 2026 09:55
projectdelta6 and others added 12 commits July 31, 2026 10:58
The file was copied from another project and had drifted from what this
repo actually needs.

- Drop `sdk install maven` / `mvn -v`. This is a pure Gradle project; the
  release build was downloading Maven 3.9.16 every time and never using it.

- Bump the SDKMAN JDK from 17 to 21.0.12, matching .github/workflows/ci.yml.
  CI and the release build were running different JDKs, which meant a green
  CI was not evidence the release would build — JitPack was the only place a
  JDK-specific break could surface, and only at release time.

- Add an explicit `install:` scoped to :PrefsHelper. JitPack's default runs
  `assemble`, which built the :app sample module as well — 96 of 186 tasks
  for something that is never published. Takes the build from ~1m53s to ~12s.
  GROUP and VERSION are JitPack-provided env vars and are passed through, or
  the artifact would publish as "unspecified".

Only :PrefsHelper applies maven-publish, so it is still the single published
module and the consumer coordinate is unchanged.

Verified locally on Corretto 21.0.12 with JitPack's own argument form, and
the command was read off the build log of the 2.0.0-beta01 release rather
than guessed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pin the convenience constructor's DataStore to Dispatchers.IO
------------------------------------------------------------
The convenience constructor was building its store with
`scope = CoroutineScope(dispatcher + SupervisorJob())`, so a caller-supplied
dispatcher also owned DataStore's internal actor. That was a silent change
from the old `preferencesDataStore` delegate, which always used its own IO
scope — and it combined badly with readValueBlocking's plain runBlocking:
a confined dispatcher (Main, single-threaded, a paused test dispatcher)
would deadlock the store against its own blocking read.

The coupling bought nothing. Passing a test dispatcher to the convenience
constructor never made anything deterministic — that is precisely why the
injectable primary constructor exists. Pinned to Dispatchers.IO, restoring
pre-2.0 isolation exactly, and documented so it doesn't get "tidied" back.

Fix the migration guide banner
------------------------------
It claimed only item 2 changes behaviour without a compile error. Item 5
(readValueBlocking dropping its dispatcher hop) is equally silent. Now names
both, while still pointing at item 2 as the one that can corrupt state.

Also
----
- Document that an injected scope does NOT own DataStore's internal actor,
  so a CoroutineExceptionHandler on it won't see DataStore's own failures.
- Add write-cancellation tests. The 2.0 claim covers clearPrefs *and* the
  suspending writes, but only clearPrefs was tested. Mutation-checked:
  restoring the foreign Job on the nullable writeValue overload fails
  exactly the new guard.
- README: use a non-precreated unique path in the DataStore test example,
  matching the injection test. The old `newFile` form does actually work
  (verified), but it depends on non-contractual DataStore internals and
  contradicted our own test.
- CLAUDE.md: two test classes -> three, named.
- jitpack.yml: correct the timing claim in the comment. The "~12s" was a
  warm local build; on JitPack wall time is dominated by the Gradle
  distribution download. The real, measured win is 158 tasks -> 31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DataStore's produceFile is contractually required to yield the same file on
every invocation. Both the injection test and the README example called a
counter-incrementing helper from inside the lambda, so a second invocation
would have handed DataStore a different path — a worse failure than the
tempFolder.newFile form this replaced, which would at least have thrown.

Current DataStore resolves produceFile once via a lazy, so neither form
misbehaves today. This is about not depending on that, and about the README
being a safe thing to copy.

Also renames the region the write-cancellation tests live in; they had been
added under "region 4 — clearPrefs() is caller-cancellable".

Mutation check re-run after the change: restoring the foreign Job on either
clearPrefs or the nullable writeValue still fails exactly its own guard, so
the rework didn't weaken the tests.

Both raised by Grok's re-review as optional nits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The DataStore usage section still described writes going to "the library's
  internal scope". That scope is injectable as of 2.0, so it now says so and
  links to the section explaining it.
- Install section gains the two facts a consumer needs before adding the
  dependency: minSdk 21, and that datastore-preferences is an api dependency
  so DataStore<Preferences> is visible without declaring it. Plus a pointer
  to the migration guide, since 2.x is breaking.
- Make the NonCancellable logout snippet valid Kotlin rather than a bare
  ellipsis inside braces, matching the comment style used in the other
  snippets.

All three internal anchors verified to resolve against the heading slugs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BasePrefsHelper gains Double; BaseDataStoreHelper gains Date. Both were
previously supported by only one of the two, which made the "supported
types" story awkward to explain and forced consumers to pick a backend
partly on type coverage.

SharedPreferences has no double primitive, so BasePrefsHelper stores a
Double as its raw IEEE-754 bit pattern via putLong, read back with
Double.fromBits. This round-trips every value exactly, including the
denormals and infinities a Float-based encoding would mangle. The one
sharp edge is documented on setDouble: reading that key with getLong
returns the bit pattern, not the number.

BaseDataStoreHelper stores a Date as epoch millis in a longPreferencesKey
and removes the key on null, matching how it already handles its other
temporal types rather than importing BasePrefsHelper's -1L sentinel. That
means DataStore can represent Date(-1L) distinctly from absent, where
SharedPreferences cannot — documented rather than papered over.

Both additions get the full surface: read/write, async write, Flow
accessors and blocking reads on the DataStore side, plus non-null and
nullable *Pref delegates on both.

Tests: 183 -> 199, all passing, coverage gate holding. Date coverage
includes the epoch and pre-epoch boundaries, which is exactly where a
sentinel-based scheme would have failed.

Docs: README "Supported types" now states the shared list plus a table of
the storage differences that genuinely remain between the backends; same
in CLAUDE.md. Also drops the beta coordinate from the README, since those
builds won't outlive the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Float and Set<String> were the real gaps: both are first-class on
SharedPreferences AND DataStore, so their absence was an oversight rather
than a design decision. ByteArray, Instant and Set<Enum> round out the set.

Backend-specific encodings, all documented at the call site:
- Float and Set<String> are native on both backends.
- ByteArray is native on DataStore; Base64 (NO_WRAP) in a String on
  SharedPreferences, which has no binary type. Undecodable data logs and
  returns null instead of throwing.
- Instant is epoch millis: null removes the key on DataStore, and uses the
  established -1L sentinel on SharedPreferences alongside the other
  temporal types.
- Set<Enum> is a set of Enum.name on both. Names that no longer match a
  constant are dropped on read, so deleting an enum value doesn't break
  existing installs.

Set<String> copies on both read and write for SharedPreferences. The
platform documents getStringSet's result as one callers must not modify,
and keeps a reference to the set it is handed on write — both directions
are traps, so both are copied. Tested from both sides.

enumSetPref uses the same thin-inline + @PublishedApi-internal split as
enumPref, for the IllegalAccessError gotcha in CLAUDE.md. This is verified,
not assumed: collapsing the split so the inline function returns the
anonymous object directly makes exactly the three enumSetPref delegate
tests fail with IllegalAccessError, and nothing else. Noted in CLAUDE.md
that only a subclass-in-another-module delegate test catches this.

New BasePrefsHelperRealPrefsTest covers what mocks structurally cannot: the
Double bit encoding, Base64, and the Set<String> copies against a real
Robolectric SharedPreferences. That also closes the gap flagged when Double
was added — its round-trip was previously only proven by composition.

Tests 199 -> 298, all passing, coverage gate holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ning

Null now means the same thing everywhere: assigning null removes the key,
an absent key reads back as null. BasePrefsHelper's -1L temporal sentinel
is gone.

The sentinel was not just an inconsistency, it was a live defect. Epoch
day -1 is 1969-12-31, so a LocalDate of that day was silently unstorable
and read back as null. Same for Date/Instant at 1ms before the epoch.
LocalTime was unaffected in practice, since -1 was never in its valid
0..86399 range — but it would now throw rather than misread, so
getLocalTime treats out-of-range values as null.

This changes how existing data reads, which is the sharp edge. A key left
at -1L by 1.x is no longer "no value", it is 1969-12-31.
migrateLegacyTemporalSentinels(vararg keys) sweeps those keys once and is
safe to re-run; documented as migration item 8, and the "silent changes"
banner now lists it as the only one that touches stored data.

Also adds migrateIfNeeded(currentVersion) { from -> }, a small
schema-version stamp so migrations that are NOT safe to repeat ("stored
seconds are millis now") can be written directly. The subtle part is
bootstrapping: an unstamped file is a pre-versioning install if it holds
anything, or a fresh install if empty. Doing this now matters — once 2.0
ships without a stamp, 1.x and 2.0 installs would both lack the key and
need different treatment, so the ambiguity only gets worse with time.
Downgrades never rewind the stamp.

BaseDataStoreHelper deliberately gets no equivalent. DataStore's own
DataMigration already tracks whether it has run and applies atomically
before the first read, which beats stamping a version afterwards; the
convenience constructor now takes a migrations list instead. Noted in
CLAUDE.md so nobody adds a parallel scheme for symmetry's sake.

Tests 298 -> 311. The existing temporal tests encoded the old semantics
and were updated; two of them turned out to be asserting that -1L means
"not set", so they were rewritten rather than mechanically converted —
one is now a regression guard proving 1969-12-31 round-trips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The getInstance() double-checked singleton on NormalDataStore was not
vestigial — DataStore still permits only one live instance per file, and
MainActivity built PrefsHelper in onCreate, so without it a rotation would
have constructed a second store and thrown. It was hand-rolled DI.

Registering the helper as a Koin `single` gives the identical guarantee
with none of the code, so getInstance() is gone. NormalPrefs, DevicePrefs
and NormalDataStore are now injected into PrefsHelper.

That also lets the sample demonstrate the two things 2.0 added that had no
runnable example:

- An application-lifetime scope carrying a CoroutineExceptionHandler,
  injected into NormalDataStore. This is the whole point of making `scope`
  injectable: without a handler, a failed fire-and-forget write reaches the
  thread's default handler and takes the process down.
- migrateIfNeeded, in NormalPrefs, sweeping the pre-2.0 -1L sentinel.

Side benefit: MainActivity was passing itself as the Context into
long-lived helpers. They now get the application context.

The library remains DI-agnostic — Koin is a :app dependency only.

New PrefsModuleTest resolves the graph for real, because reference code
that only compiles isn't proven. Mutation-checked: changing the
NormalDataStore registration to `factory` fails exactly
testDataStoreHelperIsASingleton, so the test genuinely pins the rule it
claims to.

Robolectric instantiates the manifest Application for every test, which
would call startKoin repeatedly and throw once the JVM is reused, so
robolectric.properties points it at the stock Application. No test uses
the sample's graph — they all declare their own subclasses.

README gains a "One instance per DataStore file" section covering both
paths: DI registration for containers, and a correct double-checked
singleton with applicationContext for apps without one.

Tests 311 -> 316.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
getEnumSet and the enumSetPref delegate branched on names.isEmpty(), which
conflates two different states: the key being absent, and the key holding
an explicitly stored empty set. Only the first should yield the default.

The consequence was that an empty enum set could not be stored at all
whenever the default was non-empty — writing emptySet() and reading it
back returned the default. It also broke the parity claimed a commit
earlier, since BaseDataStoreHelper reads via
`readStringSetValue(key) ?: default` and therefore distinguishes null from
empty correctly.

Both call sites now check contains(key) first, matching how every other
nullable accessor on this class decides absence.

The gap is a little embarrassing: BasePrefsHelperRealPrefsTest already had
testEmptyStringSetIsDistinctFromAbsent for Set<String> and the same
reasoning simply wasn't applied to Set<Enum>. There are now equivalents on
both helpers, including one on the DataStore side purely as a parity guard
so the two cannot drift again.

Also corrects a KDoc on BaseDataStoreHelper.writeDate that still described
BasePrefsHelper as writing a -1L sentinel — true until the previous commit
removed it.

Found by Grok in review. Tests 316 -> 318.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from Grok's third review, both real.

clearPrefs() cleared KEY_HELPER_VERSION along with user data (score 88).
That is a data-corruption path the sample's own shape sets up: stamp at
v2, logout calls clearPrefs, the helper is a DI singleton so init never
re-runs, the app writes a preference, and the file is now non-empty and
unstamped. The next cold start reads that as a pre-versioning install and
re-runs every migration. Harmless for the idempotent sentinel sweep,
corrupting for exactly the non-idempotent migrations migrateIfNeeded
exists to protect. Worse, the README recommends clearPrefs on logout in
the same guide that introduces versioned migrations, so a consumer
following both pieces of advice would hit it.

The stamp is schema metadata, not user state, so clearPrefs now preserves
it. Editor.clear() is applied before puts in the same edit regardless of
call order, so the restore is atomic rather than a second commit. Both the
unit case and the full logout-then-relaunch sequence are covered, and both
were confirmed failing before the fix.

setEnum(null) wrote "" instead of removing the key (score 82). Reads coped,
but contains(key) stayed true here and false on BaseDataStoreHelper —
directly contradicting 2.0's "null means the same thing on both helpers"
claim. Now removes the key.

Docs corrected where they overclaimed:
- CLAUDE.md still said temporal types "preserve the existing -1L sentinel"
  in one bullet while another said it was gone.
- README claimed every type has both delegate overloads; it does not, and
  the two helpers differ. Replaced with the actual matrix.
- Migration item 8 listed Instant among types 1.x wrote sentinels for.
  Instant is new in 2.0 and never had one.
- Made "list every temporal key" explicit about why the sweep is per-key
  and what a forgotten key looks like, and resolved the contradictory
  advice about whether to leave the call in place.

PrefsModuleTest's facade assertion was half-theatre — it proved PrefsHelper
was a single, not that the container's instances were the ones injected.
It now writes through the facade and reads from the separately-resolved
sub-helper.

Tests 318 -> 321.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README promised consumers need no ProGuard rules, but nothing in the
repo exercised R8 — the sample built with isMinifyEnabled = false, unit
tests never run R8, and Robolectric runs unminified code. The claim was
plausible and untested.

Now:

- The sample's release build is minified. `:app:assembleRelease` produces
  no missing_rules.txt, which is R8 stating nothing needed keeping. That
  alone is the build-time half of the claim.

- app/src/androidTest/R8SurvivalTest.kt round-trips enum, Set<Enum>,
  Double, ByteArray, Set<String> and Date preferences, plus the migration
  machinery, against the minified APK on a real device. 7/7 passing, with
  the sample obfuscated to NormalPrefs -> wn, Theme -> sw, Feature -> hh
  while Theme.DARK still persisted and reloaded as the literal "DARK" —
  the property enum prefs actually depend on.

  Manual by design: `./gradlew :app:connectedAndroidTest -PminifiedTests`.
  Not in CI, which has no device. Without the flag the tests run against
  unminified debug and prove nothing, so the first test asks the runtime
  whether its own class was renamed and fails loudly if not, rather than
  inferring from the build type.

The sample gained Theme/Feature enum preferences so the obfuscated code
under test is production-shaped rather than test-only classes.

Two sets of keep rules were needed, both harness-only and both kept out of
proguard-rules.pro on purpose, since that file staying empty is the whole
signal:
- proguard-rules-androidtest.pro disables shrinking of the test APK.
- proguard-rules-instrumentation.pro is applied to the app ONLY under
  -PminifiedTests. AGP does not duplicate classes already on the app's
  classpath into the test APK, so anything the test uses and the app does
  not gets stripped from both. Uses -keepclassmembers rather than -keep so
  classes are still renamed and the obfuscation guard stays honest.

JVM suite unaffected: 321 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…stion

All four residual items from the re-review, none of which blocked.

- R8SurvivalTest KDoc contradicted itself: said a debug run "will still
  pass" and also that the minify guard fails loudly. The guard does fail,
  so a debug run reports failure. Reworded.
- Nullable enumPref KDoc still said assigning null "clears the stored
  value"; it removes the key as of 2.0. Corrected, with the pre-2.0
  behaviour noted since that is what makes contains() differ.
- Dropped a pointless companion-object init in R8SurvivalTest that called
  InstrumentationRegistry for no effect, and its now-unused import.

The open question was whether android.r8.strictFullModeForKeepRules=false
was propping up the clean R8 result. It is not: with it flipped to true,
:app:assembleRelease still produces no missing_rules.txt and the on-device
suite still passes 7/7 against the minified build. gradle.properties keeps
its original value — changing a project-wide R8 setting is not this
change's business — but the README now states the claim holds either way
rather than hedging with "as configured".

321 JVM tests, 0 failures. Release build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@projectdelta6
projectdelta6 merged commit a5752cb into master Jul 31, 2026
1 check passed
@projectdelta6
projectdelta6 deleted the feature/coroutine-api-redesign branch July 31, 2026 15:06
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.

2 participants