Release 1.10.0 - #118
Merged
Merged
Release 1.10.0#118
Conversation
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
SegmentedControlnullable selection +enabled27fbda4BarcodeScanner+BarcodeScanner-Cameradc4a078andd50391c, and the non-blocking nit he raised alongside the approval in983455frelease/2026-09before any of themThree commits carry review surface that is new here:
5b6510dCONTRIBUTING.mdCentral file count 508 → 548, plus the formula983455fDetectedBarcode.cornersKDoc contractd50391c983455fis 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
COMMENTEDreview rather than anAPPROVEDone. 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 since1.9.1.What's in it
New modules —
BarcodeScanner,BarcodeScanner-CameraOne 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/BarcodeFormatsplusOneShotBarcodeScannerover the Play services hosted scanner. No CameraX, noCAMERApermission, no bundled model: ~140 KB net for a consumer that already shipsplay-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.
ScanPolicygathers the behaviour into one value:dwell(how long a code must be held),missTolerance,debounceWindow,modeandregion. It compares by value, which is load-bearing — the camera holds its tracking state inremember(policy), and a policy that did not compare equal would rebuild that state every recomposition and never finish a dwell.ScanMode.Singlelocks 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.ScanRegiondecides what counts, against the preview the user actually sees rather than the wider image the analyser gets.ScannerOverlayScopehands an overlay the live detections, each withbounds,cornersanddwellProgress, so an app can draw its own viewfinder.AnimatedScanFrameis 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 andenabledBoth requested by the FormolyEngine Compose renderer, which could not use the control at all without them:
selectedSegmentis 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 = falsefor submitted or locked forms. Input, semantics and visual treatment, because a greyed-out control that still accepts taps silently changes the answer.Build
play-services-base18.10.1 → 18.11.0 (see Compatibility)Compatibility
1.10.0is minor, not patch, and deliberately so. Adding a parameter to a@Composablechanges its JVM signature and generated$defaultbridge, so previously compiled callers ofSegmentedControlwill not link against it. Source-compatible for named arguments, and for positional ones up tomodifier. Consumers recompile — normal for a Compose library, but not a patch.The nullability widening on
selectedSegmentis both source- and binary-compatible on its own; it is theenabledparameter that forces the minor bump.The scanner modules require
minSdk24play-services-base18.11.0 raises its own AAR fromminSdkVersion23 to 24, so:barcodescannerand:barcodescanner-cameranow 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 readingbuildSrcor by hitting a merge failure.Verification
koverVerifygreen;:app:verifyConsumerKeepRulesgreen — 36 consumer-rule-protected classes survived R8, 3Nav3Screennames preserved unrenamedplay-services-visionis gone from that monorepo along with 2,432 lines of vendored Mobile Vision sampleSix 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, unreachableCancelled, a first-run install race), one API gap, one animation latch caught by @jakeeilbeck in #117, and one in #119 where two decodes sharing arawValuein 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
./scripts/publish.sh --dry-runfirst. It needs the signing key even in dry-run mode, by design — it exists to fail before the immutable step does.CONTRIBUTING.mdwas 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.developafterwards, per the branching strategy.developis currently well behind.minSdkis 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