Skip to content

Release 1.10.0 - #118

Merged
projectdelta6 merged 57 commits into
mainfrom
release/2026-09
Sep 22, 2026
Merged

projectdelta6 merged 57 commits into
mainfrom
release/2026-09

Conversation

@projectdelta6

@projectdelta6 projectdelta6 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Note

Most of this does not need reviewing again. This is an integration PR — the code arrived through three feature PRs that were already reviewed, and the diff is large only because it aggregates them.

Content Reviewed in Outcome
SegmentedControl nullable selection + enabled #117 approved by @jakeeilbeck; his animation-latch finding fixed in 27fbda4
BarcodeScanner + BarcodeScanner-Camera #116 reviewed by @jakeeilbeck — "in great shape"; his publish-script question answered in thread with an A/B test, no code change needed
Scan policy, dwell, scan regions, overlay scope #119 approved by @jakeeilbeck; his four findings fixed in dc4a078 and d50391c, and the non-blocking nit he raised alongside the approval in 983455f
Dependency bumps, Nav3 docs, local-publish scripts already on release/2026-09 before any of them merged there previously

Three commits carry review surface that is new here:

Commit What Why it is listed
5b6510d CONTRIBUTING.md Central file count 508 → 548, plus the formula Never went through a feature PR
983455f Front-lens corner winding fix + DetectedBarcode.corners KDoc contract Landed 10 minutes after the #119 approval, so no reviewer has seen it
d50391c One test comment corrected Also post-approval, but it fixes the exact comment @jakeeilbeck flagged in that review

983455f is the one worth a look: it changes the order of the corners an overlay draws, and edits the KDoc contract on a public property. It was verified by test rather than by a second pair of eyes.

Worth a second look only if you want it: #116 was merged on a COMMENTED review rather than an APPROVED one. Jake's concern there was resolved in-thread with evidence, but no formal approval was recorded, so if you would rather that be explicit, this PR is the place.

Releases 1.10.0: two new published modules, a scan-policy API on top of them, two SegmentedControl API additions, and the accumulated dependency bumps since 1.9.1.

Merging this does not publish anything. Releases are run manually and locally — ./scripts/publish.sh — and a version tag publishes nothing on its own. See Releasing.

What's in it

New modules — BarcodeScanner, BarcodeScanner-Camera

One implementation replacing six different barcode approaches across our Android portfolio, one of which (Google Mobile Vision) has been deprecated since 2021 and is on Google's removal list — it will stop working with no change on our side.

Split by dependency weight, not by layer:

  • :barcodescanner — ScannedBarcode / BarcodeFormat / BarcodeFormats plus OneShotBarcodeScanner over the Play services hosted scanner. No CameraX, no CAMERA permission, no bundled model: ~140 KB net for a consumer that already ships play-services-base.
  • :barcodescanner-camera — continuous in-app scanning for Compose; api-depends on the above, so it comes with one-shot for free.

App code never imports com.google.mlkit.*.

Scan policy — dwell, regions and a live overlay scope

The continuous scanner's defaults are deliberately not "report everything immediately". A scanner that fires at whatever drifts through frame reads as broken to the person holding it, and the usual complaint is that it grabbed a code they were not aiming at.

  • ScanPolicy gathers the behaviour into one value: dwell (how long a code must be held), missTolerance, debounceWindow, mode and region. It compares by value, which is load-bearing — the camera holds its tracking state in remember(policy), and a policy that did not compare equal would rebuild that state every recomposition and never finish a dwell.
  • ScanMode.Single locks onto the code nearest the region centre and ignores the rest until it has gone. That is the case that matters on a label carrying both a 1D tracking code and a QR: picking whichever the detector listed first gets it wrong about half the time.
  • ScanRegion decides what counts, against the preview the user actually sees rather than the wider image the analyser gets.
  • ScannerOverlayScope hands an overlay the live detections, each with bounds, corners and dwellProgress, so an app can draw its own viewfinder. AnimatedScanFrame is the built-in one.

One presentation is one result: a held barcode reports once, however long it is held, and has to be genuinely absent before it can report again.

SegmentedControl — nullable selection and enabled

Both requested by the FormolyEngine Compose renderer, which could not use the control at all without them:

  • selectedSegment is now nullable — "nothing selected yet", for a form question the user has not answered. The alternative was defaulting to the first segment, which renders a required unanswered field as though it had been answered.
  • enabled = false for submitted or locked forms. Input, semantics and visual treatment, because a greyed-out control that still accepts taps silently changes the answer.

Build

  • Metaspace raised so Dokka stops exhausting it mid-publish
  • Kotlin 2.4.20, AGP 9.4.1, Compose BOM 2026.09.00, Room 2.8.5, Navigation 3 1.2.0-rc01
  • play-services-base 18.10.1 → 18.11.0 (see Compatibility)
  • Local-publish scripts and run configurations

Compatibility

1.10.0 is minor, not patch, and deliberately so. Adding a parameter to a @Composable changes its JVM signature and generated $default bridge, so previously compiled callers of SegmentedControl will not link against it. Source-compatible for named arguments, and for positional ones up to modifier. Consumers recompile — normal for a Compose library, but not a patch.

The nullability widening on selectedSegment is both source- and binary-compatible on its own; it is the enabled parameter that forces the minor bump.

The scanner modules require minSdk 24

play-services-base 18.11.0 raises its own AAR from minSdkVersion 23 to 24, so :barcodescanner and :barcodescanner-camera now declare 24. An app below that fails the manifest merge at build time.

This breaks nobody — both modules are new in 1.10.0, so 24 is their starting floor rather than a change to one. It is called out because it is the most likely thing to surprise someone adopting them. Worth knowing the declared floor was wrong before this: the modules said 21 while 18.10.1 already required 23, so a consumer at 21 or 22 would have failed regardless.

Every module README now states its own minSdk, and the root README carries a table of all of them, since the floors range from 21 to 26 and were previously discoverable only by reading buildSrc or by hitting a merge failure.

Verification

  • koverVerify green; :app:verifyConsumerKeepRules green — 36 consumer-rule-protected classes survived R8, 3 Nav3Screen names preserved unrenamed
  • Two real consumer integrations, not just unit tests:
    • Boiler Benchmark migrated both apps onto the scanner — play-services-vision is gone from that monorepo along with 2,432 lines of vendored Mobile Vision sample
    • FormolyEngine ran its contract suite against SegmentedControl: 36/36 on the affected field type, 379/379 overall
  • On-device: barcode decode end-to-end and fresh-device module install on real hardware; SegmentedControl's thumb behaviour hand-verified, since no UI test can distinguish snapping from animating

Six defects were found by those integrations and by review, every one on a path this repo's own tests structurally cannot reach — three in the scanner (main-thread unbind, unreachable Cancelled, a first-run install race), one API gap, one animation latch caught by @jakeeilbeck in #117, and one in #119 where two decodes sharing a rawValue in a single frame both reported, breaking the "at most once per track" contract. All fixed, all with regression tests verified to fail against the old behaviour.

A seventh, cosmetic and front-lens only, came from the same #119 review: mirroring reversed the corner winding, so the animated outline could bow-tie mid-spring. Fixed in 983455f.

That review also found three places where a comment or KDoc contradicted working code — including a test comment that would have invited someone to "correct" a correct assertion. Documentation fixes, but the kind that prevents a future bug rather than fixing a current one.

Release notes for whoever runs the publish

  • Run ./scripts/publish.sh --dry-run first. It needs the signing key even in dry-run mode, by design — it exists to fail before the immutable step does.
  • This is September's only release. At 27 Android modules a deployment is 548 files against a 1,000/month Central cap, so a second would be ~1,096 and cannot fit. CONTRIBUTING.md was corrected in this PR — it still said 508, measured before the two new modules. feat(BarcodeScanner-Camera): dwell, scan regions and a configurable scan policy #119 added no new module, so the count is unchanged.
  • Back-merge to develop afterwards, per the branching strategy. develop is currently well behind.
  • Worth a canary against a consumer exercising BaseRepo/S3/paging — the integrations above hammered the new code but touch none of that.
  • Check the canary app's minSdk is at least 24 if it pulls the scanner modules, or the merge failure will look like a release problem rather than an expected floor.

🤖 Generated with Claude Code

projectdelta6 and others added 30 commits September 9, 2026 12:08
Central enforces per-month file count, release size and release count from
1 October 2026. One toolbox release is 508 files — about half the monthly file
allowance — because 26 modules each carry a full jar/sources/javadoc/pom/module
set with signatures and checksums. That, not release count, is our binding
constraint: a second release in the same calendar month barely fits and a third
cannot.

Two things worth writing down before someone reaches the wrong conclusion under
time pressure. Same-month point releases need batching, since an August-style
1.8.0 -> 1.8.3 flurry would be over twice the allowance. And splitting the
toolbox into separately-published repositories to shrink our footprint would do
the opposite: Central scores a multi-module bundle as one release event, so 26
repositories would be 26 events per version, past the limit of 7 immediately.

Cross-referenced from "Why one version for all modules", which recommended
splitting a module out without noting that cost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draft PRs cannot be merged, so the tests-and-coverage job now skips them rather
than spending runner minutes. On its own that guard would silently strand any PR
opened as a draft: `ready_for_review` is not in the default event type set, so
marking such a PR ready fired no event at all and CI would never report on it.
Adding the type alongside the guard is what makes the pairing safe.

The `event_name` check keeps pushes to main running — on push there is no
`event.pull_request`, and a null never equals false in GitHub expressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sonatype granted uk.co.appoly.droid an OSS exemption, so the commercial-nature
classification — which applies regardless of publishing volume and would
otherwise require Publisher Pro — does not apply to us.

The same response declined to raise the file-count ceiling in substance. It was
framed as "enhanced monthly publishing limits" of 7 releases / 80 MB / 1000
files, which is exactly what the Usage Center already showed before the request,
sized to a publishing history of a single release. So the earlier note's numbers
came from the published defaults rather than our real limits: the file ceiling is
1,000, not ~1,167, and the one-release-per-calendar-month conclusion stands.

Recorded because that reply reads as a win on both counts and is easy to
mistake for headroom we do not have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A patch-level move inside the same 2.4 language version, so the metadata version
is unchanged and no consumer that resolves 1.9.0 today is affected. The
consumer-visible kotlin-stdlib floor moves to 2.4.20, which the dependency graph
was already forcing up from 2.1.21 and 2.2.21 transitives.

Nothing else needed pinning: the Compose compiler and serialization plugins are
version.ref'd to `kotlin` and moved in lockstep, and KSP2's versioning is
decoupled from Kotlin, so the 2.3.11 pin still applies across the ten modules
that use it plus Room in :app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Version-only for production code. Every breaking change in beta01 and alpha07 is
in the deep-link API — DeepLinkRequest.extras becoming RequestExtras, the factory
function removals, DeepLinkMatcher gaining a type parameter — and this module
imports no DeepLink* symbol.

Two beta01 items did reach us. The new lint requiring Scene implementations to be
data classes or implement equals/hashCode is already satisfied: TabsScene
implements both explicitly. The contentKey change is the reason for the test edit
below.

NavEntry.contentKey now defaults to a composite of `key.toString()` and
`key::class.toString()`, so the assertion pinning it to DetailScreen(5).toString()
failed. Dropped rather than updated to the new format: the preceding assertion
already compares contentKey to contentKey, and the backStack assertion above pins
that the entry is DetailScreen(5), so identity stays covered without re-arming the
same trap on the next release. Asserting on NavEntry.key instead is not an option
— it is private in beta01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Navigator API examples told consumers to wrap the host in a BackHandler that
pops when `canPop` and otherwise switches tab. That duplicates
`TabsNav3Navigator.pop()`, which `Nav3TabsHost` already wires into
`NavDisplay.onBack`, and it intercepts the gesture before NavDisplay sees it, so
`predictivePopTransitionSpec` never scrubs — losing the native predictive back
this module exists to provide. It also hardcoded HomeTab where `startTab` is
configurable and may sit mid-strip.

Replaced with a "System back" section documenting the built-in path. Exiting the
app needs no handler either: at the start-tab root `canPop` is false and Nav3
disables its back callback, so back falls through to the Activity even though
retained tabs keep `backStack.size > 1` — asserted by
Nav3PredictiveBackDeviceTest. Genuine per-screen interception now points at
NavigationBackHandler from androidx.navigationevent, which arrives transitively
via navigation3-ui, shares NavDisplay's dispatcher, and unlike BackHandler
exposes gesture progress and cancellation.

Also documents the API 33-35 `enableOnBackInvokedCallback` opt-in. It defaults
true only on API 36+, and this module's minSdk is 23, so consumers below 36 were
silently getting commit-only pops from a module whose headline feature is
predictive back. Nothing in the repo mentioned it.

The tabs docs are reframed so the per-tab stacks read as the source of truth and
`backStack` as the derived projection NavDisplay renders from — which is what the
code does, `tabStacks` being what every push/pop mutates. Leading with "flattened
into a single backStack" invited the reading that the flat list is the model. A
new "Why one NavDisplay" section records why one display rather than one per tab:
Nav3 ties all per-entry state to back-stack membership via
NavEntryDecorator.onPop and has no retained-but-off-stack concept, so a single
display is what makes cross-tab retention possible at all, and it keeps
predictive back working across a tab boundary (predictive back being
per-NavDisplay) while avoiding a dispatcher per tab.

Stale navigation3 version in Requirements corrected alpha07 -> beta01;
UpdateReadmeVersions has no nav3 pattern, and that line is prose rather than a
dependency block, so it does not self-heal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bug fixes only. A full source diff of both artifacts between beta01 and rc01
turns up exactly two changed files, and no public API change at all — expected,
since rc freezes the API.

NavEntry's `defaultContentKey` moved from `Pair("$key", "${key::class}")` to
`"$key:${key::class}"`. It is @PublishedApi internal but used as a default
constructor argument, so it compiles into NavEntry's own synthetic rather than
inlining into consumers, and there is no binary-compatibility exposure for a
library that ships against one version while its consumers compile against
another. TabsSceneStrategyTest is unaffected: 9ab405b already rewrote those
assertions to compare contentKey to contentKey precisely because beta01 churned
this field once before, so the trap was disarmed ahead of time.

UriDeepLinkMatcher gained duplicate-placeholder validation and a ParsedPattern
refactor. Unreachable here — this module imports no DeepLink* symbol, deep links
being a seeded start stack rather than a URI-pattern framework.

Verified on rc01: the module's 120 JVM tests, koverVerify, and
:app:verifyConsumerKeepRules, plus the full 14-test on-device suite, which is the
part that matters — it covers real predictive-back gestures and Activity
recreation, and `aFreshLaunchDoesNotInheritThePreviousActivitysTabViewModels`
exercises exactly the contentKey identity behaviour rc01 changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Compose patch release, not a feature one. Diffing the two BOM POMs
artifact-by-artifact (248 entries each), every version that moves goes 1.12.0 ->
1.12.1 across animation, foundation, material, runtime and ui. Nothing else
changes, and material3 does not move at all.

No API changes to absorb, so this is version-only across the ten modules that
apply the platform. The fixes do land where this repo lives, though —
runtime-saveable and foundation back ComposeExtensions' serialization-safe
MutableState holders, SegmentedControl's drag gestures, and the lazy-list paging
extensions.

Verified with the full test task across all modules, koverVerify, and
:app:verifyConsumerKeepRules. Also re-ran Nav3Navigation's on-device suite:
animation and foundation both moved, and predictive-back scrubbing plus
rememberSaveable restore across recreation are exactly what they drive. 14/14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A patch bump with no API change, affecting DateHelperUtil-Room's type converters
and S3Uploader-Multipart's upload-state database.

The thing worth checking was the exported schemas, since S3Uploader-Multipart
writes them to a checked-in schemas/ directory and a codegen change there would
mean a migration problem rather than a build problem. Room 2.8.5 regenerates them
byte-identically: both v1 and v2 keep the same formatVersion and the same
identityHash (9fad5f76... and 2b09eace...), so nothing needed re-checking in and
no migration is implied.

The DateHelperUtil-Room README change is UpdateReadmeVersions syncing the Room
coordinates in its install block during the build, not a hand edit.

Verified with the full test task across all modules, koverVerify, and
:app:verifyConsumerKeepRules. Worth noting the gap this exposed rather than
leaving it implicit: S3Uploader-Multipart has no androidTest source set at all,
despite androidTestImplementation(room.testing) and schemas wired into androidTest
assets, so a @database(version = 2) with two exported schemas carries no automated
migration coverage. Harmless here because the schemas are unchanged, but the next
entity change walks into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split [versions], [libraries] and [plugins] into three tiers so it is obvious
which bumps consumers can see:

- PUBLISHED: on a consumer's classpath (api/implementation in a library module,
  or baked into the AAR by a code generator). Annotated the `api`-exposed ones
  (FlexiLogger, sandwich, Navigation 3) since a bump there is a breaking-change
  candidate rather than a routine build tweak.
- BUILD/TEST: test and androidTest configurations of the library modules, plus
  the toolchain. Only CI can break.
- DEMO APP: referenced solely by :app.

No entries added, removed or re-versioned in the regroup - activityCompose moves
to BUILD/TEST, where it belongs: it is the demo app plus one androidTest
dependency in Nav3Navigation, not a published dependency.

Also drop the `kover` version and plugin alias. A settings plugins block is
resolved before the version catalog exists, so settings.gradle.kts could never
have read libs.plugins.kover - it hardcodes the version and always has. With no
Renovate or Dependabot on this repo, the entry was two lines that could silently
disagree with the real version. settings.gradle.kts is now the single source of
truth and its comment says why.

Remove the commented-out testImplementation(libs.paging.common) from the two
Lazy*PagingExtensions modules; the live usages in :app and BaseRepo-Paging are
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README version references are synced by the UpdateReadmeVersions task during
Gradle sync, so they move with the bump rather than being hand-edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Testing a change before a release had one path — publish.sh --local — which
signs, so it waits on a 1Password unlock for a signature nothing local ever
verifies: Gradle does not check signatures on resolve. That makes the everyday
iteration loop cost a vault unlock for no benefit, and there was no way to undo
an install short of deleting from ~/.m2 by hand.

publish-local.sh installs unsigned and needs no credentials, and takes module
names to publish a subset while iterating on one module. --signed delegates to
publish.sh --local for the cases where the artifact set itself is under test.

clear-local-publish.sh removes the install again, optionally for one version.
It lists what it will delete and confirms first, and only ever touches the
toolbox's own group directory — derived from PUBLISH_GROUP, so a fork clears
its own coordinates rather than ours.

Both are also shared Android Studio run configurations under .run/, running in
the Run window's terminal so the confirmation prompt works there.

CONTRIBUTING.md gains the consuming-project half, which was missing entirely:
where mavenLocal() goes, why the first resolve needs --refresh-dependencies in
both directions, and the escape from version shadowing — a TOOLBOX_VERSION that
cannot exist on Central, so no version string means two different things.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two sections each told half the story. "System back" says stop wrapping the host
in a BackHandler; "Results" says popWithResult hands a value to the screen
underneath. Neither says what happens to a screen doing both — and the answer is
that system back routes through NavDisplay's onBack to a plain pop(), so the
result is dropped. The back arrow keeps working, the gesture silently stops
signalling, and nothing errors. A consumer migrating off BackHandler hits this
immediately and has no documented landing place.

Documents the host-onBack dispatch: an app-side interface read via
navigator.lastItem, so NavDisplay still owns the gesture and the predictive pop
transition still scrubs. Notes the navigator must be hoisted with
rememberBackStackNav3Navigator, since onBack is built at the call site where
LocalNav3Navigator is still the outer navigator rather than the one the host
provides.

Also warns off the obvious wrong fix — an always-enabled NavigationBackHandler —
which intercepts ahead of NavDisplay and loses predictive back, the same
regression 2bf651e removed from the tabs examples. That API is for conditional
interception, not for carrying a payload out of an unconditional pop.

Records why this stays app-side rather than becoming a host default or a
Nav3Screen.onPopResult hook: an always-popWithResult(null) default would deliver
null to receivers that only wanted explicit results, and the hook is this
interface with the library guessing the contract instead of the app declaring
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the release-candidate suffix now the branch is the 1.9.1 release. README
version references follow via the UpdateReadmeVersions sync.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two new published modules (BarcodeScanner, BarcodeScanner-Camera) land on this
branch, which is a minor bump rather than a patch. Beta first: the camera module
is the one that wants proving on real hardware across a few consumers before a
stable tag.

The 27 README changes are the UpdateReadmeVersions task doing its job on sync —
no hand edits.

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

Our Android portfolio scans barcodes via six different approaches, one of which
is Google Mobile Vision — deprecated since 2021 and on Play services' removal
list, so it will stop working with no change on our side. This is the one
implementation to migrate them onto, split in two so a consumer pays only for
what it uses.

:BarcodeScanner — ScannedBarcode/BarcodeFormat plus OneShotBarcodeScanner over
the Play services hosted scanner. No CameraX, no CAMERA permission, no bundled
model; barcode-scanning-common is api-scoped for the FORMAT_* constants only.
OneShotScanResult.Unavailable makes the Huawei/stripped-ROM case impossible to
forget, which no consumer currently handles.

:BarcodeScanner-Camera — the continuous scanner, seeded from an existing
in-house CameraX + ML Kit implementation. The analyzer holds each ImageProxy
open until process() completes and closes it in the completion listener; closing
early is why the common wrapper libraries only decode 1D formats by winning a
thread race. Teardown clears the analyzer and unbinds before the detector
closes, with close() queued onto the analysis thread so it lands after any
in-flight analyze().

Deliberately no camera-view, camera-video or camera-mlkit-vision:
CameraXViewfinder replaces PreviewView, and MlKitAnalyzer would pull the other
two in to replace a fifteen-line class.

Two deliberate API choices:
- toScannedBarcode() is public, not internal — the camera module consumes it
  across a module boundary.
- It returns ScannedBarcode? — ML Kit's rawValue is nullable, and a blank
  payload is not a barcode worth reporting.

The manifest's ML Kit DEPENDENCIES meta-data was verified absent from
play-services-mlkit-barcode-scanning's own manifest before being added here. Its
<application> element carries no attributes, only the meta-data child, so it
merges without a tools:replace.

Demo screen and tests follow; coverage is currently 76.94% against a 76% gate.

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

Unit tests (20, JVM): BarcodeFormat's ML Kit mapping in both directions plus
spot-checks against the constants directly — a round-trip alone passes even when
a constant is wired to the wrong entry, as long as it is wired consistently. The
empty/Unknown-only format set is covered because falling through to no formats
at all builds a scanner that decodes nothing, silently. BarcodeDebouncer covers
the per-code (not global) guarantee, that suppression does not extend the window,
and that pruning drops only expired entries.

Device suite (3, :BarcodeScanner-Camera): binds without error, survives repeated
mount/unmount cycles, and leaves the camera usable afterwards. Kept beside the
code it covers rather than on :app, and run against real cameras rather than
FakeCameraConfig — the bug class it guards (detector closed under an in-flight
frame) only manifests against a real pipeline, and throws from the analysis
thread rather than reporting through onError. Not CI-runnable; verified on a
Pixel 9 Pro Fold (17) and a OnePlus 6T (11).

It earned its keep immediately: it caught bindToLifecycle/unbind being called off
the main thread. In an app the composition dispatches to main anyway, but
ProcessCameraProvider.awaitInstance resumes on a CameraX executor, so the thread
at that point is whatever the ambient dispatcher decides — and under a Compose
test harness that is not main. Now pinned with Dispatchers.Main.immediate rather
than left to depend on it.

GrantPermissionRule is deliberately not used: it opens a UiAutomation connection
unconditionally and dies with "UiAutomationService ... already registered" on a
device already holding one, even when the permission is granted. The suite
asserts the grant instead, with the fixing adb command in the message.

Demo screen wires both modules into :app — one-shot with warmUp(), and the
continuous scanner in a ModalBottomSheet, which is the sheet-lifecycle case worth
demonstrating.

Aggregate coverage 76.94% -> 77.79% (gate 76%).

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

Found by running the demo app on a Pixel 9 Pro Fold: tapping the Play services
scanner's close button returned to the screen with no result at all, where it
should have shown "Cancelled".

kotlinx-coroutines-play-services maps a *cancelled* Task to a
CancellationException, and Play services cancels the Task when the user backs
out of the scanner UI. Rethrowing it — the reflexive "never swallow
CancellationException" — cancelled the calling coroutine before it could assign
a result, which made OneShotScanResult.Cancelled unreachable on that path. The
sealed class looked exhaustive and the compiler had nothing to say.

Both cases arrive as the same exception type, so they are separated by asking
whether the *caller* is still active: ensureActive() throws only when our own
coroutine was cancelled, and returning normally means the user cancelled. The
same reflex was in warmUp(), where a cancelled install Task would have cancelled
whoever called it; both now share awaitUserCancellation().

Extracted rather than inlined so the distinction is unit-testable — getting it
backwards produces unreachable code that nothing warns about. The regression test
was verified to fail against the old behaviour, not just pass against the new.

Re-checked on device: "Cancelled" now renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported from a consuming app's first integration, with a logcat timeline that
made the cause unambiguous: on a device that had never used the hosted scanner,
the first scan failed with MlKitException INTERNAL (13) and Play services logged
"No registered Chimera impl for BarcodeScanningActivityProxy". The second attempt
succeeded with no code change. The module finished registering 1.2s AFTER the
failure.

installModules().await() resolves when Play services *accepts* the request, not
when the download completes. warmUp() therefore returned true while the module
was still downloading, launching the scanner against something that had not
registered yet — so its documented promise was not merely unmet, it was actively
causing the failure it claimed to prevent. Completion is only observable through
an InstallStatusListener on the request, which is what ensureModuleInstalled()
now suspends on until a terminal state.

scan() calls it too, rather than trusting callers to have warmed up. The failure
mode here is a generic INTERNAL error indistinguishable from a real scan failure,
which an app cannot sensibly retry on, so leaving correctness to an optional call
was the wrong default. warmUp() is now purely an optimisation that moves the cost
earlier; skipping it costs latency, never correctness.

No timeout: a slow download is still a legitimate install, and callers who cannot
wait can use withTimeout, which cancels cleanly through the listener.

Not unit-testable without a GMS test double, and not reproducible on either
device here since both have the module installed — verification needs a device
that has never used the hosted scanner.

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

Play services reports INTERNAL (13) for unrelated problems — a scanner module
that has not registered yet, and a camera delivering no frames, both surfaced
during this module's first integration. Two separate investigations were
misdirected by reading meaning into that code, one of them nearly filing a
working fix as broken.

The data was never lost — Failed carries the throwable — so this is a
documentation problem rather than an API one. Says so on OneShotScanResult.Failed
and in the README: Unavailable is the only result with a reliable meaning and the
only one worth making product decisions from.

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

Second report from the consuming app's first integration, on a genuinely fresh
Samsung A50: the module install now completes correctly, but Play services
enables the scanner activity's components ~600ms AFTER the install reports
complete. startScan() in that window fails with CODE_SCANNER_UNAVAILABLE (200,
verified against the constant), which mapped straight to Unavailable.

That made the previous fix a regression in kind rather than a clean improvement.
Unavailable is documented as the one result carrying a reliable meaning and the
branch apps hang product decisions on — in the reporting app it renders "Barcode
scanning isn't available on this device. Please enter the details manually."
A first-time user on a capable phone was being told their phone cannot scan. The
old Failed(INTERNAL) at least meant "try again", which was true.

Two changes, because there are two problems:

1. Classification no longer trusts the error code. Play services reports
   CODE_SCANNER_UNAVAILABLE both for a device that can never scan and for a
   capable one mid-enablement, so the code cannot distinguish them. Ask Play
   services about itself instead — GoogleApiAvailability SUCCESS or
   SERVICE_UPDATING means whatever went wrong is not a property of the device,
   so it reports Failed. Only missing/disabled/invalid/too-old yields
   Unavailable. VERSION_TOO_OLD stays Unavailable directly, being definitive.

2. startScan() retries up to 4 times, 400ms apart, and only on
   CODE_SCANNER_UNAVAILABLE. There is no API that reports component readiness,
   so retrying is not papering over a race we could otherwise win — it is the
   only signal available. Scoping it to the one code meaning "the scanner did
   not start" keeps a user who is looking at the scanner UI from having it
   reopened underneath them, which a retry on the ambiguous INTERNAL would do.

Unavailable now carries a guarantee it did not before: it is checked against Play
services' availability, never inferred from a scanner error. Documented on the
result and in the README.

Still unverified here: both devices on this machine have the module installed, so
neither can reach this path.

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

Both from the second consumer integration.

displayValue?.let { … } is a latent scan-dropper. Older ML Kit and Mobile Vision
code reads the display value defensively, but displayValue is null whenever ML
Kit has nothing better than the raw contents — the common case for plain serials
and part numbers. A migrating app was silently discarding every scan that way:
scanner opened, decoded, closed, no value, no error. Documented on the property
and as a migration note, with `displayValue ?: rawValue` for display and rawValue
for matching.

Also pushed back on "we ship through Play, so Unavailable is dead code". Play-only
distribution rules out installing without Play services; it does not rule out Play
services being disabled after the fact, being too old
(SERVICE_VERSION_UPDATE_REQUIRED on a neglected device is the likeliest way this
is seen at all), or enterprise/MDM sideloading. Rare is not impossible, and the
branch now carries a strong enough promise to be worth handling properly rather
than left as a TODO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Requested by FormolyEngine-Android, whose Compose renderer wanted this control
for the form engine's Switch field type but could not use it: the model's value
is genuinely null until the user answers, and the only way to render that was to
pass a sentinel — typically the first segment — which shows a required,
unanswered field as though it had been answered. That is a correctness problem,
not a cosmetic one, so they shipped Material3 FilterChip instead.

selectedSegment is now nullable on all three overloads. A new overload was not
possible: `selectedSegment: T` and `selectedSegment: T?` erase to the same JVM
signature, so they clash. Widening is the better shape anyway — one API rather
than two, source-compatible for every existing caller (non-null still binds
fine), and binary-compatible since only nullability metadata changes.

onSegmentSelected stays non-null. Null is an input state, never an output: the
user can only ever tap a real segment, and clearing is done by passing null back
in.

Three things fell out of the existing design for free: indexOf already returns -1
for an absent value, which is the same NO_SEGMENT_INDEX sentinel, so "not in the
list" and "null" agree; `isSelected = i == selectedSegment` is false for every
segment at -1; and the gesture's `downOnSelected` is false at -1, so tap-to-select
works and drag-to-switch correctly does nothing until there is something to drag.

Two things did not, and needed handling:
- Thumb.pressed compared pressedSegment to selectedSegment, and both are -1 when
  nothing is pressed and nothing is selected, so every unanswered control would
  have rendered its thumb as pressed. Guarded.
- Animating the index to -1 would slide the thumb off the left edge and make the
  first selection fly in from outside the control. It now holds its last real
  position and fades, and the first selection after an empty state snaps before
  fading in, so the thumb appears under the segment the user actually tapped.

Verified on a OnePlus 6T, since neither the fade nor the snap is reachable from a
unit test: null renders no thumb, tapping the rightmost segment puts the thumb
under it without travelling, and clearing returns to no thumb with both dividers
restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second blocker from the FormolyEngine integration. Their contract suite requires
that a submitted or locked form disables every toggle, and with no way to express
that, a user could change an answer on a form that was supposed to be final. That
is a data-integrity problem, not a styling one, so they reverted to FilterChip
rather than ship it.

`enabled: Boolean = true`, following the Material convention and placed after
`modifier` for the same reason. Three things, because dimming alone would leave a
greyed-out control that still silently accepts taps:

- input: the pointer modifier is dropped entirely rather than checked inside the
  gesture, which also stops the press scale/fade animations for free — a disabled
  control that still reacted under the finger would read as interactive
- semantics: every segment reports disabled(), so accessibility services and
  assertIsNotEnabled() agree, and no click action is advertised
- visual: the control dims to SegmentedControlDefaults.DisabledAlpha (0.38f, the
  Material 3 token)

The selection stays visible. Disabled means "you cannot change this", not "this
has no value", so a locked form still shows the answer it holds. It composes with
a null selection for a locked but unanswered question.

One correction worth recording. The first version of this claimed that
withholding the semantics onClick action was what prevented activation. It is
not: leaving the action in place and re-running the tests showed disabled() alone
already blocks it. The action is still withheld, because a locked control should
not advertise a capability it will not honour, but that is a separate and weaker
property — so it now has its own test asserting the node defines no OnClick,
which is the only thing that would catch its loss.

Not source- or binary-compatible, contrary to how the request was framed: adding
a parameter to a @composable changes its JVM signature and the generated $default
bridge, so previously compiled callers break. Source-compatible for named
arguments, and for positional ones only up to `modifier`.

7 new tests. Verified on a OnePlus 6T: the locked control dims, a tap on another
segment does nothing, the answer stays on screen, and the enabled control beside
it is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, not just the first

Review catch by @jakeeilbeck on #117, and a real behaviour bug.

`hasEverBeenSelected` latched: it was set on selection but never cleared when the
selection went back to null, so the empty branch returned early with the flag
still true. Only the very first selection ever snapped. Clear to null and pick a
different segment, and the thumb animated in from wherever it had been parked —
travelling across the control while fading in, which is the exact thing the block
exists to prevent.

My device test walked null -> select -> clear and stopped, one step short of the
bug. No Compose UI test can see the difference between snapping and animating
either, so nothing in the suite would have caught it.

So rather than just resetting the flag, the decision is extracted into
ThumbSelectionTracker and unit-tested. The tracker holds "was there a selection
immediately before this change" — Jake also noted the old name read wrongly once
fixed, and he was right: it was never "ever". Reintroducing the latch now fails
two tests, which was checked rather than assumed.

Re-verified on a OnePlus 6T along the previously-broken path — select rightmost,
clear, select leftmost — capturing immediately after the tap: the thumb is
already under the tapped segment with no travel.

7 tests on the tracker; 21 in the module.

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

`publishToMavenLocal` intermittently failed a `javaDocReleaseGeneration` task on
a different module each run — :ConnectivityMonitor, :LazyGridPagingExtensions,
:BaseRepo-AppolyJson and :PagingExtensions across four runs. It reads as flaky
Dokka; it is not. Gradle says so plainly once the output is read rather than
grepped for FAILED:

    * What went wrong: Metaspace
    The Daemon will expire after the build after running out of JVM Metaspace.
    The currently configured max metaspace is '1 GiB'.

Dokka documents all 26 modules in one daemon, and the Kotlin compiler classes it
loads per module exhaust metaspace partway through. Whichever module is running
when it fills is the one that dies, which is why the name changes every time and
why re-running "fixes" it — the daemon restarts.

Confirmed in both directions with a full `--rerun-tasks` publish on a single
daemon: fails at 1 GiB, passes at 2 GiB.

Applied in two places because one is not enough. `org.gradle.jvmargs` in a user's
~/.gradle/gradle.properties overrides the project's file — which is exactly what
was happening here, so the repo's own settings were inert — and only the command
line outranks it. gradle.properties protects a fresh clone; the publish scripts
pass the same values explicitly so a release cannot depend on whatever a
developer happens to have set locally.

This matters most at `publishAndReleaseToMavenCentral`: Central releases are
immutable and run manually, so a metaspace failure partway through an upload is
the expensive case.

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

Not a release version and not intended to merge. Supersedes -local02, which
predates the thumb-snap fix from review; the suffix is bumped whenever what is
published changes so a consumer can never be unsure which build a coordinate
refers to.

Lets this branch sit in ~/.m2 beside the barcode branch's 1.10.0-beta01 without
either overwriting the other. It must not stay 1.9.1: TOOLBOX_VERSION is global,
so a local publish would write modified code over the real released 1.9.1 in the
local repository.

The 26 README changes are UpdateReadmeVersions on sync. They revert with the
version.

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

feat(SegmentedControl): nullable selection and enabled flag for form UI
Brings in #117 (SegmentedControl nullable selection + `enabled`) and the
metaspace change.

27 conflicts, all of them the version string — 26 generated READMEs and
BuildConfig. Checked rather than assumed: every conflict hunk was diffed with the
two version strings normalised away, and all 30 were identical otherwise, so the
real content from #117 (the new README sections) merged cleanly outside the
markers. Resolved to this branch's version and then regenerated the READMEs with
UpdateReadmeVersions, so they are correct by construction rather than by hand.

Kept 1.10.0-beta01 rather than taking release's 1.10.0-formsupport-local03. That
string is local-testing scaffolding from #117's temporary commit, and Boiler
Benchmark is testing the scanner against 1.10.0-beta01 in mavenLocal — changing
it here would break a coordinate a consumer is actively using. The TEMPORARY
KDoc block came across with the merge and is dropped here for the same reason.

The release branch keeps the temp version for now; the real one is set before the
release PR merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
projectdelta6 and others added 27 commits September 17, 2026 11:17
Correcting my own change in 4e1c20b. I added a GRADLE_JVM_ARGS pin to both
publish scripts on the premise that they were exposed to the metaspace failure.
They were not — 51c3ddb already exported GRADLE_OPTS with the same
MaxMetaspaceSize=2048m, well before any of this, with a comment giving the same
reasoning.

That is also why the symptom only ever appeared when running `./gradlew
publishToMavenLocal` by hand and never through ./scripts/publish-local.sh, which
I should have noticed at the time rather than reading it as the flake being
intermittent.

So the scripts were never the gap. The real one was a plain `./gradlew`
invocation, which is covered by the project gradle.properties (for a fresh
clone) and by the user-level ~/.gradle/gradle.properties. Both stay.

Two mechanisms setting the same value, with near-identical comments explaining
it, is worse than one: the next person has to work out whether the duplication is
load-bearing. Reverted to the pre-existing single mechanism, and
./scripts/publish-local.sh re-run to confirm it still publishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release version, set by Bradley. Minor rather than patch because the release
adds two published modules and changes SegmentedControl's signatures — adding a
parameter to a @composable changes its JVM signature and generated $default
bridge, so previously compiled callers would not link against a patch.

The 27 README changes are UpdateReadmeVersions on sync, not hand edits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: BarcodeScanner and BarcodeScanner-Camera modules
The 508 figure was measured at 1.9.x with 25 Android modules; 1.10.0 adds
BarcodeScanner and BarcodeScanner-Camera, taking it to 548. Left uncorrected it
understates usage against the 1,000-file monthly cap by 40 files, on the one
number release planning actually depends on.

Also records how it is derived, so the next module addition can recompute rather
than trust a stale line: 5 primary files per Android module and 2 for the BOM,
each carrying .asc/.md5/.sha1. That formula reproduces the measured 508 exactly,
which is why the 548 is a projection worth trusting.

Sharpens the consequence too. At 508 a second same-month release was ~1,016 and
merely over; at 548 it is ~1,096 and comfortably impossible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First half of making the continuous scanner stop firing at codes the user never
aimed at — the client complaint behind a sibling app's fix, which this
generalises rather than copies.

ScanPolicy carries mode, dwell, missTolerance, debounceWindow and region as one
parameter rather than five on the composable. Adding a parameter to a
@composable is binary-incompatible, so each future knob would force consumers to
recompile; adding one to a plain class is a retained secondary constructor and
breaks nobody. Not a data class for that same reason — a generated copy() and
componentN break on growth — so equals/hashCode are written by hand. Those are
load-bearing rather than tidiness: the camera holds its tracking state in
remember(policy), and a policy that does not compare equal rebuilds that state
every recomposition, so nothing would ever finish its dwell.

BarcodeTracker replaces the four-mechanisms-in-my-head design with one machine.
Per raw value: firstSeen, lastSeen, reported. Two live states, one exit, and both
timeouts expressed as absence budgets measured from the last sighting —
missTolerance before a code is reported, rearm after. Making them the same kind
of number applied to different states is what removes the interaction between
them.

That redefines debounceWindow, deliberately. It used to mean "time since
reported", so a held code re-fired every window; it now means "how long a
reported code must be absent before it can report again", so one presentation is
one report however long it is held. The old semantics is the reason a code held
for ten seconds that dipped out for one would re-fire.

Deliberately does NOT record when a code was reported. The moment that field
exists someone measures the repeat window from it and the ten-second bug comes
straight back.

Single mode starts a track only when nothing else is in play, so a label carrying
both a 1D code and a QR cannot hand back whichever ML Kit happened to list first.
Ranking is the analyzer's job (nearest region centre first); the tracker only
chooses, and never re-chooses mid-dwell.

13 tests on a TestTimeSource. One of them was initially vacuous — it asserted the
focus guard but stopped before the second code could have dwelled, so it passed
with the guard removed. Caught by deleting the guard and watching nothing fail;
tightened until it does.

BarcodeDebouncer is superseded and removed in the follow-up that wires this in.

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

Wires ScanPolicy and the tracker into the camera, and closes the two gaps a
sibling app's fix left open.

The composable loses debounceWindow and gains scanningEnabled and policy.
scanningEnabled pauses reporting while keeping the camera bound — for holding a
result on screen without the scanner running underneath it, which removing the
composable cannot do without tearing the camera down and flashing the preview.

The analyzer now filters to the acceptance region and ranks what survives by
distance from its centre. Ranking is the fix for "it scanned the wrong code": on
a label carrying both a 1D code and a QR, taking whichever the detector listed
first is arbitrary and wrong about half the time. It also reports EVERY frame,
including empty ones — absence is what expires a track, so a quiet frame is
information rather than a frame to skip.

Preview and analysis are now bound as a UseCaseGroup with a ViewPort. That is
what makes ImageProxy.cropRect mean "what the user can see", without which
ScanRegion.Visible would be a lie and the analyser would keep reading barcodes
from outside the preview entirely.

DefaultScanFrame draws ScannerOverlayScope.regionRect — the same rectangle the
analyser filters against — and dims outside it. The previous frame was decoration
over a whole-frame scan, so it promised something the scanner did not honour;
that claim is now retracted from the README rather than left to mislead. The
overlay receives a sealed scope rather than gaining parameters, so future members
are non-breaking and the animated reticle can land later without a signature
change.

BarcodeDebouncer and its tests are deleted, superseded by the tracker.

Demo app drives every knob live — mode, region, dwell, pause, torch — using
SegmentedControl from this same release. Verified on a OnePlus 6T: preview binds
through the ViewPort without error, the reticle draws at the policy's region with
the scrim outside it, and switching to Full expands the region to the whole
preview and drops the scrim.

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

The reticle that moves to the code and closes around it, which the overlay scope
was designed to make possible without a signature change. It springs to the
tracked barcode's bounds and draws a stroke around its outline as dwellProgress
fills, so the wait before a scan registers is visible rather than mysterious —
that feedback is the point, since a scanner that pauses silently reads as broken
and pushes people toward a shorter dwell than they actually want.

Follows the first detection, which the analyser ranks nearest the region centre,
so the frame shows the code Single mode would lock onto rather than an arbitrary
one. Edges are animated as four floats rather than a Rect, which needs no vector
converter; springs rather than tweens, because a barcode that jitters between
frames looks mechanical under a tween. The idle scrim drops once the frame is
tracking, where it would otherwise dim most of the preview and look like a fault.

The demo gains an overlay picker — Frame, Animated, Custom — and the Custom one
is written in the app rather than the library, deliberately. It draws crosshairs
on the acceptance region and a filling ring on each detection using nothing but
ScannerOverlayScope's regionRect and detections, which is the evidence that the
scope is a usable public contract rather than just enough for the library's own
overlays.

Verified on a OnePlus 6T: the picker switches between all three, the animated
frame renders on the region while idle with its scrim, and the custom overlay
draws crosshairs with no frame or scrim. The detection-driven behaviour — the
frame springing onto a code and the dwell ring filling — needs a barcode in shot
and is not verified here.

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

Haptic confirmation fires on each accepted scan, on by default. The person
scanning is usually looking at the thing they are scanning rather than at the
screen, so the buzz is what tells them it landed — silence is the reason people
scan the same parcel twice. It sits on the accepted-scan path, so it follows
scanningEnabled and the policy for free, and fires before the consumer callback
so it lands with the scan rather than after whatever the app does with it.

Typed as HapticFeedbackType? rather than Boolean so a different feel is a value
change instead of a new parameter — worth the thought now, given a parameter
added after publish is binary-breaking.

The demo gains CornerBracketOverlay, app-side, in the style of Google's hosted
scanner: four unconnected corner brackets at rest that grow along each edge as a
code dwells until they meet and close into a complete frame. dwellProgress is
legible as a shape, with no separate progress indicator.

That makes two app-side overlays as different from each other as either is from
the library's, all built from nothing but regionRect and detections — which is
the evidence that ScannerOverlayScope is a usable public contract rather than
merely sufficient for the overlays that ship with it.

Verified on a OnePlus 6T: four overlay styles in the picker, corner brackets
render unconnected at rest. The closing animation and the haptic both need a
barcode in shot and are unverified here.

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

Reverses the haptic parameter added one commit ago. Bradley pointed out it fails
the same test I had just used to argue sound out of the module, and he is right —
I applied the principle to the thing I had not built and not to the thing I had.

The module knows a barcode was *read*. It cannot know whether it was the right
one: that depends on a manifest, an expected item, a duplicate check. So the
confirm haptic fired before onBarcodeScanned could disagree, and an app doing any
validation got a confirm buzz immediately followed by its own reject buzz for a
single scan. The design actively prevented the correct behaviour rather than
merely failing to help with it.

There was no capability argument for keeping it either. The app has the same
LocalHapticFeedback and knows strictly more; the parameter bought convenience at
the cost of a default that is wrong for exactly the apps most likely to use this,
and that they would have to discover and disable.

Removed rather than defaulted to null: an opt-in knob that is never the right
thing to opt into is just a worse way of writing one line in the callback. Better
to take it out now, while the module is unpublished and removing is free.

The KDoc says why it is absent, so the next person does not read it as an
oversight and add it back.

The demo now does feedback app-side and shows the case that matters: a code
already in the list gets Reject, a new one gets Confirm — standing in for the
real "not on the manifest" check.

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

Two bugs Bradley found on a device, both mine, both in coordinate handling.

THE OUTLINE WAS TOO SMALL AND OFFSET. One cause, two symptoms. Detections were
scaled by the full analyser image, but with a ViewPort the preview shows only
cropRect — so the scale was short by the crop ratio and the crop's origin was
never subtracted. Undersized and shifted toward the top-left is exactly what
those two mistakes look like together. Everything now maps relative to the crop.

THE FRAME IGNORED ROTATION. Two causes. ML Kit's boundingBox is always
axis-aligned, so on a tilted barcode it is the box *around* the code rather than
the code's outline — cornerPoints are the only thing that follows the rotation,
and they were being discarded. DetectedBarcode now carries `corners`, which is
why it was built as a plain class rather than a data class: adding a field is
free.

And the crop was being rotated into ML Kit's space by transposing it, which is a
reflection about the diagonal rather than a rotation. It is indistinguishable
from the real thing while the crop is centred, so it looked correct, and lands
the region on the wrong side of the frame the moment it is not. Replaced with an
actual rotation for 0/90/180/270.

AnimatedScanFrame and the demo's corner brackets now animate four corner points
rather than four edges, so moving, resizing and rotating are one animation — a
rotation is just the corners travelling somewhere else. The brackets grow along
the quad's real edges, which keeps them correct at any angle.

Both transforms are now unit-tested — ten tests, including the off-centre crop
the transpose got wrong and the crop-vs-image scaling that caused the undersized
box. They are pure arithmetic and there was never a reason for a camera to be
what found them.

Those tests initially failed for an unrelated and instructive reason:
android.graphics.Rect is a stub on the plain JVM classpath and, with the
project's isReturnDefaultValues, silently reports every edge as 0. They would
have been meaningless rather than failing had the numbers happened to line up.
Now run under Robolectric, with a note saying why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five versions had drifted from the catalog — Kotlin 2.4.10 -> 2.4.20, AGP
9.3.2 -> 9.4.0, Compose BOM 2026.08.00 -> 2026.09.00, Room 2.8.4 -> 2.8.5 and
Nav3 1.2.0-alpha07 -> 1.2.0-rc01. This file is read at the start of every
session, so a stale version here is worse than one in a README: it is the thing
that gets believed without checking.

BarcodeScanner-Camera's line also predated everything this branch added, so it
now mentions ScanPolicy and the overlay scope rather than describing the module
as it was on day one.

Found by a sweep rather than by noticing — worth repeating occasionally, since
nothing fails when these rot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository contradicted itself. LICENSE and every published POM say GPL-3.0;
the root README embedded the full MIT licence text and ConnectivityMonitor's
README claimed MIT as well. Copyleft and permissive are not a near miss, and this
is a public repository whose artifacts have been on Maven Central since 1.9.0 —
someone could reasonably have relied on the README and been badly wrong about
what they were agreeing to.

GPL-3.0 is correct, confirmed by Bradley.

The root README no longer embeds a licence at all, it points at LICENSE. A second
copy of the terms is a second thing to drift, and drift is exactly what happened
here.

Also fixes ConnectivityMonitor's link, which pointed at LICENSE relative to its
own directory and resolved nowhere. It was left broken in the earlier sweep
deliberately, because repairing a link to a statement that was itself wrong would
only have made the wrong statement easier to reach.

Worth landing before 1.10.0 publishes: releases are immutable, so a POM and a
README disagreeing is not something a later version can retract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The checked-in report was built on 2026-06-09 and had zero mentions of
Nav3Navigation or BarcodeScanner — it did not know two modules existed, one of
them a third of this release. CLAUDE.md points people at it for onboarding, so a
graph that silently omits modules is worse than no graph.

Rebuilt: 4,184 nodes, 9,199 edges, 237 communities. Coverage of the modules it
had missed, before -> after: Nav3Navigation 0 -> 5, BarcodeScanner 0 -> 17,
SegmentedControl 2 -> 14, with ScanPolicy and BarcodeTracker now present. The
report roughly doubled, 521 -> 1073 lines.

CAVEAT worth knowing before trusting the headings: run via `graphify update`,
which re-extracts without an LLM, so the 237 communities are named after their
hub node rather than described. `graphify label` refreshes them properly but
needs a model backend. The structure is accurate; the community *names* are
mechanical.

Only GRAPH_REPORT.md is committed — .gitignore already keeps graph.json,
graph.html, manifest.json and the 2.1M dated backup out, which is why a stale
committed graph is cheap to refresh but an accurate one is not free to store.

Twelve files under LazyGridPagingExtensions hit extractor syntax errors and are
partially represented. Pre-existing, unrelated to this release, and not worth
chasing for an onboarding aid.

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

The last unverified path, and it was broken — which is why it was worth testing
rather than reasoning about.

CameraX mirrors the front camera's preview for display, because you expect to
move left and see yourself move left. The analyser receives the unmirrored
buffer, so ML Kit reports coordinates in the frame the user is not looking at.
Every overlay on the front lens was therefore drawn on the wrong side of the
screen — a code held on the left outlined on the right.

Detections now mirror horizontally when the front lens is bound. Vertical is
untouched: the flip is horizontal only. Bounds are rebuilt from the extremes of
the mapped corners rather than assuming "left" is still left, since mirroring
swaps which edge is which.

The acceptance region needs no mirroring — Full, Visible and Reticle are all
centred, so their rectangles are symmetric about the flip.

The demo had no way to select the lens at all, which is why this went unnoticed;
it now has a Back/Front toggle, and that is what made the test possible.

Verified on a OnePlus 6T with a QR code held in front of the screen: the front
camera binds with no error and the frame lands on the code, on the correct side.
Three unit tests cover the mirror, including that the vertical axis does not flip
and that mirroring is its own inverse.

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

Testing landscape turned up a demo problem rather than a library one: the control
stack is taller than a landscape sheet, so the camera preview sat below the fold
and could not be reached at all. The scanner was untestable in landscape for
layout reasons, not camera ones.

Making it scroll was the obvious fix and the wrong one — it leaves a wide sheet
mostly empty while the thing you are trying to aim with is off-screen. Landscape
is short on height and flush with width, so the knobs now sit beside the preview
instead of above it, and the preview keeps enough size to aim with.

Also drops Material's 640dp sheet cap, which wastes most of a landscape phone
when width is exactly what this layout wants.

Portrait is unchanged. The split is one `landscape` branch over two shared
composable lambdas rather than two copies of the content, so the controls cannot
drift between orientations.

Verified on a OnePlus 6T in forced landscape: sheet spans the display, controls
scroll on the left, preview live on the right with the reticle correctly
proportioned, no bind errors. Device rotation settings restored afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spotted by Bradley reading the diff. It served nothing: an artefact of how the
previous commit was edited, where the old `Column(...) {` opener was swapped for
`run {` to keep the braces balanced while hoisting the state declarations above
the layout branch.

Slightly worse than nothing, in fact — `run {}` takes no receiver, so it silently
discarded the ColumnScope that ModalBottomSheet's content lambda provides.
Nothing inside wanted it, since everything sits in the Row or Column the branch
builds, but a scope thrown away for no reason is a small trap for whoever edits
it next.

The sheet content now sits directly in the content lambda, one indent level
shallower. Behaviour is unchanged, confirmed on device rather than assumed: same
layout, same controls, live preview.

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

The viewfinder fills its bounds at one scale and centre-crops the overflow;
mapToPreview stretched the crop onto the bounds instead. The two agree only
while the crop and the preview share an aspect ratio, which a full-screen
portrait preview does and the landscape demo does not.

Measured on a OnePlus 6T in landscape: crop 360x480 (0.75), preview 1103x775
(1.42). The old transform's vertical scale was 0.527 of its horizontal one, so
every outline came out the right width and about half the height, collapsed
toward the centre of the preview.

The same mismatch hid a second bug. Only the part of the crop that fits the
bounds is on screen -- here 252 of 480 rows, so 47% of the analysed frame was
invisible and still scannable. ScanRegion.Visible now means the rectangle that
is genuinely visible, and a Reticle is measured against that rather than
against the whole crop, which is also what keeps the drawn frame and the
accepted region the same rectangle.

Six tests: three fail on the old transform, and one pins the frame/region
invariant directly.

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

The viewfinder defaults to a SurfaceView wherever the device supports one. That
surface is composited by the system outside the view hierarchy, so in a window
of its own -- a ModalBottomSheet, a Dialog -- it is positioned against the wrong
window: the preview spills outside its bounds and draws behind the sheet rather
than inside it.

Inside a dialog window the viewfinder now asks for a TextureView, which draws
inline and so clips, scrolls, rounds and animates like anything else. Elsewhere
CameraX keeps its own choice, because a full-screen scanner is where the
cheaper, lower-latency path is worth having.

The symptom is device-dependent, which is how it survived this long: CameraX
already downgrades to a TextureView on legacy camera hardware, so the same
sheet renders perfectly on a 6T and spills across the screen on a Pixel.

Detected through DialogWindowProvider, which Material3's
ModalBottomSheetDialogLayout implements -- verified on device rather than
assumed.

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

The README said preview and analysis agree because they share a ViewPort. True,
and half the story: the viewfinder then scales that shared region to fill the
composable's bounds and centre-crops the overflow, so part of it is analysed and
off-screen at once. Read literally, the old paragraph endorsed exactly the
assumption that put the landscape overlay in the wrong place.

Also notes that a preview which is not roughly 4:3 shows a zoomed slice rather
than a letterboxed whole, records that the sheet preview draws inline, and fills
in the API table, which had not grown past its first three entries while the
module gained ScanPolicy, ScanRegion, ScannerOverlayScope, DetectedBarcode and
AnimatedScanFrame.

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

The ViewPort asked for a fixed 4:3 in preview.targetRotation's coordinate
space. Both halves were wrong. Preview.Builder leaves targetRotation unset, so
it reads back as ROTATION_0 even on a landscape display, and CameraX therefore
interpreted the ratio in portrait and inverted it; the fixed ratio then cost
field of view a second time when the viewfinder cropped that region again to
fill the bounds.

Measured on a OnePlus 6T in landscape, preview 1103x775 (1.423):

  before   crop 360x480 (0.75)   visible 360x252   29% of the frame
  after    crop 640x450 (1.422)  visible 640x450   94% of the frame

The ViewPort now takes the measured preview shape at the display's actual
rotation, and the use cases are set to the same rotation so all three agree.
Rebinding is visible, so the shape is only taken up when it moves more than 2%
-- a rotation or a pane resize, not layout noise -- and binding waits for the
first measurement rather than binding to a guess and correcting.

The on-device bind suite passes, which is the test that would fail if the
measurement never arrived and nothing ever bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18.11.0 raises the AAR's declared minSdkVersion from 23 to 24. The dependency
set is otherwise unchanged — the 18.10.1 and 18.11.0 POMs are identical, and
18.10.1 already resolved play-services-basement to 18.11.0.

BARCODE_SCANNER was 21, which had never been true: 18.10.1 already required 23,
so consumers below that failed the manifest merge regardless. Raise it to the
floor the module actually has. Nothing ships the module yet — barcodescanner is
absent from Maven Central and carried by no tag — so no consumer breaks.

The demo app can't catch this: its minSdk is MinSdk.max(), which DateHelperUtil
holds at 26. Reproduced by pinning the app to 23, which fails the merge with
"minSdkVersion 23 cannot be smaller than version 24 declared in library
[com.google.android.gms:play-services-base:18.11.0]". max() is unchanged at 26.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The floors differ per module (21 to 26) and were discoverable only by reading
buildSrc, or by hitting a manifest merge failure. Each module README now carries
its own, following the **Requirements** block Nav3Navigation already used, at the
end of Installation so it reads with the dependency line. Nav3Navigation already
documented 23 and is unchanged.

The root README gains a Minimum SDK table above Modules, grouping the modules by
floor with the reason for each one above 21.

The four MockInterceptor modules are pure Kotlin/JVM with no minSdk of their own;
the root table says so rather than leaving them looking overlooked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback from @jakeeilbeck on #119.

The report step filtered an eager List, so onEach did not flip `reported` until
after every element had already tested it. Two decodes sharing a rawValue — the
same code physically in shot twice, routine on a pallet or a box labelled down
one side — both passed, and the caller got two identical callbacks. That breaks
"reported at most once per track", and "one code at a time" in Single mode.

Step 3 never had the bug because it runs over a Sequence, where the lazy filter
does observe the tracks added by earlier elements. distinctBy restores that
property to step 4, keeping the first occurrence — the one nearest the centre.

Two tests cover it, on the null-dwell and the dwell path; both fail without the
distinctBy.

Also from the same review, both documentation-only:

- dwellProgress claimed 1f for a code with no track yet; it returns 0f, and 0f
  is right — in Single mode that is a code held off while another holds the
  lock, which must not draw a full ring.
- ScanPolicy.Immediate was described as restoring fire-on-sight. It does not
  override debounceWindow, so a held code reports once and then waits 2.5s of
  absence. Say what it does instead, and say plainly that no policy reports a
  held code every frame, since rearm is floored at missTolerance. The README
  and the KDoc both said it; the KDoc is the copy that reaches IDE autocomplete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback from @jakeeilbeck on #119.

The comment said a left-edge crop lands against the bottom of a 90-degree
frame; the assertion below it checks top == 0, and the assertion is right — a
clockwise turn carries the left edge to the top. Comment only, so that nobody
later "corrects" a working assertion to match it.

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

Review nit from @jakeeilbeck on #119.

mapToPreview negates x for the mirrored preview but leaves list order alone, so
a clockwise set of cornerPoints comes back counter-clockwise. AnimatedScanFrame
springs corner i to corner i of its next target and both its fallbacks —
bounds.cornersClockwise() and regionRect.cornersClockwise() — are clockwise, so
crossing between a fallback and a front-camera detection swaps two corners and
the outline bow-ties for the length of the spring. Cosmetic, and front lens only.

clockwiseAfterMirror reverses all but the first entry, which flips the winding
back while leaving index 0 on the same physical corner, so the starting corner
is still the one the detector reported.

DetectedBarcode.corners documented itself as "clockwise from the code's top-left",
which was false on the front camera before this and only half true after: the
winding is now clockwise on either lens, but index 0 is the analyser's top-left,
which the mirrored preview draws on the right. Say that, since overlay authors
code against this KDoc.

Tested via the internal helper: one test pins that mirroring reverses winding at
all, so the premise cannot rot silently, and one pins the restore. Winding is
measured by shoelace sign rather than by comparing lists, so the tests describe
the property rather than the implementation. Note the sign is positive for
clockwise here — screen space has y growing downward, which flips the usual
convention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(BarcodeScanner-Camera): dwell, scan regions and a configurable scan policy
@projectdelta6 projectdelta6 self-assigned this Sep 22, 2026
@projectdelta6
projectdelta6 merged commit 7a31263 into main Sep 22, 2026
1 check passed
@projectdelta6
projectdelta6 deleted the release/2026-09 branch September 22, 2026 14:54
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