From 4330e02931c2124cd7d232d4e9a9386917eddf4a Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 9 Sep 2026 12:08:29 +0100 Subject: [PATCH 01/53] docs: record how Central's publishing limits constrain release cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CONTRIBUTING.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 523fc7c..f0ed0c2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,31 @@ one version; see [Why one version for all modules](#why-one-version-for-all-modu > **Releases are immutable.** A version can never be re-uploaded or corrected — the only remedy is > publishing a new one. Iterate with `--local` *before* releasing, never after. +### Central publishing limits — batch releases, do not split modules + +Maven Central enforces three per-calendar-month quotas per organisation, from 1 October 2026: file +count (~1,167), release size (78 MB) and release count (7). Track them in the +[Usage Center](https://central.sonatype.com/publishing/usage). + +One toolbox release is **508 files, 11.55 MB, and one release event** — Central scores a multi-module +deployment bundle as a single release, not one per artifact. So release count is a non-issue and size +is nowhere near. **File count is the binding constraint:** 508 files is roughly half the monthly +allowance, so a second release in the same calendar month lands at ~1,016 and a third cannot fit. + +Two consequences for release practice: + +- **Batch patch releases.** A flurry of same-month point releases — the 1.8.0 → 1.8.3 pattern of + August 2026 — would be ~2,540 files, over twice the allowance. Fold fixes into one version and + iterate through `--local` or a snapshot in the meantime. +- **Do not split modules to reduce usage; it does the opposite.** 26 separately-published + repositories would be 26 release events per version, past the limit of 7 on day one. The single + batched deployment is the cheapest possible shape under these rules — a further reason for the + caveat in [Why one version for all modules](#why-one-version-for-all-modules). + +Separately, Central's *commercial nature* classification is independent of publishing volume and can +require Publisher Pro on its own. Exemptions and limit adjustments for open-source group IDs are +requested from `central-support@sonatype.com`. + ### Credentials The script resolves credentials in two ways, in this order: @@ -142,7 +167,9 @@ coherent version set, so it cannot catch either. Republishing everything costs minutes of upload and no consumer risk. If a module ever genuinely earns its own release cadence, split it into its own repository rather than versioning it -independently here. +independently here — but weigh it against +[Central publishing limits](#central-publishing-limits--batch-releases-do-not-split-modules) first, +since each extra repository is another monthly release event. ## Documentation From 7f50c06abb0e9cf66f18a5779ccf516e5a8f003f Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 9 Sep 2026 12:08:29 +0100 Subject: [PATCH 02/53] ci: run tests when a draft PR is marked ready for review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00c4ceb..b5b3bd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,10 @@ name: CI on: pull_request: branches: [ main ] + # `ready_for_review` is NOT in the default type set (opened/synchronize/reopened). + # Without it a PR opened as a draft and later marked ready fires no event at all, so + # the draft guard below would skip every run and CI would never report on that PR. + types: [ opened, synchronize, reopened, ready_for_review ] push: branches: [ main ] @@ -14,6 +18,11 @@ concurrency: jobs: test: name: Tests & coverage gate + # Draft PRs can't be merged, so don't spend runner minutes on them. Marking a PR ready + # fires `ready_for_review` (see types above), which is when CI runs for a drafted PR. + # 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. + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 8907d6560b682b7f8b6399519cda3b8c1f052d71 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 9 Sep 2026 13:25:13 +0100 Subject: [PATCH 03/53] docs: correct the Central limits to the ones actually applied to us MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CONTRIBUTING.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0ed0c2..3db6fe2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,9 +54,9 @@ one version; see [Why one version for all modules](#why-one-version-for-all-modu ### Central publishing limits — batch releases, do not split modules -Maven Central enforces three per-calendar-month quotas per organisation, from 1 October 2026: file -count (~1,167), release size (78 MB) and release count (7). Track them in the -[Usage Center](https://central.sonatype.com/publishing/usage). +Maven Central enforces three per-calendar-month quotas per organisation, from 1 October 2026. Our +applied limits, confirmed by Sonatype on 2026-09-09, are **1,000 files, 80 MB and 7 releases**. +Track usage in the [Usage Center](https://central.sonatype.com/publishing/usage). One toolbox release is **508 files, 11.55 MB, and one release event** — Central scores a multi-module deployment bundle as a single release, not one per artifact. So release count is a non-issue and size @@ -73,9 +73,12 @@ Two consequences for release practice: batched deployment is the cheapest possible shape under these rules — a further reason for the caveat in [Why one version for all modules](#why-one-version-for-all-modules). -Separately, Central's *commercial nature* classification is independent of publishing volume and can -require Publisher Pro on its own. Exemptions and limit adjustments for open-source group IDs are -requested from `central-support@sonatype.com`. +Sonatype granted `uk.co.appoly.droid` an **OSS exemption** on 2026-09-09, so Central's +*commercial nature* classification — which is independent 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: the "enhanced" limits it granted match what was already applied, sized to a +publishing history of a single release. If the one-release-per-month cap starts to hurt, that is the +thing to go back to `central-support@sonatype.com` about, with a concrete cadence to justify it. ### Credentials From b8b38973f291f97f6bc2b52cbdce64dc7f3ed98b Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 9 Sep 2026 13:25:13 +0100 Subject: [PATCH 04/53] build: AGP 9.3.2 -> 9.4.0 Co-Authored-By: Claude Opus 5 (1M context) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a3e677..4f1d3f1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.3.2" +agp = "9.4.0" kotlin = "2.4.10" vanniktechPublish = "0.37.0" ksp = "2.3.11" From 91909ab3971bc456f3ca25abd6ba36ac311a2d7f Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 9 Sep 2026 13:25:13 +0100 Subject: [PATCH 05/53] build: Kotlin 2.4.10 -> 2.4.20 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) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4f1d3f1..60762e4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "9.4.0" -kotlin = "2.4.10" +kotlin = "2.4.20" vanniktechPublish = "0.37.0" ksp = "2.3.11" coreKtx = "1.19.0" From 9ab405b0c964d06184efebda02ce0ce151b647a8 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 9 Sep 2026 13:25:13 +0100 Subject: [PATCH 06/53] build: androidx Navigation 3 1.2.0-alpha07 -> 1.2.0-beta01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt | 7 +++++-- gradle/libs.versions.toml | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt b/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt index e33562b..5acd64b 100644 --- a/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt +++ b/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt @@ -69,9 +69,12 @@ class TabsSceneStrategyTest { val scene = calculate(entries) assertNotNull(scene) assertEquals(entries.dropLast(1), scene!!.previousEntries) - // dropLast(1) last entry is start tab top (DetailScreen(5)) + // dropLast(1) last entry is start tab top, which the backStack assertion above pins as + // DetailScreen(5). Identity is compared contentKey-to-contentKey rather than against a + // literal: Nav3 1.2.0-beta01 changed the default contentKey to a composite of + // `key.toString()` and `key::class.toString()`, and `NavEntry.key` is private, so pinning + // the format here would only re-arm this trap on the next release. assertEquals(entries[entries.lastIndex - 1].contentKey, scene.previousEntries.last().contentKey) - assertEquals(DetailScreen(5).toString(), scene.previousEntries.last().contentKey) assertEquals(listOf(entries.last()), scene.entries) assertEquals(1, entries.size - scene.previousEntries.size) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60762e4..3696d3c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ sandwichVersion = "2.4.0" kotlinxSerialization = "1.11.0" paging = "3.5.1" roomVersion = "2.8.4" -nav3 = "1.2.0-alpha07" +nav3 = "1.2.0-beta01" workManager = "2.11.2" kover = "0.9.9" robolectric = "4.16.1" From 2bf651eeb5d8f1aacc1b6f5119457ad161434d72 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 10 Sep 2026 12:19:14 +0100 Subject: [PATCH 07/53] docs(Nav3Navigation): stop recommending BackHandler for system back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Nav3Navigation/README.md | 103 +++++++++++++++--- .../uk/co/appoly/droid/nav3/Nav3Navigator.kt | 14 ++- .../co/appoly/droid/nav3/TabsNav3Navigator.kt | 46 ++++---- 3 files changed, 123 insertions(+), 40 deletions(-) diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index c99f4c2..12bfe1d 100644 --- a/Nav3Navigation/README.md +++ b/Nav3Navigation/README.md @@ -14,7 +14,7 @@ without giving up the fused-screen / ambient-navigator convenience that Voyager |--------------------------------------------|------------------------------------------------------------------------------------------------------| | `Nav3Screen` | Fused key + UI: implement `Content()` on the key class itself | | `Nav3Navigator` + `LocalNav3Navigator` | Ambient navigation: `push` / `pop` / `replace` / … + optional `parent` / `root()` / `currentOrThrow` | -| Stack peek | `canPop`, `lastItem`, `previousItem`, `items` — bottom bar, BackHandler, deep-link reconcile | +| Stack peek | `canPop`, `lastItem`, `previousItem`, `items` — bottom bar, back-enablement, deep-link reconcile | | `popWithResult` / `Nav3ResultReceiver` | Voyager-style screen-to-screen results (stable; preferred over the alpha result bus) | | `BackStackNav3Navigator` | Default navigator — navigation is list mutation on your `NavBackStack` | | `Nav3ScreenHost` | Full `NavDisplay` surface for `Nav3Screen` stacks + ambient navigator + default entry decorators | @@ -45,7 +45,16 @@ implementation("uk.co.appoly.droid:nav3navigation") **Requirements** - `minSdk` **23** (androidx.navigation3 requirement) -- Depends on `androidx.navigation3` **1.2.0-alpha07** (alpha result bus is optional; see [Results](#results)) +- Depends on `androidx.navigation3` **1.2.0-beta01** (alpha result bus is optional; see [Results](#results)) +- **Predictive back needs the manifest opt-in below API 36.** It defaults to `true` on API 36+, + but on API 33–35 the host app must set it explicitly, or pops commit with no gesture animation: + + ```xml + + ``` + + Never set it to `"false"` — that disables predictive back for the whole app, this module + included. - Screen classes need `kotlinx-serialization` (`@Serializable` + the serialization plugin) ## Usage @@ -138,13 +147,45 @@ navigator.popUntilRoot() // Bottom bar from top screen val showBottomBar = (navigator.lastItem as? ShowsBottomBar)?.showBottomBar != false +``` + +#### System back + +**Don't register a `BackHandler` for ordinary back.** `Nav3ScreenHost` forwards `onBack` to +`NavDisplay` (defaulting to `navigator.pop()`), and `Nav3TabsHost` defaults it to +`TabsNav3Navigator.pop()` — which already pops in-tab and falls back to exit-through-home at a +tab root. Registering a `BackHandler` above the host duplicates that logic *and* intercepts the +gesture before `NavDisplay` sees it, so `predictivePopTransitionSpec` never scrubs and you lose +the native predictive back this module exists to provide. + +Nor do you need one to exit the app: at the start-tab root `canPop` is `false` and Nav3 disables +its back callback, so back falls through to the Activity and finishes it as usual — even though +retained tabs keep `backStack.size > 1`. `Nav3PredictiveBackDeviceTest` asserts exactly this +(callback disabled at the start-tab root, enabled at any non-start tab root). + +To genuinely intercept back on one screen — an unsaved-changes prompt, say — use +`NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose`, which is already +on your classpath transitively via `navigation3-ui`. It registers with the same dispatcher +`NavDisplay` uses and, being added later, is invoked first (handlers run last-in-first-out within +a priority); unlike `BackHandler` it also exposes the gesture's progress and cancellation: -// System back: pop tab stack, else switch tab / finish -BackHandler(enabled = navigator.canPop || currentTab != HomeTab) { - if (navigator.canPop) navigator.pop() else selectTab(HomeTab) +```kotlin +@Composable +override fun Content() { + val backState = rememberNavigationEventState(currentInfo = NavigationEventInfo.None) + NavigationBackHandler( + state = backState, + isBackEnabled = hasUnsavedChanges, + onBackCompleted = { showDiscardDialog() }, + ) + // ...screen content } ``` +Bind one `NavigationEventState` to exactly one `NavigationBackHandler` — a second handler sharing +a state throws `IllegalArgumentException`. Branch inside `onBackCompleted` rather than registering +two conditional handlers. + ### Deep links A deep link is just a seeded start stack — no graph, no URI-pattern framework: @@ -175,11 +216,17 @@ semantics this module exists to provide. Nav3 only tears down per-entry state wh leaves the back stack; `TabsNav3Navigator` keeps every **visited** tab in `backStack`, and `Nav3TabsHost` defaults to `TabsSceneStrategy` so only the current tab’s top entry is rendered. +**Each tab really does own its own back stack.** Those per-tab stacks are the source of truth — +every `push` / `pop` / `replace` mutates exactly one of them. The single `backStack` you can read +is a *derived projection* of them, rebuilt on each mutation, because `NavDisplay` renders from one +list. The projection is a rendering adapter, not the data model — see +[Why one `NavDisplay`](#why-one-navdisplay) for what that buys. + Bottom-bar chrome stays **app-owned**. The library provides a navigator that: -- keeps **one stack per tab** and flattens **all visited tabs** into a single `backStack` for one - `NavDisplay` (`[other visited in tabOrder] + startTabStack + currentTabStack`), with the - current tab always the suffix +- keeps **one stack per tab** as the source of truth, and projects **all visited tabs** into a + single derived `backStack` for one `NavDisplay` + (`[other visited in tabOrder] + startTabStack + currentTabStack`), current tab always the suffix - pairs with **`TabsSceneStrategy`** (default on `Nav3TabsHost`) so inactive tabs stay in the stack without being composed — that is the retention mechanism - implements `Nav3Navigator` so in-tab `LocalNav3Navigator.push/pop` stay tab-local @@ -188,13 +235,13 @@ Bottom-bar chrome stays **app-owned**. The library provides a navigator that: - records **`pendingTabSlide`** so tab switches can animate directionally (see [Transitions](#transitions)) - exposes **`currentTabDepth`** (depth of the current tab only) for in-tab transition z-index — not `backStack.size`, which grows as tabs are visited -- **`items`** returns the **current tab’s** stack only (Voyager-equivalent), not the full multi-tab - flatten — use `stackFor(tab)` or `backStack` when you need another tab or the display list +- **`items`** returns the **current tab’s** stack only (Voyager-equivalent), not the full + multi-tab projection — use `stackFor(tab)` or `backStack` for another tab or the display list - separates **display order** (`tabOrder`) from the **launch / exit-through-home tab** (`startTab`) `tabOrder` is the strip order (bottom-bar left→right, and the indices used for `TabSlide.Forward` / `Backward`). `startTab` is the launch tab, the exit-through-home target, -and the stack always flattened underneath the current tab — it defaults to `tabOrder.first()` so +and the stack always projected underneath the current tab — it defaults to `tabOrder.first()` so existing call sites stay source-compatible, but can be any entry of `tabOrder` (e.g. a centre Home). ```kotlin @@ -348,7 +395,7 @@ same reflection-based `NavKey` serialization as `rememberNavBackStack`). Screens restore (not read from the saved bundle). If `KEY_CURRENT` is missing, restore falls back to the start tab's index — not `0`. -**Equal keys across tabs:** visited tabs share one flattened `backStack`. The same equal key on +**Equal keys across tabs:** visited tabs share one projected `backStack`. The same equal key on Home and on Rooms shares saveable state / ViewModelStore — use distinguishing constructor args when a destination can appear under more than one tab. @@ -360,11 +407,31 @@ composed, so no ViewModel is created until the tab is selected. `CompositionLocalProvider(LocalTabsNavigator provides tabs) { Nav3ScreenHost(...) }` yourself if you need a custom layout; pass `TabsSceneStrategy(tabs)` (or equivalent) if you want retention. -#### Multi-stack alternative +#### Why one `NavDisplay` + +Per-tab stacks are the model, but they are deliberately projected into **one** `NavDisplay` +rather than given a display each. Nav3 ties all per-entry state to back-stack membership — +`NavEntryDecorator`'s `onPop` fires when a key leaves the stack, and that is what clears an +entry's `rememberSaveable` state and `ViewModelStore`. There is no "retained but off-stack" +concept in the runtime. So one display is what makes retention possible at all, and it also: + +- **keeps predictive back working across a tab boundary.** Predictive back is per-`NavDisplay`, + so exit-through-home can only animate while both tabs' entries live in the same display's + stack (`Nav3PredictiveBackDeviceTest` covers this). +- **avoids competing back dispatchers.** One display means one `NavigationEvent` dispatcher. + A display per tab would need a child dispatcher owner scoped per tab, enabled only for the + selected one. +- **keeps inactive tabs out of composition** — retained, not composed, via `TabsSceneStrategy`. + +The cost is that stable tab-root keys never leave the stack, which is why retention needs an +explicit end — see [Retention and teardown](#retention-and-teardown) and call +`Nav3RetentionScope.clear()` on sign-out. -If you prefer independent `rememberNavBackStack` per tab (no flatten / no built-in tab-slide), -swap which stack you pass to `Nav3ScreenHost` and re-provide `LocalNav3Navigator` — same idea as -nested Voyager navigators. Cross-tab then means mutating the target tab's list yourself. +**Independent-stack alternative.** If you want a stack per tab with *no* cross-tab retention +(and no built-in tab-slide), swap which `rememberNavBackStack` you pass to `Nav3ScreenHost` and +re-provide `LocalNav3Navigator` — same idea as nested Voyager navigators. Cross-tab then means +mutating the target tab's list yourself, and switching tabs tears down the previous tab's +saveable state and ViewModels, since its keys leave the back stack. ### Transitions @@ -540,10 +607,10 @@ Drop `uniqueScreenKey` — multi-instance identity is the constructor args (and | `Nav3ResultReceiver` | interface | `onResult` target for `popWithResult` | | `popWithResult` / `popUntilWithResult` | extensions | Deliver result + pop | | `Nav3ScreenHost` | composable | Full `NavDisplay` host + ambient navigator | -| `TabsNav3Navigator` | class | Per-tab stacks retained in flatten + `startTab` + `navigateToTab` | +| `TabsNav3Navigator` | class | Per-tab stacks + derived projection + `navigateToTab` | | `TabsNav3Navigator.startTab` | property | Launch / exit-through-home tab (may sit mid-strip) | | `TabsNav3Navigator.currentTabDepth` | property | Depth of the current tab only (in-tab transition z-index) | -| `TabsNav3Navigator.items` | property | **Current tab’s** stack only (not the multi-tab flatten) | +| `TabsNav3Navigator.items` | property | **Current tab’s** stack only (not the multi-tab projection) | | `TabsNav3Navigator.exitToStartTabSlide` | property | Slide direction a committed exit-through-home `pop` would use | | `TabsSceneStrategy` | class | Renders current tab top; retains inactive tab state in back stack | | `LocalTabsNavigator` | CompositionLocal | Ambient tabs API (`null` outside a tab host) | diff --git a/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/Nav3Navigator.kt b/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/Nav3Navigator.kt index e26d5c2..e023ed5 100644 --- a/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/Nav3Navigator.kt +++ b/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/Nav3Navigator.kt @@ -140,12 +140,20 @@ interface Nav3Navigator { */ fun popUntilRoot() - // --- stack introspection (bottom bar, BackHandler, deep-link reconcile) --- + // --- stack introspection (bottom bar, back-enablement, deep-link reconcile) --- /** * `true` when there is a previous screen to pop to (stack size > 1) — Voyager's `canPop`. - * Use with system [androidx.activity.compose.BackHandler]: pop when `canPop`, otherwise - * switch tab / finish the activity. + * + * Read this for UI decisions (an up arrow, a back-enabled check). **Do not** drive system back + * from it via [androidx.activity.compose.BackHandler]: [Nav3ScreenHost] already routes + * `NavDisplay.onBack` to [pop], and a handler above the host intercepts the gesture before + * `NavDisplay` sees it, defeating the predictive-back scrub. When `canPop` is `false` Nav3 + * disables its back callback so the Activity finishes as usual. + * + * To intercept back on a single screen (e.g. an unsaved-changes prompt), use + * `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` inside that + * screen's content — it shares `NavDisplay`'s dispatcher and keeps gesture progress. */ val canPop: Boolean diff --git a/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt b/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt index b6a8f5a..3b14ed8 100644 --- a/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt +++ b/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt @@ -41,17 +41,23 @@ enum class TabSlide { val LocalTabsNavigator = staticCompositionLocalOf { null } /** - * Per-tab back stacks flattened into a single [backStack] for one [Nav3ScreenHost] / - * [androidx.navigation3.ui.NavDisplay]. + * One back stack per tab, projected into the single [backStack] that one [Nav3ScreenHost] / + * [androidx.navigation3.ui.NavDisplay] renders from. * * ## Model * - * - Each tab has its own stack. [startTab] is the launch tab and **exit-through-home** target - * (defaults to the first entry of [tabOrder]; pass an explicit [startTab] when the home tab - * is not first in the strip, e.g. a centre Home among Stations · Kerbside · Home · …). - * - The display stack retains **every visited tab** so Nav3 keeps per-tab saveable state and - * ViewModelStores across tab switches (entries are only torn down when their key leaves the - * back stack). Flatten order: + * **The per-tab stacks are the source of truth.** Each tab owns its own stack, and every + * navigator operation ([push], [pop], [replace], [navigateToTab]) mutates exactly one tab's + * stack. [backStack] is a *derived projection* of those stacks, rebuilt after each mutation, + * because `NavDisplay` renders from a single list — it is a rendering adapter, not the model. + * + * - [startTab] is the launch tab and **exit-through-home** target (defaults to the first entry + * of [tabOrder]; pass an explicit [startTab] when the home tab is not first in the strip, + * e.g. a centre Home among Stations · Kerbside · Home · …). + * - The projection retains **every visited tab** so Nav3 keeps per-tab saveable state and + * ViewModelStores across tab switches. Nav3 clears an entry's state only when its key leaves + * the back stack, so stack membership is the only retention lever it offers. Projection + * order: * `[other visited tabs in tabOrder] + startTabStack + (currentTabStack if not start)`. * The current tab is always the suffix (`backStack.last()` is its top). Pair with * [TabsSceneStrategy] (the [Nav3TabsHost] default) so only the current top is rendered. @@ -73,7 +79,7 @@ val LocalTabsNavigator = staticCompositionLocalOf { null } * * ## Equal keys across tabs * - * Visited tabs share one flattened [backStack]. If the same equal key appears under more than + * Visited tabs share one projected [backStack]. If the same equal key appears under more than * one tab (e.g. `DetailScreen(1)` on Home and on Rooms), Nav3 treats them as the same entry for * saveable state / ViewModelStore — the same rule as duplicate keys on a single stack. Prefer * distinguishing constructor args when the same destination can live under more than one tab. @@ -101,7 +107,7 @@ val LocalTabsNavigator = staticCompositionLocalOf { null } * @param tabOrder tab roots in strip order (used for [TabSlide] direction and bottom-bar order). * Must be non-empty. Each root is kept as the first entry of that tab's stack and is never * popped or replaced. Only tabs in this list may be passed to [switchTab] / [navigateToTab]. - * @param startTab the launch tab, exit-through-home target, and stack always flattened + * @param startTab the launch tab, exit-through-home target, and stack always projected * underneath the current tab. Must be one of [tabOrder]. Defaults to the first entry of * [tabOrder] so existing call sites stay source-compatible. * @param parent the navigator that nested this tab shell (typically the root host), or `null` @@ -120,7 +126,7 @@ class TabsNav3Navigator( val tabOrder: List = tabOrder.toList() /** - * The launch tab, the exit-through-home target, and the stack always flattened underneath + * The launch tab, the exit-through-home target, and the stack always projected underneath * the current tab's stack. Independent of [tabOrder] index — may sit mid-strip. */ val startTab: Nav3Screen = startTab @@ -130,8 +136,10 @@ class TabsNav3Navigator( ) /** - * Flattened stack for [Nav3ScreenHost] / [androidx.navigation3.ui.NavDisplay]. - * Mutated only via this navigator — do not edit directly. + * The per-tab stacks projected into the single list [Nav3ScreenHost] / + * [androidx.navigation3.ui.NavDisplay] renders from. **Derived state**, rebuilt on every + * mutation — the per-tab stacks are the source of truth, so mutate only via this navigator + * and never edit this list directly. */ val backStack: NavBackStack = NavBackStack(this.startTab) @@ -326,18 +334,18 @@ class TabsNav3Navigator( /** * Top of the current tab's stack (always `backStack.last()`, since the current tab is the - * flattened suffix). + * projected suffix). */ override val lastItem: Nav3Screen? get() = backStack.lastOrNull() as? Nav3Screen /** - * Entry immediately beneath the current top in the flattened [backStack]. + * Entry immediately beneath the current top in the projected [backStack]. * * - Deeper in a tab → that tab's previous screen. * - At a non-start tab root → the top of [startTab]'s stack (what exit-through-home reveals). * - At the start-tab root with retained visited tabs → another tab's entry may sit beneath - * home in the flatten; [canPop] is still `false` and [TabsSceneStrategy] reports empty + * home in the projection; [canPop] is still `false` and [TabsSceneStrategy] reports empty * `previousEntries`, so system back backgrounds the app rather than navigating there. */ override val previousItem: Nav3Screen? @@ -345,11 +353,11 @@ class TabsNav3Navigator( /** * Screens on the **current tab's** stack only (root first) — Voyager-equivalent meaning of - * “the stack”, not the full flattened multi-tab [backStack]. + * “the stack”, not the full projected multi-tab [backStack]. * * Deliberate behaviour: callers inspecting `items` for bottom-bar chrome, deep-link * reconcile, or “am I on X?” want the active tab, not every retained visited tab. - * Use [stackFor] or [backStack] when you need another tab or the display flatten. + * Use [stackFor] or [backStack] when you need another tab or the display projection. */ override val items: List get() = stackFor(currentTab) @@ -417,7 +425,7 @@ class TabsNav3Navigator( } /** - * Rebuilds the flattened [backStack] so every **visited** tab keeps its entries (Nav3 only + * Rebuilds the projected [backStack] so every **visited** tab keeps its entries (Nav3 only * tears down saveable / ViewModel state when a content key leaves the back stack). * * Order: other visited tabs in [tabOrder] (excluding [startTab] and [currentTab]) + From fdd3bd9fc3db5f98501949ebcdc508c773db3196 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 10 Sep 2026 12:19:57 +0100 Subject: [PATCH 08/53] build: androidx Navigation 3 1.2.0-beta01 -> 1.2.0-rc01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Nav3Navigation/README.md | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index 12bfe1d..dee8bac 100644 --- a/Nav3Navigation/README.md +++ b/Nav3Navigation/README.md @@ -45,7 +45,7 @@ implementation("uk.co.appoly.droid:nav3navigation") **Requirements** - `minSdk` **23** (androidx.navigation3 requirement) -- Depends on `androidx.navigation3` **1.2.0-beta01** (alpha result bus is optional; see [Results](#results)) +- Depends on `androidx.navigation3` **1.2.0-rc01** (alpha result bus is optional; see [Results](#results)) - **Predictive back needs the manifest opt-in below API 36.** It defaults to `true` on API 36+, but on API 33–35 the host app must set it explicitly, or pops commit with no gesture animation: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3696d3c..915ffd5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,7 +19,7 @@ sandwichVersion = "2.4.0" kotlinxSerialization = "1.11.0" paging = "3.5.1" roomVersion = "2.8.4" -nav3 = "1.2.0-beta01" +nav3 = "1.2.0-rc01" workManager = "2.11.2" kover = "0.9.9" robolectric = "4.16.1" From d1164e6afceff0bd66e8225d5a052e2fee7ec658 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 10 Sep 2026 12:20:08 +0100 Subject: [PATCH 09/53] build: Compose BOM 2026.08.00 -> 2026.09.00 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 915ffd5..103cef5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ androidxTestCore = "1.7.0" lifecycleRuntime = "2.11.0" appcompat = "1.8.0" activityCompose = "1.13.0" -composeBom = "2026.08.00" +composeBom = "2026.09.00" flexiLoggerVersion = "2.1.4" okhttp = "5.5.0" retrofit = "3.0.0" From db9187e9ba37690c0756e36ec7cb164286b6491a Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 10 Sep 2026 12:20:21 +0100 Subject: [PATCH 10/53] build: Room 2.8.4 -> 2.8.5 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) --- DateHelperUtil-Room/README.md | 6 +++--- gradle/libs.versions.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index ad9a688..6d2f9a1 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -20,9 +20,9 @@ implementation("uk.co.appoly.droid:datehelperutil:1.9.0") implementation("uk.co.appoly.droid:datehelperutil-room:1.9.0") // Required Room dependencies -implementation("androidx.room:room-runtime:2.8.4") -implementation("androidx.room:room-ktx:2.8.4") -ksp("androidx.room:room-compiler:2.8.4") +implementation("androidx.room:room-runtime:2.8.5") +implementation("androidx.room:room-ktx:2.8.5") +ksp("androidx.room:room-compiler:2.8.5") ``` ## Usage diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 103cef5..745d1b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,7 @@ retrofit = "3.0.0" sandwichVersion = "2.4.0" kotlinxSerialization = "1.11.0" paging = "3.5.1" -roomVersion = "2.8.4" +roomVersion = "2.8.5" nav3 = "1.2.0-rc01" workManager = "2.11.2" kover = "0.9.9" From faa03256ceb27d430b2f37c9c78a06b159bd7eee Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 11 Sep 2026 16:26:56 +0100 Subject: [PATCH 11/53] build: group the version catalog by publication blast radius 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) --- LazyGridPagingExtensions/build.gradle.kts | 1 - LazyListPagingExtensions/build.gradle.kts | 1 - gradle/libs.versions.toml | 141 ++++++++++++++-------- settings.gradle.kts | 4 +- 4 files changed, 92 insertions(+), 55 deletions(-) diff --git a/LazyGridPagingExtensions/build.gradle.kts b/LazyGridPagingExtensions/build.gradle.kts index 09f9c4e..6ac351d 100644 --- a/LazyGridPagingExtensions/build.gradle.kts +++ b/LazyGridPagingExtensions/build.gradle.kts @@ -57,7 +57,6 @@ dependencies { //Paging implementation(libs.paging.runtime) implementation(libs.paging.compose) -// testImplementation(libs.paging.common) testImplementation(libs.junit) testImplementation(libs.robolectric) diff --git a/LazyListPagingExtensions/build.gradle.kts b/LazyListPagingExtensions/build.gradle.kts index 5d6620d..df65e3c 100644 --- a/LazyListPagingExtensions/build.gradle.kts +++ b/LazyListPagingExtensions/build.gradle.kts @@ -57,7 +57,6 @@ dependencies { //Paging implementation(libs.paging.runtime) implementation(libs.paging.compose) -// testImplementation(libs.paging.common) testImplementation(libs.junit) testImplementation(libs.robolectric) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 745d1b4..ae856c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,56 +1,73 @@ +# ============================================================================= +# Version catalog +# +# Entries are grouped by blast radius: +# +# [PUBLISHED] Reaches consumers of the library. These end up on a consumer's +# classpath (api/implementation => POM runtime scope) or are baked +# into the published AAR by a code generator. Bumping one of these +# is a consumer-visible change - mind binary compatibility, the +# minimum versions consumers must tolerate, and the README tables. +# [BUILD/TEST] Compiles and verifies the library modules but never leaves the +# build - test and androidTest configurations, compilers, tooling. +# Bump freely; only CI can be broken by it. +# [DEMO APP] Used solely by the `app` demo module. Invisible to consumers. +# ============================================================================= + [versions] -agp = "9.4.0" -kotlin = "2.4.20" -vanniktechPublish = "0.37.0" -ksp = "2.3.11" +# --- PUBLISHED: these versions reach consumers ------------------------------- +kotlin = "2.4.20" # also the language/stdlib version consumers compile against coreKtx = "1.19.0" -junit = "4.13.2" -junitVersion = "1.3.0" -espressoCore = "3.7.0" -androidxTestCore = "1.7.0" -lifecycleRuntime = "2.11.0" appcompat = "1.8.0" -activityCompose = "1.13.0" +lifecycleRuntime = "2.11.0" composeBom = "2026.09.00" -flexiLoggerVersion = "2.1.4" +coroutines = "1.11.0" # -android ships; -test is build-only +flexiLoggerVersion = "2.1.4" # exposed as `api` - a bump is a consumer-visible change okhttp = "5.5.0" retrofit = "3.0.0" -sandwichVersion = "2.4.0" +sandwichVersion = "2.4.0" # exposed as `api` from BaseRepo kotlinxSerialization = "1.11.0" paging = "3.5.1" roomVersion = "2.8.5" -nav3 = "1.2.0-rc01" +nav3 = "1.2.0-rc01" # exposed as `api` from Nav3Navigation workManager = "2.11.2" -kover = "0.9.9" + +# --- BUILD/TEST: toolchain + test-only, never published ---------------------- +agp = "9.4.0" +ksp = "2.3.11" +vanniktechPublish = "0.37.0" # release tooling only +junit = "4.13.2" +junitVersion = "1.3.0" +espressoCore = "3.7.0" +androidxTestCore = "1.7.0" robolectric = "4.16.1" -coroutines = "1.11.0" +activityCompose = "1.13.0" # demo app + Nav3Navigation androidTest only [libraries] +# ============================================================================= +# PUBLISHED - on consumers' classpaths +# ============================================================================= + +#AndroidX core androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxTestCore" } -robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } -kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } -kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } -androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycleRuntime" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycleRuntime" } -#Compose -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +#Kotlin / coroutines +kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } + +#kotlinx serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } + +#Compose (BOM is imported by the Compose-facing modules and by the demo app) androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } -androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } -compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } -#FlexiLogger (Maven Central) +#FlexiLogger (Maven Central) - exposed as `api` flexiLogger = { group = "io.github.projectdelta6", name = "flexilogger", version.ref = "flexiLoggerVersion" } flexiLogger-okhttp = { group = "io.github.projectdelta6", name = "flexilogger-okhttp", version.ref = "flexiLoggerVersion" } @@ -58,37 +75,26 @@ flexiLogger-okhttp = { group = "io.github.projectdelta6", name = "flexilogger-ok okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-urlconnection = { module = "com.squareup.okhttp3:okhttp-urlconnection", version.ref = "okhttp" } -okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } #Retrofit retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-serializationConverter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } -#sandwich (versions aligned by the BOM — import platform(libs.sandwich.bom) alongside these) +#sandwich (versions aligned by the BOM - import platform(libs.sandwich.bom) alongside these) sandwich-bom = { group = "com.github.skydoves", name = "sandwich-bom", version.ref = "sandwichVersion" } sandwich = { group = "com.github.skydoves", name = "sandwich" } sandwich-retrofit = { group = "com.github.skydoves", name = "sandwich-retrofit" } -#Kotlin -kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } - -#kotlinx serialization -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } -kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } - #Paging paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" } paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } -paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } -paging-testing = { group = "androidx.paging", name = "paging-testing", version.ref = "paging" } -#Room +#Room (the compiler is `ksp`-only, but its output is baked into the published AAR) androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomVersion" } -androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomVersion" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomVersion" } -androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomVersion" } +androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomVersion" } -#Navigation 3 +#Navigation 3 - exposed as `api` from Nav3Navigation androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3" } androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "nav3" } androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleRuntime" } @@ -97,15 +103,48 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l #WorkManager androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "workManager" } + +# ============================================================================= +# BUILD/TEST ONLY - test & androidTest configurations of the library modules +# ============================================================================= + +#Unit test +junit = { group = "junit", name = "junit", version.ref = "junit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } +paging-testing = { group = "androidx.paging", name = "paging-testing", version.ref = "paging" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" } +#Instrumented test +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxTestCore" } +androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomVersion" } + +#Compose test (versions from the Compose BOM) +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +# ============================================================================= +# DEMO APP ONLY - never referenced by a published module +# ============================================================================= +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } + [plugins] -android-application = { id = "com.android.application", version.ref = "agp" } +# --- Applied by the published library modules -------------------------------- android-library = { id = "com.android.library", version.ref = "agp" } -kotlinKSP = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -vanniktech-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechPublish" } +kotlinKSP = { id = "com.google.devtools.ksp", version.ref = "ksp" } room = { id = "androidx.room", version.ref = "roomVersion" } -kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } +vanniktech-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechPublish" } + +# --- Local only -------------------------------------------------------------- +android-application = { id = "com.android.application", version.ref = "agp" } # demo app diff --git a/settings.gradle.kts b/settings.gradle.kts index a35a2f1..8652585 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,8 +13,8 @@ pluginManagement { } // Kover coverage aggregation across the whole build (Android + pure-JVM modules) into a -// single root report. Version kept in sync with `kover` in gradle/libs.versions.toml (the -// settings plugins block can't read the version catalog). +// single root report. The version lives here and nowhere else: a settings plugins block is +// resolved before the version catalog exists, so it can't read gradle/libs.versions.toml. plugins { id("org.jetbrains.kotlinx.kover.aggregation") version "0.9.9" } From 4f53a3db42367b1e5c81c974f9baa3575cd79b88 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 15 Sep 2026 09:29:08 +0100 Subject: [PATCH 12/53] build: TOOLBOX_VERSION 1.9.0 -> 1.9.1-rc01 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) --- AppSnackBar-UiState/README.md | 6 +++--- AppSnackBar/README.md | 2 +- BaseRepo-AppolyJson/README.md | 4 ++-- BaseRepo-Paging-AppolyJson/README.md | 10 +++++----- BaseRepo-Paging/README.md | 8 ++++---- BaseRepo-S3Uploader-Multipart/README.md | 6 +++--- BaseRepo-S3Uploader/README.md | 6 +++--- BaseRepo/README.md | 2 +- ComposeExtensions/README.md | 2 +- ConnectivityMonitor/README.md | 2 +- DateHelperUtil-Room/README.md | 4 ++-- DateHelperUtil-Serialization/README.md | 4 ++-- DateHelperUtil/README.md | 2 +- LazyGridPagingExtensions/README.md | 4 ++-- LazyListPagingExtensions/README.md | 4 ++-- MockInterceptor-AppolyJson/README.md | 2 +- MockInterceptor-Retrofit/README.md | 2 +- MockInterceptor-Serialization/README.md | 2 +- MockInterceptor/README.md | 2 +- Nav3Navigation/README.md | 4 ++-- PagingExtensions/README.md | 2 +- README.md | 8 ++++---- S3Uploader-Multipart/README.md | 2 +- S3Uploader/README.md | 2 +- SegmentedControl/README.md | 2 +- UiState/README.md | 2 +- buildSrc/src/main/kotlin/BuildConfig.kt | 2 +- 27 files changed, 49 insertions(+), 49 deletions(-) diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index 5c7c84b..3e66462 100644 --- a/AppSnackBar-UiState/README.md +++ b/AppSnackBar-UiState/README.md @@ -13,9 +13,9 @@ Integration module that bridges the AppSnackBar and UiState modules, providing a ```gradle.kts // Requires both base modules -implementation("uk.co.appoly.droid:uistate:1.9.0") -implementation("uk.co.appoly.droid:appsnackbar:1.9.0") -implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.0") +implementation("uk.co.appoly.droid:uistate:1.9.1-rc01") +implementation("uk.co.appoly.droid:appsnackbar:1.9.1-rc01") +implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.1-rc01") ``` ## Usage diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index 929aa99..7508ca4 100644 --- a/AppSnackBar/README.md +++ b/AppSnackBar/README.md @@ -13,7 +13,7 @@ A customizable Jetpack Compose Snackbar implementation with support for differen ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:appsnackbar:1.9.0") +implementation("uk.co.appoly.droid:appsnackbar:1.9.1-rc01") ``` ## Usage diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index ebacda9..1c8e78d 100644 --- a/BaseRepo-AppolyJson/README.md +++ b/BaseRepo-AppolyJson/README.md @@ -14,8 +14,8 @@ Appoly's JSON format. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.0") -implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.0") +implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.1-rc01") ``` ## API Response Structure diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md index 2b2f8a7..bf8e873 100644 --- a/BaseRepo-Paging-AppolyJson/README.md +++ b/BaseRepo-Paging-AppolyJson/README.md @@ -15,13 +15,13 @@ follow Appoly's paging format. ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.0") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.0") -implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.0") +implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo-paging:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.1-rc01") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.0") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.0") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1-rc01") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1-rc01") // For LazyGrid ``` ## API Response Format diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md index b8e37e4..9bacbe0 100644 --- a/BaseRepo-Paging/README.md +++ b/BaseRepo-Paging/README.md @@ -17,12 +17,12 @@ extended for specific JSON formats. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.0") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.0") +implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo-paging:1.9.1-rc01") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.0") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.0") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1-rc01") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1-rc01") // For LazyGrid ``` ## Extensions diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md index 0227437..c197fdc 100644 --- a/BaseRepo-S3Uploader-Multipart/README.md +++ b/BaseRepo-S3Uploader-Multipart/README.md @@ -15,9 +15,9 @@ Extension module that bridges BaseRepo and S3Uploader-Multipart, enabling pausab ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.0") -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.0") -implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.0") +implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.1-rc01") ``` ## Usage diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md index cad0a2d..2a00fe2 100644 --- a/BaseRepo-S3Uploader/README.md +++ b/BaseRepo-S3Uploader/README.md @@ -18,9 +18,9 @@ An extension module that bridges BaseRepo and S3Uploader, enabling seamless file ```gradle.kts // Requires both the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.0") -implementation("uk.co.appoly.droid:s3uploader:1.9.0") -implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.0") +implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") +implementation("uk.co.appoly.droid:s3uploader:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.1-rc01") ``` ## How it Works diff --git a/BaseRepo/README.md b/BaseRepo/README.md index 8719440..9b9ffc3 100644 --- a/BaseRepo/README.md +++ b/BaseRepo/README.md @@ -14,7 +14,7 @@ Foundation module for implementing the repository pattern with standardized API ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:baserepo:1.9.0") +implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") ``` ## Extensions diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md index c3b70af..6387b4c 100644 --- a/ComposeExtensions/README.md +++ b/ComposeExtensions/README.md @@ -13,7 +13,7 @@ Compose utilities for insets/IME padding, padding arithmetic, serialization-safe ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:composeextensions:1.9.0") +implementation("uk.co.appoly.droid:composeextensions:1.9.1-rc01") ``` ## Usage diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index f101039..0992c68 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -9,7 +9,7 @@ Add the following dependency to your project's `build.gradle` file: ```gradle.kts -implementation("uk.co.appoly.droid:connectivitymonitor:1.9.0") +implementation("uk.co.appoly.droid:connectivitymonitor:1.9.1-rc01") ``` ## Usage diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index 6d2f9a1..364e7c0 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides Room database integration for ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.0") -implementation("uk.co.appoly.droid:datehelperutil-room:1.9.0") +implementation("uk.co.appoly.droid:datehelperutil:1.9.1-rc01") +implementation("uk.co.appoly.droid:datehelperutil-room:1.9.1-rc01") // Required Room dependencies implementation("androidx.room:room-runtime:2.8.5") diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md index 0a5de23..f02ede8 100644 --- a/DateHelperUtil-Serialization/README.md +++ b/DateHelperUtil-Serialization/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides kotlinx.serialization integrat ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.0") -implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.0") +implementation("uk.co.appoly.droid:datehelperutil:1.9.1-rc01") +implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.1-rc01") // Required kotlinx.serialization dependencies implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") diff --git a/DateHelperUtil/README.md b/DateHelperUtil/README.md index fce722a..6128c51 100644 --- a/DateHelperUtil/README.md +++ b/DateHelperUtil/README.md @@ -14,7 +14,7 @@ A utility module for standardized date and time operations in Android applicatio ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:datehelperutil:1.9.0") +implementation("uk.co.appoly.droid:datehelperutil:1.9.1-rc01") ``` ## 1.4.1 patch note diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md index 3b7156d..5f9b564 100644 --- a/LazyGridPagingExtensions/README.md +++ b/LazyGridPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for integrating Jetpack Paging 3 with Compose LazyVerticalGr ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.0") -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.0") +implementation("uk.co.appoly.droid:pagingextensions:1.9.1-rc01") +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1-rc01") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md index 26076ff..9cffee0 100644 --- a/LazyListPagingExtensions/README.md +++ b/LazyListPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for easy integration of Jetpack Paging 3 with Compose LazyCo ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.0") -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.0") +implementation("uk.co.appoly.droid:pagingextensions:1.9.1-rc01") +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1-rc01") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md index e85ada7..74149b5 100644 --- a/MockInterceptor-AppolyJson/README.md +++ b/MockInterceptor-AppolyJson/README.md @@ -14,7 +14,7 @@ Extension for [MockInterceptor-Serialization](../MockInterceptor-Serialization/) ```gradle.kts // MockInterceptor and MockInterceptor-Serialization are included transitively -implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.0") +implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.1-rc01") ``` ## Usage diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md index 5572e72..6e0949e 100644 --- a/MockInterceptor-Retrofit/README.md +++ b/MockInterceptor-Retrofit/README.md @@ -13,7 +13,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that reads Retrofit HTTP an ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.0") +implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.1-rc01") ``` > **Note:** Retrofit is a `compileOnly` dependency — your project must already depend on Retrofit. diff --git a/MockInterceptor-Serialization/README.md b/MockInterceptor-Serialization/README.md index 59c2bb9..c970e7e 100644 --- a/MockInterceptor-Serialization/README.md +++ b/MockInterceptor-Serialization/README.md @@ -12,7 +12,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that adds type-safe JSON re ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.0") +implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.1-rc01") ``` ## Usage diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md index d37dbae..9642f7b 100644 --- a/MockInterceptor/README.md +++ b/MockInterceptor/README.md @@ -16,7 +16,7 @@ An OkHttp interceptor with a route-matching DSL for mocking API responses during ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:mockinterceptor:1.9.0") +implementation("uk.co.appoly.droid:mockinterceptor:1.9.1-rc01") ``` ## Usage diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index dee8bac..9c685d0 100644 --- a/Nav3Navigation/README.md +++ b/Nav3Navigation/README.md @@ -32,13 +32,13 @@ without giving up the fused-screen / ambient-navigator convenience that Voyager ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:nav3navigation:1.9.0") +implementation("uk.co.appoly.droid:nav3navigation:1.9.1-rc01") ``` Or via the AppolyDroid BOM (version managed by the platform): ```gradle.kts -implementation(platform("uk.co.appoly.droid:bom:1.9.0")) +implementation(platform("uk.co.appoly.droid:bom:1.9.1-rc01")) implementation("uk.co.appoly.droid:nav3navigation") ``` diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md index 4c8f405..7686a30 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -12,7 +12,7 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:pagingextensions:1.9.0") +implementation("uk.co.appoly.droid:pagingextensions:1.9.1-rc01") ``` ## Usage diff --git a/README.md b/README.md index bfcd3cb..1b88fbc 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.0" # Replace with the latest version +appolydroidToolbox = "1.9.1-rc01" # Replace with the latest version [libraries] appolydroid-toolbox-bom = { group = "uk.co.appoly.droid", name = "bom", version.ref = "appolydroidToolbox" } @@ -129,7 +129,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { // Import the BOM - implementation(platform("uk.co.appoly.droid:bom:1.9.0")) + implementation(platform("uk.co.appoly.droid:bom:1.9.1-rc01")) // Now you can use AppolyDroid modules without specifying versions implementation("uk.co.appoly.droid:baserepo") @@ -166,7 +166,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.0" # Replace with the latest version +appolydroidToolbox = "1.9.1-rc01" # Replace with the latest version [libraries] #AppolyDroid-Toolbox @@ -234,7 +234,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { - val appolydroidToolbox = "1.9.0" // Replace with the latest version + val appolydroidToolbox = "1.9.1-rc01" // Replace with the latest version // Add only the modules you need implementation("uk.co.appoly.droid:baserepo:$appolydroidToolbox") implementation("uk.co.appoly.droid:baserepo-appolyjson:$appolydroidToolbox") diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index 59d62c7..db3835e 100644 --- a/S3Uploader-Multipart/README.md +++ b/S3Uploader-Multipart/README.md @@ -16,7 +16,7 @@ Advanced S3 upload module with pause, resume, and recovery support using AWS S3 ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.0") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1-rc01") ``` This module depends on `S3Uploader` and includes it transitively. diff --git a/S3Uploader/README.md b/S3Uploader/README.md index ef00776..4918598 100644 --- a/S3Uploader/README.md +++ b/S3Uploader/README.md @@ -16,7 +16,7 @@ Standalone module for Amazon S3 file uploading with progress tracking and error ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader:1.9.0") +implementation("uk.co.appoly.droid:s3uploader:1.9.1-rc01") ``` ## Usage diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index f81df40..255ebd6 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -17,7 +17,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:segmentedcontrol:1.9.0") +implementation("uk.co.appoly.droid:segmentedcontrol:1.9.1-rc01") ``` ## Usage diff --git a/UiState/README.md b/UiState/README.md index 77a5319..6649af5 100644 --- a/UiState/README.md +++ b/UiState/README.md @@ -13,7 +13,7 @@ A standardized UI state management library for Android applications, providing c ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:uistate:1.9.0") +implementation("uk.co.appoly.droid:uistate:1.9.1-rc01") ``` ## Usage diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index fc7e0c9..2bc7b19 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -9,7 +9,7 @@ object BuildConfig { * The current version of the AppolyDroid Toolbox library. * This is used for maven publishing and README version updates. */ - const val TOOLBOX_VERSION = "1.9.0" + const val TOOLBOX_VERSION = "1.9.1-rc01" /** * SDK version configuration for Android modules. From 51c3ddbf7a282ee1bdf3681b0c61afecc2e9979a Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 15 Sep 2026 09:29:18 +0100 Subject: [PATCH 13/53] build: add local-publish and clear-local-publish scripts and run configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .run/Clear_Local_Maven_Publish.run.xml | 17 +++ .run/Publish_to_Maven_Local.run.xml | 17 +++ .run/Publish_to_Maven_Local__signed_.run.xml | 17 +++ CLAUDE.md | 3 + CONTRIBUTING.md | 107 +++++++++++++-- scripts/clear-local-publish.sh | 133 +++++++++++++++++++ scripts/publish-local.sh | 112 ++++++++++++++++ scripts/publish.sh | 6 + 8 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 .run/Clear_Local_Maven_Publish.run.xml create mode 100644 .run/Publish_to_Maven_Local.run.xml create mode 100644 .run/Publish_to_Maven_Local__signed_.run.xml create mode 100755 scripts/clear-local-publish.sh create mode 100755 scripts/publish-local.sh diff --git a/.run/Clear_Local_Maven_Publish.run.xml b/.run/Clear_Local_Maven_Publish.run.xml new file mode 100644 index 0000000..71deee6 --- /dev/null +++ b/.run/Clear_Local_Maven_Publish.run.xml @@ -0,0 +1,17 @@ + + + + diff --git a/.run/Publish_to_Maven_Local.run.xml b/.run/Publish_to_Maven_Local.run.xml new file mode 100644 index 0000000..e4833ba --- /dev/null +++ b/.run/Publish_to_Maven_Local.run.xml @@ -0,0 +1,17 @@ + + + + diff --git a/.run/Publish_to_Maven_Local__signed_.run.xml b/.run/Publish_to_Maven_Local__signed_.run.xml new file mode 100644 index 0000000..17658e1 --- /dev/null +++ b/.run/Publish_to_Maven_Local__signed_.run.xml @@ -0,0 +1,17 @@ + + + + diff --git a/CLAUDE.md b/CLAUDE.md index 3c44c5e..e02b0d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,9 @@ Published to **Maven Central** under `uk.co.appoly.droid`, with lowercase artifa Gradle reads them only under the `ORG_GRADLE_PROJECT_` prefix with exact camelCase. The vault item is set in the git-ignored `scripts/publish.conf` — this repo is public, so it is not committed. See `scripts/publish.conf.example`. +- `./scripts/publish-local.sh` installs to `~/.m2` unsigned with no credentials (the everyday + local-testing loop), and `./scripts/clear-local-publish.sh` removes that install again. Both are + also Android Studio run configurations in `.run/`. - `./scripts/publish.sh --local` publishes signed artifacts to `~/.m2`; without `--local` it releases to Central. **Releases are run manually and locally** — there is no release CI job and no Maven Central secrets in the repo, so a version tag publishes nothing on its own. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3db6fe2..bad6619 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,23 +6,105 @@ covers building, testing and releasing it. ## Testing an unreleased change Maven Central publishes only what is released, so there is no equivalent of JitPack's -build-any-branch behaviour. Two options replace it. +build-any-branch behaviour — and releases are immutable, so a mistake cannot be corrected in +place. Test locally first. Two options replace JitPack. -**Install locally.** From a checkout of the branch you want to test: +### Install locally (the normal loop) + +From a checkout of the branch you want to test: ```bash -./scripts/publish.sh --local +./scripts/publish-local.sh # every module, unsigned — no credentials needed +./scripts/publish-local.sh BaseRepo UiState # only those modules, for a tight iteration loop +./scripts/publish-local.sh --signed # every module, signed (= ./scripts/publish.sh --local) +``` + +Undo it with: + +```bash +./scripts/clear-local-publish.sh # every locally installed version +./scripts/clear-local-publish.sh 1.9.1-local1 # just that version +./scripts/clear-local-publish.sh --dry-run # list what would go, delete nothing +``` + +Both are also Android Studio run configurations, checked in under `.run/` and shared through +version control: **Publish to Maven Local**, **Publish to Maven Local (signed)** and **Clear Local +Maven Publish**. They run in the Run window's terminal, so the clear script's confirmation prompt +works there. To publish a subset from the IDE, edit the run configuration's *Script options* field — +or just use the terminal. + +`clear-local-publish.sh` only ever touches `~/.m2/repository/uk/co/appoly/droid` (or `PUBLISH_GROUP` +from `scripts/publish.conf`, for a fork). Nothing else in `~/.m2` is read or written. + +**Signed or not?** Unsigned is the default because signing needs the release key out of 1Password, +and Gradle does not verify signatures on resolve — an unsigned local install behaves identically to +a signed one for every purpose this loop has. Use `--signed` only when the thing under test *is* the +signing, or the exact artifact set a release would upload. That path is `publish.sh --local`, which +`--signed` simply delegates to. + +### Consuming a local install from another project + +Add `mavenLocal()` **first** in the consuming project's repository list, so it wins over Central: + +```kotlin +// settings.gradle.kts +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenLocal() + google() + mavenCentral() + } +} ``` -That publishes every module to `~/.m2`, signed. Add `mavenLocal()` to the consuming project's -repositories, ahead of `mavenCentral()`. +Then depend on the toolbox exactly as usual — the BOM works unchanged, since it is installed +locally alongside everything else: + +```kotlin +// is whatever TOOLBOX_VERSION you just installed — this file is not version-synced, +// so read it out of buildSrc/src/main/kotlin/BuildConfig.kt rather than trusting a number here. +implementation(platform("uk.co.appoly.droid:bom:")) +implementation("uk.co.appoly.droid:baserepo") +implementation("uk.co.appoly.droid:uistate") +``` + +Sync with `--refresh-dependencies` the first time: + +```bash +./gradlew --refresh-dependencies :app:assembleStagingDebug +``` + +Without it Gradle may serve a cached module for that version string — resolved earlier from Central +— and never look in `~/.m2` at all. The same applies in reverse *after* clearing: a consumer that +already resolved the local copy keeps serving it until refreshed. + +A narrower alternative, if you would rather `mavenLocal()` could not possibly shadow anything else, +is to scope it to the toolbox group: + +```kotlin +exclusiveContent { + forRepository { mavenLocal() } + filter { includeGroup("uk.co.appoly.droid") } +} +``` + +> **Take `mavenLocal()` back out when you are done**, and run `clear-local-publish.sh`. A local +> install carries the same version string as the real release, so leaving either in place means +> resolving your own working tree while believing you are testing the published artifacts. A partial +> install (`publish-local.sh BaseRepo`) is worse still: the other modules in `~/.m2` are whatever was +> installed last, possibly a different build of the same version. + +The cleanest way to remove the ambiguity entirely is to bump `TOOLBOX_VERSION` in +`buildSrc/src/main/kotlin/BuildConfig.kt` to something that does not and will not exist on Central — +`1.9.1-local1` — and depend on that from the consuming project. Then there is no version string in +play that could mean two different things, and the dependency-cache problem disappears with it. +Revert the bump before committing. -> Take `mavenLocal()` out again before committing, and before drawing any conclusion about a -> released version. A locally published build carries the same version string as the real one, so -> leaving it in means resolving your own artifacts while believing you are testing the release. +### Publish a snapshot -**Publish a snapshot.** Snapshot versions go to Central's snapshot repository rather than the main -one, and need it adding explicitly: +Snapshot versions go to Central's snapshot repository rather than the main one, and need it adding +explicitly: ```kotlin maven { url = uri("https://central.sonatype.com/repository/maven-snapshots/") } @@ -50,7 +132,8 @@ Bump `TOOLBOX_VERSION` in `buildSrc/src/main/kotlin/BuildConfig.kt` first. Every one version; see [Why one version for all modules](#why-one-version-for-all-modules). > **Releases are immutable.** A version can never be re-uploaded or corrected — the only remedy is -> publishing a new one. Iterate with `--local` *before* releasing, never after. +> publishing a new one. Iterate with [`publish-local.sh`](#install-locally-the-normal-loop) *before* +> releasing, never after. ### Central publishing limits — batch releases, do not split modules @@ -67,7 +150,7 @@ Two consequences for release practice: - **Batch patch releases.** A flurry of same-month point releases — the 1.8.0 → 1.8.3 pattern of August 2026 — would be ~2,540 files, over twice the allowance. Fold fixes into one version and - iterate through `--local` or a snapshot in the meantime. + iterate through a local install or a snapshot in the meantime. - **Do not split modules to reduce usage; it does the opposite.** 26 separately-published repositories would be 26 release events per version, past the limit of 7 on day one. The single batched deployment is the cheapest possible shape under these rules — a further reason for the diff --git a/scripts/clear-local-publish.sh b/scripts/clear-local-publish.sh new file mode 100755 index 0000000..d02c11b --- /dev/null +++ b/scripts/clear-local-publish.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# +# AppolyDroid Toolbox — remove a local install. +# +# Deletes the toolbox from the local Maven repository (~/.m2/repository), undoing +# scripts/publish-local.sh or scripts/publish.sh --local. +# +# ./scripts/clear-local-publish.sh remove every locally installed version +# ./scripts/clear-local-publish.sh 1.9.1-rc01 remove just that version +# ./scripts/clear-local-publish.sh --dry-run list what would go, delete nothing +# ./scripts/clear-local-publish.sh --yes skip the confirmation prompt +# +# WHY THIS MATTERS. A local install carries the same version string as the real release, and +# mavenLocal() wins over mavenCentral(). Left in place it silently shadows the published artifacts: +# the consuming project resolves your working tree while the version number says otherwise. Clearing +# it is how you get back to testing what consumers actually receive. +# +# Only ever touches the toolbox's own group directory — nothing else in ~/.m2 is read or written. +# +set -euo pipefail + +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Shares fork-specific settings (PUBLISH_GROUP) with publish.sh. Git-ignored; see publish.conf.example. +# shellcheck disable=SC1091 +[[ -f scripts/publish.conf ]] && source scripts/publish.conf + +readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}" +readonly M2_REPO="${M2_REPO:-$HOME/.m2/repository}" +readonly GROUP_DIR="$M2_REPO/${GROUP//.//}" + +RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; NC=$'\033[0m' +info() { echo "${GREEN}[INFO]${NC} $1"; } +warn() { echo "${YELLOW}[WARN]${NC} $1"; } +fail() { echo "${RED}[ERROR]${NC} $1" >&2; } + +DRY_RUN=false +ASSUME_YES=false +VERSION="" +for arg in "$@"; do + case "$arg" in + --dry-run|-n) DRY_RUN=true ;; + --yes|-y) ASSUME_YES=true ;; + -h|--help) + cat <<'USAGE' +Usage: ./scripts/clear-local-publish.sh [--dry-run] [--yes] [VERSION] + + (no args) Remove every locally installed toolbox version from ~/.m2. + VERSION Remove only that version (e.g. 1.9.1-rc01). + --dry-run, -n List what would be removed, delete nothing. + --yes, -y Do not ask for confirmation. + +Only the toolbox's own group directory is touched. Install again with +./scripts/publish-local.sh. +USAGE + exit 0 ;; + -*) fail "Unknown option: $arg"; echo "Try --help" >&2; exit 1 ;; + *) + [[ -z "$VERSION" ]] || { fail "Only one version can be given (got '$VERSION' and '$arg')."; exit 1; } + VERSION="$arg" ;; + esac +done + +if [[ ! -d "$GROUP_DIR" ]]; then + info "Nothing to clear — $GROUP is not installed in $M2_REPO." + exit 0 +fi + +# Collect the artifact/version directories to delete, so the prompt shows exactly what goes. +TARGETS=() +while IFS= read -r dir; do + TARGETS+=("$dir") +done < <( + if [[ -n "$VERSION" ]]; then + find "$GROUP_DIR" -mindepth 2 -maxdepth 2 -type d -name "$VERSION" | sort + else + find "$GROUP_DIR" -mindepth 1 -maxdepth 1 -type d | sort + fi +) + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + if [[ -n "$VERSION" ]]; then + info "Nothing to clear — no module of $GROUP is installed at version $VERSION." + else + info "Nothing to clear — $GROUP_DIR holds no module directories." + fi + exit 0 +fi + +SIZE=$(du -sh "$GROUP_DIR" 2>/dev/null | cut -f1 | tr -d "[:space:]" || echo "?") + +echo +echo "================================================" +if [[ -n "$VERSION" ]]; then + echo " Clearing ${BOLD}$GROUP${NC} ${BOLD}$VERSION${NC} from ~/.m2" +else + echo " Clearing ${BOLD}$GROUP${NC} (all versions) from ~/.m2" +fi +echo "================================================" +echo +echo "${#TARGETS[@]} director$([[ ${#TARGETS[@]} -eq 1 ]] && echo "y" || echo "ies") under $GROUP_DIR:" +for target in "${TARGETS[@]}"; do + echo " ${target#"$GROUP_DIR"/}" +done +echo +info "Group directory currently uses $SIZE on disk." + +if [[ "$DRY_RUN" == true ]]; then + echo + info "Dry run — nothing was deleted." + exit 0 +fi + +if [[ "$ASSUME_YES" != true ]]; then + echo + read -rp "Delete these? (y/N) " -n 1 reply; echo + [[ $reply =~ ^[Yy]$ ]] || { info "Cancelled — nothing was deleted."; exit 0; } +fi + +for target in "${TARGETS[@]}"; do + rm -rf "$target" +done + +# With a version filter the artifact directories survive, holding other versions — or nothing, if +# that was the only one installed. Prune the husks so a later run reports honestly instead of +# listing empty directories. +find "$GROUP_DIR" -mindepth 1 -type d -empty -delete +rmdir "$GROUP_DIR" 2>/dev/null || true + +echo +info "Cleared. The consuming project now resolves $GROUP from its remote repositories again." +warn "Gradle caches resolved modules per project. If a consumer already resolved the local copy," +warn "it needs --refresh-dependencies to notice, or it keeps serving the build you just deleted." diff --git a/scripts/publish-local.sh b/scripts/publish-local.sh new file mode 100755 index 0000000..8b0036b --- /dev/null +++ b/scripts/publish-local.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# AppolyDroid Toolbox — local install. +# +# Publishes the toolbox to the local Maven repository (~/.m2/repository) so a consuming app can +# resolve the current working tree through mavenLocal(). This is the loop for testing a change +# BEFORE releasing it — Maven Central releases are immutable, so there is no fixing one afterwards. +# +# ./scripts/publish-local.sh every module, unsigned +# ./scripts/publish-local.sh BaseRepo UiState only those modules (and their dependencies) +# ./scripts/publish-local.sh --signed every module, signed — same as publish.sh --local +# +# WHY UNSIGNED BY DEFAULT. Signing needs the release key out of 1Password, which makes the quick +# iteration loop wait on a vault unlock for a signature nothing local ever verifies. Gradle does not +# check signatures on resolve, so an unsigned local install behaves identically to a signed one for +# every purpose this script exists for. Use --signed when the thing being tested IS the signing or +# the exact artifact set a release would upload; that path is scripts/publish.sh --local. +# +# Undo with ./scripts/clear-local-publish.sh — see CONTRIBUTING.md, "Testing an unreleased change". +# +set -euo pipefail + +cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Shares fork-specific settings (PUBLISH_GROUP) with publish.sh. Git-ignored; see publish.conf.example. +# shellcheck disable=SC1091 +[[ -f scripts/publish.conf ]] && source scripts/publish.conf + +readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}" + +RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; NC=$'\033[0m' +info() { echo "${GREEN}[INFO]${NC} $1"; } +warn() { echo "${YELLOW}[WARN]${NC} $1"; } +fail() { echo "${RED}[ERROR]${NC} $1" >&2; } + +MODULES=() +for arg in "$@"; do + case "$arg" in + --signed|-s) + info "Delegating to scripts/publish.sh --local for the signed install." + exec ./scripts/publish.sh --local + ;; + -h|--help) + cat <<'USAGE' +Usage: ./scripts/publish-local.sh [--signed] [Module ...] + + (no args) Publish every module to ~/.m2, unsigned. Needs no credentials. + Module ... Publish only the named modules, by Gradle project name + (e.g. BaseRepo UiState). Faster when iterating on one module. + --signed, -s Publish every module signed, via scripts/publish.sh --local. + Needs the release signing key. + +Remove a local install again with ./scripts/clear-local-publish.sh. +USAGE + exit 0 ;; + -*) fail "Unknown option: $arg"; echo "Try --help" >&2; exit 1 ;; + *) MODULES+=("${arg#:}") ;; + esac +done + +VERSION=$(sed -n 's/.*TOOLBOX_VERSION *= *"\([^"]*\)".*/\1/p' buildSrc/src/main/kotlin/BuildConfig.kt) +[[ -n "$VERSION" ]] || { fail "Could not read TOOLBOX_VERSION from buildSrc/src/main/kotlin/BuildConfig.kt"; exit 1; } + +# Publishing drives Dokka across every module in one daemon, which needs more metaspace than a +# typical personal ~/.gradle/gradle.properties allows — and user-level properties beat the repo's, +# so the project cannot set this itself. Unpinned, this fails with a bare "Metaspace" error on an +# arbitrary module. Same reasoning as publish.sh. +export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=2048m -Dfile.encoding=UTF-8" + +if [[ ${#MODULES[@]} -eq 0 ]]; then + info "Publishing all modules of ${BOLD}$GROUP${NC} at ${BOLD}$VERSION${NC} to ~/.m2 (unsigned)..." + TASKS=(publishToMavenLocal) +else + # A partial install leaves ~/.m2 holding a mix of versions across modules. That is fine while + # iterating on one module, and wrong the moment you draw a conclusion about the set — hence + # the warning below and the whole-set default. + info "Publishing ${BOLD}${MODULES[*]}${NC} at ${BOLD}$VERSION${NC} to ~/.m2 (unsigned)..." + TASKS=() + for module in "${MODULES[@]}"; do + TASKS+=(":${module}:publishToMavenLocal") + done +fi + +./gradlew "${TASKS[@]}" + +echo +info "================================================" +info " Installed to ~/.m2 — $GROUP at $VERSION" +info "================================================" +echo +if [[ ${#MODULES[@]} -gt 0 ]]; then + warn "Partial install — only these were rebuilt: ${MODULES[*]}. Every other module in ~/.m2" + warn "is whatever was installed last, which may be a different build of the same version." +fi +cat < Date: Tue, 15 Sep 2026 14:10:11 +0100 Subject: [PATCH 14/53] docs(Nav3Navigation): how to deliver a pop result on system back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Nav3Navigation/README.md | 75 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index 9c685d0..2ef77b7 100644 --- a/Nav3Navigation/README.md +++ b/Nav3Navigation/README.md @@ -32,13 +32,13 @@ without giving up the fused-screen / ambient-navigator convenience that Voyager ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:nav3navigation:1.9.1-rc01") +implementation("uk.co.appoly.droid:nav3navigation:1.9.1") ``` Or via the AppolyDroid BOM (version managed by the platform): ```gradle.kts -implementation(platform("uk.co.appoly.droid:bom:1.9.1-rc01")) +implementation(platform("uk.co.appoly.droid:bom:1.9.1")) implementation("uk.co.appoly.droid:nav3navigation") ``` @@ -186,6 +186,10 @@ Bind one `NavigationEventState` to exactly one `NavigationBackHandler` — a sec a state throws `IllegalArgumentException`. Branch inside `onBackCompleted` rather than registering two conditional handlers. +Wanting to hand a **result** back on system back is not a reason to register one — an always-enabled +handler costs you the predictive-back scrub for no interception. See +[Delivering a result on system back](#delivering-a-result-on-system-back). + ### Deep links A deep link is just a seeded start stack — no graph, no URI-pattern framework: @@ -532,6 +536,73 @@ Default decorators include the result-bus decorator. A picker can `sendResult(.. caller observes via `ResultEffect`. **Treat as alpha** — event vs state variants differ on process-death behaviour. Prefer (A) or a shared ViewModel until this hits beta/stable. +#### Delivering a result on system back + +`popWithResult` is **child-initiated**: the screen being popped chooses to deliver. System back +does not go through it — `Nav3ScreenHost` forwards `onBack` to `NavDisplay`, which defaults to +plain `navigator.pop()`. So a screen that hands a value back from its own back arrow delivers +nothing when the user swipes or presses back instead. Nothing fails; the result is simply dropped, +and a "something changed, refresh the list" signal goes missing on the most common exit path. + +**Don't fix this with an always-enabled `NavigationBackHandler`.** It works, and it costs you the +predictive-back animation: the handler intercepts ahead of `NavDisplay`, so `predictivePopTransitionSpec` +never scrubs and the screen no longer animates out under the gesture. See [System back](#system-back) — +that API is for genuinely *conditional* interception, not for "pop, but carry a payload". + +Dispatch from the host's `onBack` instead, on an app-side interface: + +```kotlin +interface PopsWithResult { + fun popResult(): Any? +} + +@Serializable +data class PostDetailScreen(val id: Long) : Nav3Screen, PopsWithResult { + override fun popResult() = true // "something changed, refresh" + + @Composable + override fun Content() { /* back arrow still calls navigator.popWithResult(true) */ } +} +``` + +```kotlin +val backStack = rememberNavBackStack(ListScreen) +// Hoisted: `onBack` is built at the call site, where LocalNav3Navigator is still the *outer* +// navigator (null at the top level) — not the one this host provides to its screens. +val navigator = rememberBackStackNav3Navigator(backStack) + +Nav3ScreenHost( + modifier = Modifier.fillMaxSize(), + backStack = backStack, + navigator = navigator, + onBack = { + when (val top = navigator.lastItem) { + is PopsWithResult -> navigator.popWithResult(top.popResult()) + else -> navigator.pop() + } + }, +) +``` + +Predictive back is untouched — `NavDisplay` still owns the gesture and runs the pop transition; only +what happens on completion changed. Both exits now route through `popWithResult`, so the back arrow +and system back cannot drift apart. + +Three things worth knowing: + +- **`popWithResult` no-ops entirely when `canPop` is `false`** — nothing pops and the result is + dropped. Harmless here, because Nav3 disables its back callback at the root and `onBack` is never + invoked; but don't reuse the helper somewhere a pop at depth 1 is required. +- **Delivery is "pop first, deliver to the revealed top"**, gated on that screen implementing + `Nav3ResultReceiver`. A detail screen reachable from two different lists needs *both* to implement + it — otherwise the pop proceeds and the result is silently dropped. +- **Keep `popResult()` a constant on `@Serializable` keys.** Don't accumulate state on the key to + build a richer result; put the real payload in a screen-scoped ViewModel, same rule as `metadata`. + +This is deliberately app-side. A host default that always called `popWithResult(null)` would hand +`null` to receivers that only wanted results from explicit pops, and a `Nav3Screen.onPopResult` hook +would be this interface with the library guessing the contract instead of the app declaring it. + ### Screen-scoped ViewModels (ScreenModel → ViewModel) Voyager `ScreenModel` + `koinScreenModel()` maps cleanly onto real `ViewModel`s: From c4a7e5ceb9741fc455bcb94f404e65b4bad6918e Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 15 Sep 2026 14:10:53 +0100 Subject: [PATCH 15/53] build: TOOLBOX_VERSION 1.9.1-rc01 -> 1.9.1 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) --- AppSnackBar-UiState/README.md | 6 +++--- AppSnackBar/README.md | 2 +- BaseRepo-AppolyJson/README.md | 4 ++-- BaseRepo-Paging-AppolyJson/README.md | 10 +++++----- BaseRepo-Paging/README.md | 8 ++++---- BaseRepo-S3Uploader-Multipart/README.md | 6 +++--- BaseRepo-S3Uploader/README.md | 6 +++--- BaseRepo/README.md | 2 +- ComposeExtensions/README.md | 2 +- ConnectivityMonitor/README.md | 2 +- DateHelperUtil-Room/README.md | 4 ++-- DateHelperUtil-Serialization/README.md | 4 ++-- DateHelperUtil/README.md | 2 +- LazyGridPagingExtensions/README.md | 4 ++-- LazyListPagingExtensions/README.md | 4 ++-- MockInterceptor-AppolyJson/README.md | 2 +- MockInterceptor-Retrofit/README.md | 2 +- MockInterceptor-Serialization/README.md | 2 +- MockInterceptor/README.md | 2 +- PagingExtensions/README.md | 2 +- README.md | 8 ++++---- S3Uploader-Multipart/README.md | 2 +- S3Uploader/README.md | 2 +- SegmentedControl/README.md | 2 +- UiState/README.md | 2 +- buildSrc/src/main/kotlin/BuildConfig.kt | 2 +- 26 files changed, 47 insertions(+), 47 deletions(-) diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index 3e66462..66264e1 100644 --- a/AppSnackBar-UiState/README.md +++ b/AppSnackBar-UiState/README.md @@ -13,9 +13,9 @@ Integration module that bridges the AppSnackBar and UiState modules, providing a ```gradle.kts // Requires both base modules -implementation("uk.co.appoly.droid:uistate:1.9.1-rc01") -implementation("uk.co.appoly.droid:appsnackbar:1.9.1-rc01") -implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.1-rc01") +implementation("uk.co.appoly.droid:uistate:1.9.1") +implementation("uk.co.appoly.droid:appsnackbar:1.9.1") +implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.1") ``` ## Usage diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index 7508ca4..7db2e56 100644 --- a/AppSnackBar/README.md +++ b/AppSnackBar/README.md @@ -13,7 +13,7 @@ A customizable Jetpack Compose Snackbar implementation with support for differen ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:appsnackbar:1.9.1-rc01") +implementation("uk.co.appoly.droid:appsnackbar:1.9.1") ``` ## Usage diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index 1c8e78d..b34b81d 100644 --- a/BaseRepo-AppolyJson/README.md +++ b/BaseRepo-AppolyJson/README.md @@ -14,8 +14,8 @@ Appoly's JSON format. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") -implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo:1.9.1") +implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.1") ``` ## API Response Structure diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md index bf8e873..7890f83 100644 --- a/BaseRepo-Paging-AppolyJson/README.md +++ b/BaseRepo-Paging-AppolyJson/README.md @@ -15,13 +15,13 @@ follow Appoly's paging format. ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.1-rc01") -implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo:1.9.1") +implementation("uk.co.appoly.droid:baserepo-paging:1.9.1") +implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.1") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1-rc01") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1-rc01") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid ``` ## API Response Format diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md index 9bacbe0..b8dd1c7 100644 --- a/BaseRepo-Paging/README.md +++ b/BaseRepo-Paging/README.md @@ -17,12 +17,12 @@ extended for specific JSON formats. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo:1.9.1") +implementation("uk.co.appoly.droid:baserepo-paging:1.9.1") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1-rc01") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1-rc01") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid ``` ## Extensions diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md index c197fdc..d3c1724 100644 --- a/BaseRepo-S3Uploader-Multipart/README.md +++ b/BaseRepo-S3Uploader-Multipart/README.md @@ -15,9 +15,9 @@ Extension module that bridges BaseRepo and S3Uploader-Multipart, enabling pausab ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1-rc01") -implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo:1.9.1") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1") +implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.1") ``` ## Usage diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md index 2a00fe2..fdb67ca 100644 --- a/BaseRepo-S3Uploader/README.md +++ b/BaseRepo-S3Uploader/README.md @@ -18,9 +18,9 @@ An extension module that bridges BaseRepo and S3Uploader, enabling seamless file ```gradle.kts // Requires both the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") -implementation("uk.co.appoly.droid:s3uploader:1.9.1-rc01") -implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo:1.9.1") +implementation("uk.co.appoly.droid:s3uploader:1.9.1") +implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.1") ``` ## How it Works diff --git a/BaseRepo/README.md b/BaseRepo/README.md index 9b9ffc3..1061378 100644 --- a/BaseRepo/README.md +++ b/BaseRepo/README.md @@ -14,7 +14,7 @@ Foundation module for implementing the repository pattern with standardized API ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:baserepo:1.9.1-rc01") +implementation("uk.co.appoly.droid:baserepo:1.9.1") ``` ## Extensions diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md index 6387b4c..688eb43 100644 --- a/ComposeExtensions/README.md +++ b/ComposeExtensions/README.md @@ -13,7 +13,7 @@ Compose utilities for insets/IME padding, padding arithmetic, serialization-safe ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:composeextensions:1.9.1-rc01") +implementation("uk.co.appoly.droid:composeextensions:1.9.1") ``` ## Usage diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index 0992c68..cf9418f 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -9,7 +9,7 @@ Add the following dependency to your project's `build.gradle` file: ```gradle.kts -implementation("uk.co.appoly.droid:connectivitymonitor:1.9.1-rc01") +implementation("uk.co.appoly.droid:connectivitymonitor:1.9.1") ``` ## Usage diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index 364e7c0..a738f28 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides Room database integration for ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.1-rc01") -implementation("uk.co.appoly.droid:datehelperutil-room:1.9.1-rc01") +implementation("uk.co.appoly.droid:datehelperutil:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil-room:1.9.1") // Required Room dependencies implementation("androidx.room:room-runtime:2.8.5") diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md index f02ede8..7db5188 100644 --- a/DateHelperUtil-Serialization/README.md +++ b/DateHelperUtil-Serialization/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides kotlinx.serialization integrat ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.1-rc01") -implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.1-rc01") +implementation("uk.co.appoly.droid:datehelperutil:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.1") // Required kotlinx.serialization dependencies implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") diff --git a/DateHelperUtil/README.md b/DateHelperUtil/README.md index 6128c51..37af5c7 100644 --- a/DateHelperUtil/README.md +++ b/DateHelperUtil/README.md @@ -14,7 +14,7 @@ A utility module for standardized date and time operations in Android applicatio ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:datehelperutil:1.9.1-rc01") +implementation("uk.co.appoly.droid:datehelperutil:1.9.1") ``` ## 1.4.1 patch note diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md index 5f9b564..54abf19 100644 --- a/LazyGridPagingExtensions/README.md +++ b/LazyGridPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for integrating Jetpack Paging 3 with Compose LazyVerticalGr ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.1-rc01") -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1-rc01") +implementation("uk.co.appoly.droid:pagingextensions:1.9.1") +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md index 9cffee0..23738e9 100644 --- a/LazyListPagingExtensions/README.md +++ b/LazyListPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for easy integration of Jetpack Paging 3 with Compose LazyCo ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.1-rc01") -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1-rc01") +implementation("uk.co.appoly.droid:pagingextensions:1.9.1") +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md index 74149b5..3d52bef 100644 --- a/MockInterceptor-AppolyJson/README.md +++ b/MockInterceptor-AppolyJson/README.md @@ -14,7 +14,7 @@ Extension for [MockInterceptor-Serialization](../MockInterceptor-Serialization/) ```gradle.kts // MockInterceptor and MockInterceptor-Serialization are included transitively -implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.1-rc01") +implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.1") ``` ## Usage diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md index 6e0949e..2ee209f 100644 --- a/MockInterceptor-Retrofit/README.md +++ b/MockInterceptor-Retrofit/README.md @@ -13,7 +13,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that reads Retrofit HTTP an ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.1-rc01") +implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.1") ``` > **Note:** Retrofit is a `compileOnly` dependency — your project must already depend on Retrofit. diff --git a/MockInterceptor-Serialization/README.md b/MockInterceptor-Serialization/README.md index c970e7e..d9cc6ec 100644 --- a/MockInterceptor-Serialization/README.md +++ b/MockInterceptor-Serialization/README.md @@ -12,7 +12,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that adds type-safe JSON re ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.1-rc01") +implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.1") ``` ## Usage diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md index 9642f7b..9885716 100644 --- a/MockInterceptor/README.md +++ b/MockInterceptor/README.md @@ -16,7 +16,7 @@ An OkHttp interceptor with a route-matching DSL for mocking API responses during ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:mockinterceptor:1.9.1-rc01") +implementation("uk.co.appoly.droid:mockinterceptor:1.9.1") ``` ## Usage diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md index 7686a30..effab19 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -12,7 +12,7 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:pagingextensions:1.9.1-rc01") +implementation("uk.co.appoly.droid:pagingextensions:1.9.1") ``` ## Usage diff --git a/README.md b/README.md index 1b88fbc..f842695 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.1-rc01" # Replace with the latest version +appolydroidToolbox = "1.9.1" # Replace with the latest version [libraries] appolydroid-toolbox-bom = { group = "uk.co.appoly.droid", name = "bom", version.ref = "appolydroidToolbox" } @@ -129,7 +129,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { // Import the BOM - implementation(platform("uk.co.appoly.droid:bom:1.9.1-rc01")) + implementation(platform("uk.co.appoly.droid:bom:1.9.1")) // Now you can use AppolyDroid modules without specifying versions implementation("uk.co.appoly.droid:baserepo") @@ -166,7 +166,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.1-rc01" # Replace with the latest version +appolydroidToolbox = "1.9.1" # Replace with the latest version [libraries] #AppolyDroid-Toolbox @@ -234,7 +234,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { - val appolydroidToolbox = "1.9.1-rc01" // Replace with the latest version + val appolydroidToolbox = "1.9.1" // Replace with the latest version // Add only the modules you need implementation("uk.co.appoly.droid:baserepo:$appolydroidToolbox") implementation("uk.co.appoly.droid:baserepo-appolyjson:$appolydroidToolbox") diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index db3835e..5147a01 100644 --- a/S3Uploader-Multipart/README.md +++ b/S3Uploader-Multipart/README.md @@ -16,7 +16,7 @@ Advanced S3 upload module with pause, resume, and recovery support using AWS S3 ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1-rc01") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1") ``` This module depends on `S3Uploader` and includes it transitively. diff --git a/S3Uploader/README.md b/S3Uploader/README.md index 4918598..100276d 100644 --- a/S3Uploader/README.md +++ b/S3Uploader/README.md @@ -16,7 +16,7 @@ Standalone module for Amazon S3 file uploading with progress tracking and error ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader:1.9.1-rc01") +implementation("uk.co.appoly.droid:s3uploader:1.9.1") ``` ## Usage diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 255ebd6..146b4c0 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -17,7 +17,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:segmentedcontrol:1.9.1-rc01") +implementation("uk.co.appoly.droid:segmentedcontrol:1.9.1") ``` ## Usage diff --git a/UiState/README.md b/UiState/README.md index 6649af5..b1e0c53 100644 --- a/UiState/README.md +++ b/UiState/README.md @@ -13,7 +13,7 @@ A standardized UI state management library for Android applications, providing c ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:uistate:1.9.1-rc01") +implementation("uk.co.appoly.droid:uistate:1.9.1") ``` ## Usage diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 2bc7b19..377aa1c 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -9,7 +9,7 @@ object BuildConfig { * The current version of the AppolyDroid Toolbox library. * This is used for maven publishing and README version updates. */ - const val TOOLBOX_VERSION = "1.9.1-rc01" + const val TOOLBOX_VERSION = "1.9.1" /** * SDK version configuration for Android modules. From 7c9069f74743242cf6ae9ed73adab3ad5a98f8d8 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 10:32:25 +0100 Subject: [PATCH 16/53] build: TOOLBOX_VERSION 1.9.1 -> 1.10.0-beta01 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AppSnackBar-UiState/README.md | 6 +++--- AppSnackBar/README.md | 2 +- BaseRepo-AppolyJson/README.md | 4 ++-- BaseRepo-Paging-AppolyJson/README.md | 10 +++++----- BaseRepo-Paging/README.md | 8 ++++---- BaseRepo-S3Uploader-Multipart/README.md | 6 +++--- BaseRepo-S3Uploader/README.md | 6 +++--- BaseRepo/README.md | 2 +- ComposeExtensions/README.md | 2 +- ConnectivityMonitor/README.md | 2 +- DateHelperUtil-Room/README.md | 4 ++-- DateHelperUtil-Serialization/README.md | 4 ++-- DateHelperUtil/README.md | 2 +- LazyGridPagingExtensions/README.md | 4 ++-- LazyListPagingExtensions/README.md | 4 ++-- MockInterceptor-AppolyJson/README.md | 2 +- MockInterceptor-Retrofit/README.md | 2 +- MockInterceptor-Serialization/README.md | 2 +- MockInterceptor/README.md | 2 +- Nav3Navigation/README.md | 4 ++-- PagingExtensions/README.md | 2 +- README.md | 8 ++++---- S3Uploader-Multipart/README.md | 2 +- S3Uploader/README.md | 2 +- SegmentedControl/README.md | 2 +- UiState/README.md | 2 +- buildSrc/src/main/kotlin/BuildConfig.kt | 2 +- 27 files changed, 49 insertions(+), 49 deletions(-) diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index 66264e1..1f82a2f 100644 --- a/AppSnackBar-UiState/README.md +++ b/AppSnackBar-UiState/README.md @@ -13,9 +13,9 @@ Integration module that bridges the AppSnackBar and UiState modules, providing a ```gradle.kts // Requires both base modules -implementation("uk.co.appoly.droid:uistate:1.9.1") -implementation("uk.co.appoly.droid:appsnackbar:1.9.1") -implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.1") +implementation("uk.co.appoly.droid:uistate:1.10.0-beta01") +implementation("uk.co.appoly.droid:appsnackbar:1.10.0-beta01") +implementation("uk.co.appoly.droid:appsnackbar-uistate:1.10.0-beta01") ``` ## Usage diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index 7db2e56..dbd2672 100644 --- a/AppSnackBar/README.md +++ b/AppSnackBar/README.md @@ -13,7 +13,7 @@ A customizable Jetpack Compose Snackbar implementation with support for differen ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:appsnackbar:1.9.1") +implementation("uk.co.appoly.droid:appsnackbar:1.10.0-beta01") ``` ## Usage diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index b34b81d..fccb837 100644 --- a/BaseRepo-AppolyJson/README.md +++ b/BaseRepo-AppolyJson/README.md @@ -14,8 +14,8 @@ Appoly's JSON format. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo-appolyjson:1.10.0-beta01") ``` ## API Response Structure diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md index 7890f83..5ced986 100644 --- a/BaseRepo-Paging-AppolyJson/README.md +++ b/BaseRepo-Paging-AppolyJson/README.md @@ -15,13 +15,13 @@ follow Appoly's paging format. ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.1") -implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo-paging:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.10.0-beta01") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-beta01") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-beta01") // For LazyGrid ``` ## API Response Format diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md index b8dd1c7..e9f299d 100644 --- a/BaseRepo-Paging/README.md +++ b/BaseRepo-Paging/README.md @@ -17,12 +17,12 @@ extended for specific JSON formats. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo-paging:1.10.0-beta01") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-beta01") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-beta01") // For LazyGrid ``` ## Extensions diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md index d3c1724..4be7e05 100644 --- a/BaseRepo-S3Uploader-Multipart/README.md +++ b/BaseRepo-S3Uploader-Multipart/README.md @@ -15,9 +15,9 @@ Extension module that bridges BaseRepo and S3Uploader-Multipart, enabling pausab ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1") -implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.10.0-beta01") ``` ## Usage diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md index fdb67ca..6efaf72 100644 --- a/BaseRepo-S3Uploader/README.md +++ b/BaseRepo-S3Uploader/README.md @@ -18,9 +18,9 @@ An extension module that bridges BaseRepo and S3Uploader, enabling seamless file ```gradle.kts // Requires both the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:s3uploader:1.9.1") -implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") +implementation("uk.co.appoly.droid:s3uploader:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo-s3uploader:1.10.0-beta01") ``` ## How it Works diff --git a/BaseRepo/README.md b/BaseRepo/README.md index 1061378..fbb6e7e 100644 --- a/BaseRepo/README.md +++ b/BaseRepo/README.md @@ -14,7 +14,7 @@ Foundation module for implementing the repository pattern with standardized API ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:baserepo:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") ``` ## Extensions diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md index 688eb43..b018700 100644 --- a/ComposeExtensions/README.md +++ b/ComposeExtensions/README.md @@ -13,7 +13,7 @@ Compose utilities for insets/IME padding, padding arithmetic, serialization-safe ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:composeextensions:1.9.1") +implementation("uk.co.appoly.droid:composeextensions:1.10.0-beta01") ``` ## Usage diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index cf9418f..d8b6e4b 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -9,7 +9,7 @@ Add the following dependency to your project's `build.gradle` file: ```gradle.kts -implementation("uk.co.appoly.droid:connectivitymonitor:1.9.1") +implementation("uk.co.appoly.droid:connectivitymonitor:1.10.0-beta01") ``` ## Usage diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index a738f28..0869a63 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides Room database integration for ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.1") -implementation("uk.co.appoly.droid:datehelperutil-room:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0-beta01") +implementation("uk.co.appoly.droid:datehelperutil-room:1.10.0-beta01") // Required Room dependencies implementation("androidx.room:room-runtime:2.8.5") diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md index 7db5188..c952e63 100644 --- a/DateHelperUtil-Serialization/README.md +++ b/DateHelperUtil-Serialization/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides kotlinx.serialization integrat ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.1") -implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0-beta01") +implementation("uk.co.appoly.droid:datehelperutil-serialization:1.10.0-beta01") // Required kotlinx.serialization dependencies implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") diff --git a/DateHelperUtil/README.md b/DateHelperUtil/README.md index 37af5c7..b6e2232 100644 --- a/DateHelperUtil/README.md +++ b/DateHelperUtil/README.md @@ -14,7 +14,7 @@ A utility module for standardized date and time operations in Android applicatio ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:datehelperutil:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0-beta01") ``` ## 1.4.1 patch note diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md index 54abf19..e91ab4d 100644 --- a/LazyGridPagingExtensions/README.md +++ b/LazyGridPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for integrating Jetpack Paging 3 with Compose LazyVerticalGr ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.1") -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0-beta01") +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-beta01") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md index 23738e9..8ae06f7 100644 --- a/LazyListPagingExtensions/README.md +++ b/LazyListPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for easy integration of Jetpack Paging 3 with Compose LazyCo ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.1") -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0-beta01") +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-beta01") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md index 3d52bef..3d994bf 100644 --- a/MockInterceptor-AppolyJson/README.md +++ b/MockInterceptor-AppolyJson/README.md @@ -14,7 +14,7 @@ Extension for [MockInterceptor-Serialization](../MockInterceptor-Serialization/) ```gradle.kts // MockInterceptor and MockInterceptor-Serialization are included transitively -implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.10.0-beta01") ``` ## Usage diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md index 2ee209f..b98e89c 100644 --- a/MockInterceptor-Retrofit/README.md +++ b/MockInterceptor-Retrofit/README.md @@ -13,7 +13,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that reads Retrofit HTTP an ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.10.0-beta01") ``` > **Note:** Retrofit is a `compileOnly` dependency — your project must already depend on Retrofit. diff --git a/MockInterceptor-Serialization/README.md b/MockInterceptor-Serialization/README.md index d9cc6ec..641c07c 100644 --- a/MockInterceptor-Serialization/README.md +++ b/MockInterceptor-Serialization/README.md @@ -12,7 +12,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that adds type-safe JSON re ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.10.0-beta01") ``` ## Usage diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md index 9885716..4cea800 100644 --- a/MockInterceptor/README.md +++ b/MockInterceptor/README.md @@ -16,7 +16,7 @@ An OkHttp interceptor with a route-matching DSL for mocking API responses during ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:mockinterceptor:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor:1.10.0-beta01") ``` ## Usage diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index 2ef77b7..4a3ea3d 100644 --- a/Nav3Navigation/README.md +++ b/Nav3Navigation/README.md @@ -32,13 +32,13 @@ without giving up the fused-screen / ambient-navigator convenience that Voyager ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:nav3navigation:1.9.1") +implementation("uk.co.appoly.droid:nav3navigation:1.10.0-beta01") ``` Or via the AppolyDroid BOM (version managed by the platform): ```gradle.kts -implementation(platform("uk.co.appoly.droid:bom:1.9.1")) +implementation(platform("uk.co.appoly.droid:bom:1.10.0-beta01")) implementation("uk.co.appoly.droid:nav3navigation") ``` diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md index effab19..e3730b4 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -12,7 +12,7 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:pagingextensions:1.9.1") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0-beta01") ``` ## Usage diff --git a/README.md b/README.md index f842695..3156646 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.1" # Replace with the latest version +appolydroidToolbox = "1.10.0-beta01" # Replace with the latest version [libraries] appolydroid-toolbox-bom = { group = "uk.co.appoly.droid", name = "bom", version.ref = "appolydroidToolbox" } @@ -129,7 +129,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { // Import the BOM - implementation(platform("uk.co.appoly.droid:bom:1.9.1")) + implementation(platform("uk.co.appoly.droid:bom:1.10.0-beta01")) // Now you can use AppolyDroid modules without specifying versions implementation("uk.co.appoly.droid:baserepo") @@ -166,7 +166,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.1" # Replace with the latest version +appolydroidToolbox = "1.10.0-beta01" # Replace with the latest version [libraries] #AppolyDroid-Toolbox @@ -234,7 +234,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { - val appolydroidToolbox = "1.9.1" // Replace with the latest version + val appolydroidToolbox = "1.10.0-beta01" // Replace with the latest version // Add only the modules you need implementation("uk.co.appoly.droid:baserepo:$appolydroidToolbox") implementation("uk.co.appoly.droid:baserepo-appolyjson:$appolydroidToolbox") diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index 5147a01..60a31b0 100644 --- a/S3Uploader-Multipart/README.md +++ b/S3Uploader-Multipart/README.md @@ -16,7 +16,7 @@ Advanced S3 upload module with pause, resume, and recovery support using AWS S3 ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0-beta01") ``` This module depends on `S3Uploader` and includes it transitively. diff --git a/S3Uploader/README.md b/S3Uploader/README.md index 100276d..62b12b1 100644 --- a/S3Uploader/README.md +++ b/S3Uploader/README.md @@ -16,7 +16,7 @@ Standalone module for Amazon S3 file uploading with progress tracking and error ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader:1.9.1") +implementation("uk.co.appoly.droid:s3uploader:1.10.0-beta01") ``` ## Usage diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 146b4c0..5eb250c 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -17,7 +17,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:segmentedcontrol:1.9.1") +implementation("uk.co.appoly.droid:segmentedcontrol:1.10.0-beta01") ``` ## Usage diff --git a/UiState/README.md b/UiState/README.md index b1e0c53..a2dd771 100644 --- a/UiState/README.md +++ b/UiState/README.md @@ -13,7 +13,7 @@ A standardized UI state management library for Android applications, providing c ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:uistate:1.9.1") +implementation("uk.co.appoly.droid:uistate:1.10.0-beta01") ``` ## Usage diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 377aa1c..9745061 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -9,7 +9,7 @@ object BuildConfig { * The current version of the AppolyDroid Toolbox library. * This is used for maven publishing and README version updates. */ - const val TOOLBOX_VERSION = "1.9.1" + const val TOOLBOX_VERSION = "1.10.0-beta01" /** * SDK version configuration for Android modules. From ac7f7302aadaf92ed91eba314ff14572835f71d2 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 11:31:11 +0100 Subject: [PATCH 17/53] feat(BarcodeScanner): add BarcodeScanner and BarcodeScanner-Camera modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- BarcodeScanner-Camera/.gitignore | 1 + BarcodeScanner-Camera/README.md | 137 ++++++++++ BarcodeScanner-Camera/build.gradle.kts | 84 ++++++ BarcodeScanner-Camera/consumer-rules.pro | 8 + BarcodeScanner-Camera/proguard-rules.pro | 21 ++ .../src/main/AndroidManifest.xml | 35 +++ .../barcodescanner/camera/BarcodeDebouncer.kt | 55 ++++ .../camera/BarcodeScannerCamera.kt | 245 ++++++++++++++++++ .../barcodescanner/camera/DefaultScanFrame.kt | 55 ++++ .../src/test/resources/robolectric.properties | 2 + BarcodeScanner/.gitignore | 1 + BarcodeScanner/README.md | 105 ++++++++ BarcodeScanner/build.gradle.kts | 68 +++++ BarcodeScanner/consumer-rules.pro | 9 + BarcodeScanner/proguard-rules.pro | 21 ++ BarcodeScanner/src/main/AndroidManifest.xml | 2 + .../droid/barcodescanner/BarcodeFormat.kt | 109 ++++++++ .../barcodescanner/OneShotBarcodeScanner.kt | 143 ++++++++++ .../droid/barcodescanner/ScannedBarcode.kt | 35 +++ .../src/test/resources/robolectric.properties | 2 + CLAUDE.md | 4 + README.md | 21 ++ bom/build.gradle.kts | 4 + buildSrc/src/main/kotlin/BuildConfig.kt | 6 +- gradle/libs.versions.toml | 20 ++ settings.gradle.kts | 2 + 26 files changed, 1194 insertions(+), 1 deletion(-) create mode 100644 BarcodeScanner-Camera/.gitignore create mode 100644 BarcodeScanner-Camera/README.md create mode 100644 BarcodeScanner-Camera/build.gradle.kts create mode 100644 BarcodeScanner-Camera/consumer-rules.pro create mode 100644 BarcodeScanner-Camera/proguard-rules.pro create mode 100644 BarcodeScanner-Camera/src/main/AndroidManifest.xml create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt create mode 100644 BarcodeScanner-Camera/src/test/resources/robolectric.properties create mode 100644 BarcodeScanner/.gitignore create mode 100644 BarcodeScanner/README.md create mode 100644 BarcodeScanner/build.gradle.kts create mode 100644 BarcodeScanner/consumer-rules.pro create mode 100644 BarcodeScanner/proguard-rules.pro create mode 100644 BarcodeScanner/src/main/AndroidManifest.xml create mode 100644 BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/BarcodeFormat.kt create mode 100644 BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt create mode 100644 BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt create mode 100644 BarcodeScanner/src/test/resources/robolectric.properties diff --git a/BarcodeScanner-Camera/.gitignore b/BarcodeScanner-Camera/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/BarcodeScanner-Camera/.gitignore @@ -0,0 +1 @@ +/build diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md new file mode 100644 index 0000000..0ca2245 --- /dev/null +++ b/BarcodeScanner-Camera/README.md @@ -0,0 +1,137 @@ +# BarcodeScanner-Camera + +Continuous in-app barcode scanning for Compose: a CameraX preview plus an ML Kit analyzer, wired +together so that 1D formats decode as reliably as QR codes. + +Builds on [`BarcodeScanner`](../BarcodeScanner/README.md), which it exposes as `api` — adding this +module gives you the one-shot scanner for free. + +## Features + +- One `@Composable`; no `AndroidView`, no `PreviewView` +- Binds to the ambient lifecycle, so it works inside a `ModalBottomSheet` and unbinds on exit +- Per-code debouncing, so a code held in frame fires once rather than forty times a second +- Callbacks marshalled to the main thread — touch ViewModel state directly +- Replaceable overlay, with a sensible default reticle +- Torch control +- Declares `CAMERA` and the ML Kit install-time model download in its own manifest + +## Installation + +```gradle.kts +implementation("uk.co.appoly.droid:barcodescanner-camera:1.10.0-beta01") +``` + +## Usage + +```kotlin +@Composable +fun ScanSheet(viewModel: ScanViewModel) { + BarcodeScannerCamera( + modifier = Modifier.fillMaxSize(), + formats = BarcodeFormats.OneDimensional, + onError = viewModel::onScannerFailed, + onBarcodeScanned = { barcode -> + viewModel.onCodeScanned(barcode.rawValue, barcode.format) + }, + ) +} +``` + +### Permission + +**This composable does not request the `CAMERA` permission.** The manifest declaration merges into +your app, but asking for it is yours to do — every app already has a permission flow and no two +are alike. Check before composing: + +```kotlin +val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + +if (granted) { + BarcodeScannerCamera(onBarcodeScanned = ::onScanned) +} else { + PermissionPrompt(onGrant = { launcher.launch(Manifest.permission.CAMERA) }) +} +``` + +Composing it without the permission reports a bind failure through `onError` rather than crashing. + +### Debouncing + +ML Kit reports every barcode in frame on every analysed frame. `debounceWindow` (2.5s by default) +suppresses a repeat of the *same* raw value for that long — per code, so two labels in shot each +fire once rather than alternating every frame. + +If you already de-duplicate against state that outlives the composable — a ViewModel keyed on +codes already collected, say — turn it off and do it yourself: + +```kotlin +BarcodeScannerCamera( + debounceWindow = null, + onBarcodeScanned = viewModel::onCodeScanned, +) +``` + +### Custom overlay + +The `overlay` lambda is scoped to the preview's `Box`, so `Modifier.align` is available: + +```kotlin +BarcodeScannerCamera( + overlay = { + Text( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(32.dp), + text = "Point at the label on the box", + color = Color.White, + ) + }, + onBarcodeScanned = ::onScanned, +) +``` + +Pass `overlay = {}` for a bare preview. The default `DefaultScanFrame()` is decoration only — the +detector reads the whole frame, so a code outside the reticle still scans. + +### Torch + +```kotlin +var torchOn by remember { mutableStateOf(false) } + +BarcodeScannerCamera( + torchEnabled = torchOn, + onBarcodeScanned = ::onScanned, +) +``` + +Silently ignored on a camera with no flash unit. + +## API + +| Type | Purpose | +|---|---| +| `BarcodeScannerCamera` | The scanning preview composable | +| `LensFacing` | `Back` / `Front` | +| `DefaultScanFrame` | The default overlay reticle; usable standalone | + +Results arrive as `ScannedBarcode` from the `BarcodeScanner` module. + +## Why this rather than a wrapper library + +The common off-the-shelf wrappers close each camera frame *before* ML Kit has read it, so 1D +formats only decode by winning a thread race. This module holds the `ImageProxy` open until +`process()` completes and closes it in `addOnCompleteListener` — that single detail is most of the +reason it exists. + +It also deliberately avoids `camera-view`, `camera-video` and `camera-mlkit-vision`: +`CameraXViewfinder` replaces `PreviewView`, and `MlKitAnalyzer` would drag in the other two to +replace a fifteen-line class. + +## Notes + +- The ML Kit model is served by Play services, not bundled — the module adds no multi-megabyte + model to your APK. The manifest asks Play services to fetch it at install time. +- On a device with no Play services the detector is unavailable and `onError` fires; the camera + preview itself still works. diff --git a/BarcodeScanner-Camera/build.gradle.kts b/BarcodeScanner-Camera/build.gradle.kts new file mode 100644 index 0000000..2a706e8 --- /dev/null +++ b/BarcodeScanner-Camera/build.gradle.kts @@ -0,0 +1,84 @@ +import com.android.build.api.dsl.LibraryExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.vanniktech.publish) +} + + +configure { + namespace = "uk.co.appoly.droid.barcodescanner.camera" + compileSdk { + version = release(BuildConfig.Sdk.COMPILE) + } + + defaultConfig { + minSdk = BuildConfig.MinSdk.BARCODE_SCANNER + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + + // api: ScannedBarcode/BarcodeFormat are this module's callback and parameter types, and a + // consumer of the camera gets the one-shot scanner for free. + api(project(":BarcodeScanner")) + + //Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.compose.foundation) + // LocalLifecycleOwner — the lifecycle the camera use cases bind to + implementation(libs.androidx.lifecycle.runtime.compose) + + //CameraX. Deliberately no camera-view, camera-video or camera-mlkit-vision: CameraXViewfinder + //replaces PreviewView, and MlKitAnalyzer would drag in the other two for a fifteen-line class. + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.compose) + + //ML Kit barcode detection, model served by Play services rather than bundled in the APK + implementation(libs.playServices.mlkit.barcode.scanning) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.junit) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.ui.test.junit4) + testImplementation(libs.androidx.ui.test.manifest) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} +mavenPublishing { + pom { + name.set("BarcodeScanner-Camera") + description.set("Continuous in-app barcode scanning for Compose: a CameraX preview and ML Kit analyzer that decode 1D and 2D symbologies reliably.") + } +} diff --git a/BarcodeScanner-Camera/consumer-rules.pro b/BarcodeScanner-Camera/consumer-rules.pro new file mode 100644 index 0000000..500f15b --- /dev/null +++ b/BarcodeScanner-Camera/consumer-rules.pro @@ -0,0 +1,8 @@ +# No consumer R8/ProGuard rules required for this module. +# +# It ships no @Serializable models, Room entities/converters, reflection, or JNI. The CameraX and +# ML Kit artifacts carry their own consumer rules covering the classes their native and reflective +# call paths need kept, so there is nothing to restate here. +# +# This file is intentionally rule-free (kept so the absence of keeps is a deliberate, reviewed +# decision rather than an oversight). diff --git a/BarcodeScanner-Camera/proguard-rules.pro b/BarcodeScanner-Camera/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/BarcodeScanner-Camera/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/BarcodeScanner-Camera/src/main/AndroidManifest.xml b/BarcodeScanner-Camera/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4ba9d34 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt new file mode 100644 index 0000000..c97c66a --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt @@ -0,0 +1,55 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import uk.co.appoly.droid.barcodescanner.ScannedBarcode +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * Per-code rate limiter for the continuous scanner. + * + * ML Kit reports *every* barcode in the frame on *every* analysed frame, which is tens of + * callbacks a second for a code the user is simply holding still. Debouncing per code rather + * than globally matters: with two labels in shot, a global debounce would let them alternate and + * fire on every frame anyway, while this drops each one until its own window expires. + * + * Not thread-safe by design — the camera composable only ever touches it from the main thread, + * where ML Kit's callbacks are marshalled to. + * + * @param window how long a given raw value stays suppressed after being emitted. Null disables + * debouncing entirely, so every detection is reported. + * @param timeSource injectable for tests; production uses the monotonic clock. + */ +internal class BarcodeDebouncer( + private val window: Duration?, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private val lastEmitted = HashMap() + + /** + * Returns true if [barcode] should be reported to the caller, recording the emission when so. + */ + fun shouldEmit(barcode: ScannedBarcode): Boolean { + val window = window ?: return true + val previous = lastEmitted[barcode.rawValue] + if (previous != null && previous.elapsedNow() < window) return false + pruneExpired(window) + lastEmitted[barcode.rawValue] = timeSource.markNow() + return true + } + + /** + * Drops entries whose window has already expired. Without this, a session spent scanning a + * long tail of distinct codes — a warehouse pick, say — grows the map without bound for no + * benefit, since an expired entry can never suppress anything again. + */ + private fun pruneExpired(window: Duration) { + if (lastEmitted.size < PRUNE_THRESHOLD) return + lastEmitted.entries.removeAll { (_, mark) -> mark.elapsedNow() >= window } + } + + private companion object { + /** Only worth walking the map once it is big enough to be worth the walk. */ + const val PRUNE_THRESHOLD = 64 + } +} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt new file mode 100644 index 0000000..06e0c0d --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -0,0 +1,245 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import androidx.annotation.OptIn +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.lifecycle.awaitInstance +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.awaitCancellation +import uk.co.appoly.droid.barcodescanner.BarcodeFormat +import uk.co.appoly.droid.barcodescanner.BarcodeFormats +import uk.co.appoly.droid.barcodescanner.ScannedBarcode +import uk.co.appoly.droid.barcodescanner.toScannedBarcode +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** Which camera the scanner binds to. */ +enum class LensFacing(internal val selector: CameraSelector) { + Back(CameraSelector.DEFAULT_BACK_CAMERA), + Front(CameraSelector.DEFAULT_FRONT_CAMERA), +} + +/** + * A live camera preview that reports every barcode it decodes, for as long as it is composed. + * + * Camera use cases are bound to the current [LocalLifecycleOwner] while this composable is in the + * composition and unbound when it leaves — including inside a `ModalBottomSheet`, whose dialog + * inherits the host's lifecycle owner. [onBarcodeScanned] is always invoked on the main thread, + * so touching ViewModel state from it is safe. + * + * Each camera frame is released back to CameraX only once the detector has finished with it, + * which is what lets 1D formats (EAN, Code 128, ITF) decode as reliably as QR codes. + * + * **This composable does not request the `CAMERA` permission.** Check it before composing this; + * every app's permission flow differs, so the module deliberately owns none of it. Composing + * without the permission granted reports a bind failure through [onError]. + * + * ```kotlin + * BarcodeScannerCamera( + * modifier = Modifier.fillMaxSize(), + * formats = BarcodeFormats.OneDimensional, + * onError = { viewModel.onScannerFailed(it) }, + * onBarcodeScanned = { viewModel.onCodeScanned(it) }, + * ) + * ``` + * + * @param formats which symbologies to decode. Narrower is faster — see [BarcodeFormats]. + * @param lensFacing which camera to bind. + * @param torchEnabled whether the torch is on. Silently ignored on a camera with no flash unit. + * @param debounceWindow how long the same raw value is suppressed after being reported, per code. + * Null disables it, which is what you want if you already de-duplicate downstream (keyed on + * ViewModel state that outlives this composable, say). + * @param overlay drawn on top of the preview, in the same [Box] — so `Modifier.align` is + * available to it. Defaults to [DefaultScanFrame]. + * @param onError reports a camera that could not be opened or bound — no camera, permission not + * granted, or another app holding it. The preview stays blank; recovery is the caller's call. + * @param onBarcodeScanned invoked on the main thread, once per decoded barcode per analysed + * frame, subject to [debounceWindow]. + */ +@Composable +fun BarcodeScannerCamera( + modifier: Modifier = Modifier, + formats: Set = BarcodeFormats.All, + lensFacing: LensFacing = LensFacing.Back, + torchEnabled: Boolean = false, + debounceWindow: Duration? = 2.5.seconds, + overlay: @Composable BoxScope.() -> Unit = { DefaultScanFrame() }, + onError: (Throwable) -> Unit = {}, + onBarcodeScanned: (ScannedBarcode) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val currentOnBarcodeScanned by rememberUpdatedState(onBarcodeScanned) + val currentOnError by rememberUpdatedState(onError) + var surfaceRequest by remember { mutableStateOf(null) } + var camera by remember { mutableStateOf(null) } + + // Survives recomposition but is rebuilt whenever the window changes, so a caller toggling + // debouncing does not carry stale suppressions across. + val debouncer = remember(debounceWindow) { BarcodeDebouncer(debounceWindow) } + + LaunchedEffect(lifecycleOwner, formats, lensFacing) { + surfaceRequest = null + camera = null + val scanner = BarcodeScanning.getClient(formats.toScannerOptions()) + // Single thread: STRATEGY_KEEP_ONLY_LATEST already drops frames under load, so a pool + // would only buy concurrent decodes of frames we are about to discard anyway. + val analysisExecutor = Executors.newSingleThreadExecutor() + try { + val preview = Preview.Builder() + .build() + .apply { + setSurfaceProvider { request -> surfaceRequest = request } + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .apply { + setAnalyzer( + analysisExecutor, + BarcodeAnalyzer( + scanner = scanner, + callbackExecutor = ContextCompat.getMainExecutor(context), + onBarcodesDetected = { barcodes -> + barcodes + .mapNotNull { it.toScannedBarcode() } + .filter(debouncer::shouldEmit) + .forEach(currentOnBarcodeScanned) + }, + onDetectionFailed = { currentOnError(it) }, + ), + ) + } + + // No camera, permission not granted, or another app holding it: report it rather + // than crashing behind a blank preview. + val cameraProvider = try { + ProcessCameraProvider.awaitInstance(context) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + currentOnError(error) + null + } + if (cameraProvider != null) { + val bound = try { + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + lensFacing.selector, + preview, + analysis, + ) + true + } catch (error: Exception) { + currentOnError(error) + false + } + // clearAnalyzer + unbind must happen before the scanner closes below, so that no + // analyze() call can run against a closed detector. + try { + if (bound) awaitCancellation() + } finally { + camera = null + analysis.clearAnalyzer() + cameraProvider.unbind(preview, analysis) + } + } + } finally { + // Queued on the analysis thread so it lands after any in-flight analyze() returns. + analysisExecutor.execute { scanner.close() } + analysisExecutor.shutdown() + } + } + + LaunchedEffect(camera, torchEnabled) { + val control = camera?.cameraControl ?: return@LaunchedEffect + if (camera?.cameraInfo?.hasFlashUnit() == true) { + control.enableTorch(torchEnabled) + } + } + + Box(modifier = modifier) { + surfaceRequest?.let { request -> + CameraXViewfinder( + modifier = Modifier.fillMaxSize(), + surfaceRequest = request, + ) + } + overlay() + } +} + +/** Builds ML Kit detector options for [formats], skipping the filter when it would be a no-op. */ +private fun Set.toScannerOptions(): BarcodeScannerOptions { + val mlKitFormats = filter { it != BarcodeFormat.Unknown }.map { it.mlKitFormat } + val builder = BarcodeScannerOptions.Builder() + if (mlKitFormats.isEmpty()) { + builder.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) + } else { + builder.setBarcodeFormats(mlKitFormats.first(), *mlKitFormats.drop(1).toIntArray()) + } + return builder.build() +} + +/** + * Feeds each camera frame to ML Kit and releases it back to CameraX once detection completes. + * + * Holding the [ImageProxy] open until [BarcodeScanner.process] finishes is what lets ML Kit read + * the frame's planes; closing it early makes every decode a race the detector usually loses, and + * 1D formats are the ones that lose it. Both callbacks run on [callbackExecutor]. + */ +private class BarcodeAnalyzer( + private val scanner: BarcodeScanner, + private val callbackExecutor: Executor, + private val onBarcodesDetected: (List) -> Unit, + private val onDetectionFailed: (Throwable) -> Unit, +) : ImageAnalysis.Analyzer { + + @OptIn(ExperimentalGetImage::class) + override fun analyze(imageProxy: ImageProxy) { + val mediaImage = imageProxy.image + if (mediaImage == null) { + imageProxy.close() + return + } + val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(inputImage) + .addOnSuccessListener(callbackExecutor) { barcodes -> + if (barcodes.isNotEmpty()) onBarcodesDetected(barcodes) + } + .addOnFailureListener(callbackExecutor) { error -> + onDetectionFailed(error) + } + .addOnCompleteListener(callbackExecutor) { + imageProxy.close() + } + } +} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt new file mode 100644 index 0000000..e1ba0ae --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt @@ -0,0 +1,55 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * The reticle [BarcodeScannerCamera] draws over its preview by default: a centred, rounded + * rectangle outline. + * + * It is decoration, not a constraint — the detector reads the whole frame, so a code outside the + * frame still scans. It exists to tell the user where to point, which measurably speeds them up. + * Pass your own `overlay` to replace it, or `overlay = {}` for a bare preview. + * + * @param widthFraction how much of the preview's width the frame spans. + * @param aspectRatio width:height of the frame. 1f suits QR codes; try 2f or wider for the long + * thin labels of 1D symbologies. + * @param color the outline colour. + * @param strokeWidth the outline thickness. + * @param cornerRadius the corner rounding. + */ +@Composable +fun DefaultScanFrame( + modifier: Modifier = Modifier, + widthFraction: Float = 0.7f, + aspectRatio: Float = 1f, + color: Color = Color.White, + strokeWidth: Dp = 3.dp, + cornerRadius: Dp = 16.dp, +) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth(widthFraction) + .aspectRatio(aspectRatio) + .border( + width = strokeWidth, + color = color, + shape = RoundedCornerShape(cornerRadius), + ), + ) + } +} diff --git a/BarcodeScanner-Camera/src/test/resources/robolectric.properties b/BarcodeScanner-Camera/src/test/resources/robolectric.properties new file mode 100644 index 0000000..73b487a --- /dev/null +++ b/BarcodeScanner-Camera/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# targetSdk 37 has no Robolectric image yet; pin to 36 (newest Robolectric 4.16 ships). +sdk=36 diff --git a/BarcodeScanner/.gitignore b/BarcodeScanner/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/BarcodeScanner/.gitignore @@ -0,0 +1 @@ +/build diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md new file mode 100644 index 0000000..5d8ee2d --- /dev/null +++ b/BarcodeScanner/README.md @@ -0,0 +1,105 @@ +# BarcodeScanner + +Barcode scanning without the ML Kit imports: a shared result model, and a one-shot scanner backed +by the Google Play services code scanner — no camera permission, no CameraX, no bundled model. + +For continuous in-app scanning with your own UI around it, add +[`BarcodeScanner-Camera`](../BarcodeScanner-Camera/README.md), which builds on this module. + +## Features + +- `ScannedBarcode` / `BarcodeFormat` — one result type shared by both scanning modules, so app + code never imports `com.google.mlkit.*` +- `OneShotBarcodeScanner` — a single scan in Play services' own UI, as a `suspend fun` +- `warmUp()` to pre-install the scanner module, so the first scan is not a download spinner +- An explicit `Unavailable` result for devices with no (or outdated) Play services, instead of a + silent failure +- Costs the consumer no `CAMERA` permission: Play services owns the camera and the prompt + +## Installation + +```gradle.kts +implementation("uk.co.appoly.droid:barcodescanner:1.10.0-beta01") +``` + +## Usage + +### A single scan + +```kotlin +class CheckInViewModel(application: Application) : AndroidViewModel(application) { + private val scanner = OneShotBarcodeScanner( + context = application, + formats = BarcodeFormats.QrOnly, + ) + + fun onScanClicked() { + viewModelScope.launch { + when (val result = scanner.scan()) { + is OneShotScanResult.Scanned -> checkIn(result.barcode.rawValue) + OneShotScanResult.Cancelled -> Unit + is OneShotScanResult.Unavailable -> showManualEntry() + is OneShotScanResult.Failed -> showError(result.cause) + } + } + } +} +``` + +### Warming up + +The first `scan()` on a device that has never used the hosted scanner downloads a Play services +module, which can take several seconds. Call `warmUp()` from a screen the user reaches *before* +they need to scan, and they never see it: + +```kotlin +LaunchedEffect(Unit) { + scanner.warmUp() +} +``` + +It is safe to call repeatedly and returns `false` rather than throwing when the install cannot be +done. A `false` does not mean `scan()` will fail — only that it may be slower. + +### Choosing formats + +Narrowing the format set makes the detector faster and less prone to locking onto the wrong code: + +```kotlin +BarcodeFormats.All // every supported symbology (the default) +BarcodeFormats.OneDimensional // Code128, Code39, Code93, Codabar, EAN-13/8, ITF, UPC-A/E +BarcodeFormats.TwoDimensional // QR, PDF417, Aztec, Data Matrix +BarcodeFormats.QrOnly // just QR +setOf(BarcodeFormat.Ean13, BarcodeFormat.UpcA) // or roll your own +``` + +## Handling `Unavailable` + +The hosted scanner lives in Play services, so it does not exist on Huawei devices, stripped ROMs, +or installs with a Play services too old to serve it. That is a real slice of real users, and the +sealed result makes it impossible to forget: + +```kotlin +is OneShotScanResult.Unavailable -> { + // Fall back to BarcodeScanner-Camera, which needs only CameraX and the CAMERA permission, + // or to typing the code in by hand. +} +``` + +## API + +| Type | Purpose | +|---|---| +| `ScannedBarcode` | `rawValue`, `format`, optional `displayValue` | +| `BarcodeFormat` | The symbology enum, plus `fromMlKit(Int)` and `mlKitFormat` | +| `BarcodeFormats` | `All`, `OneDimensional`, `TwoDimensional`, `QrOnly` | +| `OneShotBarcodeScanner` | `suspend fun warmUp(): Boolean`, `suspend fun scan(): OneShotScanResult` | +| `OneShotScanResult` | `Scanned` / `Cancelled` / `Unavailable` / `Failed` | +| `Barcode.toScannedBarcode()` | ML Kit → toolbox conversion; returns null for an empty payload | + +## Notes + +- `barcode-scanning-common` is an `api` dependency — it is ~50KB of format constants and + interfaces, and carries no detection model. +- `scan()` does not close the scanner UI if the calling coroutine is cancelled; Play services owns + that activity. The result is simply discarded. diff --git a/BarcodeScanner/build.gradle.kts b/BarcodeScanner/build.gradle.kts new file mode 100644 index 0000000..5841d86 --- /dev/null +++ b/BarcodeScanner/build.gradle.kts @@ -0,0 +1,68 @@ +import com.android.build.api.dsl.LibraryExtension +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.vanniktech.publish) +} + + +configure { + namespace = "uk.co.appoly.droid.barcodescanner" + compileSdk { + version = release(BuildConfig.Sdk.COMPILE) + } + + defaultConfig { + minSdk = BuildConfig.MinSdk.BARCODE_SCANNER + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + + // api: Barcode.FORMAT_* constants back BarcodeFormat.mlKitFormat, and Barcode is the receiver + // of the public toScannedBarcode() extension. ~50KB of constants and interfaces — no model. + api(libs.mlkit.barcode.scanning.common) + + // The hosted scanner UI. Play services ships the implementation; this is the thin client. + implementation(libs.playServices.codeScanner) + // ModuleInstallClient, for OneShotBarcodeScanner.warmUp() + implementation(libs.playServices.base) + // Task.await() + implementation(libs.kotlinx.coroutines.playServices) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.junit) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} +mavenPublishing { + pom { + name.set("BarcodeScanner") + description.set("Barcode model and a one-shot scanner backed by the Google Play services code scanner, with no camera permission or CameraX dependency.") + } +} diff --git a/BarcodeScanner/consumer-rules.pro b/BarcodeScanner/consumer-rules.pro new file mode 100644 index 0000000..efab7eb --- /dev/null +++ b/BarcodeScanner/consumer-rules.pro @@ -0,0 +1,9 @@ +# No consumer R8/ProGuard rules required for this module. +# +# It ships no @Serializable models, Room entities/converters, reflection, or JNI — ScannedBarcode +# and BarcodeFormat are plain Kotlin, and nothing looks them up by name. The ML Kit and Play +# services artifacts carry their own consumer rules for the classes R8 would otherwise strip from +# their reflective/native call paths, so there is nothing to restate here. +# +# This file is intentionally rule-free (kept so the absence of keeps is a deliberate, reviewed +# decision rather than an oversight). diff --git a/BarcodeScanner/proguard-rules.pro b/BarcodeScanner/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/BarcodeScanner/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/BarcodeScanner/src/main/AndroidManifest.xml b/BarcodeScanner/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/BarcodeScanner/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/BarcodeFormat.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/BarcodeFormat.kt new file mode 100644 index 0000000..9675504 --- /dev/null +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/BarcodeFormat.kt @@ -0,0 +1,109 @@ +package uk.co.appoly.droid.barcodescanner + +import com.google.mlkit.vision.barcode.common.Barcode + +/** + * The symbologies this toolbox can decode. + * + * This enum exists so that app code never has to import `com.google.mlkit.*`. Both + * [OneShotBarcodeScanner] and the `BarcodeScanner-Camera` module report results as + * [ScannedBarcode], which carries one of these instead of an ML Kit `Barcode.FORMAT_*` int. + * + * @property mlKitFormat the corresponding `Barcode.FORMAT_*` constant, used when building + * scanner options. + */ +enum class BarcodeFormat(val mlKitFormat: Int) { + Code128(Barcode.FORMAT_CODE_128), + Code39(Barcode.FORMAT_CODE_39), + Code93(Barcode.FORMAT_CODE_93), + Codabar(Barcode.FORMAT_CODABAR), + Ean13(Barcode.FORMAT_EAN_13), + Ean8(Barcode.FORMAT_EAN_8), + Itf(Barcode.FORMAT_ITF), + UpcA(Barcode.FORMAT_UPC_A), + UpcE(Barcode.FORMAT_UPC_E), + QrCode(Barcode.FORMAT_QR_CODE), + Pdf417(Barcode.FORMAT_PDF417), + Aztec(Barcode.FORMAT_AZTEC), + DataMatrix(Barcode.FORMAT_DATA_MATRIX), + + /** + * A code the detector read but could not classify, or a format added to ML Kit after this + * enum was written. [mlKitFormat] is `Barcode.FORMAT_UNKNOWN`, so passing `Unknown` in a + * format filter is meaningless — it is only ever a result value. + */ + Unknown(Barcode.FORMAT_UNKNOWN), + ; + + companion object { + private val byMlKitFormat: Map = entries.associateBy { it.mlKitFormat } + + /** + * Maps a `com.google.mlkit.vision.barcode.common.Barcode.FORMAT_*` int to its enum + * constant, falling back to [Unknown] for anything unrecognised. + */ + fun fromMlKit(format: Int): BarcodeFormat = byMlKitFormat[format] ?: Unknown + } +} + +/** + * Ready-made [BarcodeFormat] sets for the `formats` parameter of the scanners. + * + * Narrowing the set is worth doing: the fewer symbologies the detector has to consider, the + * faster and more reliably it locks onto the one you actually want. + */ +object BarcodeFormats { + /** Every format this toolbox understands. The default for both scanners. */ + val All: Set = setOf( + BarcodeFormat.Code128, + BarcodeFormat.Code39, + BarcodeFormat.Code93, + BarcodeFormat.Codabar, + BarcodeFormat.Ean13, + BarcodeFormat.Ean8, + BarcodeFormat.Itf, + BarcodeFormat.UpcA, + BarcodeFormat.UpcE, + BarcodeFormat.QrCode, + BarcodeFormat.Pdf417, + BarcodeFormat.Aztec, + BarcodeFormat.DataMatrix, + ) + + /** Linear symbologies — retail and logistics labels. */ + val OneDimensional: Set = setOf( + BarcodeFormat.Code128, + BarcodeFormat.Code39, + BarcodeFormat.Code93, + BarcodeFormat.Codabar, + BarcodeFormat.Ean13, + BarcodeFormat.Ean8, + BarcodeFormat.Itf, + BarcodeFormat.UpcA, + BarcodeFormat.UpcE, + ) + + /** Matrix symbologies — QR and friends. */ + val TwoDimensional: Set = setOf( + BarcodeFormat.QrCode, + BarcodeFormat.Pdf417, + BarcodeFormat.Aztec, + BarcodeFormat.DataMatrix, + ) + + /** QR codes only — the narrowest and fastest common case. */ + val QrOnly: Set = setOf(BarcodeFormat.QrCode) +} + +/** + * Folds a set of formats into the `(first, vararg rest)` int pair that both ML Kit's + * `BarcodeScannerOptions.Builder` and `GmsBarcodeScannerOptions.Builder` expect. + * + * An empty set, or one containing only [BarcodeFormat.Unknown], means "no useful filter" and + * maps to `Barcode.FORMAT_ALL_FORMATS`. + */ +internal fun Set.toMlKitFormatArgs(): Pair { + val formats = filter { it != BarcodeFormat.Unknown }.map { it.mlKitFormat } + if (formats.isEmpty()) return Barcode.FORMAT_ALL_FORMATS to IntArray(0) + return formats.first() to formats.drop(1).toIntArray() +} diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt new file mode 100644 index 0000000..2b008de --- /dev/null +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt @@ -0,0 +1,143 @@ +package uk.co.appoly.droid.barcodescanner + +import android.content.Context +import com.google.android.gms.common.moduleinstall.ModuleInstall +import com.google.android.gms.common.moduleinstall.ModuleInstallRequest +import com.google.mlkit.common.MlKitException +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.tasks.await + +/** The outcome of a single [OneShotBarcodeScanner.scan] call. */ +sealed interface OneShotScanResult { + /** The user scanned something. */ + data class Scanned(val barcode: ScannedBarcode) : OneShotScanResult + + /** The user backed out of the scanner UI without scanning. */ + data object Cancelled : OneShotScanResult + + /** + * Play services is missing, too old, or the scanner module could not be installed. + * + * Handle this branch: it is the everyday reality on Huawei devices and stripped ROMs, where + * the hosted scanner simply does not exist. Fall back to `BarcodeScanner-Camera`, or to + * manual entry. + */ + data class Unavailable(val cause: Throwable?) : OneShotScanResult + + /** The scan failed for any other reason. */ + data class Failed(val cause: Throwable) : OneShotScanResult +} + +/** + * A single barcode scan, rendered by Google Play services rather than by your app. + * + * Play services owns the camera, the preview and the permission prompt, so this costs you no + * `CAMERA` permission in the manifest, no layout, and no CameraX on your classpath. The trade is + * that it only exists where Play services does — see [OneShotScanResult.Unavailable] — and that + * you get Google's UI, not yours. For in-app continuous scanning, use the `BarcodeScanner-Camera` + * module instead. + * + * The instance is cheap and stateless; construct it wherever it is convenient. + * + * ```kotlin + * val scanner = OneShotBarcodeScanner(context, formats = BarcodeFormats.QrOnly) + * + * when (val result = scanner.scan()) { + * is OneShotScanResult.Scanned -> onCode(result.barcode.rawValue) + * OneShotScanResult.Cancelled -> Unit + * is OneShotScanResult.Unavailable -> fallBackToManualEntry() + * is OneShotScanResult.Failed -> showError(result.cause) + * } + * ``` + * + * @param context any context; the application context is retained internally. + * @param formats which symbologies to look for. Narrower is faster — see [BarcodeFormats]. + * @param allowManualInput show Google's "enter the code by hand" affordance. Off by default. + * @param autoZoom let the scanner zoom onto small or distant codes. On by default. + */ +class OneShotBarcodeScanner( + context: Context, + formats: Set = BarcodeFormats.All, + allowManualInput: Boolean = false, + autoZoom: Boolean = true, +) { + private val appContext = context.applicationContext + + private val options: GmsBarcodeScannerOptions = GmsBarcodeScannerOptions.Builder() + .apply { + val (first, rest) = formats.toMlKitFormatArgs() + setBarcodeFormats(first, *rest) + if (allowManualInput) allowManualInput() + if (autoZoom) enableAutoZoom() + } + .build() + + private val client get() = GmsBarcodeScanning.getClient(appContext, options) + + /** + * Pre-installs the Play services scanner module so the first [scan] opens immediately + * instead of sitting on a download spinner for several seconds. + * + * Call it from a screen the user reaches before they need to scan — app start, or the screen + * hosting the scan button. Safe to call repeatedly; it is a no-op once the module is present. + * + * @return true if the module is installed and ready, false if the install could not be done + * (no Play services, no network). A false here does not mean [scan] will fail — it will just + * be slower, or return [OneShotScanResult.Unavailable]. + */ + suspend fun warmUp(): Boolean { + val scannerClient = client + return try { + val moduleInstall = ModuleInstall.getClient(appContext) + val availability = moduleInstall.areModulesAvailable(scannerClient).await() + if (availability.areModulesAvailable()) { + true + } else { + val request = ModuleInstallRequest.newBuilder() + .addApi(scannerClient) + .build() + moduleInstall.installModules(request).await() + true + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + false + } + } + + /** + * Opens the Play services scanner UI and suspends until the user scans, backs out, or it + * fails. + * + * Cancelling the calling coroutine does not close the scanner UI — Play services owns that + * activity. The result is simply discarded. + */ + suspend fun scan(): OneShotScanResult { + val barcode: Barcode = try { + client.startScan().await() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: MlKitException) { + return error.toScanResult() + } catch (error: Exception) { + return OneShotScanResult.Failed(error) + } + val scanned = barcode.toScannedBarcode() + ?: return OneShotScanResult.Failed(IllegalStateException("Scanner returned a barcode with no raw value")) + return OneShotScanResult.Scanned(scanned) + } + + private fun MlKitException.toScanResult(): OneShotScanResult = when (errorCode) { + MlKitException.CODE_SCANNER_CANCELLED -> OneShotScanResult.Cancelled + MlKitException.UNAVAILABLE, + MlKitException.CODE_SCANNER_UNAVAILABLE, + MlKitException.CODE_SCANNER_GOOGLE_PLAY_SERVICES_VERSION_TOO_OLD, + -> OneShotScanResult.Unavailable(this) + + else -> OneShotScanResult.Failed(this) + } +} diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt new file mode 100644 index 0000000..b12e583 --- /dev/null +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt @@ -0,0 +1,35 @@ +package uk.co.appoly.droid.barcodescanner + +import com.google.mlkit.vision.barcode.common.Barcode + +/** + * One decoded barcode, in toolbox terms rather than ML Kit's. + * + * @property rawValue the barcode's contents exactly as encoded. Never blank — a barcode that + * decodes to nothing is not reported at all. + * @property format the symbology it was encoded in. + * @property displayValue ML Kit's human-readable rendering, where it has one (it strips the + * `WIFI:`/`tel:`-style scheme prefixes from structured QR payloads, for instance). Null when + * ML Kit offers nothing better than [rawValue]. + */ +data class ScannedBarcode( + val rawValue: String, + val format: BarcodeFormat, + val displayValue: String? = null, +) + +/** + * Converts an ML Kit [Barcode] into a [ScannedBarcode], or null when it carries no usable + * payload (`rawValue` absent or blank). + * + * Public because the `BarcodeScanner-Camera` module's analyzer needs it across the module + * boundary; app code should not normally have an ML Kit [Barcode] in hand to convert. + */ +fun Barcode.toScannedBarcode(): ScannedBarcode? { + val raw = rawValue?.takeIf { it.isNotBlank() } ?: return null + return ScannedBarcode( + rawValue = raw, + format = BarcodeFormat.fromMlKit(format), + displayValue = displayValue?.takeIf { it.isNotBlank() && it != raw }, + ) +} diff --git a/BarcodeScanner/src/test/resources/robolectric.properties b/BarcodeScanner/src/test/resources/robolectric.properties new file mode 100644 index 0000000..73b487a --- /dev/null +++ b/BarcodeScanner/src/test/resources/robolectric.properties @@ -0,0 +1,2 @@ +# targetSdk 37 has no Robolectric image yet; pin to 36 (newest Robolectric 4.16 ships). +sdk=36 diff --git a/CLAUDE.md b/CLAUDE.md index e02b0d3..653181a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,10 @@ The library uses a layered module structure: - `MockInterceptor-AppolyJson` - Helpers for mocking Appoly's standard JSON envelope - `MockInterceptor-Retrofit` - Auto-registers mock routes by reflecting over Retrofit annotations +**Barcode Scanning:** +- `BarcodeScanner` - Shared `ScannedBarcode`/`BarcodeFormat` model plus `OneShotBarcodeScanner`, backed by the Play services hosted code scanner (no CameraX, no camera permission) +- `BarcodeScanner-Camera` - Continuous in-app scanning: CameraX preview + ML Kit analyzer, built on `BarcodeScanner` + **Standalone Utilities:** - `UiState` - Sealed class for UI state (Idle/Loading/Success/Error) - `S3Uploader` - Direct S3 uploads with progress tracking diff --git a/README.md b/README.md index 3156646..84fe7a1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ AppolyDroid Toolbox is a comprehensive collection of Android utility modules tha - Segmented controls - Jetpack Compose pagination utilities - Voyager-style Navigation 3 screens (`Nav3Navigation`) +- Barcode scanning, one-shot or continuous (`BarcodeScanner`) - And more! ## Installation @@ -73,6 +74,8 @@ appolydroid-toolbox-dateHelper-room = { group = "uk.co.appoly.droid", name = "da appolydroid-toolbox-dateHelper-serialization = { group = "uk.co.appoly.droid", name = "datehelperutil-serialization" } appolydroid-toolbox-compose-extensions = { group = "uk.co.appoly.droid", name = "composeextensions" } appolydroid-toolbox-segmentedControl = { group = "uk.co.appoly.droid", name = "segmentedcontrol" } +appolydroid-toolbox-barcodeScanner = { group = "uk.co.appoly.droid", name = "barcodescanner" } +appolydroid-toolbox-barcodeScanner-camera = { group = "uk.co.appoly.droid", name = "barcodescanner-camera" } appolydroid-toolbox-lazyListPagingExtensions = { group = "uk.co.appoly.droid", name = "lazylistpagingextensions" } appolydroid-toolbox-lazyGridPagingExtensions = { group = "uk.co.appoly.droid", name = "lazygridpagingextensions" } appolydroid-toolbox-pagingExtensions = { group = "uk.co.appoly.droid", name = "pagingextensions" } @@ -115,6 +118,8 @@ dependencies { implementation(libs.appolydroid.toolbox.s3Uploader.multipart) implementation(libs.appolydroid.toolbox.connectivityMonitor) implementation(libs.appolydroid.toolbox.nav3Navigation) + implementation(libs.appolydroid.toolbox.barcodeScanner) + implementation(libs.appolydroid.toolbox.barcodeScanner.camera) implementation(libs.appolydroid.toolbox.mockInterceptor) implementation(libs.appolydroid.toolbox.mockInterceptor.serialization) implementation(libs.appolydroid.toolbox.mockInterceptor.appolyjson) @@ -190,6 +195,8 @@ appolydroid-toolbox-s3Uploader = { group = "uk.co.appoly.droid", name = "s3uploa appolydroid-toolbox-s3Uploader-multipart = { group = "uk.co.appoly.droid", name = "s3uploader-multipart", version.ref = "appolydroidToolbox" } appolydroid-toolbox-connectivityMonitor = { group = "uk.co.appoly.droid", name = "connectivitymonitor", version.ref = "appolydroidToolbox" } appolydroid-toolbox-nav3Navigation = { group = "uk.co.appoly.droid", name = "nav3navigation", version.ref = "appolydroidToolbox" } +appolydroid-toolbox-barcodeScanner = { group = "uk.co.appoly.droid", name = "barcodescanner", version.ref = "appolydroidToolbox" } +appolydroid-toolbox-barcodeScanner-camera = { group = "uk.co.appoly.droid", name = "barcodescanner-camera", version.ref = "appolydroidToolbox" } appolydroid-toolbox-mockInterceptor = { group = "uk.co.appoly.droid", name = "mockinterceptor", version.ref = "appolydroidToolbox" } appolydroid-toolbox-mockInterceptor-serialization = { group = "uk.co.appoly.droid", name = "mockinterceptor-serialization", version.ref = "appolydroidToolbox" } appolydroid-toolbox-mockInterceptor-appolyjson = { group = "uk.co.appoly.droid", name = "mockinterceptor-appolyjson", version.ref = "appolydroidToolbox" } @@ -221,6 +228,8 @@ dependencies { implementation(libs.appolydroid.toolbox.s3Uploader.multipart) implementation(libs.appolydroid.toolbox.connectivityMonitor) implementation(libs.appolydroid.toolbox.nav3Navigation) + implementation(libs.appolydroid.toolbox.barcodeScanner) + implementation(libs.appolydroid.toolbox.barcodeScanner.camera) implementation(libs.appolydroid.toolbox.mockInterceptor) implementation(libs.appolydroid.toolbox.mockInterceptor.serialization) implementation(libs.appolydroid.toolbox.mockInterceptor.appolyjson) @@ -256,6 +265,8 @@ dependencies { implementation("uk.co.appoly.droid:s3uploader-multipart:$appolydroidToolbox") implementation("uk.co.appoly.droid:connectivitymonitor:$appolydroidToolbox") implementation("uk.co.appoly.droid:nav3navigation:$appolydroidToolbox") + implementation("uk.co.appoly.droid:barcodescanner:$appolydroidToolbox") + implementation("uk.co.appoly.droid:barcodescanner-camera:$appolydroidToolbox") implementation("uk.co.appoly.droid:mockinterceptor:$appolydroidToolbox") implementation("uk.co.appoly.droid:mockinterceptor-serialization:$appolydroidToolbox") implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:$appolydroidToolbox") @@ -338,6 +349,16 @@ Voyager-style screens on androidx Navigation 3: fused key+UI (`Nav3Screen`), amb and per-entry ViewModel/saveable/result decorators. [Learn more](Nav3Navigation/README.md) +### BarcodeScanner +Shared barcode model plus a one-shot scanner backed by the Google Play services code scanner — +no camera permission, no CameraX, no bundled model. +[Learn more](BarcodeScanner/README.md) + +### BarcodeScanner-Camera +Continuous in-app scanning for Compose: a CameraX preview and ML Kit analyzer wired so that 1D +formats decode as reliably as QR codes. +[Learn more](BarcodeScanner-Camera/README.md) + ### MockInterceptor OkHttp interceptor with a route-matching DSL for mocking API responses during development and testing. [Learn more](MockInterceptor/README.md) diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index adf2139..c6eeb71 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -54,6 +54,10 @@ dependencies { // Navigation modules api("uk.co.appoly.droid:nav3navigation:${BuildConfig.TOOLBOX_VERSION}") + // Barcode scanning modules + api("uk.co.appoly.droid:barcodescanner:${BuildConfig.TOOLBOX_VERSION}") + api("uk.co.appoly.droid:barcodescanner-camera:${BuildConfig.TOOLBOX_VERSION}") + // Mock Interceptor modules api("uk.co.appoly.droid:mockinterceptor:${BuildConfig.TOOLBOX_VERSION}") api("uk.co.appoly.droid:mockinterceptor-serialization:${BuildConfig.TOOLBOX_VERSION}") diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 9745061..9636a3d 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -54,6 +54,9 @@ object BuildConfig { /** Nav3Navigation module (androidx.navigation3 requires minSdk 23) */ const val NAV3_NAVIGATION = 23 + /** BarcodeScanner and BarcodeScanner-Camera modules */ + const val BARCODE_SCANNER = 21 + /** * Returns the highest minSdk version among all modules. * @@ -70,7 +73,8 @@ object BuildConfig { LAZY_PAGING, S3_UPLOADER, CONNECTIVITY_MONITOR, - NAV3_NAVIGATION + NAV3_NAVIGATION, + BARCODE_SCANNER ).max() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae856c9..8017810 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,11 @@ paging = "3.5.1" roomVersion = "2.8.5" nav3 = "1.2.0-rc01" # exposed as `api` from Nav3Navigation workManager = "2.11.2" +cameraX = "1.6.2" # BarcodeScanner-Camera +mlkitBarcodeCommon = "17.0.0" # exposed as `api` from BarcodeScanner (Barcode.FORMAT_* constants) +mlkitBarcodeScanning = "18.3.1" # unbundled ML Kit detector, model served by Play services +playServicesCodeScanner = "16.1.0" +playServicesBase = "18.10.1" # ModuleInstallClient, for warmUp() # --- BUILD/TEST: toolchain + test-only, never published ---------------------- agp = "9.4.0" @@ -52,6 +57,7 @@ activityCompose = "1.13.0" # demo app + Nav3Navigation androidTest only androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycleRuntime" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntime" } #Kotlin / coroutines kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" } @@ -104,6 +110,20 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l #WorkManager androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "workManager" } +#CameraX - BarcodeScanner-Camera. Deliberately no camera-view / camera-video / camera-mlkit-vision: +#the Compose viewfinder and a fifteen-line analyzer replace all three. +androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" } +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" } +androidx-camera-compose = { group = "androidx.camera", name = "camera-compose", version.ref = "cameraX" } + +#ML Kit / Play services barcode - exposed as `api` from BarcodeScanner +mlkit-barcode-scanning-common = { group = "com.google.mlkit", name = "barcode-scanning-common", version.ref = "mlkitBarcodeCommon" } +playServices-mlkit-barcode-scanning = { group = "com.google.android.gms", name = "play-services-mlkit-barcode-scanning", version.ref = "mlkitBarcodeScanning" } +playServices-codeScanner = { group = "com.google.android.gms", name = "play-services-code-scanner", version.ref = "playServicesCodeScanner" } +playServices-base = { group = "com.google.android.gms", name = "play-services-base", version.ref = "playServicesBase" } +kotlinx-coroutines-playServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines" } + # ============================================================================= # BUILD/TEST ONLY - test & androidTest configurations of the library modules # ============================================================================= diff --git a/settings.gradle.kts b/settings.gradle.kts index 8652585..f666082 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -74,3 +74,5 @@ include(":MockInterceptor-AppolyJson") include(":MockInterceptor-Retrofit") include(":SegmentedControl") include(":Nav3Navigation") +include(":BarcodeScanner") +include(":BarcodeScanner-Camera") From 94c0005e3e48a2285a9de6cba4f4e00e680a9ff7 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 11:31:17 +0100 Subject: [PATCH 18/53] test(BarcodeScanner): add unit, device and demo coverage for both modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner-Camera/README.md | 20 ++ BarcodeScanner-Camera/build.gradle.kts | 6 + .../camera/BarcodeScannerCameraDeviceTest.kt | 157 ++++++++++ .../barcodescanner/camera/BarcodeDebouncer.kt | 5 + .../camera/BarcodeScannerCamera.kt | 49 +-- .../camera/BarcodeDebouncerTest.kt | 138 +++++++++ .../droid/barcodescanner/BarcodeFormatTest.kt | 120 ++++++++ app/build.gradle.kts | 2 + .../ui/screens/BarcodeScannerDemoScreen.kt | 288 ++++++++++++++++++ .../co/appoly/droid/ui/screens/HomeScreen.kt | 6 + gradle/libs.versions.toml | 2 + 11 files changed, 773 insertions(+), 20 deletions(-) create mode 100644 BarcodeScanner-Camera/src/androidTest/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCameraDeviceTest.kt create mode 100644 BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt create mode 100644 BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/BarcodeFormatTest.kt create mode 100644 app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index 0ca2245..9982527 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -129,6 +129,26 @@ It also deliberately avoids `camera-view`, `camera-video` and `camera-mlkit-visi `CameraXViewfinder` replaces `PreviewView`, and `MlKitAnalyzer` would drag in the other two to replace a fifteen-line class. +## On-device test suite + +The module ships a small instrumented suite covering the bind/unbind lifecycle. It is +**deliberately not run in CI** — it needs a real camera, which no CI runner has. Run it before +tagging a release: + +```bash +./gradlew :BarcodeScanner-Camera:connectedDebugAndroidTest +``` + +It proves the composable binds without error and survives repeated mount/unmount cycles — the +regression surface that actually bites, since closing the detector while a frame is in flight +throws from the analysis thread rather than reporting through `onError`. Verified on a Pixel 9 Pro +Fold (Android 17) and a OnePlus 6T (Android 11). + +The suite asserts the `CAMERA` grant rather than using `GrantPermissionRule`: that rule opens a +UiAutomation connection unconditionally and dies with "UiAutomationService ... already registered" +on a device that already holds one, even when the permission is granted. The install grants it; if +you see the assertion fire, the message tells you the `adb shell pm grant` to run. + ## Notes - The ML Kit model is served by Play services, not bundled — the module adds no multi-megabyte diff --git a/BarcodeScanner-Camera/build.gradle.kts b/BarcodeScanner-Camera/build.gradle.kts index 2a706e8..e22ec18 100644 --- a/BarcodeScanner-Camera/build.gradle.kts +++ b/BarcodeScanner-Camera/build.gradle.kts @@ -73,8 +73,14 @@ dependencies { testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.ui.test.junit4) testImplementation(libs.androidx.ui.test.manifest) + // On-device suite (see README "On-device test suite"). Deliberately NOT run in CI: it needs a + // real camera, which no CI runner has. Run it before tagging a release. androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + androidTestImplementation(libs.androidx.activity.compose) + debugImplementation(libs.androidx.ui.test.manifest) } mavenPublishing { pom { diff --git a/BarcodeScanner-Camera/src/androidTest/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCameraDeviceTest.kt b/BarcodeScanner-Camera/src/androidTest/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCameraDeviceTest.kt new file mode 100644 index 0000000..bfcf014 --- /dev/null +++ b/BarcodeScanner-Camera/src/androidTest/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCameraDeviceTest.kt @@ -0,0 +1,157 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import android.Manifest +import android.content.pm.PackageManager +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CopyOnWriteArrayList + +/** + * On-device smoke test for [BarcodeScannerCamera]'s bind/unbind lifecycle. + * + * **Not run in CI** — it needs a real camera, which no CI runner has. Run it before tagging a + * release: + * + * ``` + * ./gradlew :BarcodeScanner-Camera:connectedDebugAndroidTest + * ``` + * + * What it proves: the composable binds its use cases without error, and tears them down cleanly + * enough to be mounted and unmounted repeatedly. That is the regression surface that actually + * bites here — closing the ML Kit detector while a frame is still in flight, or shutting the + * analysis executor down underneath a queued task, both throw from the analysis thread rather + * than returning an error, so they show up as a crashed test rather than an `onError` call. + * + * What it cannot prove: that no native camera resource leaked. CameraX exposes no API to observe + * the use cases this composable owns, so a leak check would have to reach inside it. The + * mount/unmount cycling below is the closest observable proxy. + */ +@RunWith(AndroidJUnit4::class) +class BarcodeScannerCameraDeviceTest { + + @get:Rule + val composeRule = createComposeRule() + + private val errors = CopyOnWriteArrayList() + + /** + * Deliberately asserted rather than granted with `GrantPermissionRule`: that rule opens a + * UiAutomation connection unconditionally, and on a device already holding one it dies with + * "UiAutomationService ... already registered" before the test body runs — even when the + * permission is already granted. The install grants CAMERA, so assert and fail loudly with an + * actionable message instead of depending on UiAutomation at all. + */ + @Before + fun requireCameraPermission() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val granted = context.checkSelfPermission(Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + assertTrue( + "CAMERA not granted to the test package. Install with `adb install -g`, or run " + + "`adb shell pm grant uk.co.appoly.droid.barcodescanner.camera.test " + + "android.permission.CAMERA`.", + granted, + ) + } + + @Test + fun bindsWithoutReportingAnError() { + composeRule.setContent { + BarcodeScannerCamera( + modifier = Modifier.fillMaxSize(), + onError = { errors.add(it) }, + onBarcodeScanned = {}, + ) + } + + composeRule.waitForIdle() + // Binding is asynchronous (awaitInstance + bindToLifecycle), so give it real time before + // concluding it succeeded. + Thread.sleep(BIND_SETTLE_MS) + composeRule.waitForIdle() + + assertNoErrors("binding the camera") + } + + @Test + fun survivesRepeatedMountAndUnmountCycles() { + var mounted by mutableStateOf(true) + + composeRule.setContent { + if (mounted) { + BarcodeScannerCamera( + modifier = Modifier.fillMaxSize(), + onError = { errors.add(it) }, + onBarcodeScanned = {}, + ) + } + } + + repeat(CYCLES) { + composeRule.runOnIdle { mounted = true } + Thread.sleep(BIND_SETTLE_MS) + composeRule.runOnIdle { mounted = false } + composeRule.waitForIdle() + // Let the teardown's queued scanner.close() actually run on the analysis thread. + Thread.sleep(TEARDOWN_SETTLE_MS) + } + + assertNoErrors("cycling the camera $CYCLES times") + } + + @Test + fun theCameraIsUsableAgainAfterTheComposableLeaves() { + var mounted by mutableStateOf(true) + + composeRule.setContent { + if (mounted) { + BarcodeScannerCamera( + modifier = Modifier.fillMaxSize(), + onError = { errors.add(it) }, + onBarcodeScanned = {}, + ) + } + } + composeRule.waitForIdle() + Thread.sleep(BIND_SETTLE_MS) + + composeRule.runOnIdle { mounted = false } + composeRule.waitForIdle() + Thread.sleep(TEARDOWN_SETTLE_MS) + + // If the composable had left the camera bound to a dead lifecycle, the provider would + // still report the back camera as unavailable to a fresh caller. + val context = InstrumentationRegistry.getInstrumentation().targetContext + val provider = ProcessCameraProvider.getInstance(context).get() + assertTrue( + "the back camera should be available again once the composable has left", + provider.hasCamera(LensFacing.Back.selector), + ) + assertNoErrors("unbinding the camera") + } + + private fun assertNoErrors(whileDoing: String) { + assertTrue( + "onError fired while $whileDoing: ${errors.joinToString { it.toString() }}", + errors.isEmpty(), + ) + } + + private companion object { + const val BIND_SETTLE_MS = 2_000L + const val TEARDOWN_SETTLE_MS = 500L + const val CYCLES = 5 + } +} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt index c97c66a..08787d8 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt @@ -1,5 +1,6 @@ package uk.co.appoly.droid.barcodescanner.camera +import androidx.annotation.VisibleForTesting import uk.co.appoly.droid.barcodescanner.ScannedBarcode import kotlin.time.Duration import kotlin.time.TimeMark @@ -26,6 +27,10 @@ internal class BarcodeDebouncer( ) { private val lastEmitted = HashMap() + /** How many codes are currently being tracked. Exists so [pruneExpired] is observable. */ + @get:VisibleForTesting + internal val trackedCodeCount: Int get() = lastEmitted.size + /** * Returns true if [barcode] should be reported to the caller, recording the emission when so. */ diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index 06e0c0d..e0892ef 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -31,7 +31,9 @@ import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.withContext import uk.co.appoly.droid.barcodescanner.BarcodeFormat import uk.co.appoly.droid.barcodescanner.BarcodeFormats import uk.co.appoly.droid.barcodescanner.ScannedBarcode @@ -150,26 +152,33 @@ fun BarcodeScannerCamera( null } if (cameraProvider != null) { - val bound = try { - camera = cameraProvider.bindToLifecycle( - lifecycleOwner, - lensFacing.selector, - preview, - analysis, - ) - true - } catch (error: Exception) { - currentOnError(error) - false - } - // clearAnalyzer + unbind must happen before the scanner closes below, so that no - // analyze() call can run against a closed detector. - try { - if (bound) awaitCancellation() - } finally { - camera = null - analysis.clearAnalyzer() - cameraProvider.unbind(preview, analysis) + // bindToLifecycle and unbind both assert they are on the main thread. In an app + // the composition dispatches there anyway, but awaitInstance above resumes on a + // CameraX executor, so the thread at this point depends on the ambient + // dispatcher — which under a Compose test harness is not main. Pin it rather + // than depend on it. + withContext(Dispatchers.Main.immediate) { + val bound = try { + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + lensFacing.selector, + preview, + analysis, + ) + true + } catch (error: Exception) { + currentOnError(error) + false + } + // clearAnalyzer + unbind must happen before the scanner closes below, so that + // no analyze() call can run against a closed detector. + try { + if (bound) awaitCancellation() + } finally { + camera = null + analysis.clearAnalyzer() + cameraProvider.unbind(preview, analysis) + } } } } finally { diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt new file mode 100644 index 0000000..cce7937 --- /dev/null +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt @@ -0,0 +1,138 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import uk.co.appoly.droid.barcodescanner.BarcodeFormat +import uk.co.appoly.droid.barcodescanner.ScannedBarcode +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +/** + * The debouncer is the one piece of the camera module that can be tested without a camera, and + * the one most likely to be got subtly wrong — a global debounce looks identical to a per-code + * one until there are two barcodes in frame, at which point it stops working entirely. + */ +class BarcodeDebouncerTest { + + private fun barcode(raw: String, format: BarcodeFormat = BarcodeFormat.Ean13) = + ScannedBarcode(rawValue = raw, format = format) + + @Test + fun `the first sighting of a code is always emitted`() { + val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = TestTimeSource()) + + assertTrue(debouncer.shouldEmit(barcode("A"))) + } + + @Test + fun `a repeat inside the window is suppressed`() { + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) + + assertTrue(debouncer.shouldEmit(barcode("A"))) + time += 400.milliseconds + assertFalse(debouncer.shouldEmit(barcode("A"))) + time += 400.milliseconds + assertFalse(debouncer.shouldEmit(barcode("A"))) + } + + @Test + fun `a repeat after the window is emitted again`() { + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) + + assertTrue(debouncer.shouldEmit(barcode("A"))) + time += 1.seconds + assertTrue(debouncer.shouldEmit(barcode("A"))) + } + + @Test + fun `suppression does not extend the window`() { + // A code held in frame is re-detected constantly. If each suppressed sighting reset the + // clock, the code would never be emitted a second time at all. + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) + + assertTrue(debouncer.shouldEmit(barcode("A"))) + repeat(9) { + time += 100.milliseconds + assertFalse(debouncer.shouldEmit(barcode("A"))) + } + time += 100.milliseconds + assertTrue("the window should have expired 1s after the emission, not after the last sighting", debouncer.shouldEmit(barcode("A"))) + } + + @Test + fun `debouncing is per code, not global`() { + // Two labels in frame: ML Kit reports both on every frame. A global debounce would let + // them alternate and fire on every single frame — the exact bug this design avoids. + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) + + assertTrue(debouncer.shouldEmit(barcode("A"))) + assertTrue(debouncer.shouldEmit(barcode("B"))) + + time += 100.milliseconds + assertFalse(debouncer.shouldEmit(barcode("A"))) + assertFalse(debouncer.shouldEmit(barcode("B"))) + } + + @Test + fun `codes are keyed on raw value, not on format`() { + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) + + assertTrue(debouncer.shouldEmit(barcode("A", BarcodeFormat.Ean13))) + time += 100.milliseconds + assertFalse(debouncer.shouldEmit(barcode("A", BarcodeFormat.QrCode))) + } + + @Test + fun `a null window disables debouncing entirely`() { + // Callers that de-duplicate downstream pass null and expect every detection through. + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = null, timeSource = time) + + repeat(50) { + assertTrue(debouncer.shouldEmit(barcode("A"))) + } + } + + @Test + fun `expired entries are pruned rather than accumulating`() { + // A long scanning session over many distinct codes must not grow the map without bound. + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) + + repeat(500) { index -> + assertTrue(debouncer.shouldEmit(barcode("code-$index"))) + time += 100.milliseconds + } + + assertTrue( + "expired entries should have been pruned, leaving roughly one window's worth", + debouncer.trackedCodeCount < 100, + ) + } + + @Test + fun `pruning does not drop entries that are still suppressing`() { + val time = TestTimeSource() + val debouncer = BarcodeDebouncer(window = 10.seconds, timeSource = time) + + // Push past the prune threshold with codes that are all still inside their window. + repeat(200) { index -> + assertTrue(debouncer.shouldEmit(barcode("code-$index"))) + } + time += 1.seconds + + repeat(200) { index -> + assertFalse( + "code-$index was pruned while still inside its window", + debouncer.shouldEmit(barcode("code-$index")), + ) + } + } +} diff --git a/BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/BarcodeFormatTest.kt b/BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/BarcodeFormatTest.kt new file mode 100644 index 0000000..d651c37 --- /dev/null +++ b/BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/BarcodeFormatTest.kt @@ -0,0 +1,120 @@ +package uk.co.appoly.droid.barcodescanner + +import com.google.mlkit.vision.barcode.common.Barcode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the ML Kit boundary. `BarcodeFormat` exists so that app code never imports + * `com.google.mlkit.*`, which only holds if the mapping in both directions is exact — a wrong + * constant here mislabels every scan of that symbology, silently and at runtime. + */ +class BarcodeFormatTest { + + @Test + fun `every format round-trips through its ML Kit constant`() { + BarcodeFormat.entries.forEach { format -> + assertEquals( + "$format did not survive the round trip", + format, + BarcodeFormat.fromMlKit(format.mlKitFormat), + ) + } + } + + @Test + fun `each format maps to a distinct ML Kit constant`() { + val constants = BarcodeFormat.entries.map { it.mlKitFormat } + assertEquals( + "two formats share an ML Kit constant, so fromMlKit cannot be a bijection", + constants.size, + constants.toSet().size, + ) + } + + @Test + fun `known constants map to the expected formats`() { + // Spot-checks against the ML Kit constants directly: the round-trip test above passes + // even if a constant is wired to the wrong enum entry, as long as it is wired consistently. + assertEquals(BarcodeFormat.Code128, BarcodeFormat.fromMlKit(Barcode.FORMAT_CODE_128)) + assertEquals(BarcodeFormat.Ean13, BarcodeFormat.fromMlKit(Barcode.FORMAT_EAN_13)) + assertEquals(BarcodeFormat.Ean8, BarcodeFormat.fromMlKit(Barcode.FORMAT_EAN_8)) + assertEquals(BarcodeFormat.QrCode, BarcodeFormat.fromMlKit(Barcode.FORMAT_QR_CODE)) + assertEquals(BarcodeFormat.Pdf417, BarcodeFormat.fromMlKit(Barcode.FORMAT_PDF417)) + assertEquals(BarcodeFormat.Aztec, BarcodeFormat.fromMlKit(Barcode.FORMAT_AZTEC)) + assertEquals(BarcodeFormat.DataMatrix, BarcodeFormat.fromMlKit(Barcode.FORMAT_DATA_MATRIX)) + assertEquals(BarcodeFormat.UpcA, BarcodeFormat.fromMlKit(Barcode.FORMAT_UPC_A)) + assertEquals(BarcodeFormat.UpcE, BarcodeFormat.fromMlKit(Barcode.FORMAT_UPC_E)) + assertEquals(BarcodeFormat.Itf, BarcodeFormat.fromMlKit(Barcode.FORMAT_ITF)) + assertEquals(BarcodeFormat.Codabar, BarcodeFormat.fromMlKit(Barcode.FORMAT_CODABAR)) + assertEquals(BarcodeFormat.Code39, BarcodeFormat.fromMlKit(Barcode.FORMAT_CODE_39)) + assertEquals(BarcodeFormat.Code93, BarcodeFormat.fromMlKit(Barcode.FORMAT_CODE_93)) + } + + @Test + fun `an unrecognised constant maps to Unknown rather than throwing`() { + // A format added to ML Kit after this enum was written must not crash a scan. + assertEquals(BarcodeFormat.Unknown, BarcodeFormat.fromMlKit(Int.MAX_VALUE)) + assertEquals(BarcodeFormat.Unknown, BarcodeFormat.fromMlKit(Barcode.FORMAT_UNKNOWN)) + } + + @Test + fun `All covers every format except Unknown`() { + // Unknown is a result value, never a filter — including it would be meaningless. + assertEquals(BarcodeFormat.entries.toSet() - BarcodeFormat.Unknown, BarcodeFormats.All) + assertFalse("Unknown is a result value, not something to filter on", BarcodeFormat.Unknown in BarcodeFormats.All) + } + + @Test + fun `the dimensional sets partition All`() { + assertEquals(BarcodeFormats.All, BarcodeFormats.OneDimensional + BarcodeFormats.TwoDimensional) + assertTrue( + "a format cannot be both 1D and 2D", + (BarcodeFormats.OneDimensional intersect BarcodeFormats.TwoDimensional).isEmpty(), + ) + } + + @Test + fun `QrOnly is the single QR format and a subset of the 2D set`() { + assertEquals(setOf(BarcodeFormat.QrCode), BarcodeFormats.QrOnly) + assertTrue(BarcodeFormats.TwoDimensional.containsAll(BarcodeFormats.QrOnly)) + } + + @Test + fun `a format set folds into first-plus-rest scanner options`() { + val (first, rest) = setOf(BarcodeFormat.Ean13, BarcodeFormat.QrCode).toMlKitFormatArgs() + assertEquals( + setOf(Barcode.FORMAT_EAN_13, Barcode.FORMAT_QR_CODE), + setOf(first) + rest.toSet(), + ) + } + + @Test + fun `a single-format set folds to that format with no rest`() { + val (first, rest) = BarcodeFormats.QrOnly.toMlKitFormatArgs() + assertEquals(Barcode.FORMAT_QR_CODE, first) + assertEquals(0, rest.size) + } + + @Test + fun `an empty or Unknown-only set falls back to all formats`() { + // Otherwise the scanner would be built with no formats at all and decode nothing — + // a silent dead scanner is the worst possible failure here. + val (emptyFirst, emptyRest) = emptySet().toMlKitFormatArgs() + assertEquals(Barcode.FORMAT_ALL_FORMATS, emptyFirst) + assertEquals(0, emptyRest.size) + + val (unknownFirst, unknownRest) = setOf(BarcodeFormat.Unknown).toMlKitFormatArgs() + assertEquals(Barcode.FORMAT_ALL_FORMATS, unknownFirst) + assertEquals(0, unknownRest.size) + } + + @Test + fun `Unknown is dropped from a set that also carries real formats`() { + val (first, rest) = setOf(BarcodeFormat.Unknown, BarcodeFormat.QrCode).toMlKitFormatArgs() + assertEquals(Barcode.FORMAT_QR_CODE, first) + assertEquals(0, rest.size) + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0e6d97..6c1cdda 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,8 @@ dependencies { implementation(project(":S3Uploader-Multipart")) implementation(project(":ConnectivityMonitor")) implementation(project(":Nav3Navigation")) + implementation(project(":BarcodeScanner")) + implementation(project(":BarcodeScanner-Camera")) implementation(project(":MockInterceptor")) implementation(project(":MockInterceptor-Serialization")) implementation(project(":MockInterceptor-AppolyJson")) diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt new file mode 100644 index 0000000..8439512 --- /dev/null +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -0,0 +1,288 @@ +package uk.co.appoly.droid.ui.screens + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import uk.co.appoly.droid.barcodescanner.BarcodeFormats +import uk.co.appoly.droid.barcodescanner.OneShotBarcodeScanner +import uk.co.appoly.droid.barcodescanner.OneShotScanResult +import uk.co.appoly.droid.barcodescanner.ScannedBarcode +import uk.co.appoly.droid.barcodescanner.camera.BarcodeScannerCamera +import uk.co.appoly.droid.nav3.Nav3Screen + +/** + * Demonstrates both barcode modules side by side. + * + * "Scan once" goes through [OneShotBarcodeScanner] — Play services renders the UI, so there is no + * permission to request here. "Scan continuously" opens a [ModalBottomSheet] hosting + * [BarcodeScannerCamera], which is also the sheet case worth demonstrating: the dialog inherits + * the host's lifecycle owner, so the camera binds and unbinds with the sheet. + */ +@Serializable +data object BarcodeScannerDemoScreen : Nav3Screen { + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + + val oneShotScanner = remember { OneShotBarcodeScanner(context, formats = BarcodeFormats.All) } + var oneShotResult by remember { mutableStateOf(null) } + + var showSheet by remember { mutableStateOf(false) } + var torchEnabled by remember { mutableStateOf(false) } + var cameraError by remember { mutableStateOf(null) } + val scannedCodes = remember { mutableStateListOf() } + + var hasCameraPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + hasCameraPermission = granted + if (granted) showSheet = true + } + + // Pre-install the Play services scanner module so the first one-shot scan is instant + // rather than a download spinner. Exactly what the README tells consumers to do. + LaunchedEffect(oneShotScanner) { + oneShotScanner.warmUp() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Barcode Scanner") }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Two modules: BarcodeScanner hosts a single scan in Play services' own " + + "UI (no CAMERA permission), BarcodeScanner-Camera runs a continuous " + + "preview inside the app.", + style = MaterialTheme.typography.bodyMedium, + ) + + HorizontalDivider() + + Text( + text = "One-shot (BarcodeScanner)", + style = MaterialTheme.typography.titleMedium, + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + oneShotResult = when (val result = oneShotScanner.scan()) { + is OneShotScanResult.Scanned -> + "${result.barcode.format}: ${result.barcode.rawValue}" + + OneShotScanResult.Cancelled -> "Cancelled" + is OneShotScanResult.Unavailable -> + "Unavailable — no Play services scanner on this device " + + "(${result.cause?.message ?: "no detail"})" + + is OneShotScanResult.Failed -> + "Failed: ${result.cause.message ?: result.cause}" + } + } + }, + ) { + Text("Scan once") + } + + oneShotResult?.let { result -> + Card(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(16.dp), + text = result, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + + HorizontalDivider() + + Text( + text = "Continuous (BarcodeScanner-Camera)", + style = MaterialTheme.typography.titleMedium, + ) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + cameraError = null + if (hasCameraPermission) { + showSheet = true + } else { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + ) { + Text("Scan continuously") + } + + if (scannedCodes.isNotEmpty()) { + OutlinedButton( + modifier = Modifier.fillMaxWidth(), + onClick = { scannedCodes.clear() }, + ) { + Text("Clear ${scannedCodes.size} scanned") + } + } + + cameraError?.let { error -> + Card(modifier = Modifier.fillMaxWidth()) { + Text( + modifier = Modifier.padding(16.dp), + text = "Camera error: $error", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + scannedCodes.forEach { barcode -> + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = barcode.format.name, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = barcode.rawValue, + style = MaterialTheme.typography.bodyMedium, + ) + barcode.displayValue?.let { display -> + Text( + text = "display: $display", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + + if (showSheet) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + sheetState = sheetState, + onDismissRequest = { showSheet = false }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + checked = torchEnabled, + onCheckedChange = { torchEnabled = it }, + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(360.dp), + ) { + BarcodeScannerCamera( + modifier = Modifier.fillMaxSize(), + torchEnabled = torchEnabled, + onError = { cameraError = it.message ?: it.toString() }, + onBarcodeScanned = { barcode -> + // The module's own 2.5s debounce stops a held code repeating; + // this keeps the demo list to distinct values across the session. + if (scannedCodes.none { it.rawValue == barcode.rawValue }) { + scannedCodes.add(barcode) + } + }, + ) + } + + Text( + text = "${scannedCodes.size} distinct code(s) scanned", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +@Composable +private fun TorchToggleRow( + modifier: Modifier = Modifier, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Torch", + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt index ed693f5..7412cf3 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt @@ -140,6 +140,12 @@ data object HomeScreen : Nav3Screen { onClick = { navigator?.push(MockInterceptorDemoScreen) } ) + FeatureButton( + title = "Barcode Scanner", + description = "One-shot Play services scan, and a continuous CameraX + ML Kit preview in a sheet", + onClick = { navigator?.push(BarcodeScannerDemoScreen) } + ) + FeatureButton( title = "Compose Extensions", description = "Serialization-safe MutableState holders and the clipboard copier", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8017810..e131785 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,7 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" androidxTestCore = "1.7.0" +androidxTestRules = "1.7.0" # GrantPermissionRule, BarcodeScanner-Camera device suite robolectric = "4.16.1" activityCompose = "1.13.0" # demo app + Nav3Navigation androidTest only @@ -141,6 +142,7 @@ androidx-work-testing = { group = "androidx.work", name = "work-testing", versio androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxTestCore" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomVersion" } #Compose test (versions from the Compose BOM) From 63cccb706e25e33dfdabcbf927cff4abdc8e7e3f Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 11:08:27 +0100 Subject: [PATCH 19/53] fix(BarcodeScanner): report user cancellation instead of cancelling the caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../barcodescanner/OneShotBarcodeScanner.kt | 28 ++++++++- .../barcodescanner/OneShotCancellationTest.kt | 59 +++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/OneShotCancellationTest.kt diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt index 2b008de..014a2b9 100644 --- a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt @@ -8,8 +8,28 @@ import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.tasks.await +/** + * Decides what a [CancellationException] out of a Play services `Task` actually means. + * + * Play services reports "the user backed out of the scanner" by *cancelling the Task*, which + * `await()` surfaces as a [CancellationException] — an ordinary outcome that must be turned into a + * result, not rethrown. Our own caller being cancelled arrives as the same exception type and must + * propagate, or a cancelled screen would silently be treated as a user decision. + * + * [kotlinx.coroutines.ensureActive] throws only in the second case, so returning normally from + * here means "the user cancelled". + * + * Extracted and internal so the distinction is unit-testable: getting it backwards makes + * [OneShotScanResult.Cancelled] unreachable, which no compiler warns about. + */ +internal suspend fun awaitUserCancellation() { + currentCoroutineContext().ensureActive() +} + /** The outcome of a single [OneShotBarcodeScanner.scan] call. */ sealed interface OneShotScanResult { /** The user scanned something. */ @@ -103,7 +123,10 @@ class OneShotBarcodeScanner( true } } catch (cancellation: CancellationException) { - throw cancellation + // A cancelled install Task is a failed warm-up, not a reason to cancel whoever called + // us. Only a genuinely cancelled caller propagates. + awaitUserCancellation() + false } catch (_: Exception) { false } @@ -120,7 +143,8 @@ class OneShotBarcodeScanner( val barcode: Barcode = try { client.startScan().await() } catch (cancellation: CancellationException) { - throw cancellation + awaitUserCancellation() + return OneShotScanResult.Cancelled } catch (error: MlKitException) { return error.toScanResult() } catch (error: Exception) { diff --git a/BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/OneShotCancellationTest.kt b/BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/OneShotCancellationTest.kt new file mode 100644 index 0000000..e332c5b --- /dev/null +++ b/BarcodeScanner/src/test/java/uk/co/appoly/droid/barcodescanner/OneShotCancellationTest.kt @@ -0,0 +1,59 @@ +package uk.co.appoly.droid.barcodescanner + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression guard for a bug found by running the demo app, not by compiling it. + * + * `kotlinx-coroutines-play-services` maps a *cancelled* Play services `Task` to a + * [CancellationException] — and Play services cancels the Task when the user backs out of the + * scanner UI. Rethrowing it (the obvious "never swallow CancellationException" reflex) cancels the + * caller instead of returning a result, which made [OneShotScanResult.Cancelled] unreachable: the + * demo screen showed no result at all after tapping ✕. + * + * Nothing in the type system catches that, so it is pinned here. + */ +class OneShotCancellationTest { + + @Test + fun `a live caller treats task cancellation as a user cancellation`() = runTest { + // Returning normally is the signal for "the user backed out" — the caller then maps it to + // OneShotScanResult.Cancelled. + awaitUserCancellation() + } + + @Test + fun `a cancelled caller propagates instead of reporting a user cancellation`() = runTest { + val job = Job() + var enteredBlock = false + var returnedNormally = false + + val outcome = runCatching { + withContext(job) { + // Cancel from *inside*, after the block is running. Cancelling beforehand would + // make withContext throw on entry and the test would pass without ever calling + // the thing under test. + enteredBlock = true + job.cancel() + awaitUserCancellation() + returnedNormally = true + } + } + + assertTrue("the block never ran, so nothing was actually exercised", enteredBlock) + assertTrue( + "awaitUserCancellation returned normally inside a cancelled caller, so a cancelled " + + "screen would be misreported as the user cancelling the scan", + !returnedNormally, + ) + assertTrue( + "expected the caller's own cancellation to propagate, got ${outcome.exceptionOrNull()}", + outcome.exceptionOrNull() is CancellationException, + ) + } +} From 0b7bb698b6c76460eaf6edb957386c40c99a43c7 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 15:02:43 +0100 Subject: [PATCH 20/53] fix(BarcodeScanner): await the real module install before scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner/README.md | 14 ++- .../barcodescanner/OneShotBarcodeScanner.kt | 105 +++++++++++++++--- 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md index 5d8ee2d..4967f1b 100644 --- a/BarcodeScanner/README.md +++ b/BarcodeScanner/README.md @@ -58,8 +58,18 @@ LaunchedEffect(Unit) { } ``` -It is safe to call repeatedly and returns `false` rather than throwing when the install cannot be -done. A `false` does not mean `scan()` will fail — only that it may be slower. +It suspends until the module is genuinely installed, is safe to call repeatedly, and returns +`false` rather than throwing when the install cannot be done. + +`warmUp()` is **purely an optimisation** — `scan()` performs the same check itself and waits if it +has to, so skipping it costs latency on the first scan, never correctness. Do not block your UI on +it: on a fresh device it needs a network and several seconds. + +> Under the hood this is more than one call, because `installModules().await()` resolves when Play +> services *accepts* the request rather than when the download completes. Launching the scanner at +> that point hits a module that has not registered yet, and Play services fails the scan with a +> generic `INTERNAL` error. Completion is only observable via an `InstallStatusListener`, which is +> what both `warmUp()` and `scan()` wait on. ### Choosing formats diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt index 014a2b9..ab629a7 100644 --- a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt @@ -1,8 +1,10 @@ package uk.co.appoly.droid.barcodescanner import android.content.Context +import com.google.android.gms.common.moduleinstall.InstallStatusListener import com.google.android.gms.common.moduleinstall.ModuleInstall import com.google.android.gms.common.moduleinstall.ModuleInstallRequest +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate import com.google.mlkit.common.MlKitException import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions @@ -10,7 +12,10 @@ import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume /** * Decides what a [CancellationException] out of a Play services `Task` actually means. @@ -98,29 +103,86 @@ class OneShotBarcodeScanner( private val client get() = GmsBarcodeScanning.getClient(appContext, options) /** - * Pre-installs the Play services scanner module so the first [scan] opens immediately - * instead of sitting on a download spinner for several seconds. + * Pre-installs the Play services scanner module, suspending until it is genuinely ready, so + * that the first [scan] opens immediately instead of sitting on a download spinner for several + * seconds. * * Call it from a screen the user reaches before they need to scan — app start, or the screen - * hosting the scan button. Safe to call repeatedly; it is a no-op once the module is present. + * hosting the scan button. Safe to call repeatedly; it returns straight away once the module + * is present. On a fresh device the first call can take several seconds and needs a network, + * so do not block your UI on it. * - * @return true if the module is installed and ready, false if the install could not be done - * (no Play services, no network). A false here does not mean [scan] will fail — it will just - * be slower, or return [OneShotScanResult.Unavailable]. + * Purely an optimisation: [scan] performs the same check itself, so skipping this costs + * latency on the first scan, never correctness. + * + * @return true if the module is installed and ready to use, false if the install could not be + * done (no Play services, no network, or the user cancelled it). A false here means [scan] + * will likely return [OneShotScanResult.Unavailable] until the situation changes. + */ + suspend fun warmUp(): Boolean = ensureModuleInstalled() + + /** + * Suspends until the Play services scanner module is installed, or the install reaches a + * terminal failure. + * + * The subtlety that makes this more than a one-liner: `installModules().await()` resolves when + * Play services *accepts* the request, not when the download finishes. Treating that as "ready" + * launches the scanner against a module that has not registered yet — Play services logs + * "No registered Chimera impl" and fails the scan with a generic `INTERNAL` error that is + * indistinguishable from a real scan failure. Completion is only observable through an + * [InstallStatusListener] on the request. + * + * There is no built-in timeout: a slow download is still a legitimate install. Callers that + * cannot wait should wrap the call in `withTimeout`, which cancels cleanly. */ - suspend fun warmUp(): Boolean { + private suspend fun ensureModuleInstalled(): Boolean { val scannerClient = client return try { val moduleInstall = ModuleInstall.getClient(appContext) - val availability = moduleInstall.areModulesAvailable(scannerClient).await() - if (availability.areModulesAvailable()) { - true - } else { - val request = ModuleInstallRequest.newBuilder() - .addApi(scannerClient) - .build() - moduleInstall.installModules(request).await() - true + if (moduleInstall.areModulesAvailable(scannerClient).await().areModulesAvailable()) { + return true + } + suspendCancellableCoroutine { continuation -> + // installModules' own callbacks and the listener race each other, and resuming a + // continuation twice throws. First one through wins. + val settled = AtomicBoolean(false) + lateinit var listener: InstallStatusListener + + fun settle(installed: Boolean) { + if (settled.compareAndSet(false, true)) { + moduleInstall.unregisterListener(listener) + continuation.resume(installed) + } + } + + listener = InstallStatusListener { update -> + when (update.installState) { + ModuleInstallStatusUpdate.InstallState.STATE_COMPLETED -> settle(true) + ModuleInstallStatusUpdate.InstallState.STATE_FAILED, + ModuleInstallStatusUpdate.InstallState.STATE_CANCELED, + -> settle(false) + // PENDING / DOWNLOADING / INSTALLING / DOWNLOAD_PAUSED: keep waiting. + } + } + + continuation.invokeOnCancellation { + if (settled.compareAndSet(false, true)) { + moduleInstall.unregisterListener(listener) + } + } + + moduleInstall.installModules( + ModuleInstallRequest.newBuilder() + .addApi(scannerClient) + .setListener(listener) + .build(), + ) + .addOnSuccessListener { response -> + // Nothing left to download means no listener callback will ever arrive, + // so this is the only thing that can resume the continuation. + if (response.areModulesAlreadyInstalled()) settle(true) + } + .addOnFailureListener { settle(false) } } } catch (cancellation: CancellationException) { // A cancelled install Task is a failed warm-up, not a reason to cancel whoever called @@ -140,6 +202,17 @@ class OneShotBarcodeScanner( * activity. The result is simply discarded. */ suspend fun scan(): OneShotScanResult { + // Not merely an optimisation. Launching the scanner before the module has registered makes + // Play services fail with a generic INTERNAL error, so [warmUp] only moves this cost + // earlier — it is not the thing that makes scanning correct. + if (!ensureModuleInstalled()) { + return OneShotScanResult.Unavailable( + IllegalStateException( + "The Play services barcode scanner module is not installed and could not be " + + "installed (no Play services, or no network).", + ), + ) + } val barcode: Barcode = try { client.startScan().await() } catch (cancellation: CancellationException) { From dce4b4cd18d483066ea1f04663afa2bb03ed1ce9 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 15:13:00 +0100 Subject: [PATCH 21/53] docs(BarcodeScanner): warn against branching on MlKitException error codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner/README.md | 12 ++++++++++++ .../droid/barcodescanner/OneShotBarcodeScanner.kt | 13 ++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md index 4967f1b..5d2648c 100644 --- a/BarcodeScanner/README.md +++ b/BarcodeScanner/README.md @@ -96,6 +96,18 @@ is OneShotScanResult.Unavailable -> { } ``` +## Don't branch on error codes + +`Failed` wraps whatever Play services threw, usually an `MlKitException`. Resist reading meaning +into its `errorCode`: Play services reports `INTERNAL` (13) for genuinely unrelated problems — a +scanner module that has not registered yet, a camera delivering no frames, and others. During this +module's first integration, two separate investigations were sent the wrong way by assuming that +code meant one specific thing. + +`Unavailable` is the only result that carries a reliable meaning, so make product decisions there. +Treat `Failed` as "retry or tell the user", and log `cause` for diagnosis rather than switching on +it. + ## API | Type | Purpose | diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt index ab629a7..d78470a 100644 --- a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt @@ -52,7 +52,18 @@ sealed interface OneShotScanResult { */ data class Unavailable(val cause: Throwable?) : OneShotScanResult - /** The scan failed for any other reason. */ + /** + * The scan failed for any other reason. + * + * **Do not branch on the underlying error code.** [cause] is usually an `MlKitException`, and + * Play services overwhelmingly reports `INTERNAL` (13) for unrelated problems — a module that + * has not registered yet, a camera delivering no frames, and more. Two separate investigations + * during this module's first integration were misdirected by reading meaning into that code. + * + * Treat this branch as "try again or tell the user", show [cause] in logs, and make product + * decisions from [Unavailable] instead, which is the only result that carries a reliable + * meaning. + */ data class Failed(val cause: Throwable) : OneShotScanResult } From 3d01bcece7340755c0934d9c323c4f545628724e Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 15:26:47 +0100 Subject: [PATCH 22/53] fix(BarcodeScanner): stop a transient first-run race reporting as Unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner/README.md | 14 ++- .../barcodescanner/OneShotBarcodeScanner.kt | 109 ++++++++++++++---- 2 files changed, 102 insertions(+), 21 deletions(-) diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md index 5d2648c..e6002bc 100644 --- a/BarcodeScanner/README.md +++ b/BarcodeScanner/README.md @@ -87,7 +87,19 @@ setOf(BarcodeFormat.Ean13, BarcodeFormat.UpcA) // or roll your own The hosted scanner lives in Play services, so it does not exist on Huawei devices, stripped ROMs, or installs with a Play services too old to serve it. That is a real slice of real users, and the -sealed result makes it impossible to forget: +sealed result makes it impossible to forget. + +**`Unavailable` means the device genuinely cannot scan**, not "it did not work this time". The +distinction is enforced rather than assumed: the module asks Play services about its own +availability instead of inferring it from a scanner error code, because Play services reports the +same `CODE_SCANNER_UNAVAILABLE` for a device that can never scan *and* for a perfectly capable one +whose freshly installed scanner components have not been enabled yet. A first-run race, a missing +network or an update in progress all report `Failed`, so you will never tell a first-time user on a +good phone that their phone cannot do this. + +The first scan on a fresh device also retries briefly while Play services finishes enabling the +scanner, so that race is usually invisible to you. + ```kotlin is OneShotScanResult.Unavailable -> { diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt index d78470a..c6b5a47 100644 --- a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt @@ -1,16 +1,18 @@ package uk.co.appoly.droid.barcodescanner import android.content.Context +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.moduleinstall.InstallStatusListener import com.google.android.gms.common.moduleinstall.ModuleInstall import com.google.android.gms.common.moduleinstall.ModuleInstallRequest import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate import com.google.mlkit.common.MlKitException -import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import kotlinx.coroutines.CancellationException import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.tasks.await @@ -44,11 +46,17 @@ sealed interface OneShotScanResult { data object Cancelled : OneShotScanResult /** - * Play services is missing, too old, or the scanner module could not be installed. + * Play services is missing, disabled, invalid, or too old to serve the scanner. * * Handle this branch: it is the everyday reality on Huawei devices and stripped ROMs, where * the hosted scanner simply does not exist. Fall back to `BarcodeScanner-Camera`, or to * manual entry. + * + * This is the one result safe to hang a product decision on, because it is checked against + * Play services' own availability rather than inferred from a scanner error code. A temporary + * problem — no network, an update in progress, or a freshly installed scanner module whose + * components are not enabled yet — reports [Failed] instead, so "this device cannot scan" is + * never said about a device that can. */ data class Unavailable(val cause: Throwable?) : OneShotScanResult @@ -213,39 +221,100 @@ class OneShotBarcodeScanner( * activity. The result is simply discarded. */ suspend fun scan(): OneShotScanResult { - // Not merely an optimisation. Launching the scanner before the module has registered makes - // Play services fail with a generic INTERNAL error, so [warmUp] only moves this cost - // earlier — it is not the thing that makes scanning correct. + // Not merely an optimisation. Launching the scanner before the module is usable makes Play + // services fail, so [warmUp] only moves this cost earlier — it is not the thing that makes + // scanning correct. if (!ensureModuleInstalled()) { - return OneShotScanResult.Unavailable( + return classify( IllegalStateException( "The Play services barcode scanner module is not installed and could not be " + "installed (no Play services, or no network).", ), ) } - val barcode: Barcode = try { - client.startScan().await() - } catch (cancellation: CancellationException) { - awaitUserCancellation() - return OneShotScanResult.Cancelled - } catch (error: MlKitException) { - return error.toScanResult() - } catch (error: Exception) { - return OneShotScanResult.Failed(error) + + // A freshly installed module is not immediately usable: Play services enables the scanner + // activity's components a few hundred milliseconds AFTER the install reports complete, and + // there is no API that reports readiness. Until then startScan() fails with + // CODE_SCANNER_UNAVAILABLE. Retrying is not a sticking plaster over a race we could + // otherwise win — it is the only signal Play services gives us. Bounded, and only for the + // code that means "the scanner did not start", so a user who is looking at the scanner UI + // never has it reopened under them. + var lastStartFailure: MlKitException? = null + repeat(START_ATTEMPTS) { attempt -> + try { + val barcode = client.startScan().await() + val scanned = barcode.toScannedBarcode() + ?: return OneShotScanResult.Failed( + IllegalStateException("Scanner returned a barcode with no raw value"), + ) + return OneShotScanResult.Scanned(scanned) + } catch (cancellation: CancellationException) { + awaitUserCancellation() + return OneShotScanResult.Cancelled + } catch (error: MlKitException) { + val isLastAttempt = attempt == START_ATTEMPTS - 1 + if (error.errorCode != MlKitException.CODE_SCANNER_UNAVAILABLE || isLastAttempt) { + return error.toScanResult() + } + lastStartFailure = error + delay(START_RETRY_DELAY_MS) + } catch (error: Exception) { + return OneShotScanResult.Failed(error) + } } - val scanned = barcode.toScannedBarcode() - ?: return OneShotScanResult.Failed(IllegalStateException("Scanner returned a barcode with no raw value")) - return OneShotScanResult.Scanned(scanned) + return lastStartFailure?.toScanResult() + ?: OneShotScanResult.Failed(IllegalStateException("The scanner could not be started")) } private fun MlKitException.toScanResult(): OneShotScanResult = when (errorCode) { MlKitException.CODE_SCANNER_CANCELLED -> OneShotScanResult.Cancelled + + // Definitively a property of the device, not of this moment. + MlKitException.CODE_SCANNER_GOOGLE_PLAY_SERVICES_VERSION_TOO_OLD -> + OneShotScanResult.Unavailable(this) + + // These are NOT reliable evidence of a permanent limitation. Play services reports + // CODE_SCANNER_UNAVAILABLE both on a device that can never scan and on a perfectly capable + // one whose freshly installed scanner components have not been enabled yet. Ask Play + // services about itself instead of trusting the code. MlKitException.UNAVAILABLE, MlKitException.CODE_SCANNER_UNAVAILABLE, - MlKitException.CODE_SCANNER_GOOGLE_PLAY_SERVICES_VERSION_TOO_OLD, - -> OneShotScanResult.Unavailable(this) + MlKitException.CODE_SCANNER_APP_NAME_UNAVAILABLE, + -> classify(this) else -> OneShotScanResult.Failed(this) } + + /** + * Decides between [OneShotScanResult.Unavailable] and [OneShotScanResult.Failed] by asking + * Play services whether it is itself usable, rather than inferring it from a scanner error + * code. + * + * This exists because [OneShotScanResult.Unavailable] is the branch apps hang product + * decisions on — typically "tell the user their device cannot scan and offer manual entry". + * That is only a fair thing to say when it is actually true of the device. A first-run race, a + * missing network or a Play services update in progress are all temporary, and reporting them + * as `Unavailable` tells a first-time user on a capable phone that their phone cannot do + * something it can do a second later. + */ + private fun classify(cause: Throwable): OneShotScanResult = + when (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(appContext)) { + // Present and usable, or mid-update: whatever went wrong is not the device's fault. + ConnectionResult.SUCCESS, + ConnectionResult.SERVICE_UPDATING, + -> OneShotScanResult.Failed(cause) + + // Missing, disabled, invalid, or too old to serve the scanner. + else -> OneShotScanResult.Unavailable(cause) + } + + private companion object { + /** + * Attempts to start the scanner before giving up. Covers the few hundred milliseconds + * between a fresh module install completing and its components being enabled. + */ + const val START_ATTEMPTS = 4 + const val START_RETRY_DELAY_MS = 400L + } } From fb23ea9d4602227d6ca4f2223f913797b9523310 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 15:45:44 +0100 Subject: [PATCH 23/53] docs(BarcodeScanner): migration hazard, and when Unavailable is reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner/README.md | 34 +++++++++++++++++++ .../droid/barcodescanner/ScannedBarcode.kt | 8 ++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md index e6002bc..d8aad05 100644 --- a/BarcodeScanner/README.md +++ b/BarcodeScanner/README.md @@ -100,6 +100,20 @@ good phone that their phone cannot do this. The first scan on a fresh device also retries briefly while Play services finishes enabling the scanner, so that race is usually invisible to you. +### "We ship through Play, so this can't happen" + +It still can, and the branch is worth keeping. Shipping only through the Play Store rules out +*installing* on a device with no Play services — it does not rule out: + +- **Play services disabled.** A user can disable it in system settings on an app that installed + fine months earlier. +- **Play services too old.** `SERVICE_VERSION_UPDATE_REQUIRED` on a neglected or long-offline + device is the single most likely way you will see this in the wild. +- **Enterprise or MDM distribution**, which bypasses Play entirely. + +So treat `Unavailable` as rare rather than impossible. It needs a sane message and, ideally, a +manual-entry path — not a `TODO`. + ```kotlin is OneShotScanResult.Unavailable -> { @@ -108,6 +122,26 @@ is OneShotScanResult.Unavailable -> { } ``` +## Migrating from an older scanner + +One hazard worth knowing, found in a real migration. Older ML Kit and Mobile Vision code often +reads the display value defensively: + +```kotlin +barcode.displayValue?.let { onSerial(it) } // silently does nothing for most codes +``` + +`displayValue` is null whenever ML Kit has nothing better to offer than the raw contents, which is +the *common* case for plain serials and part numbers — so that line drops the scan entirely. The +scanner opens, decodes, closes, and no value ever arrives, with no error anywhere. Use: + +```kotlin +val shown = barcode.displayValue ?: barcode.rawValue // for display +val key = barcode.rawValue // for matching your own data +``` + +`rawValue` is guaranteed non-blank, so it is always a safe fallback. + ## Don't branch on error codes `Failed` wraps whatever Play services threw, usually an `MlKitException`. Resist reading meaning diff --git a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt index b12e583..2bc0d1c 100644 --- a/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt @@ -10,7 +10,13 @@ import com.google.mlkit.vision.barcode.common.Barcode * @property format the symbology it was encoded in. * @property displayValue ML Kit's human-readable rendering, where it has one (it strips the * `WIFI:`/`tel:`-style scheme prefixes from structured QR payloads, for instance). Null when - * ML Kit offers nothing better than [rawValue]. + * ML Kit offers nothing better than [rawValue] — including when it would simply repeat it. + * + * Reach for `displayValue ?: rawValue` when showing a code to a user, and for [rawValue] alone + * when matching against your own data. What you should not write is `displayValue?.let { … }`: + * null is the common case, not the exceptional one, so that quietly does nothing for most codes. + * A migrating app was found dropping every scanned serial that way — the scan succeeded, the + * scanner closed, and no value ever appeared. */ data class ScannedBarcode( val rawValue: String, From 1ecc406e1fb00ae4492f1906d56294ae53adada8 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 16:42:34 +0100 Subject: [PATCH 24/53] feat(SegmentedControl): allow a null selection for unanswered state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- SegmentedControl/README.md | 30 ++++ .../ui/segmentedcontrol/SegmentedControl.kt | 71 +++++++-- .../SegmentedControlNullSelectionTest.kt | 138 ++++++++++++++++++ .../ui/screens/SegmentedControlDemoScreen.kt | 38 +++++ 4 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlNullSelectionTest.kt diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 146b4c0..51385c3 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -4,6 +4,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot ## Features +- Optional "nothing selected yet" state for unanswered form questions - Smooth animated thumb sliding between segments - Drag gesture support on the selected segment to switch - Press animations with configurable scale effect @@ -38,6 +39,35 @@ fun MyScreen() { } ``` +### Nothing selected yet + +`selectedSegment` is nullable. Pass `null` and no segment is selected and no thumb is drawn — which +is what you want for a form question the user has not answered: + +```kotlin +@Composable +fun SwitchField(item: SwitchFormItem, onAnswer: (Int) -> Unit) { + SegmentedControl( + segments = item.options, + selectedSegment = item.value, // null until answered + onSegmentSelected = onAnswer, + ) +} +``` + +This matters for correctness, not just looks. The alternative — defaulting to the first segment — +renders a required, unanswered field as though the user had already answered it, and invites a +wrong submission. + +`onSegmentSelected` stays non-null: null is an input state, never an output, because the user can +only ever tap a real segment. Clearing a selection is done by passing `null` back in. + +A `selectedSegment` that is not present in `segments` behaves the same way as `null`. + +**Interaction notes.** With nothing selected, tapping any segment selects it; drag-to-switch only +applies once there is a selection to drag. The thumb fades in under the segment the user taps +rather than sliding in from the edge. + ### With Custom Objects ```kotlin diff --git a/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt b/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt index 9074e7c..6b6d515 100644 --- a/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt +++ b/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt @@ -25,6 +25,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf @@ -398,7 +400,11 @@ private const val NO_SEGMENT_INDEX = -1 * This is the simplest overload for when your segments are already strings. * * @param segments The list of string segments to display. - * @param selectedSegment The segment that should be selected. + * @param selectedSegment The segment that should be selected, or null for none — use it for a + * form question the user has not answered yet, rather than defaulting to the first segment and + * showing an unanswered field as though it had been answered. A value that is not in [segments] + * is treated the same way. The thumb is hidden while nothing is selected and appears under the + * segment the user picks. * @param onSegmentSelected A callback that will be called when the user selects a segment. * @param modifier A modifier to apply to the control. * @param trackShape The shape of the track that the segments are placed on. @@ -415,7 +421,7 @@ private const val NO_SEGMENT_INDEX = -1 @Composable fun SegmentedControl( segments: List, - selectedSegment: String, + selectedSegment: String?, onSegmentSelected: (String) -> Unit, modifier: Modifier = Modifier, trackShape: Shape = RoundedCornerShape(8.dp), @@ -455,7 +461,11 @@ fun SegmentedControl( * content, use the overload that accepts a `content` composable lambda. * * @param segments The list of segments to display. - * @param selectedSegment The segment that should be selected. + * @param selectedSegment The segment that should be selected, or null for none — use it for a + * form question the user has not answered yet, rather than defaulting to the first segment and + * showing an unanswered field as though it had been answered. A value that is not in [segments] + * is treated the same way. The thumb is hidden while nothing is selected and appears under the + * segment the user picks. * @param onSegmentSelected A callback that will be called when the user selects a segment. * @param modifier A modifier to apply to the control. * @param trackShape The shape of the track that the segments are placed on. @@ -473,7 +483,7 @@ fun SegmentedControl( @Composable fun SegmentedControl( segments: List, - selectedSegment: T, + selectedSegment: T?, onSegmentSelected: (T) -> Unit, modifier: Modifier = Modifier, trackShape: Shape = RoundedCornerShape(8.dp), @@ -513,7 +523,11 @@ fun SegmentedControl( * represented by a piece of content that is passed in as a lambda. * * @param segments The list of segments to display. - * @param selectedSegment The segment that should be selected. + * @param selectedSegment The segment that should be selected, or null for none — use it for a + * form question the user has not answered yet, rather than defaulting to the first segment and + * showing an unanswered field as though it had been answered. A value that is not in [segments] + * is treated the same way. The thumb is hidden while nothing is selected and appears under the + * segment the user picks. * @param onSegmentSelected A callback that will be called when the user selects a segment. * @param modifier A modifier to apply to the control. * @param trackShape The shape of the track that the segments are placed on. @@ -532,7 +546,7 @@ fun SegmentedControl( @Composable fun SegmentedControl( segments: List, - selectedSegment: T, + selectedSegment: T?, onSegmentSelected: (T) -> Unit, modifier: Modifier = Modifier, trackShape: Shape = RoundedCornerShape(8.dp), @@ -549,13 +563,35 @@ fun SegmentedControl( ) { val state = remember { SegmentedControlState(trackPressedPadding = trackPressedPadding) } state.segmentCount = segments.size - state.selectedSegment = segments.indexOf(selectedSegment) + // indexOf already yields -1 for a value that is not in the list, which is the same + // NO_SEGMENT_INDEX sentinel a null selection produces — both mean "draw nothing selected". + state.selectedSegment = selectedSegment + ?.let { segments.indexOf(it) } + ?: NO_SEGMENT_INDEX state.onSegmentSelected = { onSegmentSelected(segments[it]) } + val hasSelection = state.selectedSegment != NO_SEGMENT_INDEX + // Animate between whole-number indices so we don't need to do pixel calculations. - val selectedIndexOffset by animateFloatAsState( - state.selectedSegment.toFloat(), - label = "selectedIndexOffset_animatedFloatAsState" + // + // The thumb keeps its last real position while nothing is selected, rather than tracking the + // -1 sentinel: animating to -1 would slide it off the left edge, and the first selection would + // then fly in from outside the control. Instead it fades out in place, and the first selection + // after an empty state snaps the position before fading back in — so the thumb appears under + // the segment the user actually tapped rather than travelling there. + val thumbIndex = remember { Animatable(state.selectedSegment.coerceAtLeast(0).toFloat()) } + var hasEverBeenSelected by remember { mutableStateOf(hasSelection) } + LaunchedEffect(state.selectedSegment) { + if (!hasSelection) return@LaunchedEffect + val target = state.selectedSegment.toFloat() + if (hasEverBeenSelected) thumbIndex.animateTo(target) else thumbIndex.snapTo(target) + hasEverBeenSelected = true + } + val selectedIndexOffset = thumbIndex.value + + val thumbAlpha by animateFloatAsState( + targetValue = if (hasSelection) 1f else 0f, + label = "thumbAlpha" ) // Use a custom layout so that we can measure the thumb using the height of the segments. The thumb @@ -567,7 +603,8 @@ fun SegmentedControl( Thumb( state = state, thumbShape = thumbShape, - colors = colors + colors = colors, + alpha = thumbAlpha ) Dividers( state = state, @@ -656,10 +693,15 @@ fun SegmentText( private fun Thumb( state: SegmentedControlState, thumbShape: Shape, - colors: SegmentedControlColors + colors: SegmentedControlColors, + alpha: Float ) { val density = LocalDensity.current - val pressed = state.pressedSegment == state.selectedSegment + // The NO_SEGMENT_INDEX guard is load-bearing: pressedSegment and selectedSegment are both -1 + // when nothing is pressed and nothing is selected, so a bare equality check would report the + // thumb as pressed for every unanswered control. + val pressed = state.selectedSegment != NO_SEGMENT_INDEX && + state.pressedSegment == state.selectedSegment val scale by animateFloatAsState( targetValue = if (pressed) state.pressedSelectedScale else 1f, label = "thumbScale" @@ -671,10 +713,11 @@ private fun Thumb( Box( Modifier + .graphicsLayer { this.alpha = alpha } .segmentScale( scale = scale, xOffset = with(density) { xOffset.toPx() }, - segment = state.selectedSegment, + segment = state.selectedSegment.coerceAtLeast(0), segmentCount = state.segmentCount ) .shadow(4.dp, thumbShape) diff --git a/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlNullSelectionTest.kt b/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlNullSelectionTest.kt new file mode 100644 index 0000000..208d84d --- /dev/null +++ b/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlNullSelectionTest.kt @@ -0,0 +1,138 @@ +package uk.co.appoly.droid.ui.segmentedcontrol + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Covers the "nothing selected yet" state, which exists so a form can render an unanswered + * question honestly. + * + * Before `selectedSegment` accepted null, the only way to render such a control was to pass a + * sentinel — typically the first segment — which showed a required, unanswered field as though the + * user had already answered it. That is a correctness problem rather than a cosmetic one, so these + * tests pin the behaviour rather than leaving it to the rendering. + */ +@RunWith(AndroidJUnit4::class) +class SegmentedControlNullSelectionTest { + + @get:Rule + val composeRule = createComposeRule() + + private val segments = listOf("Yes", "No", "N/A") + + @Test + fun `a null selection leaves every segment unselected`() { + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = null, + onSegmentSelected = {}, + ) + } + } + + segments.forEach { composeRule.onNodeWithText(it).assertIsNotSelected() } + } + + @Test + fun `a segment absent from the list also selects nothing`() { + // indexOf yields -1 for a value that is not present, which lands on the same sentinel as + // null. Pinned so the two paths cannot drift apart. + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "Maybe", + onSegmentSelected = {}, + ) + } + } + + segments.forEach { composeRule.onNodeWithText(it).assertIsNotSelected() } + } + + @Test + fun `selecting from an empty state reports and marks the tapped segment`() { + var reported: String? = null + composeRule.setContent { + MaterialTheme { + var current by remember { mutableStateOf(null) } + SegmentedControl( + segments = segments, + selectedSegment = current, + onSegmentSelected = { + current = it + reported = it + }, + ) + } + } + + composeRule.onNodeWithText("N/A").assertIsNotSelected() + composeRule.onNodeWithText("N/A").performClick() + + assertEquals("N/A", reported) + composeRule.onNodeWithText("N/A").assertIsSelected() + composeRule.onNodeWithText("Yes").assertIsNotSelected() + } + + @Test + fun `a selection can be cleared back to nothing`() { + // Forms reset. Going back to null must genuinely deselect rather than strand the selection + // on the previously chosen segment. + // + // Driven from outside the composition rather than by tapping: tapping the *already + // selected* segment is deliberately a no-op in this control (that gesture is the start of + // a drag, and only fires once the pointer reaches a different segment), so a click here + // would prove nothing. + val selection = mutableStateOf("No") + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = selection.value, + onSegmentSelected = { selection.value = it }, + ) + } + } + + composeRule.onNodeWithText("No").assertIsSelected() + + composeRule.runOnIdle { selection.value = null } + + segments.forEach { composeRule.onNodeWithText(it).assertIsNotSelected() } + } + + @Test + fun `a non-null selection still behaves exactly as before`() { + // The widening is meant to be invisible to existing callers; this is the guard against a + // regression in the common path. + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "No", + onSegmentSelected = {}, + ) + } + } + + composeRule.onNodeWithText("No").assertIsSelected() + composeRule.onNodeWithText("Yes").assertIsNotSelected() + composeRule.onNodeWithText("N/A").assertIsNotSelected() + } +} diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt index 53c5bc2..c8d87be 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -81,6 +82,43 @@ data object SegmentedControlDemoScreen : Nav3Screen { style = MaterialTheme.typography.bodyLarge ) + // Unanswered state — the form-engine case + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Nothing selected yet", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "selectedSegment = null renders no thumb, so an unanswered form " + + "question doesn't look answered. Tap to answer, Clear to reset.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + + val segments = remember { listOf("Yes", "No", "N/A") } + var answer by remember { mutableStateOf(null) } + + SegmentedControl( + segments = segments, + selectedSegment = answer, + onSegmentSelected = { answer = it } + ) + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Answer: ${answer ?: "unanswered"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary + ) + TextButton(onClick = { answer = null }) { + Text("Clear") + } + } + } + // Basic usage with strings Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { From 6c827c74f598c70c7032b596202e48221c4602af Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 16 Sep 2026 17:06:43 +0100 Subject: [PATCH 25/53] feat(SegmentedControl): add `enabled` for read-only forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- SegmentedControl/README.md | 27 +++ .../ui/segmentedcontrol/SegmentedControl.kt | 50 ++++- .../SegmentedControlEnabledTest.kt | 177 ++++++++++++++++++ .../ui/screens/SegmentedControlDemoScreen.kt | 25 ++- 4 files changed, 275 insertions(+), 4 deletions(-) create mode 100644 SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlEnabledTest.kt diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 51385c3..1d2e065 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -5,6 +5,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot ## Features - Optional "nothing selected yet" state for unanswered form questions +- `enabled = false` for read-only / locked forms - Smooth animated thumb sliding between segments - Drag gesture support on the selected segment to switch - Press animations with configurable scale effect @@ -68,6 +69,32 @@ A `selectedSegment` that is not present in `segments` behaves the same way as `n applies once there is a selection to drag. The thumb fades in under the segment the user taps rather than sliding in from the edge. +### Read-only / locked + +`enabled = false` makes the control non-interactive: + +```kotlin +SegmentedControl( + segments = item.options, + selectedSegment = item.value, + enabled = !form.isSubmitted, + onSegmentSelected = onAnswer, +) +``` + +It covers three things, because dimming alone is not enough — a greyed-out control that still +accepts taps silently changes the answer on a submitted form, which is a data-integrity bug: + +| | | +|---|---| +| Input | taps and drag-to-switch are both ignored; press animations stop too | +| Semantics | every segment reports `disabled`, so accessibility services and `assertIsNotEnabled()` agree, and no click action is advertised | +| Visual | the whole control is dimmed 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. Combine with `selectedSegment = null` for a +locked form with an unanswered question. + ### With Custom Objects ```kotlin diff --git a/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt b/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt index 6b6d515..61c7bd9 100644 --- a/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt +++ b/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.disabled import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics @@ -170,6 +171,15 @@ data class SegmentedControlTextStyle( ) object SegmentedControlDefaults { + /** + * Opacity applied to the whole control when `enabled = false`. + * + * Matches Material 3's disabled-content token. The thumb keeps its position and stays + * visible, so a locked form still shows the answer it holds — disabled means "you cannot + * change this", not "this has no value". + */ + const val DisabledAlpha = 0.38f + /** * Creates Default [SegmentedControlColors] with solid colors. */ @@ -407,6 +417,10 @@ private const val NO_SEGMENT_INDEX = -1 * segment the user picks. * @param onSegmentSelected A callback that will be called when the user selects a segment. * @param modifier A modifier to apply to the control. + * @param enabled Whether the control responds to input. When false, taps and drag-to-switch are + * both ignored, the segments report themselves as disabled to accessibility services, and the + * whole control is dimmed to [SegmentedControlDefaults.DisabledAlpha]. Use it for a form that has + * been submitted or locked — the current selection stays visible, it simply cannot be changed. * @param trackShape The shape of the track that the segments are placed on. * @param trackPadding The padding around the track. * @param trackPressedPadding The padding around the track when a segment is pressed. @@ -424,6 +438,7 @@ fun SegmentedControl( selectedSegment: String?, onSegmentSelected: (String) -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, trackShape: Shape = RoundedCornerShape(8.dp), trackPadding: Dp = 2.dp, trackPressedPadding: Dp = 1.dp, @@ -440,6 +455,7 @@ fun SegmentedControl( selectedSegment = selectedSegment, onSegmentSelected = onSegmentSelected, modifier = modifier, + enabled = enabled, trackShape = trackShape, trackPadding = trackPadding, trackPressedPadding = trackPressedPadding, @@ -468,6 +484,10 @@ fun SegmentedControl( * segment the user picks. * @param onSegmentSelected A callback that will be called when the user selects a segment. * @param modifier A modifier to apply to the control. + * @param enabled Whether the control responds to input. When false, taps and drag-to-switch are + * both ignored, the segments report themselves as disabled to accessibility services, and the + * whole control is dimmed to [SegmentedControlDefaults.DisabledAlpha]. Use it for a form that has + * been submitted or locked — the current selection stays visible, it simply cannot be changed. * @param trackShape The shape of the track that the segments are placed on. * @param trackPadding The padding around the track. * @param trackPressedPadding The padding around the track when a segment is pressed. @@ -486,6 +506,7 @@ fun SegmentedControl( selectedSegment: T?, onSegmentSelected: (T) -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, trackShape: Shape = RoundedCornerShape(8.dp), trackPadding: Dp = 2.dp, trackPressedPadding: Dp = 1.dp, @@ -503,6 +524,7 @@ fun SegmentedControl( selectedSegment = selectedSegment, onSegmentSelected = onSegmentSelected, modifier = modifier, + enabled = enabled, trackShape = trackShape, trackPadding = trackPadding, trackPressedPadding = trackPressedPadding, @@ -530,6 +552,10 @@ fun SegmentedControl( * segment the user picks. * @param onSegmentSelected A callback that will be called when the user selects a segment. * @param modifier A modifier to apply to the control. + * @param enabled Whether the control responds to input. When false, taps and drag-to-switch are + * both ignored, the segments report themselves as disabled to accessibility services, and the + * whole control is dimmed to [SegmentedControlDefaults.DisabledAlpha]. Use it for a form that has + * been submitted or locked — the current selection stays visible, it simply cannot be changed. * @param trackShape The shape of the track that the segments are placed on. * @param trackPadding The padding around the track. * @param trackPressedPadding The padding around the track when a segment is pressed. @@ -549,6 +575,7 @@ fun SegmentedControl( selectedSegment: T?, onSegmentSelected: (T) -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, trackShape: Shape = RoundedCornerShape(8.dp), trackPadding: Dp = 2.dp, trackPressedPadding: Dp = 1.dp, @@ -616,6 +643,7 @@ fun SegmentedControl( Segments( state = state, segments = segments, + enabled = enabled, trackPadding = trackPadding, segmentsPadding = segmentsPadding, content = content, @@ -627,7 +655,11 @@ fun SegmentedControl( }, modifier = modifier .fillMaxWidth() - .then(state.inputModifier) + // Dropping the pointer input entirely, rather than checking `enabled` inside the + // gesture, also kills the press animations for free — a disabled control that still + // scaled and faded under the finger would read as interactive. + .then(if (enabled) state.inputModifier else Modifier) + .alpha(if (enabled) 1f else SegmentedControlDefaults.DisabledAlpha) .background(colors.trackBrush, trackShape) .padding(trackPadding) ) { (thumbMeasurable, dividersMeasurable, segmentsMeasurable), constraints -> @@ -768,6 +800,7 @@ private fun Dividers( private fun Segments( state: SegmentedControlState, segments: List, + enabled: Boolean, trackPadding: Dp, segmentsPadding: Dp, colors: SegmentedControlColors, @@ -813,8 +846,21 @@ private fun Segments( val semanticsModifier = Modifier.semantics(mergeDescendants = true) { selected = isSelected role = Role.Button - onClick { state.onSegmentSelected(i); true } stateDescription = if (isSelected) "Selected" else "Not selected" + if (enabled) { + onClick { state.onSegmentSelected(i); true } + } else { + // disabled() is what assistive tech and assertIsNotEnabled() read, and it is + // on its own enough to stop activation — verified by leaving the onClick + // action in place and watching the tests still pass. + // + // The action is withheld anyway so the node does not advertise a capability + // it will not honour: an accessibility service reading the tree should see no + // click action on a locked control rather than one that silently does + // nothing. That property is pinned by a test, since nothing else would catch + // its loss. + disabled() + } } Box( diff --git a/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlEnabledTest.kt b/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlEnabledTest.kt new file mode 100644 index 0000000..2942390 --- /dev/null +++ b/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlEnabledTest.kt @@ -0,0 +1,177 @@ +package uk.co.appoly.droid.ui.segmentedcontrol + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Covers `enabled = false`, which exists so a submitted or locked form cannot be edited. + * + * This is a data-integrity concern rather than a styling one: without it, a read-only form still + * accepts taps and silently changes the answer underneath the user. Dimming alone would not be + * enough, which is why these tests assert behaviour and semantics rather than appearance. + */ +@RunWith(AndroidJUnit4::class) +class SegmentedControlEnabledTest { + + @get:Rule + val composeRule = createComposeRule() + + private val segments = listOf("Yes", "No", "N/A") + + @Test + fun `a disabled control reports every segment as disabled`() { + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "Yes", + onSegmentSelected = {}, + enabled = false, + ) + } + } + + segments.forEach { composeRule.onNodeWithText(it).assertIsNotEnabled() } + } + + @Test + fun `an enabled control reports every segment as enabled`() { + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "Yes", + onSegmentSelected = {}, + ) + } + } + + segments.forEach { composeRule.onNodeWithText(it).assertIsEnabled() } + } + + @Test + fun `a disabled control advertises no click action`() { + // Withholding the semantics onClick action is not what blocks activation — disabled() is + // enough on its own, confirmed by leaving the action in place and watching these tests + // still pass. This pins the separate property it does buy: a locked control should not + // advertise a capability it will not honour, so an accessibility service reading the tree + // sees no click action rather than one that silently does nothing. + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "Yes", + onSegmentSelected = {}, + enabled = false, + ) + } + } + + segments.forEach { + composeRule.onNodeWithText(it) + .assert(SemanticsMatcher.keyNotDefined(SemanticsActions.OnClick)) + } + } + + @Test + fun `a disabled control ignores activation`() { + var reported: String? = null + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "Yes", + onSegmentSelected = { reported = it }, + enabled = false, + ) + } + } + + composeRule.onNodeWithText("No").performClick() + + assertNull("a disabled control changed its value", reported) + composeRule.onNodeWithText("Yes").assertIsSelected() + composeRule.onNodeWithText("No").assertIsNotSelected() + } + + @Test + fun `a disabled control still shows its selection`() { + // Disabled means "you cannot change this", not "this has no value". A locked form must + // still display the answer it holds. + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "N/A", + onSegmentSelected = {}, + enabled = false, + ) + } + } + + composeRule.onNodeWithText("N/A").assertIsSelected() + } + + @Test + fun `a disabled and unanswered control shows nothing selected`() { + // The two new states compose: an unanswered question on a locked form. + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = null, + onSegmentSelected = {}, + enabled = false, + ) + } + } + + segments.forEach { + composeRule.onNodeWithText(it).assertIsNotSelected() + composeRule.onNodeWithText(it).assertIsNotEnabled() + } + } + + @Test + fun `re-enabling restores interaction`() { + // Forms unlock as well as lock, and the pointer input is rebuilt when enabled flips, so + // this guards against it not being reattached. + val enabled = mutableStateOf(false) + var reported: String? = null + composeRule.setContent { + MaterialTheme { + SegmentedControl( + segments = segments, + selectedSegment = "Yes", + onSegmentSelected = { reported = it }, + enabled = enabled.value, + ) + } + } + + composeRule.onNodeWithText("No").performClick() + assertNull(reported) + + composeRule.runOnIdle { enabled.value = true } + + composeRule.onNodeWithText("No").assertIsEnabled() + composeRule.onNodeWithText("No").performClick() + assertEquals("No", reported) + } +} diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt index c8d87be..0dec935 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/SegmentedControlDemoScreen.kt @@ -113,8 +113,29 @@ data object SegmentedControlDemoScreen : Nav3Screen { style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary ) - TextButton(onClick = { answer = null }) { - Text("Clear") + var locked by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "enabled = false locks it: taps ignored, dimmed, but the answer " + + "stays visible.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + SegmentedControl( + segments = segments, + selectedSegment = answer, + enabled = !locked, + onSegmentSelected = { answer = it } + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + TextButton(onClick = { answer = null }) { + Text("Clear") + } + TextButton(onClick = { locked = !locked }) { + Text(if (locked) "Unlock" else "Lock") + } } } } From 27fbda445dc4d4e3e145278894f34644d925461e Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 17 Sep 2026 08:56:19 +0100 Subject: [PATCH 26/53] fix(SegmentedControl): snap the thumb on every selection out of empty, not just the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ui/segmentedcontrol/SegmentedControl.kt | 37 +++++++- .../ThumbSelectionTrackerTest.kt | 88 +++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/ThumbSelectionTrackerTest.kt diff --git a/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt b/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt index 61c7bd9..143427a 100644 --- a/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt +++ b/SegmentedControl/src/main/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControl.kt @@ -607,12 +607,14 @@ fun SegmentedControl( // after an empty state snaps the position before fading back in — so the thumb appears under // the segment the user actually tapped rather than travelling there. val thumbIndex = remember { Animatable(state.selectedSegment.coerceAtLeast(0).toFloat()) } - var hasEverBeenSelected by remember { mutableStateOf(hasSelection) } + val thumbTracker = remember { ThumbSelectionTracker(hasSelection) } LaunchedEffect(state.selectedSegment) { - if (!hasSelection) return@LaunchedEffect val target = state.selectedSegment.toFloat() - if (hasEverBeenSelected) thumbIndex.animateTo(target) else thumbIndex.snapTo(target) - hasEverBeenSelected = true + when { + thumbTracker.onSelectionChanged(hasSelection) -> thumbIndex.snapTo(target) + hasSelection -> thumbIndex.animateTo(target) + // Nothing selected: hold position and let the alpha fade handle it. + } } val selectedIndexOffset = thumbIndex.value @@ -899,6 +901,33 @@ private fun Segments( } } +/** + * Decides whether the thumb should snap to a new selection or animate to it. + * + * Snapping is right when the control is coming *out of* an empty state: the thumb is invisible and + * parked wherever it last was, so animating would slide it across the control from a stale + * position while it fades in. Animating is right when moving between two real selections, which is + * the ordinary sliding behaviour. + * + * Extracted and internal because the state is a latch and latches are easy to get wrong in exactly + * one direction — [hadSelection] must go back to false when the selection is cleared, or only the + * very first selection ever snaps and every later empty→selection travels. That bug shipped in + * review and nothing in a UI test would have caught it, so it is pinned by unit tests instead. + */ +internal class ThumbSelectionTracker(initialHasSelection: Boolean) { + + /** Whether there was a selection immediately before the most recent change. */ + var hadSelection: Boolean = initialHasSelection + private set + + /** Records a selection change and returns true if the thumb should snap rather than animate. */ + fun onSelectionChanged(hasSelection: Boolean): Boolean { + val shouldSnap = hasSelection && !hadSelection + hadSelection = hasSelection + return shouldSnap + } +} + private class SegmentedControlState( val trackPressedPadding: Dp ) { diff --git a/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/ThumbSelectionTrackerTest.kt b/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/ThumbSelectionTrackerTest.kt new file mode 100644 index 0000000..6f15b80 --- /dev/null +++ b/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/ThumbSelectionTrackerTest.kt @@ -0,0 +1,88 @@ +package uk.co.appoly.droid.ui.segmentedcontrol + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the snap-vs-animate decision for the thumb. + * + * The first implementation latched: the flag was set on selection and never cleared when the + * selection went back to null, so only the very first selection ever snapped and every later + * empty→selection slid the thumb in from wherever it had been parked — the exact travelling the + * feature set out to remove. It survived a hand test on a device because that test cleared the + * selection and stopped, one step short of the bug, and no UI test can see the difference between + * snapping and animating. + * + * Hence unit tests on the decision itself. + */ +class ThumbSelectionTrackerTest { + + @Test + fun `the first selection from empty snaps`() { + val tracker = ThumbSelectionTracker(initialHasSelection = false) + + assertTrue(tracker.onSelectionChanged(hasSelection = true)) + } + + @Test + fun `moving between two real selections animates`() { + val tracker = ThumbSelectionTracker(initialHasSelection = true) + + assertFalse(tracker.onSelectionChanged(hasSelection = true)) + } + + @Test + fun `every selection after a clear snaps, not just the first`() { + // The regression. Each empty→selection must snap, however many times it happens. + val tracker = ThumbSelectionTracker(initialHasSelection = false) + + repeat(5) { round -> + assertTrue( + "selection #${round + 1} out of an empty state should snap", + tracker.onSelectionChanged(hasSelection = true), + ) + tracker.onSelectionChanged(hasSelection = false) + } + } + + @Test + fun `clearing the selection does not itself snap`() { + val tracker = ThumbSelectionTracker(initialHasSelection = true) + + assertFalse(tracker.onSelectionChanged(hasSelection = false)) + } + + @Test + fun `a control that starts with a selection animates its first change`() { + // Not every control starts empty. One that opens with an answer should behave like an + // ordinary segmented control from the outset. + val tracker = ThumbSelectionTracker(initialHasSelection = true) + + assertFalse(tracker.onSelectionChanged(hasSelection = true)) + assertFalse(tracker.onSelectionChanged(hasSelection = true)) + } + + @Test + fun `repeated empty updates keep the next selection snapping`() { + // Recomposition can deliver the same empty state more than once; that must not be + // mistaken for "there was a selection". + val tracker = ThumbSelectionTracker(initialHasSelection = false) + + repeat(3) { tracker.onSelectionChanged(hasSelection = false) } + + assertTrue(tracker.onSelectionChanged(hasSelection = true)) + } + + @Test + fun `hadSelection tracks the previous state rather than latching`() { + val tracker = ThumbSelectionTracker(initialHasSelection = false) + assertFalse(tracker.hadSelection) + + tracker.onSelectionChanged(hasSelection = true) + assertTrue(tracker.hadSelection) + + tracker.onSelectionChanged(hasSelection = false) + assertFalse("hadSelection latched instead of following the selection", tracker.hadSelection) + } +} From 4e1c20be0e39c94b33dbc67c9bdff4a8a5766ddc Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 17 Sep 2026 09:04:49 +0100 Subject: [PATCH 27/53] build: raise metaspace so Dokka stops failing a random module per publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- gradle.properties | 11 ++++++++++- scripts/publish-local.sh | 13 ++++++++++++- scripts/publish.sh | 25 ++++++++++++++++++------- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/gradle.properties b/gradle.properties index 20e2a01..08c9b61 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,16 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# Metaspace, not heap, is the binding constraint here: Dokka generates javadoc for every module +# in one daemon and the Kotlin compiler classes it loads per module exhaust the default. The build +# then dies on whichever module was mid-run, which differs every time and reads as a flaky Dokka +# rather than an out-of-memory. Measured on this repo: a full `--rerun-tasks` publish fails at +# 1 GiB and passes at 2 GiB. +# +# NOTE: `org.gradle.jvmargs` in a user's ~/.gradle/gradle.properties OVERRIDES this file, so this +# line protects a fresh clone but cannot be relied on if a developer has set their own. The +# publish scripts therefore pass the same values on the command line, which outranks both. +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=2048m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects diff --git a/scripts/publish-local.sh b/scripts/publish-local.sh index 8b0036b..e5f40b1 100755 --- a/scripts/publish-local.sh +++ b/scripts/publish-local.sh @@ -26,6 +26,17 @@ cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck disable=SC1091 [[ -f scripts/publish.conf ]] && source scripts/publish.conf +# Dokka generates javadoc for every module inside one Gradle daemon, and the Kotlin compiler +# classes it loads per module exhaust the default metaspace partway through — the build then fails +# on whichever module happened to be running, which is a different one each time and looks like a +# flaky Dokka rather than an out-of-memory. Measured on this repo: a full `--rerun-tasks` publish +# fails at 1 GiB and passes at 2 GiB. +# +# Passed on the command line because that is the only level that wins. `org.gradle.jvmargs` in a +# user's ~/.gradle/gradle.properties overrides the project's gradle.properties, so a value set in +# the repo cannot be relied on to take effect on someone else's machine. +readonly GRADLE_JVM_ARGS="-Dorg.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=2048M -Dfile.encoding=UTF-8" + readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}" RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; NC=$'\033[0m' @@ -81,7 +92,7 @@ else done fi -./gradlew "${TASKS[@]}" +./gradlew "$GRADLE_JVM_ARGS" "${TASKS[@]}" echo info "================================================" diff --git a/scripts/publish.sh b/scripts/publish.sh index 719dc90..36febcf 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -45,6 +45,17 @@ CONF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/publish.conf" # No default: the vault coordinates are deployment-specific and this repository is public. # Set PUBLISH_VAULT_ITEM in scripts/publish.conf — see scripts/publish.conf.example. +# Dokka generates javadoc for every module inside one Gradle daemon, and the Kotlin compiler +# classes it loads per module exhaust the default metaspace partway through — the build then fails +# on whichever module happened to be running, which is a different one each time and looks like a +# flaky Dokka rather than an out-of-memory. Measured on this repo: a full `--rerun-tasks` publish +# fails at 1 GiB and passes at 2 GiB. +# +# Passed on the command line because that is the only level that wins. `org.gradle.jvmargs` in a +# user's ~/.gradle/gradle.properties overrides the project's gradle.properties, so a value set in +# the repo cannot be relied on to take effect on someone else's machine. +readonly GRADLE_JVM_ARGS="-Dorg.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=2048M -Dfile.encoding=UTF-8" + readonly VAULT_ITEM="${PUBLISH_VAULT_ITEM:-}" readonly RELEASE_BRANCH="${PUBLISH_RELEASE_BRANCH:-main}" readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}" @@ -220,7 +231,7 @@ fi if [[ "$MODE" == "local" ]]; then info "Publishing signed artifacts to ~/.m2 ..." - ./gradlew publishToMavenLocal + ./gradlew "$GRADLE_JVM_ARGS" publishToMavenLocal echo info "Installed $GROUP:* at $VERSION in ~/.m2" warn "Add mavenLocal() to the consuming project — and take it out again afterwards." @@ -234,17 +245,17 @@ fi # Everything runs before the confirmation prompt, so a broken build never waits on it. info "Cleaning..." -./gradlew clean +./gradlew "$GRADLE_JVM_ARGS" clean info "Running tests and the coverage gate..." -./gradlew test koverVerify || { fail "Tests or coverage gate failed. Fix before publishing."; exit 1; } +./gradlew "$GRADLE_JVM_ARGS" test koverVerify || { fail "Tests or coverage gate failed. Fix before publishing."; exit 1; } info "Verifying consumer R8 keep rules..." -./gradlew :app:verifyConsumerKeepRules || { fail "Consumer keep rules regressed."; exit 1; } +./gradlew "$GRADLE_JVM_ARGS" :app:verifyConsumerKeepRules || { fail "Consumer keep rules regressed."; exit 1; } info "Verifying published metadata resolves for an Android consumer..." -./gradlew publishToMavenLocal || { fail "Publishing to ~/.m2 failed; the metadata gate cannot run."; exit 1; } -./gradlew -p publishing-check verifyPublishedVariantResolution --refresh-dependencies \ +./gradlew "$GRADLE_JVM_ARGS" publishToMavenLocal || { fail "Publishing to ~/.m2 failed; the metadata gate cannot run."; exit 1; } +./gradlew "$GRADLE_JVM_ARGS" -p publishing-check verifyPublishedVariantResolution --refresh-dependencies \ || { fail "Published metadata would break an Android consumer."; exit 1; } info "All gates passed." @@ -282,7 +293,7 @@ read -rp "Proceed with publish? (y/N) " -n 1 reply; echo # ---------------------------------------------------------------- publish --- info "Publishing to Maven Central..." -./gradlew publishAndReleaseToMavenCentral --no-configuration-cache +./gradlew "$GRADLE_JVM_ARGS" publishAndReleaseToMavenCentral --no-configuration-cache # Tag only after a successful upload, so a failed publish never leaves a tag claiming otherwise. if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then From 419613efc8c8429e7ce73fc1d317cd7fd35f41bf Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 17 Sep 2026 09:04:55 +0100 Subject: [PATCH 28/53] =?UTF-8?q?build:=20TEMPORARY=20version=201.10.0-for?= =?UTF-8?q?msupport-local03=20=E2=80=94=20REVERT=20BEFORE=20MERGE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AppSnackBar-UiState/README.md | 6 +++--- AppSnackBar/README.md | 2 +- BaseRepo-AppolyJson/README.md | 4 ++-- BaseRepo-Paging-AppolyJson/README.md | 10 +++++----- BaseRepo-Paging/README.md | 8 ++++---- BaseRepo-S3Uploader-Multipart/README.md | 6 +++--- BaseRepo-S3Uploader/README.md | 6 +++--- BaseRepo/README.md | 2 +- ComposeExtensions/README.md | 2 +- ConnectivityMonitor/README.md | 2 +- DateHelperUtil-Room/README.md | 4 ++-- DateHelperUtil-Serialization/README.md | 4 ++-- DateHelperUtil/README.md | 2 +- LazyGridPagingExtensions/README.md | 4 ++-- LazyListPagingExtensions/README.md | 4 ++-- MockInterceptor-AppolyJson/README.md | 2 +- MockInterceptor-Retrofit/README.md | 2 +- MockInterceptor-Serialization/README.md | 2 +- MockInterceptor/README.md | 2 +- Nav3Navigation/README.md | 4 ++-- PagingExtensions/README.md | 2 +- README.md | 8 ++++---- S3Uploader-Multipart/README.md | 2 +- S3Uploader/README.md | 2 +- SegmentedControl/README.md | 2 +- UiState/README.md | 2 +- buildSrc/src/main/kotlin/BuildConfig.kt | 13 ++++++++++++- 27 files changed, 60 insertions(+), 49 deletions(-) diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index 66264e1..9b30c93 100644 --- a/AppSnackBar-UiState/README.md +++ b/AppSnackBar-UiState/README.md @@ -13,9 +13,9 @@ Integration module that bridges the AppSnackBar and UiState modules, providing a ```gradle.kts // Requires both base modules -implementation("uk.co.appoly.droid:uistate:1.9.1") -implementation("uk.co.appoly.droid:appsnackbar:1.9.1") -implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.1") +implementation("uk.co.appoly.droid:uistate:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:appsnackbar:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:appsnackbar-uistate:1.10.0-formsupport-local03") ``` ## Usage diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index 7db2e56..895a93d 100644 --- a/AppSnackBar/README.md +++ b/AppSnackBar/README.md @@ -13,7 +13,7 @@ A customizable Jetpack Compose Snackbar implementation with support for differen ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:appsnackbar:1.9.1") +implementation("uk.co.appoly.droid:appsnackbar:1.10.0-formsupport-local03") ``` ## Usage diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index b34b81d..d85b4d4 100644 --- a/BaseRepo-AppolyJson/README.md +++ b/BaseRepo-AppolyJson/README.md @@ -14,8 +14,8 @@ Appoly's JSON format. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:baserepo-appolyjson:1.10.0-formsupport-local03") ``` ## API Response Structure diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md index 7890f83..2267a27 100644 --- a/BaseRepo-Paging-AppolyJson/README.md +++ b/BaseRepo-Paging-AppolyJson/README.md @@ -15,13 +15,13 @@ follow Appoly's paging format. ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.1") -implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:baserepo-paging:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.10.0-formsupport-local03") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-formsupport-local03") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-formsupport-local03") // For LazyGrid ``` ## API Response Format diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md index b8dd1c7..63a5a42 100644 --- a/BaseRepo-Paging/README.md +++ b/BaseRepo-Paging/README.md @@ -17,12 +17,12 @@ extended for specific JSON formats. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:baserepo-paging:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:baserepo-paging:1.10.0-formsupport-local03") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-formsupport-local03") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-formsupport-local03") // For LazyGrid ``` ## Extensions diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md index d3c1724..3801633 100644 --- a/BaseRepo-S3Uploader-Multipart/README.md +++ b/BaseRepo-S3Uploader-Multipart/README.md @@ -15,9 +15,9 @@ Extension module that bridges BaseRepo and S3Uploader-Multipart, enabling pausab ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1") -implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.10.0-formsupport-local03") ``` ## Usage diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md index fdb67ca..60afe4c 100644 --- a/BaseRepo-S3Uploader/README.md +++ b/BaseRepo-S3Uploader/README.md @@ -18,9 +18,9 @@ An extension module that bridges BaseRepo and S3Uploader, enabling seamless file ```gradle.kts // Requires both the base modules -implementation("uk.co.appoly.droid:baserepo:1.9.1") -implementation("uk.co.appoly.droid:s3uploader:1.9.1") -implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:s3uploader:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:baserepo-s3uploader:1.10.0-formsupport-local03") ``` ## How it Works diff --git a/BaseRepo/README.md b/BaseRepo/README.md index 1061378..4ad24f7 100644 --- a/BaseRepo/README.md +++ b/BaseRepo/README.md @@ -14,7 +14,7 @@ Foundation module for implementing the repository pattern with standardized API ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:baserepo:1.9.1") +implementation("uk.co.appoly.droid:baserepo:1.10.0-formsupport-local03") ``` ## Extensions diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md index 688eb43..69451c5 100644 --- a/ComposeExtensions/README.md +++ b/ComposeExtensions/README.md @@ -13,7 +13,7 @@ Compose utilities for insets/IME padding, padding arithmetic, serialization-safe ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:composeextensions:1.9.1") +implementation("uk.co.appoly.droid:composeextensions:1.10.0-formsupport-local03") ``` ## Usage diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index cf9418f..535b9bc 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -9,7 +9,7 @@ Add the following dependency to your project's `build.gradle` file: ```gradle.kts -implementation("uk.co.appoly.droid:connectivitymonitor:1.9.1") +implementation("uk.co.appoly.droid:connectivitymonitor:1.10.0-formsupport-local03") ``` ## Usage diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index a738f28..676537a 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides Room database integration for ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.1") -implementation("uk.co.appoly.droid:datehelperutil-room:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:datehelperutil-room:1.10.0-formsupport-local03") // Required Room dependencies implementation("androidx.room:room-runtime:2.8.5") diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md index 7db5188..2c3d5ff 100644 --- a/DateHelperUtil-Serialization/README.md +++ b/DateHelperUtil-Serialization/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides kotlinx.serialization integrat ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.9.1") -implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:datehelperutil-serialization:1.10.0-formsupport-local03") // Required kotlinx.serialization dependencies implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") diff --git a/DateHelperUtil/README.md b/DateHelperUtil/README.md index 37af5c7..083d238 100644 --- a/DateHelperUtil/README.md +++ b/DateHelperUtil/README.md @@ -14,7 +14,7 @@ A utility module for standardized date and time operations in Android applicatio ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:datehelperutil:1.9.1") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0-formsupport-local03") ``` ## 1.4.1 patch note diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md index 54abf19..a61f2e2 100644 --- a/LazyGridPagingExtensions/README.md +++ b/LazyGridPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for integrating Jetpack Paging 3 with Compose LazyVerticalGr ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.1") -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-formsupport-local03") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md index 23738e9..7e094b3 100644 --- a/LazyListPagingExtensions/README.md +++ b/LazyListPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for easy integration of Jetpack Paging 3 with Compose LazyCo ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.9.1") -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0-formsupport-local03") +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-formsupport-local03") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md index 3d52bef..141b86e 100644 --- a/MockInterceptor-AppolyJson/README.md +++ b/MockInterceptor-AppolyJson/README.md @@ -14,7 +14,7 @@ Extension for [MockInterceptor-Serialization](../MockInterceptor-Serialization/) ```gradle.kts // MockInterceptor and MockInterceptor-Serialization are included transitively -implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.10.0-formsupport-local03") ``` ## Usage diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md index 2ee209f..2fccda9 100644 --- a/MockInterceptor-Retrofit/README.md +++ b/MockInterceptor-Retrofit/README.md @@ -13,7 +13,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that reads Retrofit HTTP an ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.10.0-formsupport-local03") ``` > **Note:** Retrofit is a `compileOnly` dependency — your project must already depend on Retrofit. diff --git a/MockInterceptor-Serialization/README.md b/MockInterceptor-Serialization/README.md index d9cc6ec..f369e1c 100644 --- a/MockInterceptor-Serialization/README.md +++ b/MockInterceptor-Serialization/README.md @@ -12,7 +12,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that adds type-safe JSON re ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.10.0-formsupport-local03") ``` ## Usage diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md index 9885716..85fa0b2 100644 --- a/MockInterceptor/README.md +++ b/MockInterceptor/README.md @@ -16,7 +16,7 @@ An OkHttp interceptor with a route-matching DSL for mocking API responses during ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:mockinterceptor:1.9.1") +implementation("uk.co.appoly.droid:mockinterceptor:1.10.0-formsupport-local03") ``` ## Usage diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index 2ef77b7..62ab4d4 100644 --- a/Nav3Navigation/README.md +++ b/Nav3Navigation/README.md @@ -32,13 +32,13 @@ without giving up the fused-screen / ambient-navigator convenience that Voyager ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:nav3navigation:1.9.1") +implementation("uk.co.appoly.droid:nav3navigation:1.10.0-formsupport-local03") ``` Or via the AppolyDroid BOM (version managed by the platform): ```gradle.kts -implementation(platform("uk.co.appoly.droid:bom:1.9.1")) +implementation(platform("uk.co.appoly.droid:bom:1.10.0-formsupport-local03")) implementation("uk.co.appoly.droid:nav3navigation") ``` diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md index effab19..7d64bee 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -12,7 +12,7 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:pagingextensions:1.9.1") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0-formsupport-local03") ``` ## Usage diff --git a/README.md b/README.md index f842695..1bf44e5 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.1" # Replace with the latest version +appolydroidToolbox = "1.10.0-formsupport-local03" # Replace with the latest version [libraries] appolydroid-toolbox-bom = { group = "uk.co.appoly.droid", name = "bom", version.ref = "appolydroidToolbox" } @@ -129,7 +129,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { // Import the BOM - implementation(platform("uk.co.appoly.droid:bom:1.9.1")) + implementation(platform("uk.co.appoly.droid:bom:1.10.0-formsupport-local03")) // Now you can use AppolyDroid modules without specifying versions implementation("uk.co.appoly.droid:baserepo") @@ -166,7 +166,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.1" # Replace with the latest version +appolydroidToolbox = "1.10.0-formsupport-local03" # Replace with the latest version [libraries] #AppolyDroid-Toolbox @@ -234,7 +234,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { - val appolydroidToolbox = "1.9.1" // Replace with the latest version + val appolydroidToolbox = "1.10.0-formsupport-local03" // Replace with the latest version // Add only the modules you need implementation("uk.co.appoly.droid:baserepo:$appolydroidToolbox") implementation("uk.co.appoly.droid:baserepo-appolyjson:$appolydroidToolbox") diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index 5147a01..6206334 100644 --- a/S3Uploader-Multipart/README.md +++ b/S3Uploader-Multipart/README.md @@ -16,7 +16,7 @@ Advanced S3 upload module with pause, resume, and recovery support using AWS S3 ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0-formsupport-local03") ``` This module depends on `S3Uploader` and includes it transitively. diff --git a/S3Uploader/README.md b/S3Uploader/README.md index 100276d..e8a5bd9 100644 --- a/S3Uploader/README.md +++ b/S3Uploader/README.md @@ -16,7 +16,7 @@ Standalone module for Amazon S3 file uploading with progress tracking and error ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader:1.9.1") +implementation("uk.co.appoly.droid:s3uploader:1.10.0-formsupport-local03") ``` ## Usage diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 1d2e065..31b1a68 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -19,7 +19,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:segmentedcontrol:1.9.1") +implementation("uk.co.appoly.droid:segmentedcontrol:1.10.0-formsupport-local03") ``` ## Usage diff --git a/UiState/README.md b/UiState/README.md index b1e0c53..d782697 100644 --- a/UiState/README.md +++ b/UiState/README.md @@ -13,7 +13,7 @@ A standardized UI state management library for Android applications, providing c ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:uistate:1.9.1") +implementation("uk.co.appoly.droid:uistate:1.10.0-formsupport-local03") ``` ## Usage diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 377aa1c..7064c8b 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -8,8 +8,19 @@ object BuildConfig { /** * The current version of the AppolyDroid Toolbox library. * This is used for maven publishing and README version updates. + * + * TEMPORARY - local testing only, REVERT BEFORE MERGE. + * + * Lets this branch sit in ~/.m2 alongside the barcode branch's 1.10.0-beta01 without either + * overwriting the other. The suffix is bumped whenever what is published changes, so a + * consumer can never be unsure which build a coordinate refers to - local01 and local02 + * predate the thumb-snap fix and should not be used. + * + * It must not stay 1.9.1 while doing any of this: publishing modified code over a real + * released version in the local repository makes every project on this machine that resolves + * mavenLocal silently pick up an impostor. */ - const val TOOLBOX_VERSION = "1.9.1" + const val TOOLBOX_VERSION = "1.10.0-formsupport-local03" /** * SDK version configuration for Android modules. From 4cdaed2ecb24e712e983bbf6e35e21a49ed43b67 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 17 Sep 2026 11:17:09 +0100 Subject: [PATCH 29/53] build: drop duplicate metaspace pinning from the publish scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/publish-local.sh | 13 +------------ scripts/publish.sh | 25 +++++++------------------ 2 files changed, 8 insertions(+), 30 deletions(-) diff --git a/scripts/publish-local.sh b/scripts/publish-local.sh index e5f40b1..8b0036b 100755 --- a/scripts/publish-local.sh +++ b/scripts/publish-local.sh @@ -26,17 +26,6 @@ cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck disable=SC1091 [[ -f scripts/publish.conf ]] && source scripts/publish.conf -# Dokka generates javadoc for every module inside one Gradle daemon, and the Kotlin compiler -# classes it loads per module exhaust the default metaspace partway through — the build then fails -# on whichever module happened to be running, which is a different one each time and looks like a -# flaky Dokka rather than an out-of-memory. Measured on this repo: a full `--rerun-tasks` publish -# fails at 1 GiB and passes at 2 GiB. -# -# Passed on the command line because that is the only level that wins. `org.gradle.jvmargs` in a -# user's ~/.gradle/gradle.properties overrides the project's gradle.properties, so a value set in -# the repo cannot be relied on to take effect on someone else's machine. -readonly GRADLE_JVM_ARGS="-Dorg.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=2048M -Dfile.encoding=UTF-8" - readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}" RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; NC=$'\033[0m' @@ -92,7 +81,7 @@ else done fi -./gradlew "$GRADLE_JVM_ARGS" "${TASKS[@]}" +./gradlew "${TASKS[@]}" echo info "================================================" diff --git a/scripts/publish.sh b/scripts/publish.sh index 36febcf..719dc90 100755 --- a/scripts/publish.sh +++ b/scripts/publish.sh @@ -45,17 +45,6 @@ CONF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/publish.conf" # No default: the vault coordinates are deployment-specific and this repository is public. # Set PUBLISH_VAULT_ITEM in scripts/publish.conf — see scripts/publish.conf.example. -# Dokka generates javadoc for every module inside one Gradle daemon, and the Kotlin compiler -# classes it loads per module exhaust the default metaspace partway through — the build then fails -# on whichever module happened to be running, which is a different one each time and looks like a -# flaky Dokka rather than an out-of-memory. Measured on this repo: a full `--rerun-tasks` publish -# fails at 1 GiB and passes at 2 GiB. -# -# Passed on the command line because that is the only level that wins. `org.gradle.jvmargs` in a -# user's ~/.gradle/gradle.properties overrides the project's gradle.properties, so a value set in -# the repo cannot be relied on to take effect on someone else's machine. -readonly GRADLE_JVM_ARGS="-Dorg.gradle.jvmargs=-Xmx4096M -XX:MaxMetaspaceSize=2048M -Dfile.encoding=UTF-8" - readonly VAULT_ITEM="${PUBLISH_VAULT_ITEM:-}" readonly RELEASE_BRANCH="${PUBLISH_RELEASE_BRANCH:-main}" readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}" @@ -231,7 +220,7 @@ fi if [[ "$MODE" == "local" ]]; then info "Publishing signed artifacts to ~/.m2 ..." - ./gradlew "$GRADLE_JVM_ARGS" publishToMavenLocal + ./gradlew publishToMavenLocal echo info "Installed $GROUP:* at $VERSION in ~/.m2" warn "Add mavenLocal() to the consuming project — and take it out again afterwards." @@ -245,17 +234,17 @@ fi # Everything runs before the confirmation prompt, so a broken build never waits on it. info "Cleaning..." -./gradlew "$GRADLE_JVM_ARGS" clean +./gradlew clean info "Running tests and the coverage gate..." -./gradlew "$GRADLE_JVM_ARGS" test koverVerify || { fail "Tests or coverage gate failed. Fix before publishing."; exit 1; } +./gradlew test koverVerify || { fail "Tests or coverage gate failed. Fix before publishing."; exit 1; } info "Verifying consumer R8 keep rules..." -./gradlew "$GRADLE_JVM_ARGS" :app:verifyConsumerKeepRules || { fail "Consumer keep rules regressed."; exit 1; } +./gradlew :app:verifyConsumerKeepRules || { fail "Consumer keep rules regressed."; exit 1; } info "Verifying published metadata resolves for an Android consumer..." -./gradlew "$GRADLE_JVM_ARGS" publishToMavenLocal || { fail "Publishing to ~/.m2 failed; the metadata gate cannot run."; exit 1; } -./gradlew "$GRADLE_JVM_ARGS" -p publishing-check verifyPublishedVariantResolution --refresh-dependencies \ +./gradlew publishToMavenLocal || { fail "Publishing to ~/.m2 failed; the metadata gate cannot run."; exit 1; } +./gradlew -p publishing-check verifyPublishedVariantResolution --refresh-dependencies \ || { fail "Published metadata would break an Android consumer."; exit 1; } info "All gates passed." @@ -293,7 +282,7 @@ read -rp "Proceed with publish? (y/N) " -n 1 reply; echo # ---------------------------------------------------------------- publish --- info "Publishing to Maven Central..." -./gradlew "$GRADLE_JVM_ARGS" publishAndReleaseToMavenCentral --no-configuration-cache +./gradlew publishAndReleaseToMavenCentral --no-configuration-cache # Tag only after a successful upload, so a failed publish never leaves a tag claiming otherwise. if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then From 1839fe136cf30e503591f1978a47b773583f244b Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 17 Sep 2026 11:25:36 +0100 Subject: [PATCH 30/53] build: TOOLBOX_VERSION 1.10.0-beta01 -> 1.10.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AppSnackBar-UiState/README.md | 6 +++--- AppSnackBar/README.md | 2 +- BarcodeScanner-Camera/README.md | 2 +- BarcodeScanner/README.md | 2 +- BaseRepo-AppolyJson/README.md | 4 ++-- BaseRepo-Paging-AppolyJson/README.md | 10 +++++----- BaseRepo-Paging/README.md | 8 ++++---- BaseRepo-S3Uploader-Multipart/README.md | 6 +++--- BaseRepo-S3Uploader/README.md | 6 +++--- BaseRepo/README.md | 2 +- ComposeExtensions/README.md | 2 +- ConnectivityMonitor/README.md | 2 +- DateHelperUtil-Room/README.md | 4 ++-- DateHelperUtil-Serialization/README.md | 4 ++-- DateHelperUtil/README.md | 2 +- LazyGridPagingExtensions/README.md | 4 ++-- LazyListPagingExtensions/README.md | 4 ++-- MockInterceptor-AppolyJson/README.md | 2 +- MockInterceptor-Retrofit/README.md | 2 +- MockInterceptor-Serialization/README.md | 2 +- MockInterceptor/README.md | 2 +- Nav3Navigation/README.md | 4 ++-- PagingExtensions/README.md | 2 +- README.md | 8 ++++---- S3Uploader-Multipart/README.md | 2 +- S3Uploader/README.md | 2 +- SegmentedControl/README.md | 2 +- UiState/README.md | 2 +- buildSrc/src/main/kotlin/BuildConfig.kt | 2 +- 29 files changed, 51 insertions(+), 51 deletions(-) diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index 1f82a2f..aaa4fb1 100644 --- a/AppSnackBar-UiState/README.md +++ b/AppSnackBar-UiState/README.md @@ -13,9 +13,9 @@ Integration module that bridges the AppSnackBar and UiState modules, providing a ```gradle.kts // Requires both base modules -implementation("uk.co.appoly.droid:uistate:1.10.0-beta01") -implementation("uk.co.appoly.droid:appsnackbar:1.10.0-beta01") -implementation("uk.co.appoly.droid:appsnackbar-uistate:1.10.0-beta01") +implementation("uk.co.appoly.droid:uistate:1.10.0") +implementation("uk.co.appoly.droid:appsnackbar:1.10.0") +implementation("uk.co.appoly.droid:appsnackbar-uistate:1.10.0") ``` ## Usage diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index dbd2672..6345147 100644 --- a/AppSnackBar/README.md +++ b/AppSnackBar/README.md @@ -13,7 +13,7 @@ A customizable Jetpack Compose Snackbar implementation with support for differen ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:appsnackbar:1.10.0-beta01") +implementation("uk.co.appoly.droid:appsnackbar:1.10.0") ``` ## Usage diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index 9982527..4e37f90 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -19,7 +19,7 @@ module gives you the one-shot scanner for free. ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:barcodescanner-camera:1.10.0-beta01") +implementation("uk.co.appoly.droid:barcodescanner-camera:1.10.0") ``` ## Usage diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md index d8aad05..af19946 100644 --- a/BarcodeScanner/README.md +++ b/BarcodeScanner/README.md @@ -19,7 +19,7 @@ For continuous in-app scanning with your own UI around it, add ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:barcodescanner:1.10.0-beta01") +implementation("uk.co.appoly.droid:barcodescanner:1.10.0") ``` ## Usage diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index fccb837..7ef1f48 100644 --- a/BaseRepo-AppolyJson/README.md +++ b/BaseRepo-AppolyJson/README.md @@ -14,8 +14,8 @@ Appoly's JSON format. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") -implementation("uk.co.appoly.droid:baserepo-appolyjson:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo:1.10.0") +implementation("uk.co.appoly.droid:baserepo-appolyjson:1.10.0") ``` ## API Response Structure diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md index 5ced986..a69b951 100644 --- a/BaseRepo-Paging-AppolyJson/README.md +++ b/BaseRepo-Paging-AppolyJson/README.md @@ -15,13 +15,13 @@ follow Appoly's paging format. ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") -implementation("uk.co.appoly.droid:baserepo-paging:1.10.0-beta01") -implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo:1.10.0") +implementation("uk.co.appoly.droid:baserepo-paging:1.10.0") +implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.10.0") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-beta01") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-beta01") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0") // For LazyGrid ``` ## API Response Format diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md index e9f299d..7e38bf8 100644 --- a/BaseRepo-Paging/README.md +++ b/BaseRepo-Paging/README.md @@ -17,12 +17,12 @@ extended for specific JSON formats. ```gradle.kts // Requires the base BaseRepo module -implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") -implementation("uk.co.appoly.droid:baserepo-paging:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo:1.10.0") +implementation("uk.co.appoly.droid:baserepo-paging:1.10.0") // For Compose UI integration -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-beta01") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-beta01") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0") // For LazyColumn +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0") // For LazyGrid ``` ## Extensions diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md index 4be7e05..9d367ee 100644 --- a/BaseRepo-S3Uploader-Multipart/README.md +++ b/BaseRepo-S3Uploader-Multipart/README.md @@ -15,9 +15,9 @@ Extension module that bridges BaseRepo and S3Uploader-Multipart, enabling pausab ```gradle.kts // Requires the base modules -implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") -implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0-beta01") -implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo:1.10.0") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0") +implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.10.0") ``` ## Usage diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md index 6efaf72..49c7e16 100644 --- a/BaseRepo-S3Uploader/README.md +++ b/BaseRepo-S3Uploader/README.md @@ -18,9 +18,9 @@ An extension module that bridges BaseRepo and S3Uploader, enabling seamless file ```gradle.kts // Requires both the base modules -implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") -implementation("uk.co.appoly.droid:s3uploader:1.10.0-beta01") -implementation("uk.co.appoly.droid:baserepo-s3uploader:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo:1.10.0") +implementation("uk.co.appoly.droid:s3uploader:1.10.0") +implementation("uk.co.appoly.droid:baserepo-s3uploader:1.10.0") ``` ## How it Works diff --git a/BaseRepo/README.md b/BaseRepo/README.md index fbb6e7e..a4580de 100644 --- a/BaseRepo/README.md +++ b/BaseRepo/README.md @@ -14,7 +14,7 @@ Foundation module for implementing the repository pattern with standardized API ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:baserepo:1.10.0-beta01") +implementation("uk.co.appoly.droid:baserepo:1.10.0") ``` ## Extensions diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md index b018700..0756abd 100644 --- a/ComposeExtensions/README.md +++ b/ComposeExtensions/README.md @@ -13,7 +13,7 @@ Compose utilities for insets/IME padding, padding arithmetic, serialization-safe ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:composeextensions:1.10.0-beta01") +implementation("uk.co.appoly.droid:composeextensions:1.10.0") ``` ## Usage diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index d8b6e4b..f5a988c 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -9,7 +9,7 @@ Add the following dependency to your project's `build.gradle` file: ```gradle.kts -implementation("uk.co.appoly.droid:connectivitymonitor:1.10.0-beta01") +implementation("uk.co.appoly.droid:connectivitymonitor:1.10.0") ``` ## Usage diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index 0869a63..2515897 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides Room database integration for ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.10.0-beta01") -implementation("uk.co.appoly.droid:datehelperutil-room:1.10.0-beta01") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0") +implementation("uk.co.appoly.droid:datehelperutil-room:1.10.0") // Required Room dependencies implementation("androidx.room:room-runtime:2.8.5") diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md index c952e63..697a32a 100644 --- a/DateHelperUtil-Serialization/README.md +++ b/DateHelperUtil-Serialization/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides kotlinx.serialization integrat ```gradle.kts // Requires base DateHelperUtil module -implementation("uk.co.appoly.droid:datehelperutil:1.10.0-beta01") -implementation("uk.co.appoly.droid:datehelperutil-serialization:1.10.0-beta01") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0") +implementation("uk.co.appoly.droid:datehelperutil-serialization:1.10.0") // Required kotlinx.serialization dependencies implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") diff --git a/DateHelperUtil/README.md b/DateHelperUtil/README.md index b6e2232..ade3b82 100644 --- a/DateHelperUtil/README.md +++ b/DateHelperUtil/README.md @@ -14,7 +14,7 @@ A utility module for standardized date and time operations in Android applicatio ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:datehelperutil:1.10.0-beta01") +implementation("uk.co.appoly.droid:datehelperutil:1.10.0") ``` ## 1.4.1 patch note diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md index e91ab4d..1d0db72 100644 --- a/LazyGridPagingExtensions/README.md +++ b/LazyGridPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for integrating Jetpack Paging 3 with Compose LazyVerticalGr ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.10.0-beta01") -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0-beta01") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0") +implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md index 8ae06f7..5353fcf 100644 --- a/LazyListPagingExtensions/README.md +++ b/LazyListPagingExtensions/README.md @@ -15,8 +15,8 @@ Extension functions for easy integration of Jetpack Paging 3 with Compose LazyCo ```gradle.kts // Requires the base PagingExtensions module -implementation("uk.co.appoly.droid:pagingextensions:1.10.0-beta01") -implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0-beta01") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0") +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.1") diff --git a/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md index 3d994bf..27695e1 100644 --- a/MockInterceptor-AppolyJson/README.md +++ b/MockInterceptor-AppolyJson/README.md @@ -14,7 +14,7 @@ Extension for [MockInterceptor-Serialization](../MockInterceptor-Serialization/) ```gradle.kts // MockInterceptor and MockInterceptor-Serialization are included transitively -implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.10.0-beta01") +implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.10.0") ``` ## Usage diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md index b98e89c..dd1ceab 100644 --- a/MockInterceptor-Retrofit/README.md +++ b/MockInterceptor-Retrofit/README.md @@ -13,7 +13,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that reads Retrofit HTTP an ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.10.0-beta01") +implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.10.0") ``` > **Note:** Retrofit is a `compileOnly` dependency — your project must already depend on Retrofit. diff --git a/MockInterceptor-Serialization/README.md b/MockInterceptor-Serialization/README.md index 641c07c..838a52a 100644 --- a/MockInterceptor-Serialization/README.md +++ b/MockInterceptor-Serialization/README.md @@ -12,7 +12,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that adds type-safe JSON re ```gradle.kts // MockInterceptor is included transitively -implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.10.0-beta01") +implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.10.0") ``` ## Usage diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md index 4cea800..c5fe975 100644 --- a/MockInterceptor/README.md +++ b/MockInterceptor/README.md @@ -16,7 +16,7 @@ An OkHttp interceptor with a route-matching DSL for mocking API responses during ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:mockinterceptor:1.10.0-beta01") +implementation("uk.co.appoly.droid:mockinterceptor:1.10.0") ``` ## Usage diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index 4a3ea3d..22d3da6 100644 --- a/Nav3Navigation/README.md +++ b/Nav3Navigation/README.md @@ -32,13 +32,13 @@ without giving up the fused-screen / ambient-navigator convenience that Voyager ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:nav3navigation:1.10.0-beta01") +implementation("uk.co.appoly.droid:nav3navigation:1.10.0") ``` Or via the AppolyDroid BOM (version managed by the platform): ```gradle.kts -implementation(platform("uk.co.appoly.droid:bom:1.10.0-beta01")) +implementation(platform("uk.co.appoly.droid:bom:1.10.0")) implementation("uk.co.appoly.droid:nav3navigation") ``` diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md index e3730b4..0abde3a 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -12,7 +12,7 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:pagingextensions:1.10.0-beta01") +implementation("uk.co.appoly.droid:pagingextensions:1.10.0") ``` ## Usage diff --git a/README.md b/README.md index 84fe7a1..ca4ccd0 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.10.0-beta01" # Replace with the latest version +appolydroidToolbox = "1.10.0" # Replace with the latest version [libraries] appolydroid-toolbox-bom = { group = "uk.co.appoly.droid", name = "bom", version.ref = "appolydroidToolbox" } @@ -134,7 +134,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { // Import the BOM - implementation(platform("uk.co.appoly.droid:bom:1.10.0-beta01")) + implementation(platform("uk.co.appoly.droid:bom:1.10.0")) // Now you can use AppolyDroid modules without specifying versions implementation("uk.co.appoly.droid:baserepo") @@ -171,7 +171,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.10.0-beta01" # Replace with the latest version +appolydroidToolbox = "1.10.0" # Replace with the latest version [libraries] #AppolyDroid-Toolbox @@ -243,7 +243,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { - val appolydroidToolbox = "1.10.0-beta01" // Replace with the latest version + val appolydroidToolbox = "1.10.0" // Replace with the latest version // Add only the modules you need implementation("uk.co.appoly.droid:baserepo:$appolydroidToolbox") implementation("uk.co.appoly.droid:baserepo-appolyjson:$appolydroidToolbox") diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index 60a31b0..050f416 100644 --- a/S3Uploader-Multipart/README.md +++ b/S3Uploader-Multipart/README.md @@ -16,7 +16,7 @@ Advanced S3 upload module with pause, resume, and recovery support using AWS S3 ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0-beta01") +implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0") ``` This module depends on `S3Uploader` and includes it transitively. diff --git a/S3Uploader/README.md b/S3Uploader/README.md index 62b12b1..e7294fb 100644 --- a/S3Uploader/README.md +++ b/S3Uploader/README.md @@ -16,7 +16,7 @@ Standalone module for Amazon S3 file uploading with progress tracking and error ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:s3uploader:1.10.0-beta01") +implementation("uk.co.appoly.droid:s3uploader:1.10.0") ``` ## Usage diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 153dcd6..4edca28 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -19,7 +19,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:segmentedcontrol:1.10.0-beta01") +implementation("uk.co.appoly.droid:segmentedcontrol:1.10.0") ``` ## Usage diff --git a/UiState/README.md b/UiState/README.md index a2dd771..4375501 100644 --- a/UiState/README.md +++ b/UiState/README.md @@ -13,7 +13,7 @@ A standardized UI state management library for Android applications, providing c ## Installation ```gradle.kts -implementation("uk.co.appoly.droid:uistate:1.10.0-beta01") +implementation("uk.co.appoly.droid:uistate:1.10.0") ``` ## Usage diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 9636a3d..6535eb5 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -9,7 +9,7 @@ object BuildConfig { * The current version of the AppolyDroid Toolbox library. * This is used for maven publishing and README version updates. */ - const val TOOLBOX_VERSION = "1.10.0-beta01" + const val TOOLBOX_VERSION = "1.10.0" /** * SDK version configuration for Android modules. From 5b6510d0298b945299c0107a58174e83b5c0d051 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Thu, 17 Sep 2026 14:53:16 +0100 Subject: [PATCH 31/53] docs(CONTRIBUTING): file count is 548 at 1.10.0, not 508 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) --- CONTRIBUTING.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bad6619..33ff523 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -141,10 +141,22 @@ Maven Central enforces three per-calendar-month quotas per organisation, from 1 applied limits, confirmed by Sonatype on 2026-09-09, are **1,000 files, 80 MB and 7 releases**. Track usage in the [Usage Center](https://central.sonatype.com/publishing/usage). -One toolbox release is **508 files, 11.55 MB, and one release event** — Central scores a multi-module +One toolbox release is **548 files and one release event** — Central scores a multi-module deployment bundle as a single release, not one per artifact. So release count is a non-issue and size -is nowhere near. **File count is the binding constraint:** 508 files is roughly half the monthly -allowance, so a second release in the same calendar month lands at ~1,016 and a third cannot fit. +is nowhere near. **File count is the binding constraint:** 548 files is over half the monthly +allowance, so a second release in the same calendar month lands at ~1,096 and does not fit at all. + +The figure scales with module count, so recompute it when modules are added rather than trusting +this line. Each Android module contributes 5 primary files (`.aar`, `-sources.jar`, `-javadoc.jar`, +`.pom`, `.module`) and the BOM 2 (`.pom`, `.module`), each primary carrying a `.asc`, `.md5` and +`.sha1` alongside it: + + files = android_modules × 5 × 4 + 2 × 4 + +That reproduces the 508 measured at 1.9.x (25 Android modules + BOM) exactly, and gives 548 for +1.10.0, which adds `BarcodeScanner` and `BarcodeScanner-Camera`. At this rate the cap is reached at +roughly 49 Android modules, but the practical limit arrives far sooner: **one release per calendar +month, with no room for a second.** Two consequences for release practice: From 6b27134d7ce9c9e9ebaeb85012811d9a426217c9 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 11:59:10 +0100 Subject: [PATCH 32/53] feat(BarcodeScanner-Camera): add ScanPolicy and the scan state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../barcodescanner/camera/BarcodeTracker.kt | 97 ++++++++ .../droid/barcodescanner/camera/ScanPolicy.kt | 146 +++++++++++ .../camera/BarcodeTrackerTest.kt | 230 ++++++++++++++++++ 3 files changed, 473 insertions(+) create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt create mode 100644 BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt new file mode 100644 index 0000000..4b77326 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt @@ -0,0 +1,97 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import uk.co.appoly.droid.barcodescanner.ScannedBarcode +import kotlin.time.Duration +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * Decides which decoded barcodes are deliberate scans. + * + * ML Kit re-reports every barcode in view on every analysed frame — tens of times a second. Turning + * that into "the user scanned this" is one state machine, not a stack of independent filters, which + * is why dwell, miss tolerance and the repeat window all live here rather than in separate gates + * that would have to agree with each other. + * + * The whole model is one **track** per raw value, holding when it was first and last seen and + * whether it has been reported. Everything else is derived: + * + * | State | Seen this frame | Absent, within budget | Absent, past budget | + * |---|---|---|---| + * | Dwelling | report once [ScanPolicy.dwell] has elapsed | keep waiting | forget it | + * | Reported | nothing | nothing | forget it | + * + * The budget is [ScanPolicy.missTolerance] before a code has been reported and + * [ScanPolicy.rearm] after. Both are absence budgets measured from the last sighting, which is what + * keeps them from interacting confusingly — they are the same kind of number applied to different + * states. + * + * The rule that falls out, and the one worth remembering: **a code is reported at most once per + * track; a track lives while the code keeps being seen; reporting it again needs a fresh track.** + * So holding one barcode steady reports it once however long you hold it, and drifting out briefly + * and back does not re-report it. + * + * Deliberately *not* storing when a code was reported. The moment that field exists someone + * measures the repeat window from it, and a code held for ten seconds that dips out for one + * re-fires — the exact behaviour this design removes. + * + * Not thread-safe, and does not need to be: the camera composable only ever touches it from the + * main thread, where ML Kit's callbacks are marshalled. + * + * @param timeSource injectable so tests can drive the clock instead of sleeping. + */ +internal class BarcodeTracker( + private val policy: ScanPolicy, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private class Track( + val firstSeen: TimeMark, + var lastSeen: TimeMark, + var reported: Boolean, + ) + + private val tracks = LinkedHashMap() + + /** How many codes are currently tracked. Exists so the expiry pass is observable to tests. */ + internal val trackedCount: Int get() = tracks.size + + /** + * Feeds one frame's worth of decodes and returns those that count as scans. + * + * @param visible barcodes that passed the region filter, ordered nearest-to-region-centre + * first. The ordering is what makes [ScanMode.Single] lock onto the code the user is actually + * aiming at rather than whichever one ML Kit happened to list first. + */ + fun accept(visible: List): List { + // 1. Expire first, and before touching anything. A code gone longer than its budget must + // get a fresh track — and therefore a fresh dwell — rather than resuming the old one. + tracks.entries.removeAll { (_, track) -> + track.lastSeen.elapsedNow() >= if (track.reported) policy.rearm else policy.missTolerance + } + + // 2. Touch surviving tracks so presence keeps them alive. + val now = timeSource.markNow() + visible.forEach { barcode -> tracks[barcode.rawValue]?.lastSeen = now } + + // 3. Create tracks for codes we are not already following. + // + // In Single mode a new track may only start when nothing else is still in play, which + // is what stops a second barcode in the reticle stealing focus mid-dwell. The cost is + // that panning from one code to the next takes missTolerance + dwell; that is the knob + // to turn if it feels sluggish, rather than a new one. + val somethingInPlay = tracks.values.any { it.lastSeen.elapsedNow() < policy.missTolerance } + if (policy.mode == ScanMode.Multi || !somethingInPlay) { + visible.asSequence() + .filter { it.rawValue !in tracks } + .let { if (policy.mode == ScanMode.Single) it.take(1) else it } + .forEach { tracks[it.rawValue] = Track(firstSeen = now, lastSeen = now, reported = false) } + } + + // 4. Report anything present that has now dwelled long enough. + val dwell = policy.dwell ?: Duration.ZERO + return visible.filter { barcode -> + val track = tracks[barcode.rawValue] ?: return@filter false + !track.reported && track.firstSeen.elapsedNow() >= dwell + }.onEach { tracks.getValue(it.rawValue).reported = true } + } +} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt new file mode 100644 index 0000000..e6289cf --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt @@ -0,0 +1,146 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import androidx.compose.runtime.Immutable +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** How many barcodes the scanner tracks at once. */ +enum class ScanMode { + /** + * One code at a time — the scanner locks onto a single barcode and ignores others until it + * has gone. + * + * Right for "scan this item, then the next": a label carrying both a 1D code and a QR, or two + * parcels in shot, cannot produce a result the user did not aim at. + */ + Single, + + /** + * Every code in the region is tracked independently and reported on its own schedule. + * + * Right for collecting several codes from one view — a shelf, a pallet label set. + */ + Multi, +} + +/** + * Which part of the camera frame a barcode must be in to count. + * + * The distinction matters more than it looks: the image the analyser sees is **wider** than the + * preview the user sees, because the preview is cropped to the composable's bounds. So "the + * scanner found it" and "the user could see it" are genuinely different things. + */ +sealed interface ScanRegion { + + /** + * Anything the analyser can decode, including barcodes outside the visible preview. + * + * The widest setting and rarely what you want — a code can be read from off-screen, which + * looks to the user like the scanner inventing results. + */ + data object Full : ScanRegion + + /** Only barcodes actually visible in the preview. */ + data object Visible : ScanRegion + + /** + * Only barcodes inside the aiming reticle — the default, and what [DefaultScanFrame] draws. + * + * A barcode counts when the *centre* of its bounding box falls inside the region, so a code + * larger than the reticle still scans when aimed at properly. + * + * @param widthFraction how much of the preview's width the region spans. + * @param aspectRatio width:height of the region. 1f is square; widen it for the long thin + * labels of 1D symbologies. + */ + class Reticle( + val widthFraction: Float = 0.7f, + val aspectRatio: Float = 1f, + ) : ScanRegion { + override fun equals(other: Any?): Boolean = this === other || + (other is Reticle && widthFraction == other.widthFraction && aspectRatio == other.aspectRatio) + + override fun hashCode(): Int = 31 * widthFraction.hashCode() + aspectRatio.hashCode() + + override fun toString(): String = "Reticle(widthFraction=$widthFraction, aspectRatio=$aspectRatio)" + } +} + +/** + * How [BarcodeScannerCamera] decides that a barcode is a deliberate scan rather than something + * that drifted through the frame. + * + * Deliberately a single parameter rather than several on the composable. Adding a parameter to a + * `@Composable` is a binary-incompatible change, so every future tuning knob would force consumers + * to recompile; adding one here is a retained secondary constructor and breaks nobody. + * + * Not a `data class` for the same reason — a generated `copy()` and `componentN` would themselves + * break on growth. `equals` and `hashCode` are implemented by hand instead, and they are + * load-bearing: the scanner keeps its tracking state in `remember(policy)`, so a policy that does + * not compare equal rebuilds that state on every recomposition and nothing would ever complete its + * dwell. + * + * @param mode how many barcodes to track at once. + * @param dwell how long a barcode must be held in the region before it is reported. Null reports + * the first sighting immediately, which is what makes a scanner feel "trigger-happy". + * @param missTolerance how long a barcode that has not yet been reported survives not being seen. + * Absorbs the decode flicker that is normal for 1D symbologies, so a wobble does not restart the + * dwell. + * @param debounceWindow how long an **already reported** barcode must be absent before it can be + * reported again. Null falls back to [missTolerance]. Note this is measured from when the code was + * last *seen*, not from when it was reported: holding one code steady reports it once, however + * long you hold it. + * @param region which part of the frame a barcode must be in to count at all. + */ +@Immutable +class ScanPolicy( + val mode: ScanMode = ScanMode.Single, + val dwell: Duration? = 500.milliseconds, + val missTolerance: Duration = 750.milliseconds, + val debounceWindow: Duration? = 2.5.seconds, + val region: ScanRegion = ScanRegion.Reticle(), +) { + /** + * How long a reported barcode must be absent before it may be reported again. + * + * Never shorter than [missTolerance] — a code that can re-arm faster than it can be forgotten + * would report twice from one continuous presentation. + */ + internal val rearm: Duration + get() = maxOf(debounceWindow ?: missTolerance, missTolerance) + + override fun equals(other: Any?): Boolean = this === other || ( + other is ScanPolicy && + mode == other.mode && + dwell == other.dwell && + missTolerance == other.missTolerance && + debounceWindow == other.debounceWindow && + region == other.region + ) + + override fun hashCode(): Int { + var result = mode.hashCode() + result = 31 * result + dwell.hashCode() + result = 31 * result + missTolerance.hashCode() + result = 31 * result + debounceWindow.hashCode() + result = 31 * result + region.hashCode() + return result + } + + override fun toString(): String = + "ScanPolicy(mode=$mode, dwell=$dwell, missTolerance=$missTolerance, " + + "debounceWindow=$debounceWindow, region=$region)" + + companion object { + /** Sensible behaviour for aiming at one code at a time. */ + val Default = ScanPolicy() + + /** Reports every code in the region as soon as it is decoded, with no dwell or region. */ + val Immediate = ScanPolicy( + mode = ScanMode.Multi, + dwell = null, + region = ScanRegion.Full, + ) + } +} diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt new file mode 100644 index 0000000..787ca6f --- /dev/null +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt @@ -0,0 +1,230 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import uk.co.appoly.droid.barcodescanner.BarcodeFormat +import uk.co.appoly.droid.barcodescanner.ScannedBarcode +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TestTimeSource + +/** + * Pins the scan state machine, which is the whole of "did the user mean to scan that". + * + * None of this is reachable from a UI test — the difference between reporting a barcode and + * declining to is invisible to the view tree — and the failure modes are the ones a client + * actually complains about: firing at a code glimpsed in passing, or firing twice for one parcel. + */ +class BarcodeTrackerTest { + + private val a = ScannedBarcode("AAA", BarcodeFormat.Code128) + private val b = ScannedBarcode("BBB", BarcodeFormat.Code128) + + private fun tracker( + time: TestTimeSource, + mode: ScanMode = ScanMode.Single, + dwell: kotlin.time.Duration? = 500.milliseconds, + missTolerance: kotlin.time.Duration = 750.milliseconds, + debounceWindow: kotlin.time.Duration? = 2.5.seconds, + ) = BarcodeTracker( + policy = ScanPolicy( + mode = mode, + dwell = dwell, + missTolerance = missTolerance, + debounceWindow = debounceWindow, + ), + timeSource = time, + ) + + @Test + fun `a code glimpsed briefly is never reported`() { + // The client complaint that started all this: a barcode that passes through frame for a + // few frames must not register. + val time = TestTimeSource() + val tracker = tracker(time) + + repeat(4) { + assertTrue(tracker.accept(listOf(a)).isEmpty()) + time += 100.milliseconds + } + } + + @Test + fun `a code held for the dwell is reported once`() { + val time = TestTimeSource() + val tracker = tracker(time) + + tracker.accept(listOf(a)) + time += 500.milliseconds + + assertEquals(listOf(a), tracker.accept(listOf(a))) + } + + @Test + fun `holding a code steady reports it exactly once, however long`() { + // The old debounce measured from the moment of reporting, so a held code re-fired every + // window. Measuring absence instead means one presentation is one report. + val time = TestTimeSource() + val tracker = tracker(time) + + tracker.accept(listOf(a)) + time += 500.milliseconds + assertEquals(listOf(a), tracker.accept(listOf(a))) + + var extraReports = 0 + repeat(100) { + time += 100.milliseconds + extraReports += tracker.accept(listOf(a)).size + } + assertEquals("a held code re-fired", 0, extraReports) + } + + @Test + fun `a brief wobble does not restart the dwell`() { + // 1D codes flicker in and out as the detector loses them. Restarting the dwell on every + // dropped frame would make them almost unscannable. + val time = TestTimeSource() + val tracker = tracker(time) + + tracker.accept(listOf(a)) + time += 300.milliseconds + tracker.accept(emptyList()) // dropped frame, well inside missTolerance + time += 200.milliseconds + + assertEquals("the wobble restarted the dwell", listOf(a), tracker.accept(listOf(a))) + } + + @Test + fun `a reported code that dips out briefly does not re-report`() { + val time = TestTimeSource() + val tracker = tracker(time) + + tracker.accept(listOf(a)) + time += 500.milliseconds + assertEquals(listOf(a), tracker.accept(listOf(a))) + + time += 1.seconds // gone, but under the 2.5s rearm budget + tracker.accept(emptyList()) + assertTrue("came back too soon and re-reported", tracker.accept(listOf(a)).isEmpty()) + } + + @Test + fun `a code properly taken away and presented again reports again`() { + val time = TestTimeSource() + val tracker = tracker(time) + + tracker.accept(listOf(a)) + time += 500.milliseconds + assertEquals(listOf(a), tracker.accept(listOf(a))) + + time += 3.seconds // past the rearm budget + tracker.accept(emptyList()) + + tracker.accept(listOf(a)) // fresh track, so a fresh dwell + time += 500.milliseconds + assertEquals(listOf(a), tracker.accept(listOf(a))) + } + + @Test + fun `single mode locks the first-ranked code and ignores the other`() { + // The label-with-two-codes case: a 1D tracking number and a QR on the same parcel, both in + // the reticle. Only the one the user is aiming at — first in the ordered list — may win. + val time = TestTimeSource() + val tracker = tracker(time, mode = ScanMode.Single) + + tracker.accept(listOf(a, b)) + time += 500.milliseconds + + assertEquals(listOf(a), tracker.accept(listOf(a, b))) + } + + @Test + fun `single mode never reports a second code while the first is still in view`() { + // Deliberately run long enough that b WOULD have dwelled if it had been given a track — + // an earlier version of this test stopped before that point and passed whether or not the + // focus guard existed at all. + val time = TestTimeSource() + val tracker = tracker(time, mode = ScanMode.Single) + + tracker.accept(listOf(a)) + time += 300.milliseconds + tracker.accept(listOf(b, a)) // b arrives, ranked ahead of a + time += 300.milliseconds + assertEquals("focus was stolen mid-dwell", listOf(a), tracker.accept(listOf(b, a))) + + // a is now reported and still in view, so it holds focus: b must stay silent no matter + // how long it sits there. + var bReports = 0 + repeat(20) { + time += 200.milliseconds + bReports += tracker.accept(listOf(b, a)).count { it == b } + } + assertEquals("b was reported while a was still in view", 0, bReports) + } + + @Test + fun `multi mode reports every code on its own schedule`() { + val time = TestTimeSource() + val tracker = tracker(time, mode = ScanMode.Multi) + + tracker.accept(listOf(a)) + time += 300.milliseconds + tracker.accept(listOf(a, b)) // b arrives later, so dwells later + time += 200.milliseconds + + assertEquals("a should report on its own dwell", listOf(a), tracker.accept(listOf(a, b))) + + time += 300.milliseconds + assertEquals("b should report once its own dwell elapses", listOf(b), tracker.accept(listOf(a, b))) + } + + @Test + fun `a null dwell reports on first sighting`() { + val time = TestTimeSource() + val tracker = tracker(time, dwell = null) + + assertEquals(listOf(a), tracker.accept(listOf(a))) + } + + @Test + fun `tracks expire rather than accumulating`() { + // Sweeping past a shelf of codes must not grow the map without bound. Expiry is by time, + // so anything not seen recently is gone regardless of how many there were. + val time = TestTimeSource() + val tracker = tracker(time, mode = ScanMode.Multi) + + repeat(200) { index -> + tracker.accept(listOf(ScannedBarcode("code-$index", BarcodeFormat.Code128))) + time += 100.milliseconds + } + + assertTrue( + "tracks accumulated: ${tracker.trackedCount}", + tracker.trackedCount < 40, + ) + } + + @Test + fun `rearm is never shorter than missTolerance`() { + // A code that can re-arm faster than it can be forgotten would report twice from one + // continuous presentation. + val policy = ScanPolicy(missTolerance = 2.seconds, debounceWindow = 100.milliseconds) + + assertEquals(2.seconds, policy.rearm) + } + + @Test + fun `policies compare equal by value so remember does not rebuild the tracker`() { + // Load-bearing: the camera holds its tracking state in remember(policy). A policy that + // does not compare equal rebuilds that state every recomposition, and nothing would ever + // finish its dwell. + assertEquals(ScanPolicy(), ScanPolicy()) + assertEquals(ScanPolicy().hashCode(), ScanPolicy().hashCode()) + assertEquals(ScanRegion.Reticle(), ScanRegion.Reticle()) + assertEquals( + ScanPolicy(region = ScanRegion.Reticle(0.5f)), + ScanPolicy(region = ScanRegion.Reticle(0.5f)), + ) + } +} From 820227ee992af1180f67a9c982b7db94170d3fab Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 12:14:35 +0100 Subject: [PATCH 33/53] feat(BarcodeScanner-Camera): dwell, scan regions and a live overlay scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner-Camera/README.md | 68 +++++-- .../barcodescanner/camera/BarcodeDebouncer.kt | 60 ------- .../camera/BarcodeScannerCamera.kt | 167 ++++++++++++++---- .../barcodescanner/camera/BarcodeTracker.kt | 14 ++ .../barcodescanner/camera/DefaultScanFrame.kt | 71 ++++---- .../camera/ScanRegionResolver.kt | 93 ++++++++++ .../camera/ScannerOverlayScope.kt | 86 +++++++++ .../camera/BarcodeDebouncerTest.kt | 138 --------------- .../ui/screens/BarcodeScannerDemoScreen.kt | 67 ++++++- 9 files changed, 486 insertions(+), 278 deletions(-) delete mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt delete mode 100644 BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index 4e37f90..99db455 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -10,7 +10,9 @@ module gives you the one-shot scanner for free. - One `@Composable`; no `AndroidView`, no `PreviewView` - Binds to the ambient lifecycle, so it works inside a `ModalBottomSheet` and unbinds on exit -- Per-code debouncing, so a code held in frame fires once rather than forty times a second +- A dwell gate, so a code has to be held deliberately rather than glimpsed in passing +- A centre-of-frame acceptance region that the drawn reticle actually matches +- Single- or multi-code tracking, ranked nearest-the-centre first - Callbacks marshalled to the main thread — touch ViewModel state directly - Replaceable overlay, with a sensible default reticle - Torch control @@ -57,22 +59,62 @@ if (granted) { Composing it without the permission reports a bind failure through `onError` rather than crashing. -### Debouncing +### Deciding what counts as a scan -ML Kit reports every barcode in frame on every analysed frame. `debounceWindow` (2.5s by default) -suppresses a repeat of the *same* raw value for that long — per code, so two labels in shot each -fire once rather than alternating every frame. - -If you already de-duplicate against state that outlives the composable — a ViewModel keyed on -codes already collected, say — turn it off and do it yourself: +ML Kit re-reports every barcode in view on every analysed frame — tens of times a second. Turning +that into "the user scanned this" is [`ScanPolicy`](src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt): ```kotlin BarcodeScannerCamera( - debounceWindow = null, - onBarcodeScanned = viewModel::onCodeScanned, + policy = ScanPolicy( + mode = ScanMode.Single, // or Multi + dwell = 500.milliseconds, // hold it steady this long + missTolerance = 750.milliseconds, // absorb decode flicker + debounceWindow = 2.5.seconds, // absence needed before it can scan again + region = ScanRegion.Reticle(), // or Full / Visible + ), + onBarcodeScanned = ::onScanned, ) ``` +The defaults are deliberately not "report everything immediately". A scanner that fires at whatever +drifts through the frame reads as broken to the person holding it — the usual complaint being that +it grabs a code they were not aiming at. + +**One presentation is one result.** A held barcode reports once, however long it is held. To report +it again it has to be genuinely absent for `debounceWindow` first — not merely for that long since +it was last reported, which is a different and worse rule that re-fires a code you never put down. + +**`Single` locks onto the code nearest the 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 happened to list first gets it wrong about half the time. + +`ScanPolicy.Immediate` restores the old fire-on-sight behaviour if you want to do your own filtering. + +### Where a barcode has to be + +`ScanRegion` decides what counts, and the distinction is sharper than it looks: **the image the +analyser sees is wider than the preview the user sees.** + +| | | +|---|---| +| `Full` | anything decodable, including barcodes off-screen. Rarely what you want | +| `Visible` | only what is actually on screen | +| `Reticle(widthFraction, aspectRatio)` | only inside the aiming frame — the default | + +A barcode counts by the *centre* of its bounding box, so a code bigger than the reticle still scans +when aimed at properly. + +Preview and analysis are bound through one CameraX `ViewPort`, which is what makes those two fields +of view agree — and what lets `DefaultScanFrame` draw the exact rectangle the analyser filters +against, so the box on screen and the region that accepts codes cannot drift apart. + +### Pausing without tearing down + +`scanningEnabled = false` keeps the camera bound and the preview live but reports nothing — for +holding a result on screen without the scanner running underneath it. Removing the composable +instead unbinds the camera and flashes the preview on the way back. + ### Custom overlay The `overlay` lambda is scoped to the preview's `Box`, so `Modifier.align` is available: @@ -92,8 +134,10 @@ BarcodeScannerCamera( ) ``` -Pass `overlay = {}` for a bare preview. The default `DefaultScanFrame()` is decoration only — the -detector reads the whole frame, so a code outside the reticle still scans. +Pass `overlay = {}` for a bare preview. `DefaultScanFrame()` draws the *resolved* acceptance +region from `ScannerOverlayScope.regionRect`, so what it shows is what the analyser filters +against. The overlay scope also carries the current `detections` with their bounds in preview +pixels and their dwell progress, for drawing something richer than a static box. ### Torch diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt deleted file mode 100644 index 08787d8..0000000 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncer.kt +++ /dev/null @@ -1,60 +0,0 @@ -package uk.co.appoly.droid.barcodescanner.camera - -import androidx.annotation.VisibleForTesting -import uk.co.appoly.droid.barcodescanner.ScannedBarcode -import kotlin.time.Duration -import kotlin.time.TimeMark -import kotlin.time.TimeSource - -/** - * Per-code rate limiter for the continuous scanner. - * - * ML Kit reports *every* barcode in the frame on *every* analysed frame, which is tens of - * callbacks a second for a code the user is simply holding still. Debouncing per code rather - * than globally matters: with two labels in shot, a global debounce would let them alternate and - * fire on every frame anyway, while this drops each one until its own window expires. - * - * Not thread-safe by design — the camera composable only ever touches it from the main thread, - * where ML Kit's callbacks are marshalled to. - * - * @param window how long a given raw value stays suppressed after being emitted. Null disables - * debouncing entirely, so every detection is reported. - * @param timeSource injectable for tests; production uses the monotonic clock. - */ -internal class BarcodeDebouncer( - private val window: Duration?, - private val timeSource: TimeSource = TimeSource.Monotonic, -) { - private val lastEmitted = HashMap() - - /** How many codes are currently being tracked. Exists so [pruneExpired] is observable. */ - @get:VisibleForTesting - internal val trackedCodeCount: Int get() = lastEmitted.size - - /** - * Returns true if [barcode] should be reported to the caller, recording the emission when so. - */ - fun shouldEmit(barcode: ScannedBarcode): Boolean { - val window = window ?: return true - val previous = lastEmitted[barcode.rawValue] - if (previous != null && previous.elapsedNow() < window) return false - pruneExpired(window) - lastEmitted[barcode.rawValue] = timeSource.markNow() - return true - } - - /** - * Drops entries whose window has already expired. Without this, a session spent scanning a - * long tail of distinct codes — a warehouse pick, say — grows the map without bound for no - * benefit, since an expired entry can never suppress anything again. - */ - private fun pruneExpired(window: Duration) { - if (lastEmitted.size < PRUNE_THRESHOLD) return - lastEmitted.entries.removeAll { (_, mark) -> mark.elapsedNow() >= window } - } - - private companion object { - /** Only worth walking the map once it is big enough to be worth the walk. */ - const val PRUNE_THRESHOLD = 64 - } -} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index e0892ef..d19ec71 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -2,6 +2,7 @@ package uk.co.appoly.droid.barcodescanner.camera import androidx.annotation.OptIn import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.AspectRatio import androidx.camera.core.Camera import androidx.camera.core.CameraSelector import androidx.camera.core.ExperimentalGetImage @@ -9,6 +10,8 @@ import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest +import androidx.camera.core.UseCaseGroup +import androidx.camera.core.ViewPort import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.awaitInstance import androidx.compose.foundation.layout.Box @@ -22,6 +25,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner @@ -50,15 +57,20 @@ enum class LensFacing(internal val selector: CameraSelector) { } /** - * A live camera preview that reports every barcode it decodes, for as long as it is composed. + * A live camera preview that reports the barcodes the user deliberately aims at. * - * Camera use cases are bound to the current [LocalLifecycleOwner] while this composable is in the - * composition and unbound when it leaves — including inside a `ModalBottomSheet`, whose dialog - * inherits the host's lifecycle owner. [onBarcodeScanned] is always invoked on the main thread, - * so touching ViewModel state from it is safe. + * Camera use cases bind to the current [LocalLifecycleOwner] while this composable is in the + * composition and unbind when it leaves — including inside a `ModalBottomSheet`, whose dialog + * inherits the host's lifecycle owner. [onBarcodeScanned] is always invoked on the main thread, so + * touching ViewModel state from it is safe. * - * Each camera frame is released back to CameraX only once the detector has finished with it, - * which is what lets 1D formats (EAN, Code 128, ITF) decode as reliably as QR codes. + * Each camera frame is released back to CameraX only once the detector has finished with it, which + * is what lets 1D formats (EAN, Code 128, ITF) decode as reliably as QR codes. + * + * **What counts as a scan is [policy]'s job**, and the defaults are deliberately not + * "report everything immediately": a barcode must be held inside the aiming region for half a + * second, and one presentation produces one result however long it is held. A scanner that fires + * at whatever drifts through the frame reads as broken to the person holding it. * * **This composable does not request the `CAMERA` permission.** Check it before composing this; * every app's permission flow differs, so the module deliberately owns none of it. Composing @@ -68,7 +80,7 @@ enum class LensFacing(internal val selector: CameraSelector) { * BarcodeScannerCamera( * modifier = Modifier.fillMaxSize(), * formats = BarcodeFormats.OneDimensional, - * onError = { viewModel.onScannerFailed(it) }, + * onError = viewModel::onScannerFailed, * onBarcodeScanned = { viewModel.onCodeScanned(it) }, * ) * ``` @@ -76,15 +88,17 @@ enum class LensFacing(internal val selector: CameraSelector) { * @param formats which symbologies to decode. Narrower is faster — see [BarcodeFormats]. * @param lensFacing which camera to bind. * @param torchEnabled whether the torch is on. Silently ignored on a camera with no flash unit. - * @param debounceWindow how long the same raw value is suppressed after being reported, per code. - * Null disables it, which is what you want if you already de-duplicate downstream (keyed on - * ViewModel state that outlives this composable, say). - * @param overlay drawn on top of the preview, in the same [Box] — so `Modifier.align` is - * available to it. Defaults to [DefaultScanFrame]. + * @param scanningEnabled whether results are reported. False keeps the camera bound and the + * preview live but reports nothing — for holding a result on screen without the scanner running on + * underneath it. Cheaper and far less jarring than removing the composable, which tears the camera + * down and flashes the preview on the way back. + * @param policy how long a barcode must be held, how many are tracked at once, and where in the + * frame they count. See [ScanPolicy]. + * @param overlay drawn on top of the preview. Receives the resolved acceptance region and the + * current detections, so it can show what is about to be accepted; see [ScannerOverlayScope]. * @param onError reports a camera that could not be opened or bound — no camera, permission not * granted, or another app holding it. The preview stays blank; recovery is the caller's call. - * @param onBarcodeScanned invoked on the main thread, once per decoded barcode per analysed - * frame, subject to [debounceWindow]. + * @param onBarcodeScanned invoked on the main thread for each barcode that satisfies [policy]. */ @Composable fun BarcodeScannerCamera( @@ -92,8 +106,9 @@ fun BarcodeScannerCamera( formats: Set = BarcodeFormats.All, lensFacing: LensFacing = LensFacing.Back, torchEnabled: Boolean = false, - debounceWindow: Duration? = 2.5.seconds, - overlay: @Composable BoxScope.() -> Unit = { DefaultScanFrame() }, + scanningEnabled: Boolean = true, + policy: ScanPolicy = ScanPolicy.Default, + overlay: @Composable ScannerOverlayScope.() -> Unit = { DefaultScanFrame() }, onError: (Throwable) -> Unit = {}, onBarcodeScanned: (ScannedBarcode) -> Unit, ) { @@ -104,11 +119,16 @@ fun BarcodeScannerCamera( var surfaceRequest by remember { mutableStateOf(null) } var camera by remember { mutableStateOf(null) } - // Survives recomposition but is rebuilt whenever the window changes, so a caller toggling - // debouncing does not carry stale suppressions across. - val debouncer = remember(debounceWindow) { BarcodeDebouncer(debounceWindow) } + var previewSize by remember { mutableStateOf(Size.Zero) } + var detections by remember { mutableStateOf>(emptyList()) } + val currentScanningEnabled by rememberUpdatedState(scanningEnabled) + + // Rebuilt only when the policy actually changes — which is why ScanPolicy implements equals by + // hand. A policy constructed inline that did not compare equal would reset every dwell on + // every recomposition and nothing would ever scan. + val tracker = remember(policy) { BarcodeTracker(policy) } - LaunchedEffect(lifecycleOwner, formats, lensFacing) { + LaunchedEffect(lifecycleOwner, formats, lensFacing, policy) { surfaceRequest = null camera = null val scanner = BarcodeScanning.getClient(formats.toScannerOptions()) @@ -129,12 +149,22 @@ fun BarcodeScannerCamera( analysisExecutor, BarcodeAnalyzer( scanner = scanner, + region = policy.region, callbackExecutor = ContextCompat.getMainExecutor(context), - onBarcodesDetected = { barcodes -> - barcodes - .mapNotNull { it.toScannedBarcode() } - .filter(debouncer::shouldEmit) - .forEach(currentOnBarcodeScanned) + onFrameAnalysed = { ranked, imageRegion, imageSize -> + // Every frame ticks the tracker, including empty ones: absence is + // what expires a track, so skipping quiet frames would leave a + // code "present" long after it had gone. + val visible = ranked.mapNotNull { it.toScannedBarcode() } + if (currentScanningEnabled) { + tracker.accept(visible).forEach(currentOnBarcodeScanned) + } + detections = ranked.toDetections( + tracker = tracker, + imageRegion = imageRegion, + imageSize = imageSize, + previewSize = previewSize, + ) }, onDetectionFailed = { currentOnError(it) }, ), @@ -159,11 +189,19 @@ fun BarcodeScannerCamera( // than depend on it. withContext(Dispatchers.Main.immediate) { val bound = try { + // Bound as a group with a ViewPort so preview and analysis share one field + // of view. Without it the analyser sees a wider image than the preview + // shows, ImageProxy.cropRect means nothing, and the scanner can read a + // barcode that is not on screen at all. + val group = UseCaseGroup.Builder() + .setViewPort(ViewPort.Builder(android.util.Rational(4, 3), preview.targetRotation).build()) + .addUseCase(preview) + .addUseCase(analysis) + .build() camera = cameraProvider.bindToLifecycle( lifecycleOwner, lensFacing.selector, - preview, - analysis, + group, ) true } catch (error: Exception) { @@ -195,14 +233,22 @@ fun BarcodeScannerCamera( } } - Box(modifier = modifier) { + Box( + modifier = modifier.onSizeChanged { + previewSize = Size(it.width.toFloat(), it.height.toFloat()) + }, + ) { surfaceRequest?.let { request -> CameraXViewfinder( modifier = Modifier.fillMaxSize(), surfaceRequest = request, ) } - overlay() + ScannerOverlayScopeImpl( + boxScope = this, + regionRect = ScanRegionResolver.inPreview(policy.region, previewSize), + detections = detections, + ).overlay() } } @@ -219,16 +265,22 @@ private fun Set.toScannerOptions(): BarcodeScannerOptions { } /** - * Feeds each camera frame to ML Kit and releases it back to CameraX once detection completes. + * Feeds each camera frame to ML Kit, keeps only the barcodes inside the acceptance region, and + * ranks them so the one nearest the centre comes first. * * Holding the [ImageProxy] open until [BarcodeScanner.process] finishes is what lets ML Kit read * the frame's planes; closing it early makes every decode a race the detector usually loses, and - * 1D formats are the ones that lose it. Both callbacks run on [callbackExecutor]. + * 1D formats are the ones that lose it. + * + * [onFrameAnalysed] runs on [callbackExecutor] for **every** analysed frame, including ones with + * nothing in them. That matters: the tracker expires a barcode by its absence, so a frame with no + * detections is information, not a frame to skip. */ private class BarcodeAnalyzer( private val scanner: BarcodeScanner, + private val region: ScanRegion, private val callbackExecutor: Executor, - private val onBarcodesDetected: (List) -> Unit, + private val onFrameAnalysed: (ranked: List, imageRegion: android.graphics.Rect, imageSize: IntSize) -> Unit, private val onDetectionFailed: (Throwable) -> Unit, ) : ImageAnalysis.Analyzer { @@ -239,10 +291,22 @@ private class BarcodeAnalyzer( imageProxy.close() return } - val inputImage = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + val rotation = imageProxy.imageInfo.rotationDegrees + // ML Kit reports bounding boxes in the rotation-corrected space, so the region has to be + // expressed there too — width and height swap on a portrait sensor. + val upright = rotation == 90 || rotation == 270 + val width = if (upright) imageProxy.height else imageProxy.width + val height = if (upright) imageProxy.width else imageProxy.height + val crop = if (upright) imageProxy.cropRect.transposed() else imageProxy.cropRect + val imageRegion = ScanRegionResolver.inImage(region, crop, width, height) + + val inputImage = InputImage.fromMediaImage(mediaImage, rotation) scanner.process(inputImage) .addOnSuccessListener(callbackExecutor) { barcodes -> - if (barcodes.isNotEmpty()) onBarcodesDetected(barcodes) + val ranked = barcodes + .filter { it.isWithin(imageRegion) } + .sortedBy { it.distanceToCentreOf(imageRegion) } + onFrameAnalysed(ranked, imageRegion, IntSize(width, height)) } .addOnFailureListener(callbackExecutor) { error -> onDetectionFailed(error) @@ -252,3 +316,36 @@ private class BarcodeAnalyzer( } } } + +private fun android.graphics.Rect.transposed() = android.graphics.Rect(top, left, bottom, right) + +/** + * Maps ranked detections from analyser image space into preview pixels for an overlay to draw. + * + * Preview and analysis share a field of view because they are bound through one `ViewPort`, so a + * point maps across by the ratio of their sizes and no further correction is needed. + */ +private fun List.toDetections( + tracker: BarcodeTracker, + imageRegion: android.graphics.Rect, + imageSize: IntSize, + previewSize: Size, +): List { + if (previewSize.width <= 0f || imageSize.width == 0 || imageSize.height == 0) return emptyList() + val scaleX = previewSize.width / imageSize.width + val scaleY = previewSize.height / imageSize.height + return mapNotNull { barcode -> + val box = barcode.boundingBox ?: return@mapNotNull null + val scanned = barcode.toScannedBarcode() ?: return@mapNotNull null + DetectedBarcode( + barcode = scanned, + bounds = Rect( + left = box.left * scaleX, + top = box.top * scaleY, + right = box.right * scaleX, + bottom = box.bottom * scaleY, + ), + dwellProgress = tracker.dwellProgress(scanned.rawValue), + ) + } +} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt index 4b77326..94872b7 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt @@ -55,6 +55,20 @@ internal class BarcodeTracker( /** How many codes are currently tracked. Exists so the expiry pass is observable to tests. */ internal val trackedCount: Int get() = tracks.size + /** + * How far through its dwell [rawValue] is, from 0f to 1f, for an overlay to draw. + * + * 1f for a code with no track yet (nothing to wait for), for one already reported, and when + * the policy has no dwell — in every one of those cases there is no progress left to show. + */ + fun dwellProgress(rawValue: String): Float { + val dwell = policy.dwell ?: return 1f + if (dwell <= Duration.ZERO) return 1f + val track = tracks[rawValue] ?: return 0f + if (track.reported) return 1f + return (track.firstSeen.elapsedNow() / dwell).toFloat().coerceIn(0f, 1f) + } + /** * Feeds one frame's worth of decodes and returns those that count as scans. * diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt index e1ba0ae..dd81c12 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt @@ -1,55 +1,66 @@ package uk.co.appoly.droid.barcodescanner.camera -import androidx.compose.foundation.border -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp /** - * The reticle [BarcodeScannerCamera] draws over its preview by default: a centred, rounded - * rectangle outline. + * The reticle [BarcodeScannerCamera] draws over its preview by default. * - * It is decoration, not a constraint — the detector reads the whole frame, so a code outside the - * frame still scans. It exists to tell the user where to point, which measurably speeds them up. - * Pass your own `overlay` to replace it, or `overlay = {}` for a bare preview. + * **It marks the region barcodes are actually accepted in.** The frame is drawn from + * [ScannerOverlayScope.regionRect], which is the same rectangle the analyser filters against, so + * the two cannot drift apart — a scanner that shows a box and then accepts codes outside it is + * worse than one that draws no box at all. + * + * Change the region through [ScanPolicy.region] rather than by drawing a different frame; the + * drawing follows the policy, not the other way round. With [ScanRegion.Full] or + * [ScanRegion.Visible] the region is the whole preview, so the frame fills it and is not + * especially useful — pass `overlay = {}` in that case. * - * @param widthFraction how much of the preview's width the frame spans. - * @param aspectRatio width:height of the frame. 1f suits QR codes; try 2f or wider for the long - * thin labels of 1D symbologies. * @param color the outline colour. * @param strokeWidth the outline thickness. * @param cornerRadius the corner rounding. + * @param scrimColor painted outside the region to dim what will not be scanned. Fully transparent + * disables it. */ @Composable -fun DefaultScanFrame( +fun ScannerOverlayScope.DefaultScanFrame( modifier: Modifier = Modifier, - widthFraction: Float = 0.7f, - aspectRatio: Float = 1f, color: Color = Color.White, strokeWidth: Dp = 3.dp, cornerRadius: Dp = 16.dp, + scrimColor: Color = Color.Black.copy(alpha = 0.4f), ) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .fillMaxWidth(widthFraction) - .aspectRatio(aspectRatio) - .border( - width = strokeWidth, - color = color, - shape = RoundedCornerShape(cornerRadius), - ), + val region = regionRect + Canvas(modifier = modifier.fillMaxSize()) { + if (region.width <= 0f || region.height <= 0f) return@Canvas + val radius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()) + val outline = RoundRect(rect = region, cornerRadius = radius) + + // Dim everything outside the region, so it reads as "this bit is live" rather than as + // decoration. Even-odd filling punches the region out of a full-size rectangle. + if (scrimColor.alpha > 0f) { + val scrim = Path().apply { + addRect(androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height)) + addRoundRect(outline) + fillType = androidx.compose.ui.graphics.PathFillType.EvenOdd + } + drawPath(scrim, scrimColor) + } + + drawOutline( + outline = androidx.compose.ui.graphics.Outline.Rounded(outline), + color = color, + style = Stroke(width = strokeWidth.toPx()), ) } } diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt new file mode 100644 index 0000000..81d9cd7 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt @@ -0,0 +1,93 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import android.graphics.Rect as AndroidRect +import androidx.camera.core.ImageProxy +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import com.google.mlkit.vision.barcode.common.Barcode +import kotlin.math.hypot +import kotlin.math.roundToInt + +/** + * Turns a [ScanRegion] into the concrete rectangles the scanner needs — one in analyser image + * coordinates for deciding what counts, and one in preview pixels for drawing. + * + * The two are kept consistent by binding preview and analysis through a single `ViewPort`, which + * makes `ImageProxy.cropRect` the region the user can actually see. Without that the analyser's + * field of view is wider than the preview and the two rectangles describe different parts of the + * world — which is exactly how a scanner ends up reading a barcode that is not on screen. + */ +internal object ScanRegionResolver { + + /** + * The acceptance region in the analysed image's coordinate space. + * + * @param cropRect what the preview shows, as reported by CameraX for a view-ported binding. + * @param imageWidth the full analysed width, after rotation correction. + * @param imageHeight the full analysed height, after rotation correction. + */ + fun inImage( + region: ScanRegion, + cropRect: AndroidRect, + imageWidth: Int, + imageHeight: Int, + ): AndroidRect = when (region) { + ScanRegion.Full -> AndroidRect(0, 0, imageWidth, imageHeight) + ScanRegion.Visible -> cropRect + is ScanRegion.Reticle -> cropRect.centredSubRect(region) + } + + /** The same region in preview pixels, for an overlay to draw. */ + fun inPreview(region: ScanRegion, previewSize: Size): Rect = when (region) { + // Both cover the whole preview: Full also takes in more than the preview shows, but an + // overlay can only meaningfully draw the part the user can see. + ScanRegion.Full, ScanRegion.Visible -> Rect(0f, 0f, previewSize.width, previewSize.height) + + is ScanRegion.Reticle -> { + val width = previewSize.width * region.widthFraction + val height = (width / region.aspectRatio).coerceAtMost(previewSize.height) + Rect( + offset = androidx.compose.ui.geometry.Offset( + x = (previewSize.width - width) / 2f, + y = (previewSize.height - height) / 2f, + ), + size = Size(width, height), + ) + } + } + + private fun AndroidRect.centredSubRect(reticle: ScanRegion.Reticle): AndroidRect { + val w = (width() * reticle.widthFraction).roundToInt() + val h = (w / reticle.aspectRatio).roundToInt().coerceAtMost(height()) + val cx = centerX() + val cy = centerY() + return AndroidRect(cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2) + } +} + +/** + * Whether this barcode counts as being in [region]. + * + * Tested by the *centre* of the bounding box rather than requiring full containment, so a barcode + * larger than the reticle still scans when it is aimed at properly — which is the common case for + * a long 1D label inside a square guide. + */ +internal fun Barcode.isWithin(region: AndroidRect): Boolean { + val box = boundingBox ?: return false + return region.contains(box.centerX(), box.centerY()) +} + +/** + * Distance from this barcode's centre to the centre of [region]. + * + * Ranking by this is what makes single-code mode lock onto the code the user is pointing at. The + * obvious alternative — take whichever the detector listed first — is arbitrary, and on a label + * carrying both a 1D code and a QR it picks the wrong one about half the time. + */ +internal fun Barcode.distanceToCentreOf(region: AndroidRect): Float { + val box = boundingBox ?: return Float.MAX_VALUE + return hypot( + (box.centerX() - region.centerX()).toFloat(), + (box.centerY() - region.centerY()).toFloat(), + ) +} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt new file mode 100644 index 0000000..1a03621 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt @@ -0,0 +1,86 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.Rect +import uk.co.appoly.droid.barcodescanner.ScannedBarcode + +/** + * A barcode the scanner can currently see, with where it is on screen and how close it is to + * counting as a scan. + * + * Positions are in **preview pixels**, relative to the scanner's own bounds, so they can be drawn + * directly by an overlay without further transformation. + * + * A plain class rather than a `data class` on purpose: a generated `copy()` and `componentN` fix + * the field list at the first release, and this type is expected to gain fields as overlays get + * more ambitious. + * + * @property barcode the decoded barcode. + * @property bounds its bounding box, in preview pixels. + * @property dwellProgress how far through [ScanPolicy.dwell] this code is, from 0f to 1f. Already + * 1f when the policy has no dwell. Useful for drawing a progress ring that fills as the user holds + * steady. + */ +@Immutable +class DetectedBarcode( + val barcode: ScannedBarcode, + val bounds: Rect, + val dwellProgress: Float, +) { + override fun equals(other: Any?): Boolean = this === other || ( + other is DetectedBarcode && + barcode == other.barcode && + bounds == other.bounds && + dwellProgress == other.dwellProgress + ) + + override fun hashCode(): Int { + var result = barcode.hashCode() + result = 31 * result + bounds.hashCode() + result = 31 * result + dwellProgress.hashCode() + return result + } + + override fun toString(): String = + "DetectedBarcode(barcode=$barcode, bounds=$bounds, dwellProgress=$dwellProgress)" +} + +/** + * What an overlay drawn over [BarcodeScannerCamera] can see. + * + * Extends [BoxScope], so `Modifier.align` works as it would in any `Box` and an overlay written + * before this scope existed still compiles. + * + * Sealed deliberately. Only this library implements it, so **members can be added in future + * releases without breaking anyone** — which is the whole reason the overlay takes a receiver + * rather than parameters. A lambda's parameter list is part of its type, so adding to it would be + * a breaking change; adding a member here is not. + */ +@Stable +sealed interface ScannerOverlayScope : BoxScope { + + /** + * The region a barcode must be in to count, in preview pixels. + * + * Drawing from this rather than recomputing it is what keeps the reticle and the acceptance + * region from drifting apart — a control that shows a box and accepts codes outside it is + * worse than one that shows no box at all. + */ + val regionRect: Rect + + /** + * Barcodes currently visible, nearest the centre of [regionRect] first. + * + * Includes codes that have not been reported yet — that is the point, since an overlay wants + * to show what it is about to accept. + */ + val detections: List +} + +internal class ScannerOverlayScopeImpl( + private val boxScope: BoxScope, + override val regionRect: Rect, + override val detections: List, +) : ScannerOverlayScope, BoxScope by boxScope diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt deleted file mode 100644 index cce7937..0000000 --- a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeDebouncerTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package uk.co.appoly.droid.barcodescanner.camera - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test -import uk.co.appoly.droid.barcodescanner.BarcodeFormat -import uk.co.appoly.droid.barcodescanner.ScannedBarcode -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds -import kotlin.time.TestTimeSource - -/** - * The debouncer is the one piece of the camera module that can be tested without a camera, and - * the one most likely to be got subtly wrong — a global debounce looks identical to a per-code - * one until there are two barcodes in frame, at which point it stops working entirely. - */ -class BarcodeDebouncerTest { - - private fun barcode(raw: String, format: BarcodeFormat = BarcodeFormat.Ean13) = - ScannedBarcode(rawValue = raw, format = format) - - @Test - fun `the first sighting of a code is always emitted`() { - val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = TestTimeSource()) - - assertTrue(debouncer.shouldEmit(barcode("A"))) - } - - @Test - fun `a repeat inside the window is suppressed`() { - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) - - assertTrue(debouncer.shouldEmit(barcode("A"))) - time += 400.milliseconds - assertFalse(debouncer.shouldEmit(barcode("A"))) - time += 400.milliseconds - assertFalse(debouncer.shouldEmit(barcode("A"))) - } - - @Test - fun `a repeat after the window is emitted again`() { - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) - - assertTrue(debouncer.shouldEmit(barcode("A"))) - time += 1.seconds - assertTrue(debouncer.shouldEmit(barcode("A"))) - } - - @Test - fun `suppression does not extend the window`() { - // A code held in frame is re-detected constantly. If each suppressed sighting reset the - // clock, the code would never be emitted a second time at all. - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) - - assertTrue(debouncer.shouldEmit(barcode("A"))) - repeat(9) { - time += 100.milliseconds - assertFalse(debouncer.shouldEmit(barcode("A"))) - } - time += 100.milliseconds - assertTrue("the window should have expired 1s after the emission, not after the last sighting", debouncer.shouldEmit(barcode("A"))) - } - - @Test - fun `debouncing is per code, not global`() { - // Two labels in frame: ML Kit reports both on every frame. A global debounce would let - // them alternate and fire on every single frame — the exact bug this design avoids. - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) - - assertTrue(debouncer.shouldEmit(barcode("A"))) - assertTrue(debouncer.shouldEmit(barcode("B"))) - - time += 100.milliseconds - assertFalse(debouncer.shouldEmit(barcode("A"))) - assertFalse(debouncer.shouldEmit(barcode("B"))) - } - - @Test - fun `codes are keyed on raw value, not on format`() { - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) - - assertTrue(debouncer.shouldEmit(barcode("A", BarcodeFormat.Ean13))) - time += 100.milliseconds - assertFalse(debouncer.shouldEmit(barcode("A", BarcodeFormat.QrCode))) - } - - @Test - fun `a null window disables debouncing entirely`() { - // Callers that de-duplicate downstream pass null and expect every detection through. - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = null, timeSource = time) - - repeat(50) { - assertTrue(debouncer.shouldEmit(barcode("A"))) - } - } - - @Test - fun `expired entries are pruned rather than accumulating`() { - // A long scanning session over many distinct codes must not grow the map without bound. - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = 1.seconds, timeSource = time) - - repeat(500) { index -> - assertTrue(debouncer.shouldEmit(barcode("code-$index"))) - time += 100.milliseconds - } - - assertTrue( - "expired entries should have been pruned, leaving roughly one window's worth", - debouncer.trackedCodeCount < 100, - ) - } - - @Test - fun `pruning does not drop entries that are still suppressing`() { - val time = TestTimeSource() - val debouncer = BarcodeDebouncer(window = 10.seconds, timeSource = time) - - // Push past the prune threshold with codes that are all still inside their window. - repeat(200) { index -> - assertTrue(debouncer.shouldEmit(barcode("code-$index"))) - } - time += 1.seconds - - repeat(200) { index -> - assertFalse( - "code-$index was pruned while still inside its window", - debouncer.shouldEmit(barcode("code-$index")), - ) - } - } -} diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index 8439512..78ccc11 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -45,6 +45,12 @@ import uk.co.appoly.droid.barcodescanner.BarcodeFormats import uk.co.appoly.droid.barcodescanner.OneShotBarcodeScanner import uk.co.appoly.droid.barcodescanner.OneShotScanResult import uk.co.appoly.droid.barcodescanner.ScannedBarcode +import androidx.compose.runtime.mutableIntStateOf +import kotlin.time.Duration.Companion.milliseconds +import uk.co.appoly.droid.barcodescanner.camera.ScanMode +import uk.co.appoly.droid.barcodescanner.camera.ScanPolicy +import uk.co.appoly.droid.barcodescanner.camera.ScanRegion +import uk.co.appoly.droid.ui.segmentedcontrol.SegmentedControl import uk.co.appoly.droid.barcodescanner.camera.BarcodeScannerCamera import uk.co.appoly.droid.nav3.Nav3Screen @@ -230,11 +236,55 @@ data object BarcodeScannerDemoScreen : Nav3Screen { .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + // Every ScanPolicy knob is driven live from here, so the sheet doubles as the + // place to feel what each one does rather than reason about it. + var mode by remember { mutableStateOf(ScanMode.Single) } + var regionChoice by remember { mutableStateOf("Reticle") } + var dwellMs by remember { mutableIntStateOf(500) } + var paused by remember { mutableStateOf(false) } + var lastScan by remember { mutableStateOf(null) } + + val policy = remember(mode, regionChoice, dwellMs) { + ScanPolicy( + mode = mode, + dwell = dwellMs.takeIf { it > 0 }?.milliseconds, + region = when (regionChoice) { + "Full" -> ScanRegion.Full + "Visible" -> ScanRegion.Visible + else -> ScanRegion.Reticle() + }, + ) + } + + SegmentedControl( + segments = listOf(ScanMode.Single, ScanMode.Multi), + selectedSegment = mode, + onSegmentSelected = { mode = it }, + segmentText = { it.name }, + ) + SegmentedControl( + segments = listOf("Full", "Visible", "Reticle"), + selectedSegment = regionChoice, + onSegmentSelected = { regionChoice = it }, + ) + SegmentedControl( + segments = listOf(0, 250, 500, 1000), + selectedSegment = dwellMs, + onSegmentSelected = { dwellMs = it }, + segmentText = { if (it == 0) "no dwell" else "${it}ms" }, + ) + TorchToggleRow( modifier = Modifier.fillMaxWidth(), checked = torchEnabled, onCheckedChange = { torchEnabled = it }, ) + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + label = "Pause scanning", + checked = paused, + onCheckedChange = { paused = it }, + ) Box( modifier = Modifier @@ -244,10 +294,14 @@ data object BarcodeScannerDemoScreen : Nav3Screen { BarcodeScannerCamera( modifier = Modifier.fillMaxSize(), torchEnabled = torchEnabled, + scanningEnabled = !paused, + policy = policy, onError = { cameraError = it.message ?: it.toString() }, onBarcodeScanned = { barcode -> - // The module's own 2.5s debounce stops a held code repeating; - // this keeps the demo list to distinct values across the session. + lastScan = "${barcode.format}: ${barcode.rawValue}" + // The policy reports one result per presentation, so anything + // arriving here is a deliberate scan. Kept distinct only so the + // list below stays readable across a long session. if (scannedCodes.none { it.rawValue == barcode.rawValue }) { scannedCodes.add(barcode) } @@ -255,6 +309,12 @@ data object BarcodeScannerDemoScreen : Nav3Screen { ) } + Text( + text = lastScan?.let { "Last: $it" } + ?: "Hold a code inside the frame for ${dwellMs}ms", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) Text( text = "${scannedCodes.size} distinct code(s) scanned", style = MaterialTheme.typography.bodyMedium, @@ -268,6 +328,7 @@ data object BarcodeScannerDemoScreen : Nav3Screen { @Composable private fun TorchToggleRow( modifier: Modifier = Modifier, + label: String = "Torch", checked: Boolean, onCheckedChange: (Boolean) -> Unit, ) { @@ -277,7 +338,7 @@ private fun TorchToggleRow( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "Torch", + text = label, style = MaterialTheme.typography.bodyMedium, ) Switch( From 6fb10430e26461b0040211a0967d1a651124b70f Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 12:25:11 +0100 Subject: [PATCH 34/53] feat(BarcodeScanner-Camera): AnimatedScanFrame, and a custom overlay in the demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../camera/AnimatedScanFrame.kt | 117 ++++++++++++++++++ .../ui/screens/BarcodeScannerDemoScreen.kt | 70 +++++++++++ 2 files changed, 187 insertions(+) create mode 100644 BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt new file mode 100644 index 0000000..74eb811 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt @@ -0,0 +1,117 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * A reticle that moves to the barcode it is about to accept and draws its dwell progress around it, + * in the manner of Google's hosted scanner. + * + * Idle, it sits on [ScannerOverlayScope.regionRect] — the region the analyser is actually + * filtering against. When a barcode appears it springs to that barcode's bounds and a progress + * stroke closes around the outline as [DetectedBarcode.dwellProgress] fills, so the wait before a + * scan registers is visible rather than mysterious. That matters: a scanner that pauses with no + * feedback reads as broken, which is the usual reason people reach for a shorter dwell than they + * want. + * + * Follows the *first* detection, which the scanner ranks nearest the centre of the region — the + * same one [ScanMode.Single] would lock onto, so the frame shows what is about to be scanned. + * + * Costs nothing when unused: with [ScanPolicy.dwell] set to null the progress stroke is always + * complete, and the frame simply tracks whatever is in view. + * + * @param color the outline colour when nothing is being tracked. + * @param trackingColor the outline colour once a barcode is being followed. + * @param strokeWidth the outline thickness. + * @param cornerRadius the corner rounding. + * @param padding how far outside the barcode's bounds to draw, so the frame does not sit on top of + * the code it is trying to read. + * @param scrimColor painted outside the acceptance region while idle. Transparent disables it. + */ +@Composable +fun ScannerOverlayScope.AnimatedScanFrame( + modifier: Modifier = Modifier, + color: Color = Color.White, + trackingColor: Color = Color(0xFF4CAF50), + strokeWidth: Dp = 3.dp, + cornerRadius: Dp = 16.dp, + padding: Dp = 12.dp, + scrimColor: Color = Color.Black.copy(alpha = 0.4f), +) { + val tracked = detections.firstOrNull() + val target = tracked?.bounds ?: regionRect + val progress = tracked?.dwellProgress ?: 0f + + // Animate the edges rather than the whole Rect so this needs no vector converter, and springs + // rather than tweens so a barcode that jitters between frames does not look mechanical. + val spec = spring(stiffness = 420f, dampingRatio = 0.82f) + val left by animateFloatAsState(target.left, spec, label = "frameLeft") + val top by animateFloatAsState(target.top, spec, label = "frameTop") + val right by animateFloatAsState(target.right, spec, label = "frameRight") + val bottom by animateFloatAsState(target.bottom, spec, label = "frameBottom") + val outlineColor by animateColorAsState( + targetValue = if (tracked != null) trackingColor else color, + label = "frameColor", + ) + + Canvas(modifier = modifier.fillMaxSize()) { + if (size.width <= 0f || size.height <= 0f) return@Canvas + val pad = if (tracked != null) padding.toPx() else 0f + val rect = Rect( + left = (left - pad).coerceAtLeast(0f), + top = (top - pad).coerceAtLeast(0f), + right = (right + pad).coerceAtMost(size.width), + bottom = (bottom + pad).coerceAtMost(size.height), + ) + if (rect.width <= 0f || rect.height <= 0f) return@Canvas + val radius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()) + val rounded = RoundRect(rect = rect, cornerRadius = radius) + + // Only dim while idle. Once the frame has moved onto a barcode the scrim would be dimming + // most of the preview, which looks like a fault rather than a hint. + if (tracked == null && scrimColor.alpha > 0f) { + drawPath( + Path().apply { + addRect(Rect(0f, 0f, size.width, size.height)) + addRoundRect(rounded) + fillType = PathFillType.EvenOdd + }, + scrimColor, + ) + } + + drawOutline( + outline = Outline.Rounded(rounded), + color = outlineColor.copy(alpha = if (tracked != null) 0.35f else 1f), + style = Stroke(width = strokeWidth.toPx()), + ) + + // The progress stroke: a segment of the outline, growing from nothing to the whole way + // round as the dwell completes. + if (tracked != null && progress > 0f) { + val path = Path().apply { addRoundRect(rounded) } + val measure = PathMeasure().apply { setPath(path, false) } + val drawn = Path() + measure.getSegment(0f, measure.length * progress.coerceIn(0f, 1f), drawn, true) + drawPath(drawn, outlineColor, style = Stroke(width = strokeWidth.toPx() * 1.6f)) + } + } +} diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index 78ccc11..3168bc8 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -51,6 +51,14 @@ import uk.co.appoly.droid.barcodescanner.camera.ScanMode import uk.co.appoly.droid.barcodescanner.camera.ScanPolicy import uk.co.appoly.droid.barcodescanner.camera.ScanRegion import uk.co.appoly.droid.ui.segmentedcontrol.SegmentedControl +import androidx.compose.foundation.Canvas +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.Stroke +import uk.co.appoly.droid.barcodescanner.camera.AnimatedScanFrame +import uk.co.appoly.droid.barcodescanner.camera.DefaultScanFrame +import uk.co.appoly.droid.barcodescanner.camera.ScannerOverlayScope import uk.co.appoly.droid.barcodescanner.camera.BarcodeScannerCamera import uk.co.appoly.droid.nav3.Nav3Screen @@ -242,6 +250,7 @@ data object BarcodeScannerDemoScreen : Nav3Screen { var regionChoice by remember { mutableStateOf("Reticle") } var dwellMs by remember { mutableIntStateOf(500) } var paused by remember { mutableStateOf(false) } + var overlayStyle by remember { mutableStateOf("Animated") } var lastScan by remember { mutableStateOf(null) } val policy = remember(mode, regionChoice, dwellMs) { @@ -267,6 +276,11 @@ data object BarcodeScannerDemoScreen : Nav3Screen { selectedSegment = regionChoice, onSegmentSelected = { regionChoice = it }, ) + SegmentedControl( + segments = listOf("Frame", "Animated", "Custom"), + selectedSegment = overlayStyle, + onSegmentSelected = { overlayStyle = it }, + ) SegmentedControl( segments = listOf(0, 250, 500, 1000), selectedSegment = dwellMs, @@ -296,6 +310,15 @@ data object BarcodeScannerDemoScreen : Nav3Screen { torchEnabled = torchEnabled, scanningEnabled = !paused, policy = policy, + overlay = { + when (overlayStyle) { + "Frame" -> DefaultScanFrame() + "Animated" -> AnimatedScanFrame() + // Written here rather than in the library, to show the scope + // gives a consumer everything needed to draw their own. + else -> BullseyeOverlay() + } + }, onError = { cameraError = it.message ?: it.toString() }, onBarcodeScanned = { barcode -> lastScan = "${barcode.format}: ${barcode.rawValue}" @@ -347,3 +370,50 @@ private fun TorchToggleRow( ) } } + +/** + * A hand-rolled overlay, built only from [ScannerOverlayScope]'s public surface. + * + * Exists to prove the point: a consumer needs nothing from the library beyond [regionRect] and + * [detections] to draw something completely different — here, crosshairs on the acceptance region + * and a filling ring on whatever the scanner is about to accept. + */ +@Composable +private fun ScannerOverlayScope.BullseyeOverlay() { + Canvas(modifier = Modifier.fillMaxSize()) { + val region = regionRect + if (region.width <= 0f) return@Canvas + + // Crosshairs marking where the scanner is looking. + val centre = region.center + val arm = 24.dp.toPx() + listOf( + Offset(centre.x - arm, centre.y) to Offset(centre.x + arm, centre.y), + Offset(centre.x, centre.y - arm) to Offset(centre.x, centre.y + arm), + ).forEach { (from, to) -> + drawLine(Color.White.copy(alpha = 0.7f), from, to, strokeWidth = 2.dp.toPx()) + } + + detections.forEach { detection -> + val box = detection.bounds + val radius = maxOf(box.width, box.height) / 2f + 16.dp.toPx() + drawCircle( + color = Color.Cyan.copy(alpha = 0.35f), + radius = radius, + center = box.center, + style = Stroke(width = 2.dp.toPx()), + ) + // The ring fills as the code dwells — the same signal AnimatedScanFrame draws, just + // shaped differently. + drawArc( + color = Color.Cyan, + startAngle = -90f, + sweepAngle = 360f * detection.dwellProgress, + useCenter = false, + topLeft = Offset(box.center.x - radius, box.center.y - radius), + size = Size(radius * 2, radius * 2), + style = Stroke(width = 4.dp.toPx()), + ) + } + } +} From 3c717a2cdd156fc33dd0d762bf1c7f9a3b3373bd Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 12:36:37 +0100 Subject: [PATCH 35/53] feat(BarcodeScanner-Camera): haptic on scan, and a corner-bracket overlay in the demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner-Camera/README.md | 19 ++++- .../camera/BarcodeScannerCamera.kt | 17 +++- .../ui/screens/BarcodeScannerDemoScreen.kt | 81 ++++++++++++++++++- 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index 99db455..2536afd 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -14,7 +14,8 @@ module gives you the one-shot scanner for free. - A centre-of-frame acceptance region that the drawn reticle actually matches - Single- or multi-code tracking, ranked nearest-the-centre first - Callbacks marshalled to the main thread — touch ViewModel state directly -- Replaceable overlay, with a sensible default reticle +- Replaceable overlay — a static frame, an animated one that tracks the code, or your own +- Haptic confirmation on scan - Torch control - Declares `CAMERA` and the ML Kit install-time model download in its own manifest @@ -109,6 +110,22 @@ Preview and analysis are bound through one CameraX `ViewPort`, which is what mak of view agree — and what lets `DefaultScanFrame` draw the exact rectangle the analyser filters against, so the box on screen and the region that accepts codes cannot drift apart. +### Feedback on a scan + +A haptic fires on each accepted scan by default: + +```kotlin +BarcodeScannerCamera( + scanHaptic = HapticFeedbackType.Confirm, // null for silence + onBarcodeScanned = ::onScanned, +) +``` + +On by default because the person scanning is usually looking at the thing they are scanning rather +than at the screen — the buzz is what tells them it landed. It follows `scanningEnabled` and the +policy automatically, since it only fires for accepted scans. Taking a `HapticFeedbackType?` rather +than a `Boolean` means a different feel is a value change rather than a new parameter. + ### Pausing without tearing down `scanningEnabled = false` keeps the camera bound and the preview live but reports nothing — for diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index d19ec71..96596a8 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -25,10 +25,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.IntSize import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner @@ -92,6 +94,10 @@ enum class LensFacing(internal val selector: CameraSelector) { * preview live but reports nothing — for holding a result on screen without the scanner running on * underneath it. Cheaper and far less jarring than removing the composable, which tears the camera * down and flashes the preview on the way back. + * @param scanHaptic played when a barcode is accepted, or null for silence. On by default: the + * person scanning is usually looking at the thing they are scanning rather than at the screen, so + * a buzz is what tells them it landed. Fires only for accepted scans, so it follows + * [scanningEnabled] and [policy] automatically. * @param policy how long a barcode must be held, how many are tracked at once, and where in the * frame they count. See [ScanPolicy]. * @param overlay drawn on top of the preview. Receives the resolved acceptance region and the @@ -107,6 +113,7 @@ fun BarcodeScannerCamera( lensFacing: LensFacing = LensFacing.Back, torchEnabled: Boolean = false, scanningEnabled: Boolean = true, + scanHaptic: HapticFeedbackType? = HapticFeedbackType.Confirm, policy: ScanPolicy = ScanPolicy.Default, overlay: @Composable ScannerOverlayScope.() -> Unit = { DefaultScanFrame() }, onError: (Throwable) -> Unit = {}, @@ -122,6 +129,8 @@ fun BarcodeScannerCamera( var previewSize by remember { mutableStateOf(Size.Zero) } var detections by remember { mutableStateOf>(emptyList()) } val currentScanningEnabled by rememberUpdatedState(scanningEnabled) + val haptics = LocalHapticFeedback.current + val currentScanHaptic by rememberUpdatedState(scanHaptic) // Rebuilt only when the policy actually changes — which is why ScanPolicy implements equals by // hand. A policy constructed inline that did not compare equal would reset every dwell on @@ -157,7 +166,13 @@ fun BarcodeScannerCamera( // code "present" long after it had gone. val visible = ranked.mapNotNull { it.toScannedBarcode() } if (currentScanningEnabled) { - tracker.accept(visible).forEach(currentOnBarcodeScanned) + tracker.accept(visible).forEach { scanned -> + // Once per accepted scan, before the callback, so the buzz + // lands with the scan rather than after whatever the + // consumer does with it. + currentScanHaptic?.let(haptics::performHapticFeedback) + currentOnBarcodeScanned(scanned) + } } detections = ranked.toDetections( tracker = tracker, diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index 3168bc8..c452358 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -53,7 +53,14 @@ import uk.co.appoly.droid.barcodescanner.camera.ScanRegion import uk.co.appoly.droid.ui.segmentedcontrol.SegmentedControl import androidx.compose.foundation.Canvas import androidx.compose.ui.geometry.Offset +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.Dp +import uk.co.appoly.droid.barcodescanner.camera.DetectedBarcode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.Stroke import uk.co.appoly.droid.barcodescanner.camera.AnimatedScanFrame @@ -251,6 +258,7 @@ data object BarcodeScannerDemoScreen : Nav3Screen { var dwellMs by remember { mutableIntStateOf(500) } var paused by remember { mutableStateOf(false) } var overlayStyle by remember { mutableStateOf("Animated") } + var haptics by remember { mutableStateOf(true) } var lastScan by remember { mutableStateOf(null) } val policy = remember(mode, regionChoice, dwellMs) { @@ -277,7 +285,7 @@ data object BarcodeScannerDemoScreen : Nav3Screen { onSegmentSelected = { regionChoice = it }, ) SegmentedControl( - segments = listOf("Frame", "Animated", "Custom"), + segments = listOf("Frame", "Animated", "Corners", "Bullseye"), selectedSegment = overlayStyle, onSegmentSelected = { overlayStyle = it }, ) @@ -293,6 +301,12 @@ data object BarcodeScannerDemoScreen : Nav3Screen { checked = torchEnabled, onCheckedChange = { torchEnabled = it }, ) + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + label = "Haptic on scan", + checked = haptics, + onCheckedChange = { haptics = it }, + ) TorchToggleRow( modifier = Modifier.fillMaxWidth(), label = "Pause scanning", @@ -309,11 +323,13 @@ data object BarcodeScannerDemoScreen : Nav3Screen { modifier = Modifier.fillMaxSize(), torchEnabled = torchEnabled, scanningEnabled = !paused, + scanHaptic = HapticFeedbackType.Confirm.takeIf { haptics }, policy = policy, overlay = { when (overlayStyle) { "Frame" -> DefaultScanFrame() "Animated" -> AnimatedScanFrame() + "Corners" -> CornerBracketOverlay() // Written here rather than in the library, to show the scope // gives a consumer everything needed to draw their own. else -> BullseyeOverlay() @@ -417,3 +433,66 @@ private fun ScannerOverlayScope.BullseyeOverlay() { } } } + +/** + * A second hand-rolled overlay, app-side, in the style of Google's hosted scanner. + * + * At rest it marks the acceptance region with four **unconnected** corner brackets. As a barcode + * dwells, the arms grow along each edge until they meet in the middle and the brackets close into a + * complete frame — so [DetectedBarcode.dwellProgress] is legible as a shape rather than needing a + * separate progress indicator. + * + * Built, like [BullseyeOverlay], from nothing but [ScannerOverlayScope.regionRect] and + * [ScannerOverlayScope.detections]. Two overlays this different sharing one contract is the point: + * the library ships an opinionated frame, and an app that wants its own look is not stuck with it. + */ +@Composable +private fun ScannerOverlayScope.CornerBracketOverlay( + restingArm: Dp = 28.dp, + strokeWidth: Dp = 4.dp, + idleColor: Color = Color.White, + trackingColor: Color = Color(0xFF4CAF50), +) { + val tracked = detections.firstOrNull() + val target = tracked?.bounds ?: regionRect + val progress = tracked?.dwellProgress ?: 0f + + val spec = spring(stiffness = 420f, dampingRatio = 0.82f) + val left by animateFloatAsState(target.left, spec, label = "cornerLeft") + val top by animateFloatAsState(target.top, spec, label = "cornerTop") + val right by animateFloatAsState(target.right, spec, label = "cornerRight") + val bottom by animateFloatAsState(target.bottom, spec, label = "cornerBottom") + // Animated separately from the spring so the arms close smoothly even when the box is still. + val closure by animateFloatAsState(progress, label = "cornerClosure") + val color by animateColorAsState( + targetValue = if (tracked != null) trackingColor else idleColor, + label = "cornerColor", + ) + + Canvas(modifier = Modifier.fillMaxSize()) { + val pad = if (tracked != null) 12.dp.toPx() else 0f + val l = (left - pad).coerceAtLeast(0f) + val t = (top - pad).coerceAtLeast(0f) + val r = (right + pad).coerceAtMost(size.width) + val b = (bottom + pad).coerceAtMost(size.height) + if (r - l <= 0f || b - t <= 0f) return@Canvas + + val base = restingArm.toPx() + // Each arm grows from its resting length to half the edge; at full closure the two arms on + // an edge meet in the middle and the brackets become a continuous rectangle. + val armX = base + ((r - l) / 2f - base).coerceAtLeast(0f) * closure + val armY = base + ((b - t) / 2f - base).coerceAtLeast(0f) * closure + val stroke = strokeWidth.toPx() + + listOf( + // corner horizontal arm vertical arm + Triple(Offset(l, t), Offset(l + armX, t), Offset(l, t + armY)), + Triple(Offset(r, t), Offset(r - armX, t), Offset(r, t + armY)), + Triple(Offset(l, b), Offset(l + armX, b), Offset(l, b - armY)), + Triple(Offset(r, b), Offset(r - armX, b), Offset(r, b - armY)), + ).forEach { (corner, horizontal, vertical) -> + drawLine(color, corner, horizontal, strokeWidth = stroke, cap = StrokeCap.Round) + drawLine(color, corner, vertical, strokeWidth = stroke, cap = StrokeCap.Round) + } + } +} From 91dcb7190322ef872f7f6537dcb4c1a3df819225 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 12:49:26 +0100 Subject: [PATCH 36/53] refactor(BarcodeScanner-Camera): drop scanHaptic, feedback belongs to the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner-Camera/README.md | 34 ++++++++++++++----- .../camera/BarcodeScannerCamera.kt | 23 ++++--------- .../ui/screens/BarcodeScannerDemoScreen.kt | 23 +++++++++---- 3 files changed, 50 insertions(+), 30 deletions(-) diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index 2536afd..ea5d519 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -15,7 +15,6 @@ module gives you the one-shot scanner for free. - Single- or multi-code tracking, ranked nearest-the-centre first - Callbacks marshalled to the main thread — touch ViewModel state directly - Replaceable overlay — a static frame, an animated one that tracks the code, or your own -- Haptic confirmation on scan - Torch control - Declares `CAMERA` and the ML Kit install-time model download in its own manifest @@ -112,19 +111,38 @@ against, so the box on screen and the region that accepts codes cannot drift apa ### Feedback on a scan -A haptic fires on each accepted scan by default: +**The module plays nothing — no haptic, no sound.** Deliberately: it knows a barcode was *read*, +never whether it was the right one. Anything it played would have to fire before your callback +could disagree, so an app that validates would produce a confirm buzz followed by its own reject +buzz for a single scan. + +Both belong in `onBarcodeScanned`, where the verdict is known: ```kotlin +val haptics = LocalHapticFeedback.current + BarcodeScannerCamera( - scanHaptic = HapticFeedbackType.Confirm, // null for silence - onBarcodeScanned = ::onScanned, + onBarcodeScanned = { barcode -> + when (viewModel.match(barcode.rawValue)) { + is Matched -> { + haptics.performHapticFeedback(HapticFeedbackType.Confirm) + sounds.play(R.raw.scan_ok) + } + is NoMatch -> { + haptics.performHapticFeedback(HapticFeedbackType.Reject) + sounds.play(R.raw.scan_bad) + } + } + }, ) ``` -On by default because the person scanning is usually looking at the thing they are scanning rather -than at the screen — the buzz is what tells them it landed. It follows `scanningEnabled` and the -policy automatically, since it only fires for accepted scans. Taking a `HapticFeedbackType?` rather -than a `Boolean` means a different feel is a value change rather than a new parameter. +If you only want "I read something" and have no notion of a bad scan, that is one line in the same +place — the point is that it is your call, not ours. + +Sound stays with you for its own reasons on top of that one: it needs an asset, an audio stream, a +silent-mode policy and usually a chosen sound to match whatever hardware scanners your users +already know. Four decisions a library should not be making on your behalf. ### Pausing without tearing down diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index 96596a8..b7e5c9a 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -25,12 +25,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.IntSize import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner @@ -94,10 +92,6 @@ enum class LensFacing(internal val selector: CameraSelector) { * preview live but reports nothing — for holding a result on screen without the scanner running on * underneath it. Cheaper and far less jarring than removing the composable, which tears the camera * down and flashes the preview on the way back. - * @param scanHaptic played when a barcode is accepted, or null for silence. On by default: the - * person scanning is usually looking at the thing they are scanning rather than at the screen, so - * a buzz is what tells them it landed. Fires only for accepted scans, so it follows - * [scanningEnabled] and [policy] automatically. * @param policy how long a barcode must be held, how many are tracked at once, and where in the * frame they count. See [ScanPolicy]. * @param overlay drawn on top of the preview. Receives the resolved acceptance region and the @@ -105,6 +99,12 @@ enum class LensFacing(internal val selector: CameraSelector) { * @param onError reports a camera that could not be opened or bound — no camera, permission not * granted, or another app holding it. The preview stays blank; recovery is the caller's call. * @param onBarcodeScanned invoked on the main thread for each barcode that satisfies [policy]. + * + * Feedback — haptics, sounds — is deliberately not a parameter here. This composable knows only + * that a barcode was *read*, never whether it was the right one, so anything it played would have + * to fire before your callback could disagree: a confirm buzz followed by your reject buzz, for + * one scan. Play it in [onBarcodeScanned], where the verdict is known. The README shows the + * pattern. */ @Composable fun BarcodeScannerCamera( @@ -113,7 +113,6 @@ fun BarcodeScannerCamera( lensFacing: LensFacing = LensFacing.Back, torchEnabled: Boolean = false, scanningEnabled: Boolean = true, - scanHaptic: HapticFeedbackType? = HapticFeedbackType.Confirm, policy: ScanPolicy = ScanPolicy.Default, overlay: @Composable ScannerOverlayScope.() -> Unit = { DefaultScanFrame() }, onError: (Throwable) -> Unit = {}, @@ -129,8 +128,6 @@ fun BarcodeScannerCamera( var previewSize by remember { mutableStateOf(Size.Zero) } var detections by remember { mutableStateOf>(emptyList()) } val currentScanningEnabled by rememberUpdatedState(scanningEnabled) - val haptics = LocalHapticFeedback.current - val currentScanHaptic by rememberUpdatedState(scanHaptic) // Rebuilt only when the policy actually changes — which is why ScanPolicy implements equals by // hand. A policy constructed inline that did not compare equal would reset every dwell on @@ -166,13 +163,7 @@ fun BarcodeScannerCamera( // code "present" long after it had gone. val visible = ranked.mapNotNull { it.toScannedBarcode() } if (currentScanningEnabled) { - tracker.accept(visible).forEach { scanned -> - // Once per accepted scan, before the callback, so the buzz - // lands with the scan rather than after whatever the - // consumer does with it. - currentScanHaptic?.let(haptics::performHapticFeedback) - currentOnBarcodeScanned(scanned) - } + tracker.accept(visible).forEach(currentOnBarcodeScanned) } detections = ranked.toDetections( tracker = tracker, diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index c452358..9452151 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.unit.Dp import uk.co.appoly.droid.barcodescanner.camera.DetectedBarcode import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.Stroke import uk.co.appoly.droid.barcodescanner.camera.AnimatedScanFrame @@ -259,6 +260,7 @@ data object BarcodeScannerDemoScreen : Nav3Screen { var paused by remember { mutableStateOf(false) } var overlayStyle by remember { mutableStateOf("Animated") } var haptics by remember { mutableStateOf(true) } + val hapticFeedback = LocalHapticFeedback.current var lastScan by remember { mutableStateOf(null) } val policy = remember(mode, regionChoice, dwellMs) { @@ -323,7 +325,6 @@ data object BarcodeScannerDemoScreen : Nav3Screen { modifier = Modifier.fillMaxSize(), torchEnabled = torchEnabled, scanningEnabled = !paused, - scanHaptic = HapticFeedbackType.Confirm.takeIf { haptics }, policy = policy, overlay = { when (overlayStyle) { @@ -337,12 +338,22 @@ data object BarcodeScannerDemoScreen : Nav3Screen { }, onError = { cameraError = it.message ?: it.toString() }, onBarcodeScanned = { barcode -> - lastScan = "${barcode.format}: ${barcode.rawValue}" - // The policy reports one result per presentation, so anything - // arriving here is a deliberate scan. Kept distinct only so the - // list below stays readable across a long session. - if (scannedCodes.none { it.rawValue == barcode.rawValue }) { + // Feedback lives here rather than in the module, because only the + // app knows whether a scan was any *good*. Here "already in the + // list" stands in for the real thing — a code that is not on the + // manifest, or the wrong item — and gets the reject signal. + val isNew = scannedCodes.none { it.rawValue == barcode.rawValue } + if (haptics) { + hapticFeedback.performHapticFeedback( + if (isNew) HapticFeedbackType.Confirm + else HapticFeedbackType.Reject, + ) + } + lastScan = if (isNew) { scannedCodes.add(barcode) + "${barcode.format}: ${barcode.rawValue}" + } else { + "Already scanned: ${barcode.rawValue}" } }, ) From a10fa44547acee73ebe22eac0ba5bea799a52121 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:02:55 +0100 Subject: [PATCH 37/53] fix(BarcodeScanner-Camera): correct the preview mapping and follow rotated codes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../camera/AnimatedScanFrame.kt | 70 +++++---- .../camera/BarcodeScannerCamera.kt | 34 ++--- .../camera/ScanRegionResolver.kt | 41 ++++++ .../camera/ScannerOverlayScope.kt | 12 +- .../camera/ScanRegionResolverTest.kt | 138 ++++++++++++++++++ .../ui/screens/BarcodeScannerDemoScreen.kt | 76 +++++----- 6 files changed, 280 insertions(+), 91 deletions(-) create mode 100644 BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt index 74eb811..2eaac0b 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt @@ -8,15 +8,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Outline import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathFillType import androidx.compose.ui.graphics.PathMeasure -import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -41,9 +38,6 @@ import androidx.compose.ui.unit.dp * @param color the outline colour when nothing is being tracked. * @param trackingColor the outline colour once a barcode is being followed. * @param strokeWidth the outline thickness. - * @param cornerRadius the corner rounding. - * @param padding how far outside the barcode's bounds to draw, so the frame does not sit on top of - * the code it is trying to read. * @param scrimColor painted outside the acceptance region while idle. Transparent disables it. */ @Composable @@ -52,21 +46,27 @@ fun ScannerOverlayScope.AnimatedScanFrame( color: Color = Color.White, trackingColor: Color = Color(0xFF4CAF50), strokeWidth: Dp = 3.dp, - cornerRadius: Dp = 16.dp, - padding: Dp = 12.dp, scrimColor: Color = Color.Black.copy(alpha = 0.4f), ) { val tracked = detections.firstOrNull() - val target = tracked?.bounds ?: regionRect val progress = tracked?.dwellProgress ?: 0f - // Animate the edges rather than the whole Rect so this needs no vector converter, and springs - // rather than tweens so a barcode that jitters between frames does not look mechanical. + // Follow the code's own corners rather than its bounding box: ML Kit's boundingBox is always + // axis-aligned, so on a barcode held at an angle it is the box *around* the code and the + // outline visibly fails to sit on it. Falling back to the bounds keeps this working for a + // detector that reports no corners. + val targetCorners = tracked?.corners?.takeIf { it.size == 4 } + ?: tracked?.bounds?.cornersClockwise() + ?: regionRect.cornersClockwise() + val spec = spring(stiffness = 420f, dampingRatio = 0.82f) - val left by animateFloatAsState(target.left, spec, label = "frameLeft") - val top by animateFloatAsState(target.top, spec, label = "frameTop") - val right by animateFloatAsState(target.right, spec, label = "frameRight") - val bottom by animateFloatAsState(target.bottom, spec, label = "frameBottom") + // Four corners as eight springs: one animation that covers moving, resizing and rotating, + // because a rotation is just the corners travelling to new places. + val animated = targetCorners.mapIndexed { index, corner -> + val x by animateFloatAsState(corner.x, spec, label = "cornerX$index") + val y by animateFloatAsState(corner.y, spec, label = "cornerY$index") + Offset(x, y) + } val outlineColor by animateColorAsState( targetValue = if (tracked != null) trackingColor else color, label = "frameColor", @@ -74,44 +74,42 @@ fun ScannerOverlayScope.AnimatedScanFrame( Canvas(modifier = modifier.fillMaxSize()) { if (size.width <= 0f || size.height <= 0f) return@Canvas - val pad = if (tracked != null) padding.toPx() else 0f - val rect = Rect( - left = (left - pad).coerceAtLeast(0f), - top = (top - pad).coerceAtLeast(0f), - right = (right + pad).coerceAtMost(size.width), - bottom = (bottom + pad).coerceAtMost(size.height), - ) - if (rect.width <= 0f || rect.height <= 0f) return@Canvas - val radius = CornerRadius(cornerRadius.toPx(), cornerRadius.toPx()) - val rounded = RoundRect(rect = rect, cornerRadius = radius) + val outline = Path().apply { + moveTo(animated[0].x, animated[0].y) + animated.drop(1).forEach { lineTo(it.x, it.y) } + close() + } - // Only dim while idle. Once the frame has moved onto a barcode the scrim would be dimming - // most of the preview, which looks like a fault rather than a hint. + // Only dim while idle. Once the frame is on a barcode the scrim would be dimming most of + // the preview, which looks like a fault rather than a hint. if (tracked == null && scrimColor.alpha > 0f) { drawPath( Path().apply { addRect(Rect(0f, 0f, size.width, size.height)) - addRoundRect(rounded) + addPath(outline) fillType = PathFillType.EvenOdd }, scrimColor, ) } - drawOutline( - outline = Outline.Rounded(rounded), - color = outlineColor.copy(alpha = if (tracked != null) 0.35f else 1f), + drawPath( + outline, + outlineColor.copy(alpha = if (tracked != null) 0.35f else 1f), style = Stroke(width = strokeWidth.toPx()), ) - // The progress stroke: a segment of the outline, growing from nothing to the whole way - // round as the dwell completes. + // The progress stroke: a segment of the outline, closing around the code as the dwell + // completes. if (tracked != null && progress > 0f) { - val path = Path().apply { addRoundRect(rounded) } - val measure = PathMeasure().apply { setPath(path, false) } + val measure = PathMeasure().apply { setPath(outline, false) } val drawn = Path() measure.getSegment(0f, measure.length * progress.coerceIn(0f, 1f), drawn, true) drawPath(drawn, outlineColor, style = Stroke(width = strokeWidth.toPx() * 1.6f)) } } } + +/** The four corners of an axis-aligned rect, clockwise from top-left, to match ML Kit's ordering. */ +internal fun Rect.cornersClockwise(): List = + listOf(topLeft, topRight, bottomRight, bottomLeft) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index b7e5c9a..42db1b9 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -157,7 +157,7 @@ fun BarcodeScannerCamera( scanner = scanner, region = policy.region, callbackExecutor = ContextCompat.getMainExecutor(context), - onFrameAnalysed = { ranked, imageRegion, imageSize -> + onFrameAnalysed = { ranked, crop -> // Every frame ticks the tracker, including empty ones: absence is // what expires a track, so skipping quiet frames would leave a // code "present" long after it had gone. @@ -167,8 +167,7 @@ fun BarcodeScannerCamera( } detections = ranked.toDetections( tracker = tracker, - imageRegion = imageRegion, - imageSize = imageSize, + crop = crop, previewSize = previewSize, ) }, @@ -286,7 +285,7 @@ private class BarcodeAnalyzer( private val scanner: BarcodeScanner, private val region: ScanRegion, private val callbackExecutor: Executor, - private val onFrameAnalysed: (ranked: List, imageRegion: android.graphics.Rect, imageSize: IntSize) -> Unit, + private val onFrameAnalysed: (ranked: List, crop: android.graphics.Rect) -> Unit, private val onDetectionFailed: (Throwable) -> Unit, ) : ImageAnalysis.Analyzer { @@ -303,7 +302,7 @@ private class BarcodeAnalyzer( val upright = rotation == 90 || rotation == 270 val width = if (upright) imageProxy.height else imageProxy.width val height = if (upright) imageProxy.width else imageProxy.height - val crop = if (upright) imageProxy.cropRect.transposed() else imageProxy.cropRect + val crop = imageProxy.cropRect.rotatedInto(rotation, imageProxy.width, imageProxy.height) val imageRegion = ScanRegionResolver.inImage(region, crop, width, height) val inputImage = InputImage.fromMediaImage(mediaImage, rotation) @@ -312,7 +311,7 @@ private class BarcodeAnalyzer( val ranked = barcodes .filter { it.isWithin(imageRegion) } .sortedBy { it.distanceToCentreOf(imageRegion) } - onFrameAnalysed(ranked, imageRegion, IntSize(width, height)) + onFrameAnalysed(ranked, crop) } .addOnFailureListener(callbackExecutor) { error -> onDetectionFailed(error) @@ -323,8 +322,6 @@ private class BarcodeAnalyzer( } } -private fun android.graphics.Rect.transposed() = android.graphics.Rect(top, left, bottom, right) - /** * Maps ranked detections from analyser image space into preview pixels for an overlay to draw. * @@ -333,24 +330,23 @@ private fun android.graphics.Rect.transposed() = android.graphics.Rect(top, left */ private fun List.toDetections( tracker: BarcodeTracker, - imageRegion: android.graphics.Rect, - imageSize: IntSize, + crop: android.graphics.Rect, previewSize: Size, ): List { - if (previewSize.width <= 0f || imageSize.width == 0 || imageSize.height == 0) return emptyList() - val scaleX = previewSize.width / imageSize.width - val scaleY = previewSize.height / imageSize.height + if (previewSize.width <= 0f || crop.width() <= 0 || crop.height() <= 0) return emptyList() return mapNotNull { barcode -> val box = barcode.boundingBox ?: return@mapNotNull null val scanned = barcode.toScannedBarcode() ?: return@mapNotNull null + val topLeft = mapToPreview(box.left, box.top, crop, previewSize) + val bottomRight = mapToPreview(box.right, box.bottom, crop, previewSize) DetectedBarcode( barcode = scanned, - bounds = Rect( - left = box.left * scaleX, - top = box.top * scaleY, - right = box.right * scaleX, - bottom = box.bottom * scaleY, - ), + bounds = Rect(topLeft, bottomRight), + // cornerPoints follow the code's own rotation, unlike boundingBox which is always + // axis-aligned — they are the only way an overlay can outline a tilted barcode. + corners = barcode.cornerPoints + ?.map { mapToPreview(it.x, it.y, crop, previewSize) } + .orEmpty(), dwellProgress = tracker.dwellProgress(scanned.rawValue), ) } diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt index 81d9cd7..543e584 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt @@ -91,3 +91,44 @@ internal fun Barcode.distanceToCentreOf(region: AndroidRect): Float { (box.centerY() - region.centerY()).toFloat(), ) } + +/** + * Maps a rectangle from the camera buffer's coordinate space into the rotation-corrected space + * ML Kit reports barcodes in. + * + * [rotationDegrees] is the clockwise rotation needed to make the buffer upright, so this applies + * exactly that rotation. Transposing the rectangle instead — swapping x and y — is a reflection + * about the diagonal rather than a rotation, and happens to look correct only while the crop is + * centred. An off-centre crop lands the region on the wrong side of the frame. + */ +internal fun AndroidRect.rotatedInto( + rotationDegrees: Int, + bufferWidth: Int, + bufferHeight: Int, +): AndroidRect = when (((rotationDegrees % 360) + 360) % 360) { + 90 -> AndroidRect(bufferHeight - bottom, left, bufferHeight - top, right) + 180 -> AndroidRect(bufferWidth - right, bufferHeight - bottom, bufferWidth - left, bufferHeight - top) + 270 -> AndroidRect(top, bufferWidth - right, bottom, bufferWidth - left) + else -> AndroidRect(this) +} + +/** + * Maps a point from the rotation-corrected analyser space into preview pixels. + * + * Everything hangs off [crop] rather than the full image: with a `ViewPort` the preview shows + * exactly the cropped region, so scaling by the whole image makes every box too small and + * forgetting the crop's origin shifts them all toward the top-left. Both at once is what a barcode + * outline that is undersized *and* offset looks like. + */ +internal fun mapToPreview( + x: Int, + y: Int, + crop: AndroidRect, + previewSize: Size, +): androidx.compose.ui.geometry.Offset { + if (crop.width() <= 0 || crop.height() <= 0) return androidx.compose.ui.geometry.Offset.Zero + return androidx.compose.ui.geometry.Offset( + x = (x - crop.left) * previewSize.width / crop.width(), + y = (y - crop.top) * previewSize.height / crop.height(), + ) +} diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt index 1a03621..d22288a 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt @@ -3,6 +3,7 @@ package uk.co.appoly.droid.barcodescanner.camera import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import uk.co.appoly.droid.barcodescanner.ScannedBarcode @@ -18,7 +19,11 @@ import uk.co.appoly.droid.barcodescanner.ScannedBarcode * more ambitious. * * @property barcode the decoded barcode. - * @property bounds its bounding box, in preview pixels. + * @property bounds its axis-aligned bounding box, in preview pixels. Simple to draw, but for a + * barcode held at an angle it is the box *around* the code rather than the code's own outline. + * @property corners the code's four corners in its own orientation, in preview pixels, clockwise + * from the code's top-left. Use these to draw an outline that follows a rotated barcode. Empty if + * the detector did not report them. * @property dwellProgress how far through [ScanPolicy.dwell] this code is, from 0f to 1f. Already * 1f when the policy has no dwell. Useful for drawing a progress ring that fills as the user holds * steady. @@ -27,24 +32,27 @@ import uk.co.appoly.droid.barcodescanner.ScannedBarcode class DetectedBarcode( val barcode: ScannedBarcode, val bounds: Rect, + val corners: List, val dwellProgress: Float, ) { override fun equals(other: Any?): Boolean = this === other || ( other is DetectedBarcode && barcode == other.barcode && bounds == other.bounds && + corners == other.corners && dwellProgress == other.dwellProgress ) override fun hashCode(): Int { var result = barcode.hashCode() result = 31 * result + bounds.hashCode() + result = 31 * result + corners.hashCode() result = 31 * result + dwellProgress.hashCode() return result } override fun toString(): String = - "DetectedBarcode(barcode=$barcode, bounds=$bounds, dwellProgress=$dwellProgress)" + "DetectedBarcode(barcode=$barcode, bounds=$bounds, corners=$corners, dwellProgress=$dwellProgress)" } /** diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt new file mode 100644 index 0000000..bb606fb --- /dev/null +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt @@ -0,0 +1,138 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import android.graphics.Rect +import androidx.compose.ui.geometry.Size +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import androidx.test.ext.junit.runners.AndroidJUnit4 + +/** + * Pins the two coordinate transforms, both of which shipped wrong and were caught on a device + * rather than here. + * + * The preview mapping scaled by the full analyser image instead of the visible crop, which made + * every outline undersized *and* shifted toward the top-left at once. The rotation was a transpose + * — a reflection about the diagonal — which is indistinguishable from a real rotation while the + * crop is centred, and wrong the moment it is not. + * + * Both are pure arithmetic, so there was never a reason for a camera to be the thing that found + * them. + * + * Runs under Robolectric because android.graphics.Rect is a stub on the plain JVM classpath — with + * isReturnDefaultValues on it silently reports every edge as 0, which would make these assertions + * meaningless rather than failing. + */ +@RunWith(AndroidJUnit4::class) +class ScanRegionResolverTest { + + @Test + fun `no rotation leaves a rect alone`() { + val rect = Rect(10, 20, 110, 220) + + assertEquals(rect, rect.rotatedInto(0, bufferWidth = 640, bufferHeight = 480)) + } + + @Test + fun `90 degrees moves the top-left corner to the top-right`() { + // A 640x480 buffer rotated clockwise is 480x640. The buffer's top-left corner region + // should land against the right edge of the upright frame. + val topLeft = Rect(0, 0, 100, 50) + + val rotated = topLeft.rotatedInto(90, bufferWidth = 640, bufferHeight = 480) + + assertEquals(Rect(430, 0, 480, 100), rotated) + } + + @Test + fun `180 degrees mirrors both axes`() { + val rect = Rect(0, 0, 100, 50) + + assertEquals( + Rect(540, 430, 640, 480), + rect.rotatedInto(180, bufferWidth = 640, bufferHeight = 480), + ) + } + + @Test + fun `270 degrees moves the top-left corner to the bottom-left`() { + val topLeft = Rect(0, 0, 100, 50) + + assertEquals( + Rect(0, 540, 50, 640), + topLeft.rotatedInto(270, bufferWidth = 640, bufferHeight = 480), + ) + } + + @Test + fun `an off-centre crop is not merely transposed`() { + // The case the old transpose got wrong. A crop hugging the buffer's left edge must end up + // against the *bottom* of a 90-degree-rotated frame, not against its left edge. + val leftEdge = Rect(0, 100, 40, 300) + + val rotated = leftEdge.rotatedInto(90, bufferWidth = 640, bufferHeight = 480) + + assertEquals("a transpose would have left this at x=100", 180, rotated.left) + assertEquals(0, rotated.top) + assertEquals(40, rotated.height()) + } + + @Test + fun `rotating four times returns the original`() { + val rect = Rect(10, 20, 110, 220) + + val once = rect.rotatedInto(90, 640, 480) + val twice = once.rotatedInto(90, 480, 640) + val thrice = twice.rotatedInto(90, 640, 480) + val full = thrice.rotatedInto(90, 480, 640) + + assertEquals(rect, full) + } + + @Test + fun `preview mapping scales by the crop, not the whole image`() { + // The shipped bug: a 1000-wide image cropped to its middle 500 shown in a 500px preview is + // 1:1 against the crop. Scaling by the image instead halves everything. + val crop = Rect(250, 0, 750, 500) + val preview = Size(500f, 500f) + + val mapped = mapToPreview(x = 500, y = 250, crop = crop, previewSize = preview) + + assertEquals(250f, mapped.x, 0.01f) + assertEquals(250f, mapped.y, 0.01f) + } + + @Test + fun `preview mapping subtracts the crop origin`() { + // The other half of the same bug: without this every outline drifts toward the top-left by + // the crop's offset. + val crop = Rect(200, 100, 600, 500) + val preview = Size(400f, 400f) + + val atCropOrigin = mapToPreview(x = 200, y = 100, crop = crop, previewSize = preview) + + assertEquals(0f, atCropOrigin.x, 0.01f) + assertEquals(0f, atCropOrigin.y, 0.01f) + } + + @Test + fun `preview mapping puts the crop's far corner at the preview's far corner`() { + val crop = Rect(200, 100, 600, 500) + val preview = Size(800f, 800f) + + val farCorner = mapToPreview(x = 600, y = 500, crop = crop, previewSize = preview) + + assertEquals(800f, farCorner.x, 0.01f) + assertEquals(800f, farCorner.y, 0.01f) + } + + @Test + fun `a degenerate crop maps to the origin rather than dividing by zero`() { + val empty = Rect(0, 0, 0, 0) + + val mapped = mapToPreview(x = 10, y = 10, crop = empty, previewSize = Size(100f, 100f)) + + assertEquals(0f, mapped.x, 0.01f) + assertEquals(0f, mapped.y, 0.01f) + } +} diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index 9452151..8a3db1a 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -330,18 +330,19 @@ data object BarcodeScannerDemoScreen : Nav3Screen { when (overlayStyle) { "Frame" -> DefaultScanFrame() "Animated" -> AnimatedScanFrame() - "Corners" -> CornerBracketOverlay() // Written here rather than in the library, to show the scope // gives a consumer everything needed to draw their own. + "Corners" -> CornerBracketOverlay() else -> BullseyeOverlay() } }, onError = { cameraError = it.message ?: it.toString() }, onBarcodeScanned = { barcode -> - // Feedback lives here rather than in the module, because only the - // app knows whether a scan was any *good*. Here "already in the - // list" stands in for the real thing — a code that is not on the - // manifest, or the wrong item — and gets the reject signal. + // Feedback (haptic, sound etc.) lives here rather than in the module, + // because only the app knows whether a scan was any *good*. + // Here "already in the list" stands in for the real thing — a + // code that is not on the manifest, or the wrong item — and gets + // the reject signal. val isNew = scannedCodes.none { it.rawValue == barcode.rawValue } if (haptics) { hapticFeedback.performHapticFeedback( @@ -448,7 +449,7 @@ private fun ScannerOverlayScope.BullseyeOverlay() { /** * A second hand-rolled overlay, app-side, in the style of Google's hosted scanner. * - * At rest it marks the acceptance region with four **unconnected** corner brackets. As a barcode + * At rest, it marks the acceptance region with four **unconnected** corner brackets. As a barcode * dwells, the arms grow along each edge until they meet in the middle and the brackets close into a * complete frame — so [DetectedBarcode.dwellProgress] is legible as a shape rather than needing a * separate progress indicator. @@ -465,45 +466,52 @@ private fun ScannerOverlayScope.CornerBracketOverlay( trackingColor: Color = Color(0xFF4CAF50), ) { val tracked = detections.firstOrNull() - val target = tracked?.bounds ?: regionRect val progress = tracked?.dwellProgress ?: 0f + // The code's own corners, so the brackets sit on a tilted barcode rather than on the + // axis-aligned box around it. + val target = tracked?.corners?.takeIf { it.size == 4 } + ?: tracked?.bounds?.let { listOf(it.topLeft, it.topRight, it.bottomRight, it.bottomLeft) } + ?: regionRect.let { listOf(it.topLeft, it.topRight, it.bottomRight, it.bottomLeft) } + val spec = spring(stiffness = 420f, dampingRatio = 0.82f) - val left by animateFloatAsState(target.left, spec, label = "cornerLeft") - val top by animateFloatAsState(target.top, spec, label = "cornerTop") - val right by animateFloatAsState(target.right, spec, label = "cornerRight") - val bottom by animateFloatAsState(target.bottom, spec, label = "cornerBottom") - // Animated separately from the spring so the arms close smoothly even when the box is still. - val closure by animateFloatAsState(progress, label = "cornerClosure") + val corners = target.mapIndexed { index, corner -> + val x by animateFloatAsState(corner.x, spec, label = "bracketX$index") + val y by animateFloatAsState(corner.y, spec, label = "bracketY$index") + Offset(x, y) + } + val closure by animateFloatAsState(progress, label = "bracketClosure") val color by animateColorAsState( targetValue = if (tracked != null) trackingColor else idleColor, - label = "cornerColor", + label = "bracketColor", ) Canvas(modifier = Modifier.fillMaxSize()) { - val pad = if (tracked != null) 12.dp.toPx() else 0f - val l = (left - pad).coerceAtLeast(0f) - val t = (top - pad).coerceAtLeast(0f) - val r = (right + pad).coerceAtMost(size.width) - val b = (bottom + pad).coerceAtMost(size.height) - if (r - l <= 0f || b - t <= 0f) return@Canvas - val base = restingArm.toPx() - // Each arm grows from its resting length to half the edge; at full closure the two arms on - // an edge meet in the middle and the brackets become a continuous rectangle. - val armX = base + ((r - l) / 2f - base).coerceAtLeast(0f) * closure - val armY = base + ((b - t) / 2f - base).coerceAtLeast(0f) * closure val stroke = strokeWidth.toPx() - listOf( - // corner horizontal arm vertical arm - Triple(Offset(l, t), Offset(l + armX, t), Offset(l, t + armY)), - Triple(Offset(r, t), Offset(r - armX, t), Offset(r, t + armY)), - Triple(Offset(l, b), Offset(l + armX, b), Offset(l, b - armY)), - Triple(Offset(r, b), Offset(r - armX, b), Offset(r, b - armY)), - ).forEach { (corner, horizontal, vertical) -> - drawLine(color, corner, horizontal, strokeWidth = stroke, cap = StrokeCap.Round) - drawLine(color, corner, vertical, strokeWidth = stroke, cap = StrokeCap.Round) + // Walk the quad's four edges. Each corner grows an arm along both edges it touches, from a + // resting stub to half the edge — at which point the two arms meet and the brackets become + // a continuous outline. Working along edges rather than in x/y keeps it correct at any + // rotation. + corners.forEachIndexed { index, corner -> + val next = corners[(index + 1) % corners.size] + val previous = corners[(index + corners.size - 1) % corners.size] + listOf(next, previous).forEach { neighbour -> + val edge = neighbour - corner + val length = edge.getDistance() + if (length <= 0f) return@forEach + val arm = (base + ((length / 2f) - base).coerceAtLeast(0f) * closure) + .coerceAtMost(length) + drawLine( + color = color, + start = corner, + end = corner + edge / length * arm, + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } } } } + From d75985d8622a3e3020d618a67d7afd9a29fa0748 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:11:40 +0100 Subject: [PATCH 38/53] docs(CLAUDE): refresh the tech stack and the camera module's description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 653181a..b863f69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ The library uses a layered module structure: **Barcode Scanning:** - `BarcodeScanner` - Shared `ScannedBarcode`/`BarcodeFormat` model plus `OneShotBarcodeScanner`, backed by the Play services hosted code scanner (no CameraX, no camera permission) -- `BarcodeScanner-Camera` - Continuous in-app scanning: CameraX preview + ML Kit analyzer, built on `BarcodeScanner` +- `BarcodeScanner-Camera` - Continuous in-app scanning: CameraX preview + ML Kit analyzer, built on `BarcodeScanner`. `ScanPolicy` controls dwell, single/multi tracking and the acceptance region; overlays receive a `ScannerOverlayScope` with live detections **Standalone Utilities:** - `UiState` - Sealed class for UI state (Idle/Loading/Success/Error) @@ -107,13 +107,13 @@ flow.collect { state -> ## Tech Stack -- Kotlin 2.4.10, AGP 9.3.2, Gradle 9.7.1 +- Kotlin 2.4.20, AGP 9.4.0, Gradle 9.7.1 - Target/Compile SDK 37, Java 11 -- Jetpack Compose BOM 2026.08.00 +- Jetpack Compose BOM 2026.09.00 - OkHttp 5.5.0, Retrofit 3.0.0 - Sandwich 2.4.0 (API response handling) -- Jetpack Paging 3.5.1, Room 2.8.4 -- androidx Navigation 3 1.2.0-alpha07 (Nav3Navigation module) +- Jetpack Paging 3.5.1, Room 2.8.5 +- androidx Navigation 3 1.2.0-rc01 (Nav3Navigation module) - kotlinx-serialization 1.11.0 ## Publishing From 969c8239f6e51687d12d46296835657dbcf0662b Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:13:56 +0100 Subject: [PATCH 39/53] docs: correct the licence to GPL-3.0 in the READMEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ConnectivityMonitor/README.md | 3 ++- README.md | 29 ++++++----------------------- 2 files changed, 8 insertions(+), 24 deletions(-) diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index f5a988c..74d5276 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -50,4 +50,5 @@ Don't forget to update your `AndroidManifest.xml` to use your custom application ## License -`ConnectivityMonitor` is released under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file +`ConnectivityMonitor` is released under the GNU General Public License v3.0, like the rest of the +toolbox. See the [LICENSE](../LICENSE) file for details. \ No newline at end of file diff --git a/README.md b/README.md index ca4ccd0..7258263 100644 --- a/README.md +++ b/README.md @@ -456,26 +456,9 @@ minified consuming app, including a parameterised `NavKey` round-tripping its ar ## License -```text -MIT License - -Copyright (c) 2025 Appoly Ltd - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` +AppolyDroid Toolbox is released under the **GNU General Public License v3.0**. See +[LICENSE](LICENSE) for the full text. + +This is the licence recorded in every published POM, so it is what a consumer resolving these +artifacts from Maven Central agrees to. Do not restate the terms here — a second copy is a second +thing to drift. From b9fa4e3ca79f39d9be21876644f1c6e9d1f2c913 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:16:37 +0100 Subject: [PATCH 40/53] docs(graphify): regenerate the graph report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- graphify-out/GRAPH_REPORT.md | 1358 ++++++++++++++++++++++++---------- 1 file changed, 955 insertions(+), 403 deletions(-) diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index a44ed3b..91e3249 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -1,522 +1,1074 @@ -# Graph Report - . (2026-06-09) +# Graph Report - AppolyDroid (2026-09-18) ## Corpus Check -- Large corpus: 293 files · ~149,834 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only. +- 371 files · ~209,399 words +- Verdict: corpus is large enough that graph structure adds value. +- Unclassified: 135 file(s) not represented in the graph (top: .pro 47, (none) 34, .xml 34) ## Summary -- 2117 nodes · 2680 edges · 205 communities (108 shown, 97 thin omitted) -- Extraction: 90% EXTRACTED · 10% INFERRED · 0% AMBIGUOUS · INFERRED: 278 edges (avg confidence: 0.8) -- Token cost: 0 input · 1,037,772 output +- 4184 nodes · 9199 edges · 237 communities (184 shown, 53 thin omitted) +- Extraction: 93% EXTRACTED · 7% INFERRED · 0% AMBIGUOUS · INFERRED: 618 edges (avg confidence: 0.87) +- Token cost: 0 input · 0 output + +## Graph Freshness +- Built from commit: `969c8239` +- Run `git rev-parse HEAD` and compare to check if the graph is stale. +- Run `graphify update .` after code changes (no API cost). ## Community Hubs (Navigation) -- [[_COMMUNITY_S3 Multipart API Models & Headers|S3 Multipart API Models & Headers]] -- [[_COMMUNITY_Multipart DAO Constraint Tests|Multipart DAO Constraint Tests]] -- [[_COMMUNITY_Demo App Screens & Navigation|Demo App Screens & Navigation]] -- [[_COMMUNITY_APIResult APIFlowState Core|APIResult / APIFlowState Core]] -- [[_COMMUNITY_SnackBar & Enum Serializers|SnackBar & Enum Serializers]] -- [[_COMMUNITY_BaseRepo Paging & Test Backend|BaseRepo Paging & Test Backend]] -- [[_COMMUNITY_Compose Animation & Mock Demo|Compose Animation & Mock Demo]] -- [[_COMMUNITY_Multipart Upload Config|Multipart Upload Config]] -- [[_COMMUNITY_UiState & Date Concepts|UiState & Date Concepts]] -- [[_COMMUNITY_Appoly JSON Envelope & Nested Paging|Appoly JSON Envelope & Nested Paging]] -- [[_COMMUNITY_S3 Progress Body & Lazy Paging|S3 Progress Body & Lazy Paging]] -- [[_COMMUNITY_Server Timestamp Format Tests|Server Timestamp Format Tests]] -- [[_COMMUNITY_Multipart Upload DAO|Multipart Upload DAO]] -- [[_COMMUNITY_MultipartUploadManager Lifecycle|MultipartUploadManager Lifecycle]] -- [[_COMMUNITY_RefreshableAPIFlow & Composables|RefreshableAPIFlow & Composables]] -- [[_COMMUNITY_MockInterceptor Core|MockInterceptor Core]] -- [[_COMMUNITY_Lazy GridList Paging Entry Points|Lazy Grid/List Paging Entry Points]] -- [[_COMMUNITY_Mock Appoly JSON Envelopes|Mock Appoly JSON Envelopes]] -- [[_COMMUNITY_GenericBaseRepo doAPICall Tests|GenericBaseRepo doAPICall Tests]] -- [[_COMMUNITY_ConnectivityMonitor Tests|ConnectivityMonitor Tests]] -- [[_COMMUNITY_MockInterceptor Demo ViewModel|MockInterceptor Demo ViewModel]] -- [[_COMMUNITY_Demo Bootstrap & Upload Callbacks|Demo Bootstrap & Upload Callbacks]] -- [[_COMMUNITY_Multipart Worker & API URLs|Multipart Worker & API URLs]] -- [[_COMMUNITY_BaseRepo S3 Multipart Constraints|BaseRepo S3 Multipart Constraints]] -- [[_COMMUNITY_Date Serializers (kotlinx)|Date Serializers (kotlinx)]] -- [[_COMMUNITY_MockInterceptor Response Tests|MockInterceptor Response Tests]] -- [[_COMMUNITY_Multipart RequestResponse Models|Multipart Request/Response Models]] -- [[_COMMUNITY_DateHelper Legacy Format Tests|DateHelper Legacy Format Tests]] -- [[_COMMUNITY_Paging Source & Date Rationale|Paging Source & Date Rationale]] -- [[_COMMUNITY_AppolyRepo & Date Regression Fix|AppolyRepo & Date Regression Fix]] -- [[_COMMUNITY_ConnectivityMonitor & Mock|ConnectivityMonitor & Mock]] -- [[_COMMUNITY_APIFlowState Sealed Class|APIFlowState Sealed Class]] -- [[_COMMUNITY_Multipart Upload Demo ViewModel|Multipart Upload Demo ViewModel]] -- [[_COMMUNITY_Naive DateTime Tests|Naive DateTime Tests]] -- [[_COMMUNITY_Appoly Response & Lazy State Items|Appoly Response & Lazy State Items]] -- [[_COMMUNITY_ConnectivityMonitor Application|ConnectivityMonitor Application]] -- [[_COMMUNITY_Mock Route Builder|Mock Route Builder]] -- [[_COMMUNITY_Mock Retrofit Annotation Tests|Mock Retrofit Annotation Tests]] -- [[_COMMUNITY_Paging State Providers|Paging State Providers]] -- [[_COMMUNITY_NetworkInterceptor & APIResult Tests|NetworkInterceptor & APIResult Tests]] -- [[_COMMUNITY_Nested Paged Response (AppolyJson)|Nested Paged Response (AppolyJson)]] -- [[_COMMUNITY_DateHelper Core|DateHelper Core]] -- [[_COMMUNITY_Date Log Attribution Tests|Date Log Attribution Tests]] -- [[_COMMUNITY_Appoly BaseResponse Tests|Appoly BaseResponse Tests]] -- [[_COMMUNITY_GenericPagingSource|GenericPagingSource]] -- [[_COMMUNITY_S3Uploader Standalone Core|S3Uploader Standalone Core]] -- [[_COMMUNITY_PagingData Bridge|PagingData Bridge]] -- [[_COMMUNITY_DateHelper Extensions|DateHelper Extensions]] -- [[_COMMUNITY_APIResult Sealed Class|APIResult Sealed Class]] -- [[_COMMUNITY_S3 Upload WorkManager|S3 Upload WorkManager]] -- [[_COMMUNITY_ServiceManager & Retrofit Client|ServiceManager & Retrofit Client]] -- [[_COMMUNITY_ComposeExtensions|ComposeExtensions]] -- [[_COMMUNITY_UiState Sealed Class|UiState Sealed Class]] -- [[_COMMUNITY_UiState Tests|UiState Tests]] -- [[_COMMUNITY_APIFlowState Tests|APIFlowState Tests]] -- [[_COMMUNITY_DateHelper Extras Tests|DateHelper Extras Tests]] -- [[_COMMUNITY_Room Date Converter Tests|Room Date Converter Tests]] -- [[_COMMUNITY_Room Date Converters|Room Date Converters]] -- [[_COMMUNITY_Multipart Retrofit Client Tests|Multipart Retrofit Client Tests]] -- [[_COMMUNITY_Upload Lifecycle Callbacks|Upload Lifecycle Callbacks]] -- [[_COMMUNITY_Instant Serializer Tests|Instant Serializer Tests]] -- [[_COMMUNITY_Appoly BaseResponse Handling|Appoly BaseResponse Handling]] -- [[_COMMUNITY_APIFlowState Cache Tests|APIFlowState Cache Tests]] -- [[_COMMUNITY_Silent Test Loggers|Silent Test Loggers]] -- [[_COMMUNITY_DateHelper Extensions Tests|DateHelper Extensions Tests]] -- [[_COMMUNITY_Date Serializers Tests|Date Serializers Tests]] -- [[_COMMUNITY_Lazy List Paging Entry Tests|Lazy List Paging Entry Tests]] -- [[_COMMUNITY_Lazy Grid Paging States|Lazy Grid Paging States]] -- [[_COMMUNITY_Lazy Grid Paging Entry Tests|Lazy Grid Paging Entry Tests]] -- [[_COMMUNITY_Multipart Manager & DB Entities|Multipart Manager & DB Entities]] -- [[_COMMUNITY_PageData Tests|PageData Tests]] -- [[_COMMUNITY_StringOrList Serialiser Tests|StringOrList Serialiser Tests]] -- [[_COMMUNITY_Silent Test Loggers (Date)|Silent Test Loggers (Date)]] -- [[_COMMUNITY_Network Model Serialization Tests|Network Model Serialization Tests]] -- [[_COMMUNITY_Multipart Config Group|Multipart Config Group]] -- [[_COMMUNITY_app (misc)|app (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_LazyListPagingExtensions (misc)|LazyListPagingExtensions (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_app (misc)|app (misc)]] -- [[_COMMUNITY_BaseRepo-Paging (misc)|BaseRepo-Paging (misc)]] -- [[_COMMUNITY_ConnectivityMonitor (misc)|ConnectivityMonitor (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_DateHelperUtil (misc)|DateHelperUtil (misc)]] -- [[_COMMUNITY_BaseRepo-AppolyJson (misc)|BaseRepo-AppolyJson (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_PagingExtensions (misc)|PagingExtensions (misc)]] -- [[_COMMUNITY_MockInterceptor (misc)|MockInterceptor (misc)]] -- [[_COMMUNITY_LazyGridPagingExtensions (misc)|LazyGridPagingExtensions (misc)]] -- [[_COMMUNITY_MockInterceptor-Retrofit (misc)|MockInterceptor-Retrofit (misc)]] -- [[_COMMUNITY_ConnectivityMonitor (misc)|ConnectivityMonitor (misc)]] -- [[_COMMUNITY_buildSrc (misc)|buildSrc (misc)]] -- [[_COMMUNITY_buildSrc (misc)|buildSrc (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_ComposeExtensions (misc)|ComposeExtensions (misc)]] -- [[_COMMUNITY_ComposeExtensions (misc)|ComposeExtensions (misc)]] -- [[_COMMUNITY_AppSnackBar-UiState (misc)|AppSnackBar-UiState (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_PagingExtensions (misc)|PagingExtensions (misc)]] -- [[_COMMUNITY_BaseRepo-Paging (misc)|BaseRepo-Paging (misc)]] -- [[_COMMUNITY_BaseRepo-Paging (misc)|BaseRepo-Paging (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_PagingExtensions (misc)|PagingExtensions (misc)]] -- [[_COMMUNITY_LazyListPagingExtensions (misc)|LazyListPagingExtensions (misc)]] -- [[_COMMUNITY_LazyListPagingExtensions (misc)|LazyListPagingExtensions (misc)]] -- [[_COMMUNITY_LazyGridPagingExtensions (misc)|LazyGridPagingExtensions (misc)]] -- [[_COMMUNITY_LazyListPagingExtensions (misc)|LazyListPagingExtensions (misc)]] -- [[_COMMUNITY_LazyGridPagingExtensions (misc)|LazyGridPagingExtensions (misc)]] -- [[_COMMUNITY_MockInterceptor (misc)|MockInterceptor (misc)]] -- [[_COMMUNITY_PagingExtensions (misc)|PagingExtensions (misc)]] -- [[_COMMUNITY_ComposeExtensions (misc)|ComposeExtensions (misc)]] -- [[_COMMUNITY_app (misc)|app (misc)]] -- [[_COMMUNITY_BaseRepo-Paging (misc)|BaseRepo-Paging (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_LazyGridPagingExtensions (misc)|LazyGridPagingExtensions (misc)]] -- [[_COMMUNITY_DateHelperUtil (misc)|DateHelperUtil (misc)]] -- [[_COMMUNITY_ComposeExtensions (misc)|ComposeExtensions (misc)]] -- [[_COMMUNITY_buildSrc (misc)|buildSrc (misc)]] -- [[_COMMUNITY_BaseRepo-S3Uploader-Multipart (misc)|BaseRepo-S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_app (misc)|app (misc)]] -- [[_COMMUNITY_BaseRepo-Paging (misc)|BaseRepo-Paging (misc)]] -- [[_COMMUNITY_ConnectivityMonitor (misc)|ConnectivityMonitor (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_BaseRepo-AppolyJson (misc)|BaseRepo-AppolyJson (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_DateHelperUtil (misc)|DateHelperUtil (misc)]] -- [[_COMMUNITY_build.gradle.kts (misc)|build.gradle.kts (misc)]] -- [[_COMMUNITY_ComposeExtensions (misc)|ComposeExtensions (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_S3Uploader-Multipart (misc)|S3Uploader-Multipart (misc)]] -- [[_COMMUNITY_BaseRepo (misc)|BaseRepo (misc)]] -- [[_COMMUNITY_S3Uploader (misc)|S3Uploader (misc)]] -- [[_COMMUNITY_build.gradle.kts (misc)|build.gradle.kts (misc)]] -- [[_COMMUNITY_ComposeExtensions (misc)|ComposeExtensions (misc)]] -- [[_COMMUNITY_MockInterceptor-Serialization (misc)|MockInterceptor-Serialization (misc)]] -- [[_COMMUNITY_SegmentedControl (misc)|SegmentedControl (misc)]] +- test +- Nav3TestActivity.kt +- serializable +- S3Uploader.uploadFile +- androidjunit4 +- MultipartUploadWorkerTest.kt +- MultipartUploadDemoScreen +- SegmentedControl.kt +- README.md +- TestBackendRepository +- composable +- Nav3ScreenHost +- BarcodeScannerCamera.kt +- DetailScreen +- DateHelperServerTimestampTest +- MockApiInterceptorTest +- LazyGridScope.lazyPagingItemsStates +- ClipboardCopier.kt +- Nav3TabsHost +- DateSerializers +- GenericBaseRepoMockWebServerTest +- MultipartUploadWorker +- logginglevel +- NavKey +- MultipartUploadResult +- MultipartUploadManager +- MultipartUploadDao +- jvmtarget +- GenericPagingSource +- FlexiLog +- MockApiInterceptor +- MockInterceptorDemoViewModel +- DefaultUploadNotificationProvider +- PageData +- TabsNav3Navigator +- BackStackNav3NavigatorTest +- ScanRegionResolver.kt +- FilePartRequestBodyTest +- MultipartUploadConfig +- AppSnackBar +- GenericBaseRepoTest +- MultipartUploadProgress +- AnimatedScanFrame.kt +- Nav3Navigator +- AppolyJsonDemoViewModel.kt +- ProgressRequestBody +- lazyPagingItemsIndexedStatesWithNeighbours +- MultipartUploadDemoViewModel +- NoConnectivityException +- pagedBody +- Modules +- ComposeExtensions.kt +- Nav3TabsRetentionTest.kt +- OneShotBarcodeScanner +- EmptyArrayAsEmptyMapSerializer.kt +- API surface +- MultipartUploadManagerTest +- BaseRepoDemoViewModel +- DateSerializersTest +- TabsDemoScreen.kt +- file +- BaseResponse +- DateHelperNaiveAliasTest +- DateHelperLegacyBehaviourTest +- MultipartApiServiceTest +- AnimatedVisibility.kt +- MockRetrofitTest +- Nav3ResultsTest +- MultipartUploadDaoTest +- UploadSessionStatus +- DemoDatabase.kt +- DateSerializationRoomDemoViewModel.kt +- SegmentedControl +- BarcodeFormat +- UploadConstraints +- AppolyBaseRepo.kt +- GenericNestedPagedResponse +- APIFlowStateTest +- ConnectivityMonitorApplication.kt +- MockSerializationTest +- MultipartUploadManager.kt +- OneShotBarcodeScanner.kt +- ScannedBarcode +- APIFlowState.kt +- AppolyBaseRepoTest +- .tracker +- ConnectivityMonitorApplicationTest +- UploadResult +- nav3HostViewModelStoreOwner +- MultipartApiService.kt +- SerializableMutableState +- RefreshableAPIFlow +- ConnectivityMonitorApplication +- DateHelper +- TestBackendRepository.kt +- SegmentedControlDemoScreen +- SegmentedControl +- MockRouteBuilder +- MockAppolyJsonTest +- RetrofitClient +- MultipartApis +- AppSnackBar +- AppolyBaseRepoS3MultipartExtensions.kt +- APIResult.kt +- Module Architecture +- BaseRetrofitClient +- ErrorState.kt +- transientMutableStateOf +- DateHelperExtensions.kt +- MultipartRetrofitClient +- ProgressRequestBodyTest +- clear-local-publish.sh +- GenericInvalidatingPagingSourceFactory.kt +- ConnectivityMonitorApplicationTest.kt +- DateHelper.kt +- LazyListPagingEntryPointsTest +- TabsSceneStrategy +- BarcodeScanner-Camera +- APIFlowStatePagingExtensions.kt +- NetworkTransportType +- .centreStartTabs +- TabsSceneStrategyTest +- MockRequestContext +- PagingExtensionsTest +- .`lifecycle hooks delegate to the configured callbacks` +- PagingDemoViewModel +- RefreshableAPIFlow.kt +- TransferRateTrackerTest +- MainActivity.kt +- BarcodeFormatTest +- APIFlowState +- DateHelperUtil-Room +- DateHelperIsoToleranceTest +- lazyPagingItemsIndexedStatesWithNeighbours +- ZonedDateTime.toUTC +- ComposeExtensions +- UpdateReadmeVersions +- S3UploadWorkManager +- RecordingTestLogger +- LazyGridPagingItemsStatesTest +- lazyPagingItemsIndexedStatesWithNeighbours +- LazyPagingItemsStatesTest +- MultipartUploadDaoConstraintsTest +- publish.sh +- Usage +- MultipartApis.kt +- serializableMutableStateOf +- .seedActiveValidatedNetwork +- lazyPagingItemsStates +- LazyListScope.lazyPagingItemsStates +- AppolyDroid Toolbox +- GetPreSignedUrlResponse +- MultipartUploadManager +- ScannerOverlayScope.kt +- EnumSerializersTest +- EnumAsStringSerializer +- NullableEnumAsIntSerializer.kt +- DateHelperExtensionsTest +- Kover report task wiring +- serialname +- EnumAsIntSerializer +- ConnectivityLogger +- DateHelperUtil-Serialization +- PagingSource +- LazyListAllOverloadsTest +- LazyPagingItemsStatesOverloadsTest +- TestScreens.kt +- S3UploaderTest +- ThumbSelectionTracker +- BaseRepoLogger +- Extensions.kt +- NullableEnumAsStringSerializer.kt +- .successRoot +- FlowRepo +- getActivity +- DateHelperLogAttributionTest +- DBDateConverters +- DBDateConvertersInstantTest +- InstantSerializerTest +- DateHelperExtrasTest +- LazyGridLoadingStateItem.kt +- lazyPagingItemsStatesWithNeighbours +- MultipartRetrofitClientTest +- Log +- Nav3NavigationDemoScreen +- BaseAppolyRepoLogger +- SilentTestLogger +- SilentTestLogger +- SilentTestLogger +- APIFlowStateCacheTest +- .success +- SilentTestLogger +- SilentTestLogger +- DateHelperLogger +- SilentTestLogger +- MockInterceptorLogger +- Nav3RetentionScopeTest +- S3UploadWorkManagerTest +- firstNotNullOrBlank +- APIResult +- S3Uploader +- Centralised unit-test testOptions +- APIFlowStatePagingExtensionsTest +- SilentTestLogger +- Features +- DateHelper.parseServerInstant +- PagingDataDistinctExtensionsTest +- UploadConstraintsTest +- NetworkModelsSerializationTest +- StringOrListSerialiserTest +- AppolyBaseRepoS3Extensions.kt +- Reticle +- MultipartUploadResultTest +- UploadResultTest +- .Content +- AppSnackBarExtensionsTest +- PaddingValuesPlusTest +- gradlew +- Q: How do UploadResult and APIResult relate? Should they converge or is the decoupling deliberate? +- Q: Are LazyGridPagingExtensions and LazyListPagingExtensions duplicated code that should be DRYed up? +- Log (FlexiLog) +- AppolyJsonDemoScreen +- .Content +- DateSerializationRoomDemoScreen +- SessionWithParts.kt +- build.gradle.kts +- AppolyBaseRepo +- MockInterceptor-AppolyJson +- jsonBody +- PageSlice +- paginate ## God Nodes (most connected - your core abstractions) -1. `DateHelperServerTimestampTest` - 38 edges -2. `MultipartUploadDao` - 37 edges -3. `MultipartUploadManager` - 28 edges -4. `ConnectivityMonitorApplicationTest` - 27 edges -5. `MockApiInterceptorTest` - 24 edges -6. `DateHelperLegacyBehaviourTest` - 23 edges -7. `GenericBaseRepoTest` - 22 edges -8. `MultipartUploadDemoViewModel` - 22 edges -9. `DateHelperNaiveAliasTest` - 22 edges -10. `MultipartUploadManagerTest` - 22 edges +1. `MultipartUploadDemoScreen` - 80 edges +2. `APIResult` - 69 edges +3. `MockInterceptorDemoViewModel` - 64 edges +4. `DetailScreen` - 59 edges +5. `MultipartUploadManager` - 53 edges +6. `MultipartUploadWorker` - 53 edges +7. `MultipartUploadDao` - 51 edges +8. `SegmentedControlDemoScreen` - 50 edges +9. `MultipartUploadDemoViewModel` - 46 edges +10. `SnackBarDemoScreen` - 45 edges ## Surprising Connections (you probably didn't know these) -- `LazyGridScope.lazyPagingItemsIndexedStates` --semantically_similar_to--> `LazyListScope.lazyPagingItemsIndexedStates` [INFERRED] [semantically similar] - LazyGridPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/LazyGridPagingItemsIndexedStates.kt → LazyListPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/LazyPagingItemsIndexedStates.kt -- `LazyGridScope.errorStateItem` --semantically_similar_to--> `LazyListScope.errorStateItem` [INFERRED] [semantically similar] - LazyGridPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/LazyGridErrorStateItem.kt → LazyListPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/ErrorStateItem.kt -- `LazyGridScope.emptyStateItem` --semantically_similar_to--> `LazyListScope.emptyStateItem` [INFERRED] [semantically similar] - LazyGridPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/LazyGridEmptyStateItem.kt → LazyListPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/EmptyStateItem.kt -- `LazyGridScope.lazyPagingItemsStatesWithNeighbours` --semantically_similar_to--> `LazyListScope.lazyPagingItemsStatesWithNeighbours` [INFERRED] [semantically similar] - LazyGridPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/LazyGridPagingItemsStatesWithNeighbours.kt → LazyListPagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/LazyPagingItemsStatesWithNeighbours.kt -- `UploadResult` --semantically_similar_to--> `APIResult` [INFERRED] [semantically similar] - S3Uploader/src/main/java/uk/co/appoly/droid/s3upload/UploadResult.kt → BaseRepo/src/main/java/uk/co/appoly/droid/data/remote/model/APIResult.kt +- `Nav3Navigation` --references--> `Nav3ScreenHost()` [INFERRED] + README.md → Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/Nav3ScreenHost.kt +- `Module Architecture` --references--> `AppSnackBar()` [INFERRED] + CLAUDE.md → AppSnackBar/src/main/java/uk/co/appoly/droid/ui/snackbar/AppSnackBar.kt +- `Module Architecture` --references--> `ScanPolicy` [INFERRED] + CLAUDE.md → BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt +- `Module Architecture` --references--> `ScannerOverlayScope` [INFERRED] + CLAUDE.md → BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt +- `Module Architecture` --references--> `BarcodeFormat` [INFERRED] + CLAUDE.md → BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/BarcodeFormat.kt + +## Import Cycles +- None detected. ## Hyperedges (group relationships) -- **Multipart Upload State Persistence** — multipartuploadmanager_multipartuploadmanager, s3uploaderdatabase_s3uploaderdatabase, uploadsessionentity_uploadsessionentity, uploadstatusconverters_uploadstatusconverters [INFERRED 0.85] -- **Multipart S3 upload API lifecycle (initiate, presign, complete, abort)** — multipartapiservice_class, multipartapis_interface, initiatemultipartrequest_class, presignpartrequest_class, completemultipartrequest_class, abortmultipartrequest_class [INFERRED 0.85] -- **WorkManager scheduling driven by upload constraints and config** — s3uploadworkmanager_object, uploadconstraints_class, uploadnetworktype_enum, multipartuploadconfig_class, multipartuploadworker_class [INFERRED 0.85] -- **Room persistence of upload sessions and parts** — multipartuploaddao_interface, uploadsessionentity_class, uploadpartentity_class, sessionwithparts_class, partuploadstatus_enum, uploadsessionstatus_enum [INFERRED 0.85] -- **Multipart Upload Worker Customization** — worker_multipartuploadworker, interfaces_uploadlifecyclecallbacks, interfaces_uploadnotificationprovider, interfaces_defaultuploadnotificationprovider [EXTRACTED 1.00] -- **Multipart Upload Demo Flow** — viewmodels_multipartuploaddemoviewmodel, data_testbackendrepository, multipart_multipartuploadmanager, result_multipartuploadresult [INFERRED 0.85] -- **Demo App Screen Navigation** — app_mainactivity, navigation_appnavigation, screens_baserepodemoscreen, screens_pagingdemoscreen, screens_mockinterceptordemoscreen [EXTRACTED 1.00] -- **Multipart upload demo flow** — screens_multipartuploaddemoscreen, viewmodels_multipartuploaddemoviewmodel, data_testbackendrepository, s3upload_multipartuploadmanager, network_authapi [INFERRED 0.85] -- **BaseRepo APIResult/APIFlowState test suite** — test_genericbaserepotest, test_apiresulttest, test_apiflowstatetest, test_refreshableapiflowtest, test_apiflowstatecachetest [EXTRACTED 1.00] -- **Test backend Retrofit/repo/API stack** — network_testbackendretrofitclient, data_testbackendrepository, network_authapi, remote_baseretrofitclient [EXTRACTED 1.00] -- **API call to result-state pipeline** — genericbaserepo_doapicall, apiresult_apiresult, apiflowstate_apiflowstate, apiflowstate_asapiflowstate, refreshableapiflow_refreshableapiflow [INFERRED 0.85] -- **Service resolution chain** — genericbaserepo_genericbaserepo, servicemanager_servicemanager, servicemanager_baseservice, servicemanager_baseretrofitclient [INFERRED 0.85] -- **Appoly JSON response envelope** — rootjson_rootjson, rootjsonwithdata_rootjsonwithdata, genericbaserepo_doapicall [INFERRED 0.85] -- **S3 pre-signed URL upload flow** — s3upload_api_service, s3upload_apis, s3upload_retrofit_client, s3upload_progress_request_body [INFERRED 0.75] +- **Multipart Upload State Persistence** — s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_multipartuploadmanager_multipartuploadmanager, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_database_s3uploaderdatabase_s3uploaderdatabase, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_database_entity_uploadsessionentity_uploadsessionentity, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_database_converter_uploadstatusconverters_uploadstatusconverters [INFERRED 0.85] +- **Multipart S3 upload API lifecycle (initiate, presign, complete, abort)** — s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_network_multipartapiservice_multipartapiservice, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_network_multipartapis_multipartapis, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_network_model_initiatemultipartrequest_initiatemultipartrequest, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_network_model_presignpartrequest_presignpartrequest, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_network_model_completemultipartrequest_completemultipartrequest, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_network_model_abortmultipartrequest_abortmultipartrequest [INFERRED 0.85] +- **WorkManager scheduling driven by upload constraints and config** — s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_worker_s3uploadworkmanager_s3uploadworkmanager, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_config_uploadconstraints_uploadconstraints, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_config_uploadconstraints_uploadnetworktype, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_config_multipartuploadconfig_multipartuploadconfig, multipartuploadworker_class [INFERRED 0.85] +- **Room persistence of upload sessions and parts** — s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_database_dao_multipartuploaddao_multipartuploaddao, uploadsessionentity_class, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_database_entity_uploadpartentity_uploadpartentity, sessionwithparts_class, partuploadstatus_enum, uploadsessionstatus_enum [INFERRED 0.85] +- **Multipart Upload Worker Customization** — s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_worker_multipartuploadworker_multipartuploadworker, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_interfaces_uploadlifecyclecallbacks_uploadlifecyclecallbacks, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_interfaces_uploadnotificationprovider_uploadnotificationprovider, s3uploader_multipart_src_main_java_uk_co_appoly_droid_s3upload_multipart_interfaces_defaultuploadnotificationprovider_defaultuploadnotificationprovider [EXTRACTED 1.00] +- **Multipart Upload Demo Flow** — app_src_main_java_uk_co_appoly_droid_ui_viewmodels_multipartuploaddemoviewmodel_multipartuploaddemoviewmodel, data_testbackendrepository, multipart_multipartuploadmanager, result_multipartuploadresult [INFERRED 0.85] +- **Demo App Screen Navigation** — app_src_main_java_uk_co_appoly_droid_mainactivity_mainactivity, app_src_main_java_uk_co_appoly_droid_ui_navigation_appnavigation, app_src_main_java_uk_co_appoly_droid_ui_screens_baserepodemoscreen_baserepodemoscreen, app_src_main_java_uk_co_appoly_droid_ui_screens_pagingdemoscreen_pagingdemoscreen, app_src_main_java_uk_co_appoly_droid_ui_screens_mockinterceptordemoscreen_mockinterceptordemoscreen [EXTRACTED 1.00] +- **Multipart upload demo flow** — app_src_main_java_uk_co_appoly_droid_ui_screens_multipartuploaddemoscreen_multipartuploaddemoscreen, app_src_main_java_uk_co_appoly_droid_ui_viewmodels_multipartuploaddemoviewmodel_multipartuploaddemoviewmodel, data_testbackendrepository, s3upload_multipartuploadmanager, app_src_main_java_uk_co_appoly_droid_network_testbackendapis_authapi [INFERRED 0.85] +- **BaseRepo APIResult/APIFlowState test suite** — baserepo_src_test_java_uk_co_appoly_droid_data_repo_genericbaserepotest_genericbaserepotest, baserepo_src_test_java_uk_co_appoly_droid_data_remote_model_apiresulttest_apiresulttest, baserepo_src_test_java_uk_co_appoly_droid_data_repo_apiflowstatetest_apiflowstatetest, baserepo_src_test_java_uk_co_appoly_droid_data_repo_refreshableapiflowtest_refreshableapiflowtest, baserepo_src_test_java_uk_co_appoly_droid_data_repo_apiflowstatecachetest_apiflowstatecachetest [EXTRACTED 1.00] +- **Test backend Retrofit/repo/API stack** — app_src_main_java_uk_co_appoly_droid_network_testbackendretrofitclient_testbackendretrofitclient, data_testbackendrepository, app_src_main_java_uk_co_appoly_droid_network_testbackendapis_authapi, remote_baseretrofitclient [EXTRACTED 1.00] +- **API call to result-state pipeline** — baserepo_src_main_java_uk_co_appoly_droid_data_repo_genericbaserepo_doapicall, baserepo_src_main_java_uk_co_appoly_droid_data_remote_model_apiresult_apiresult, baserepo_src_main_java_uk_co_appoly_droid_data_repo_apiflowstate_apiflowstate, baserepo_src_main_java_uk_co_appoly_droid_data_repo_apiflowstate_asapiflowstate, baserepo_src_main_java_uk_co_appoly_droid_data_repo_refreshableapiflow_refreshableapiflow [INFERRED 0.85] +- **Service resolution chain** — baserepo_src_main_java_uk_co_appoly_droid_data_repo_genericbaserepo_genericbaserepo, baserepo_src_main_java_uk_co_appoly_droid_data_remote_servicemanager_servicemanager, baserepo_src_main_java_uk_co_appoly_droid_data_remote_servicemanager_servicemanager_makebaseservice_object_baseservice_l56, baserepo_src_main_java_uk_co_appoly_droid_data_remote_servicemanager_baseretrofitclient [INFERRED 0.85] +- **Appoly JSON response envelope** — baserepo_src_main_java_uk_co_appoly_droid_data_remote_model_response_rootjson_rootjson, baserepo_src_main_java_uk_co_appoly_droid_data_remote_model_response_rootjsonwithdata_rootjsonwithdata, baserepo_src_main_java_uk_co_appoly_droid_data_repo_genericbaserepo_doapicall [INFERRED 0.85] +- **S3 pre-signed URL upload flow** — s3uploader_src_main_java_uk_co_appoly_droid_s3upload_network_apiservice_apiservice, s3uploader_src_main_java_uk_co_appoly_droid_s3upload_network_api_apis_apis, s3uploader_src_main_java_uk_co_appoly_droid_s3upload_network_retrofitclient_retrofitclient, s3uploader_src_main_java_uk_co_appoly_droid_s3upload_network_progressrequestbody_progressrequestbody [INFERRED 0.75] - **LazyGrid paging entry points** — lazygrid_paging_items, lazygrid_paging_items_with_neighbours, lazygrid_loading_state_item [INFERRED 0.75] - **Grid paging state dispatch (loading/error/empty/items)** — grid_lazypagingitemsstates, grid_errorstateitem, grid_emptystateitem, grid_loadingstateitem [EXTRACTED 1.00] - **Parallel grid/list paging extension implementations** — grid_lazypagingitemsstates, list_lazypagingitemsstates, grid_lazypagingitemsstateswithneighbours, list_lazypagingitemsstateswithneighbours [INFERRED 0.85] -- **BaseRepo paging pipeline: RootJsonPage to PageData to PagingSource** — response_root_json_page, response_page_data, paging_generic_paging_source, paging_do_paged_api_call [INFERRED 0.85] -- **DateHelper naive helpers migration and backward-compat tests** — util_date_helper, test_date_helper_naive_alias, test_date_helper_legacy, datehelper_literal_z_backward_compat [INFERRED 0.85] +- **BaseRepo paging pipeline: RootJsonPage to PageData to PagingSource** — baserepo_paging_src_main_java_uk_co_appoly_droid_data_remote_model_response_rootjsonpage_rootjsonpage, baserepo_paging_src_main_java_uk_co_appoly_droid_data_remote_model_response_pagedata_pagedata, baserepo_paging_src_main_java_uk_co_appoly_droid_data_repo_paging_genericpagingsource_genericpagingsource, paging_do_paged_api_call [INFERRED 0.85] +- **DateHelper naive helpers migration and backward-compat tests** — util_date_helper, datehelperutil_src_test_java_uk_co_appoly_droid_util_datehelpernaivealiastest_datehelpernaivealiastest, datehelperutil_src_test_java_uk_co_appoly_droid_util_datehelperlegacybehaviourtest_datehelperlegacybehaviourtest, datehelper_literal_z_backward_compat [INFERRED 0.85] - **Lazy paging state item composables** — paging_loading_state_item, paging_error_state_item, paging_empty_state_item [INFERRED 0.75] -- **Appoly JSON response envelope models** — baseresponse_model, genericresponse_model, errorbody_model, rootjson_interface [EXTRACTED 0.95] -- **Type-safe UTC server timestamp I/O** — datehelper_formatservertimestamp, datehelper_parseserverinstant, datehelper_parseserverzonedatetime, datehelper_server_pattern_full_offset [EXTRACTED 0.95] -- **MockInterceptor route-handler DSL** — mockapiinterceptor_class, mockrequestcontext_class, mockresponsebuilder_class [EXTRACTED 0.95] -- **Customizable paging UI state providers via CompositionLocal** — pagingextensions_loadingstateprovider, pagingextensions_errorstateprovider, pagingextensions_emptystatetextprovider, pagingextensions_compositionlocal_rationale [INFERRED 0.85] -- **Mock request matching and response flow** — mockinterceptor_mockapiinterceptor, mockinterceptor_mockroute, mockinterceptor_mockrequestcontext, mockinterceptor_mockresponsebuilder [INFERRED 0.85] -- **Network transport state tracking and change events** — connectivitymonitor_app, connectivitymonitor_transporttype, connectivitymonitor_typechangedevent, connectivitymonitor_restoredevent [INFERRED 0.85] -- **Appoly JSON envelope model family** — appolyenvelopemodels_appolybaseenvelope, appolyenvelopemodels_appolysuccessenvelope, appolyenvelopemodels_appolypagedenvelope, appolyenvelopemodels_appolynestedpagedata [EXTRACTED 1.00] -- **Mock Appoly JSON response builders** — mockappolyjsonhelpers_successbody, mockappolyjsonhelpers_successmessage, mockappolyjsonhelpers_errorbody, mockappolypaginationhelpers_pagedbody [EXTRACTED 1.00] -- **IME-aware Compose inset modifiers** — composeextensions_navigationbarsorimepadding, composeextensions_navigationbarsornoneifimepadding, composeextensions_hidewithime [INFERRED 0.85] -- **kotlinx serializers delegate to DateHelper** — util_localdateserializer, util_datetimeserializer, util_instantserializer, util_datehelper [INFERRED 0.85] -- **Room and Serialization variants share DateHelper wire formats** — util_dbdateconverters, util_dateserializers, util_datehelper [INFERRED 0.75] -- **SegmentedControl composed of defaults, colors and text styles** — segmentedcontrol_segmentedcontrol, segmentedcontrol_segmentedcontroldefaults, segmentedcontrol_segmentedcontrolcolors, segmentedcontrol_segmentedcontroltextstyle [EXTRACTED 1.00] -- **AppSnackBar-UiState bridge** — snackbar_snackbartype_property, ui_uistate, snackbar_snackbartype [EXTRACTED 1.00] -- **Retrofit annotation mock route resolution** — mock_mockapi, mock_mockapibuilder, mock_extractretrofitroute, mock_routebuilder [EXTRACTED 1.00] -- **buildSrc version-management tooling** — build_buildconfig, build_toolbox_version, build_updatereadmeversions [EXTRACTED 1.00] +- **Appoly JSON response envelope models** — baserepo_appolyjson_src_main_java_uk_co_appoly_droid_data_remote_model_response_baseresponse_baseresponse, baserepo_appolyjson_src_main_java_uk_co_appoly_droid_data_remote_model_response_genericresponse_genericresponse, baserepo_appolyjson_src_main_java_uk_co_appoly_droid_data_remote_model_response_errorbody_errorbody, rootjson_interface [EXTRACTED 0.95] +- **Type-safe UTC server timestamp I/O** — datehelperutil_src_main_java_uk_co_appoly_droid_util_datehelper_formatservertimestamp, datehelperutil_src_main_java_uk_co_appoly_droid_util_datehelper_parseserverinstant, datehelperutil_src_main_java_uk_co_appoly_droid_util_datehelper_parseserverzonedatetime, datehelperutil_src_main_java_uk_co_appoly_droid_util_datehelper_server_pattern_full_offset [EXTRACTED 0.95] +- **MockInterceptor route-handler DSL** — mockapiinterceptor_class, mockinterceptor_src_main_java_uk_co_appoly_droid_mockinterceptor_mockrequestcontext_mockrequestcontext, mockinterceptor_src_main_java_uk_co_appoly_droid_mockinterceptor_mockresponsebuilder_mockresponsebuilder [EXTRACTED 0.95] +- **Customizable paging UI state providers via CompositionLocal** — pagingextensions_src_main_java_uk_co_appoly_droid_ui_paging_loadingstate_loadingstateprovider, pagingextensions_src_main_java_uk_co_appoly_droid_ui_paging_errorstate_errorstateprovider, pagingextensions_src_main_java_uk_co_appoly_droid_ui_paging_emptystatetext_emptystatetextprovider, pagingextensions_compositionlocal_rationale [INFERRED 0.85] +- **Mock request matching and response flow** — mockinterceptor_src_main_java_uk_co_appoly_droid_mockinterceptor_mockapiinterceptor_mockapiinterceptor, mockinterceptor_src_main_java_uk_co_appoly_droid_mockinterceptor_mockroute_mockroute, mockinterceptor_mockrequestcontext, mockinterceptor_mockresponsebuilder [INFERRED 0.85] +- **Network transport state tracking and change events** — connectivitymonitor_src_main_java_uk_co_appoly_droid_connectivitymonitorapplication_connectivitymonitorapplication, connectivitymonitor_src_main_java_uk_co_appoly_droid_networktransporttype_networktransporttype, connectivitymonitor_src_main_java_uk_co_appoly_droid_networktypechangedevent_networktypechangedevent, connectivitymonitor_src_main_java_uk_co_appoly_droid_connectivitymonitorapplication_connectivityrestoredevent [INFERRED 0.85] +- **kotlinx serializers delegate to DateHelper** — datehelperutil_serialization_src_main_java_uk_co_appoly_droid_util_dateserializers_localdateserializer, datehelperutil_serialization_src_main_java_uk_co_appoly_droid_util_dateserializers_datetimeserializer, datehelperutil_serialization_src_main_java_uk_co_appoly_droid_util_dateserializers_instantserializer, util_datehelper [INFERRED 0.85] +- **Room and Serialization variants share DateHelper wire formats** — datehelperutil_room_src_main_java_uk_co_appoly_droid_util_dbdateconverters_dbdateconverters, datehelperutil_serialization_src_main_java_uk_co_appoly_droid_util_dateserializers, util_datehelper [INFERRED 0.75] +- **SegmentedControl composed of defaults, colors and text styles** — segmentedcontrol_segmentedcontrol, segmentedcontrol_src_main_java_uk_co_appoly_droid_ui_segmentedcontrol_segmentedcontrol_segmentedcontroldefaults, segmentedcontrol_src_main_java_uk_co_appoly_droid_ui_segmentedcontrol_segmentedcontrol_segmentedcontrolcolors, segmentedcontrol_src_main_java_uk_co_appoly_droid_ui_segmentedcontrol_segmentedcontrol_segmentedcontroltextstyle [EXTRACTED 1.00] +- **AppSnackBar-UiState bridge** — snackbar_snackbartype_property, ui_uistate, appsnackbar_src_main_java_uk_co_appoly_droid_ui_snackbar_appsnackbar_snackbartype [EXTRACTED 1.00] +- **Retrofit annotation mock route resolution** — mock_mockapi, mockinterceptor_retrofit_src_main_java_uk_co_appoly_droid_mockinterceptor_retrofit_mockapibuilder_mockapibuilder, mock_extractretrofitroute, mock_routebuilder [EXTRACTED 1.00] +- **buildSrc version-management tooling** — buildsrc_src_main_kotlin_buildconfig_buildconfig, build_toolbox_version, buildsrc_src_main_kotlin_updatereadmeversions_updatereadmeversions [EXTRACTED 1.00] - **S3 Result Types Converted to APIResult** — uploadresult, directuploadresult, multipartuploadresult, apiresult [INFERRED 0.85] - **Multipart Upload Lifecycle Extensions** — appolybaserepomp_startmultipartupload, appolybaserepomp_pausemultipartupload, appolybaserepomp_resumemultipartupload, appolybaserepomp_cancelmultipartupload, multipartuploadmanager [EXTRACTED 1.00] - **BOM Version Constraints Across Modules** — bom_module, baserepo_module, s3uploader_module, s3uploader_multipart_module [EXTRACTED 1.00] -## Communities (205 total, 97 thin omitted) +## Communities (237 total, 53 thin omitted) -### Community 0 - "S3 Multipart API Models & Headers" -Cohesion: 0.06 -Nodes (12): bearer(), custom(), HeaderProvider, AbortMultipartRequest, CompletedPart, CompleteMultipartRequest, InitiateMultipartRequest, PresignPartRequest (+4 more) +### Community 0 - "test" +Cohesion: 0.09 +Nodes (29): advanceuntilidle, assertequals, assertfalse, assertnotequals, assertnull, asserttrue, assnapshot, backeventcompat (+21 more) + +### Community 1 - "Nav3TestActivity.kt" +Cohesion: 0.05 +Nodes (28): activityscenario, instrumentationregistry, awaitRetentionScope(), awaitTabs(), idle(), Nav3Screen, recreateAndAwait(), switchTabAndAwait() (+20 more) + +### Community 2 - "serializable" +Cohesion: 0.17 +Nodes (50): AppNavigation, BaseRepoDemoScreen, Nav3Screen, DateHelperDemoScreen, Nav3Screen, FeatureButton(), HomeScreen, Nav3Screen (+42 more) + +### Community 3 - "S3Uploader.uploadFile" +Cohesion: 0.17 +Nodes (15): GenericBaseRepo.uploadFileDirectToS3, GenericBaseRepo.uploadFileToS3, GenericBaseRepo.uploadFileToS3 (sendPathApiCall), BaseRepo, BaseRepo-S3 Bridge Pattern, BaseRepo-S3Uploader, BaseRepo-S3Uploader-Multipart, AppolyDroid-Toolbox-bom (+7 more) + +### Community 4 - "androidjunit4" +Cohesion: 0.16 +Nodes (31): androidjunit4, assert, assertcountequals, assertisdisplayed, assertisenabled, assertisnotenabled, assertisnotselected, assertisselected (+23 more) -### Community 1 - "Multipart DAO Constraint Tests" +### Community 5 - "MultipartUploadWorkerTest.kt" Cohesion: 0.06 -Nodes (5): MultipartUploadDaoConstraintsTest, MultipartUploadDaoTest, UploadPartEntity, UploadSessionEntity, MultipartUploadManagerTest +Nodes (35): after, applicationprovider, assertnotnull, collections, configuration, listenableworker, MockResponse, mockwebserver (+27 more) -### Community 2 - "Demo App Screens & Navigation" +### Community 6 - "MultipartUploadDemoScreen" Cohesion: 0.05 -Nodes (31): AppPreview(), MainActivity, AppNavigation(), BaseRepoDemoScreen(), DateHelperDemoScreen(), FeatureButton(), HomeScreen(), ButtonRow() (+23 more) +Nodes (40): activityresultcontracts, BarcodeScannerDemoScreen, BullseyeOverlay(), CornerBracketOverlay(), Dp, Modifier, Nav3Screen, TorchToggleRow() (+32 more) -### Community 3 - "APIResult / APIFlowState Core" -Cohesion: 0.05 -Nodes (52): APIFlowState, asApiFlowState, cacheSuccessData, APIFlowState.map, rememberSuccessDataAsState, APIResult, APIError, APIResult (+44 more) +### Community 7 - "SegmentedControl.kt" +Cohesion: 0.06 +Nodes (46): alpha, animatable, animatedpasstate, awaiteachgesture, awaitfirstdown, awaitpointereventscope, BaselineShift, changedtoup (+38 more) -### Community 4 - "SnackBar & Enum Serializers" -Cohesion: 0.05 -Nodes (24): AllUploadsSection(), AuthSection(), ErrorCard(), FileSelectionSection(), LogSection(), MultipartUploadDemoScreen(), ProgressSection(), StatusChip() (+16 more) +### Community 8 - "README.md" +Cohesion: 0.12 +Nodes (17): AppSnackBarExtensions, BaseRepo, Extensions, Features, Installation, ConnectivityMonitor, DateHelperUtil README, AppSnackBar-UiState bridge (concept) (+9 more) -### Community 5 - "BaseRepo Paging & Test Backend" -Cohesion: 0.07 -Nodes (12): TestBackendRepository, AuthApi, LoginRequest, LoginResponse, UserData, AppolyBaseRepo, doNestedPagedAPICall(), DoPagedAPICallTest (+4 more) +### Community 9 - "TestBackendRepository" +Cohesion: 0.33 +Nodes (6): BaseRepo build.gradle.kts, TestBackendRepository, GenericBaseRepo, MultipartUploadConfig, MultipartUploadManager, S3Uploader + +### Community 10 - "composable" +Cohesion: 0.09 +Nodes (37): composable, dp, error, griditemspan, itemcontenttype, itemkey, lazygriditemscope, lazygriditemspanscope (+29 more) -### Community 6 - "Compose Animation & Mock Demo" +### Community 11 - "Nav3ScreenHost" Cohesion: 0.06 -Nodes (8): ScrollIntoViewAnimatedVisibility(), ScrollIntoViewAnimatedVisibilityTest, MockResponseBuilder, BaseRepoDemoViewModel, PostData, UserData, S3UploaderDemoViewModel, UiStateDemoViewModel +Nodes (30): currentcompositekeyhashcode, defaultpoptransitionspec, defaultpredictivepoptransitionspec, defaulttransitionspec, Modifier, NavBackStack, SceneStrategy, ViewModelStoreOwner (+22 more) -### Community 7 - "Multipart Upload Config" +### Community 12 - "BarcodeScannerCamera.kt" Cohesion: 0.06 -Nodes (12): forLargeFiles(), forUnreliableNetwork(), MultipartUploadConfig, powerSaving(), wifiOnly(), DefaultUploadNotificationProvider, simple(), DefaultUploadNotificationProviderTest (+4 more) +Nodes (36): Analyzer, aspectratio, awaitinstance, barcodescanner, API, BarcodeScannerCameraDeviceTest, BarcodeAnalyzer, BarcodeScannerCamera() (+28 more) -### Community 8 - "UiState & Date Concepts" -Cohesion: 0.05 -Nodes (46): AppSnackBar-UiState bridge (concept), UiState management (concept), Naive vs server date helper distinction, MultipartUploadProgress, UploadResult, UploadSessionStatus, DateHelperDemoScreen, HomeScreen (+38 more) +### Community 15 - "MockApiInterceptorTest" +Cohesion: 0.10 +Nodes (6): Request, Response, MockResponseBuilder, Response, MockApiInterceptorTest, Protocol -### Community 9 - "Appoly JSON Envelope & Nested Paging" +### Community 16 - "LazyGridScope.lazyPagingItemsStates" Cohesion: 0.05 -Nodes (45): AppolyBaseRepo, doNestedPagedAPICall, AppolyBaseEnvelope, AppolyNestedPageData, AppolyPagedEnvelope, AppolySuccessEnvelope, BaseRepoLogger, BaseRepo build.gradle.kts (+37 more) +Nodes (40): LazyGridScope.emptyStateItem, LazyGridScope.errorStateItem, LazyGridScope.lazyPagingItems, LazyGridScope.lazyPagingItemsIndexed, LazyGridScope.lazyPagingItemsIndexedStates, LazyGridScope.lazyPagingItemsIndexedStatesWithNeighbours, LazyGridScope.lazyPagingItemsIndexedWithNeighbours, LazyGridScope.lazyPagingItemsStates (+32 more) -### Community 10 - "S3 Progress Body & Lazy Paging" -Cohesion: 0.06 -Nodes (9): ProgressRequestBody, lazyPagingItems(), LazyGridPagingItemsTest, lazyPagingItems(), lazyPagingItemsIndexed(), LazyPagingItemsTest, S3Uploader, parseBody() (+1 more) +### Community 17 - "ClipboardCopier.kt" +Cohesion: 0.09 +Nodes (26): clipdata, Clipboard copier, ComposeExtensions, Dependencies, Insets and IME padding, Installation, Padding helpers, Serialization-safe Compose state (+18 more) -### Community 13 - "MultipartUploadManager Lifecycle" -Cohesion: 0.11 -Nodes (9): AllPartsUploaded, Failed, getInstance(), MultipartUploadManager, PartUploadResult, Paused, SinglePartResult, Success (+1 more) +### Community 18 - "Nav3TabsHost" +Cohesion: 0.14 +Nodes (15): TabItem, mainthread, Retention and teardown, ViewModel, ViewModelStore, ViewModelStoreOwner, Nav3RetentionScope, rememberNav3RetentionScope() (+7 more) -### Community 14 - "RefreshableAPIFlow & Composables" -Cohesion: 0.08 -Nodes (4): APIFlowStateComposablesTest, RefreshableAPIFlow, FlowRepo, RefreshableAPIFlowTest +### Community 19 - "DateSerializers" +Cohesion: 0.13 +Nodes (20): Available Serializers, Strict vs lenient, DateSerializers, DateTimeSerializer, InstantSerializer, Decoder, Encoder, KSerializer (+12 more) + +### Community 20 - "GenericBaseRepoMockWebServerTest" +Cohesion: 0.12 +Nodes (15): ApiEnvelope, EnvelopeWireResponse, GenericBaseRepoMockWebServerTest, BaseRetrofitClient, ApiResponse, BaseRetrofitClient, Json, MockWebServer (+7 more) + +### Community 21 - "MultipartUploadWorker" +Cohesion: 0.09 +Nodes (22): cancelandjoin, coroutinescope, Data, filternotnull, firstornull, Multipart Backend API Specification, MultipartUploadManager, S3Uploader-Multipart README (+14 more) + +### Community 22 - "logginglevel" +Cohesion: 0.12 +Nodes (21): apiresponsecalladapterfactory, BaseRetrofitClient, Interceptor, Json, OkHttpClient, Request, Retrofit, T (+13 more) -### Community 15 - "MockInterceptor Core" -Cohesion: 0.1 -Nodes (6): MockApiInterceptor, MockRequestContext, PageSlice, paginate(), MockSerializationTest, TestUser +### Community 23 - "NavKey" +Cohesion: 0.12 +Nodes (22): AnimatedContentTransitionScope, ContentTransform, intoffset, Hosting a stack, Manual cold-restore checklist, Testing, The module's own suites, Transitions (+14 more) + +### Community 24 - "MultipartUploadResult" +Cohesion: 0.07 +Nodes (14): UploadLifecycleCallbacks, UploadLifecycleCallbacks, MultipartUploadResult, Abort, BeforeUploadResult, Continue, UploadLifecycleCallbacks, Cancelled (+6 more) + +### Community 25 - "MultipartUploadManager" +Cohesion: 0.12 +Nodes (9): AllPartsUploaded, Failed, Context, Result, MultipartUploadManager, PartUploadResult, Paused, SinglePartResult (+1 more) -### Community 16 - "Lazy Grid/List Paging Entry Points" +### Community 26 - "MultipartUploadDao" Cohesion: 0.08 -Nodes (32): LazyGridScope.emptyStateItem, LazyGridScope.errorStateItem, LazyGridScope.lazyPagingItems, LazyGridScope.lazyPagingItemsIndexed, LazyGridScope.lazyPagingItemsIndexedStates, LazyGridScope.lazyPagingItemsIndexedStatesWithNeighbours, LazyGridScope.lazyPagingItemsIndexedWithNeighbours, LazyGridScope.lazyPagingItemsStates (+24 more) +Nodes (8): PartUploadStatus, Flow, MultipartUploadDao, SessionWithParts, UploadPartEntity, UploadSessionEntity, SessionWithParts, UploadSessionEntity -### Community 17 - "Mock Appoly JSON Envelopes" -Cohesion: 0.16 -Nodes (11): AppolyBaseEnvelope, AppolyNestedPageData, AppolyPagedEnvelope, AppolySuccessEnvelope, errorBody(), successBody(), successMessage(), MockAppolyJsonTest (+3 more) +### Community 27 - "jvmtarget" +Cohesion: 0.11 +Nodes (3): applicationextension, jvmtarget, libraryextension -### Community 18 - "GenericBaseRepo doAPICall Tests" -Cohesion: 0.14 -Nodes (4): GenericBaseRepoTest, TestRepo, TestRootJson, TestRootJsonWithData +### Community 28 - "GenericPagingSource" +Cohesion: 0.19 +Nodes (10): PagingSource, GenericPagingSource, LoadParams, LoadResult, PagingSource, PagingState, T, GenericPagingSourceTest (+2 more) -### Community 20 - "MockInterceptor Demo ViewModel" -Cohesion: 0.15 -Nodes (5): DemoRetrofitApi, MockInterceptorDemoViewModel, MockProduct, MockUser, RequestResult +### Community 29 - "FlexiLog" +Cohesion: 0.25 +Nodes (9): SilentTestLogger (AppolyJson test), SilentTestLogger (DateHelper test), DateHelperLog, DateHelper.parseLocalDate, DateHelper.parseNaiveDateTime, DateHelper.parseNaiveDateTimeInternal, DateHelper.setLogger, FlexiLog (+1 more) + +### Community 30 - "MockApiInterceptor" +Cohesion: 0.29 +Nodes (7): MockInterceptorLog, MockResponseBuilder, Interceptor, Response, MockApiInterceptor, defaultMockJson, jsonBody + +### Community 31 - "MockInterceptorDemoViewModel" +Cohesion: 0.13 +Nodes (10): DemoRetrofitApi, Request, StateFlow, ViewModel, MockInterceptorDemoViewModel, MockProduct, MockUser, RequestResult (+2 more) + +### Community 32 - "DefaultUploadNotificationProvider" +Cohesion: 0.08 +Nodes (20): activity, build, darkcolorscheme, drawableres, dynamicdarkcolorscheme, dynamiclightcolorscheme, issystemindarktheme, lightcolorscheme (+12 more) -### Community 21 - "Demo Bootstrap & Upload Callbacks" +### Community 33 - "PageData" Cohesion: 0.11 -Nodes (27): app build.gradle.kts, Log (FlexiLog), MainActivity, SampleApplication, BeforeUploadResult, DefaultUploadNotificationProvider, UploadLifecycleCallbacks, UploadNotificationProvider (+19 more) +Nodes (11): PageData, T, RootJsonPage, PageDataTest, DoPagedAPICallTest, ApiResponse, T, PagingRepo (+3 more) + +### Community 34 - "TabsNav3Navigator" +Cohesion: 0.14 +Nodes (13): decodefromsavedstate, encodetosavedstate, mutablestatelistof, Bundle, Nav3Screen, NavBackStack, TabSlide, Backward (+5 more) -### Community 22 - "Multipart Worker & API URLs" +### Community 36 - "ScanRegionResolver.kt" Cohesion: 0.11 -Nodes (4): fromBaseUrl(), MultipartApiUrls, MultipartUploadWorker, S3UploadWorkManagerTest +Nodes (15): AndroidRect, androidx, Where a barcode has to be, Full, ScanRegion, Visible, distanceToCentreOf(), isWithin() (+7 more) -### Community 23 - "BaseRepo S3 Multipart Constraints" -Cohesion: 0.1 -Nodes (14): lowPriority(), powerSaving(), UploadConstraints, UploadNetworkType, wifiOnly(), UploadConstraintsTest, cancelMultipartUpload(), MultipartUploadSuccess (+6 more) +### Community 37 - "FilePartRequestBodyTest" +Cohesion: 0.11 +Nodes (7): fileinputstream, FilePartRequestBody, BufferedSink, MediaType, RequestBody, FilePartRequestBodyTest, source -### Community 24 - "Date Serializers (kotlinx)" +### Community 38 - "MultipartUploadConfig" Cohesion: 0.08 -Nodes (8): DateTimeSerializer, InstantSerializer, LocalDateSerializer, NullableDateTimeSerializer, NullableInstantSerializer, NullableLocalDateSerializer, NullableZonedDateTimeSerializer, ZonedDateTimeSerializer +Nodes (11): MultipartUploadManager, MultipartUploadConfig, android, Context, Notification, UploadLifecycleCallbacks, UploadNotificationProvider, MultipartUploadWorkerTest (+3 more) -### Community 26 - "Multipart Request/Response Models" +### Community 39 - "AppSnackBar" +Cohesion: 0.10 +Nodes (22): AppSnackBar, AppSnackBar(), AppSnackBarColors, AppSnackBarDefaults, showSnackbar(), SnackBarType, Error, Info (+14 more) + +### Community 40 - "GenericBaseRepoTest" Cohesion: 0.09 -Nodes (25): AbortMultipartRequest, AbortMultipartResponse, CompletedPart, CompleteMultipartRequest, CompleteMultipartResponse, EmptyArrayAsEmptyMapSerializer, InitiateMultipartRequest, InitiateMultipartResponse (+17 more) +Nodes (3): GenericBaseRepoTest, TestRepo, ServiceManager + +### Community 41 - "MultipartUploadProgress" +Cohesion: 0.12 +Nodes (10): roundtolong, Context, ForegroundInfo, Notification, UploadNotificationProvider, Flow, MultipartUploadProgress, Sample (+2 more) -### Community 28 - "Paging Source & Date Rationale" +### Community 42 - "AnimatedScanFrame.kt" Cohesion: 0.11 -Nodes (24): Pager, PagingSource, PagingSourceFactory, Legacy literal-Z format retained for backward compat, Recoverable fallback must not log ERROR, BaseRepo-Paging README, FlexiLog, APIResult (+16 more) +Nodes (25): animatecolorasstate, animatefloatasstate, AnimatedScanFrame(), cornersClockwise(), Dp, Modifier, DefaultScanFrame(), Dp (+17 more) -### Community 29 - "AppolyRepo & Date Regression Fix" -Cohesion: 0.1 -Nodes (24): AppolyBaseRepo, AppolyBaseRepoTest, BaseRepo-AppolyJson README, SilentTestLogger (AppolyJson test), BaseAppolyRepoLogger, Carbon/Laravel short-format fallback (1.4.1 regression fix), DateHelper.formatServerTimestamp, DateHelper (+16 more) +### Community 43 - "Nav3Navigator" +Cohesion: 0.13 +Nodes (7): BackStackNav3Navigator, Nav3Screen, NavBackStack, Nav3Navigator, rememberBackStackNav3Navigator(), Nav3NavigatorParentTest, providablecompositionlocal -### Community 30 - "ConnectivityMonitor & Mock" -Cohesion: 0.11 -Nodes (24): ConnectivityMonitorApplication, ConnectivityMonitorApplicationTest, Connectivity debounce, ConnectivityLogger, ConnectivityMonitor, ConnectivityRestoredEvent, NetworkTransportType, NetworkTransportTypeTest (+16 more) +### Community 44 - "AppolyJsonDemoViewModel.kt" +Cohesion: 0.14 +Nodes (11): AppolyDemoApi, AppolyDemoRepo, AppolyJsonDemoViewModel, DemoProduct, DemoUser, ApiResponse, StateFlow, ViewModel (+3 more) -### Community 31 - "APIFlowState Sealed Class" -Cohesion: 0.15 -Nodes (20): APIFlowState, asApiFlowState(), cacheSuccessData(), Error, errorMessage(), isError(), isSuccess(), Loading (+12 more) +### Community 45 - "ProgressRequestBody" +Cohesion: 0.17 +Nodes (10): Standalone (no BaseRepo dependency), mutablestateflow, S3Uploader README, S3Uploader, BufferedSink, MediaType, RequestBody, ProgressRequestBody (+2 more) + +### Community 46 - "lazyPagingItemsIndexedStatesWithNeighbours" +Cohesion: 0.18 +Nodes (12): LazyListPagingExtensions README, LazyGridPagingExtensions build.gradle.kts, LazyGrid loadingStateItem, PagingExtensions module, emptyStateItem, errorStateItem, PagingErrorType, lazyPagingItemsIndexedStatesWithNeighbours (+4 more) -### Community 34 - "Appoly Response & Lazy State Items" -Cohesion: 0.1 -Nodes (9): emptyStateItem(), errorStateItem(), emptyStateItem(), errorStateItem(), loadingStateItem(), loadingStateItem(), GenericResponse, Item (+1 more) +### Community 47 - "MultipartUploadDemoViewModel" +Cohesion: 0.13 +Nodes (5): AndroidViewModel, StateFlow, Uri, MultipartUploadDemoViewModel, fileoutputstream + +### Community 48 - "NoConnectivityException" +Cohesion: 0.12 +Nodes (11): Network error handling, asNoConnectivityException(), asServerTimeoutException(), asServerUnreachableException(), Interceptor, Response, NetworkConnectionInterceptor, NoConnectivityException (+3 more) -### Community 35 - "ConnectivityMonitor Application" +### Community 49 - "pagedBody" +Cohesion: 0.14 +Nodes (21): defaultmockjson, encodetojsonelement, jsonelement, AppolyBaseEnvelope, AppolyNestedPageData, AppolyPagedEnvelope, AppolySuccessEnvelope, errorBody() (+13 more) + +### Community 50 - "Modules" +Cohesion: 0.08 +Nodes (26): AppSnackBar, AppSnackBar-UiState, BarcodeScanner, BarcodeScanner-Camera, BaseRepo, BaseRepo-AppolyJson, BaseRepo-Paging, BaseRepo-Paging-AppolyJson (+18 more) + +### Community 51 - "ComposeExtensions.kt" +Cohesion: 0.10 +Nodes (23): aspaddingvalues, blendmode, calculateendpadding, calculatestartpadding, cliptobounds, PaddingValues, plus(), compositingstrategy (+15 more) + +### Community 52 - "Nav3TabsRetentionTest.kt" +Cohesion: 0.12 +Nodes (18): assertnotsame, assertsame, key, ViewModelStore, ViewModelStoreOwner, ViewModelStoreOwner, Nav3Screen, ViewModelStore (+10 more) + +### Community 53 - "OneShotBarcodeScanner" Cohesion: 0.16 -Nodes (7): ConnectivityMonitorApplication, ConnectivityRestoredEvent, onAvailable(), onBlockedStatusChanged(), onCapabilitiesChanged(), onLost(), NetworkTypeChangedEvent +Nodes (20): A single scan, API, BarcodeScanner, Choosing formats, Don't branch on error codes, Features, Handling `Unavailable`, Installation (+12 more) + +### Community 54 - "EmptyArrayAsEmptyMapSerializer.kt" +Cohesion: 0.12 +Nodes (18): Tolerate S3 header value as string or array, jointostring, jsonarray, jsondecoder, jsonprimitive, mapserializer, EmptyArrayAsEmptyMapSerializer, Decoder (+10 more) + +### Community 55 - "API surface" +Cohesion: 0.12 +Nodes (22): A. `Nav3ResultReceiver` + `popWithResult` (recommended, stable), API surface, B. Nav3 1.2 `ResultEventBus` (alpha — optional), Custom / per-screen transitions, Declaring screens, Deep links, Delivering a result on system back, Dependencies (+14 more) + +### Community 57 - "BaseRepoDemoViewModel" +Cohesion: 0.12 +Nodes (8): BaseRepoDemoViewModel, StateFlow, ViewModel, PostData, UserData, StateFlow, ViewModel, UiStateDemoViewModel + +### Community 58 - "DateSerializersTest" +Cohesion: 0.12 +Nodes (7): DateSerializersTest, DateTimeHolder, LenientDateTimeHolder, LenientLocalDateHolder, LenientZonedHolder, LocalDateHolder, ZonedHolder + +### Community 59 - "TabsDemoScreen.kt" +Cohesion: 0.13 +Nodes (18): Nav3Screen, TabPage(), TabsDemoScreen, TabsHomeScreen, TabsRoomDetailScreen, TabsRoomsScreen, TabsSettingsScreen, carddefaults (+10 more) -### Community 36 - "Mock Route Builder" +### Community 60 - "file" Cohesion: 0.14 -Nodes (7): MockRoute, MockRouteBuilder, MockApiBuilder, extractRetrofitRoute(), getAnnotationValue(), isRetrofitAnnotation(), mockApi() +Nodes (13): assertarrayequals, Buffer, countdownlatch, eofexception, fail, file, IOException, okhttpclient (+5 more) -### Community 38 - "Paging State Providers" -Cohesion: 0.1 -Nodes (7): DefaultEmptyStateTextProvider, EmptyStateTextProvider, DefaultErrorStateProvider, ErrorStateProvider, DefaultLoadingStateProvider, LoadingStateProvider, PagingStateComposablesTest +### Community 61 - "BaseResponse" +Cohesion: 0.14 +Nodes (9): APIResult, BaseResponse, ErrorBody, AppolyBaseRepo.doAPICallWithBaseResponse, AppolyBaseRepo.extractErrorMessage, Item, ResponseModelsTest, parseBody (+1 more) + +### Community 64 - "MultipartApiServiceTest" +Cohesion: 0.17 +Nodes (3): MockWebServer, RecordedRequest, MultipartApiServiceTest -### Community 39 - "NetworkInterceptor & APIResult Tests" +### Community 65 - "AnimatedVisibility.kt" Cohesion: 0.12 -Nodes (4): APIResultTest, asNoConnectivityException(), NetworkConnectionInterceptor, NoConnectivityException +Nodes (17): animatedvisibilityscope, bringintoviewrequester, columnscope, Modifier, ScrollIntoViewAnimatedVisibility(), ScrollIntoViewAnimatedVisibilityTest, EnterTransition, ExitTransition (+9 more) -### Community 40 - "Nested Paged Response (AppolyJson)" -Cohesion: 0.18 -Nodes (4): DoNestedPagedAPICallTest, Repo, GenericNestedPagedResponse, NestedPageData +### Community 66 - "MockRetrofitTest" +Cohesion: 0.13 +Nodes (3): Response, MockRetrofitTest, TestApi -### Community 42 - "Date Log Attribution Tests" +### Community 67 - "Nav3ResultsTest" Cohesion: 0.12 -Nodes (3): DateHelperLogAttributionTest, Entry, RecordingTestLogger +Nodes (6): Nav3Screen, NavBackStack, Nav3ResultsTest, PickerScreen, PushingReceiverScreen, ResultListScreen + +### Community 69 - "UploadSessionStatus" +Cohesion: 0.08 +Nodes (17): UploadStatusConverters, PartUploadStatus, FAILED, PENDING, UPLOADED, UPLOADING, UploadSessionStatus, ABORTED (+9 more) + +### Community 70 - "DemoDatabase.kt" +Cohesion: 0.13 +Nodes (16): automigration, columninfo, dao, database, delete, entity, foreignkey, index (+8 more) + +### Community 71 - "DateSerializationRoomDemoViewModel.kt" +Cohesion: 0.13 +Nodes (11): DateNoteDao, DateNoteEntity, DemoDatabase, RoomDatabase, DateSerializationRoomDemoViewModel, EventDto, AndroidViewModel, StateFlow (+3 more) + +### Community 72 - "SegmentedControl" +Cohesion: 0.15 +Nodes (7): Modifier, T, SegmentedControl(), SegmentText, SegmentedControlEnabledTest, SegmentedControlNullSelectionTest, Shape + +### Community 73 - "BarcodeFormat" +Cohesion: 0.10 +Nodes (19): barcode, BarcodeFormat, Aztec, Codabar, Code128, Code39, Code93, DataMatrix (+11 more) + +### Community 74 - "UploadConstraints" +Cohesion: 0.08 +Nodes (23): backoffpolicy, Constraints, encodetostring, existingperiodicworkpolicy, existingworkpolicy, LiveData, MultipartUploadWorker, NetworkType (+15 more) -### Community 43 - "Appoly BaseResponse Tests" -Cohesion: 0.2 -Nodes (3): AppolyBaseRepoTest, TestAppolyRepo, BaseResponse +### Community 75 - "AppolyBaseRepo.kt" +Cohesion: 0.10 +Nodes (12): ApiResponse, BaseRetrofitClient, T, parseBody, contract, errorbody, experimentalcontracts, flexilog (+4 more) -### Community 44 - "GenericPagingSource" +### Community 76 - "GenericNestedPagedResponse" +Cohesion: 0.14 +Nodes (9): GenericNestedPagedResponse, T, NestedPageData, DoNestedPagedAPICallTest, T, Repo, GenericNestedPagedResponse, PageData (+1 more) + +### Community 77 - "APIFlowStateTest" +Cohesion: 0.10 +Nodes (6): APIFlowStateComposablesTest, APIFlowStateTest, APIResult, cacheSuccessData retains last success to avoid UI flicker, APIFlowState, RefreshableAPIFlow + +### Community 78 - "ConnectivityMonitorApplication.kt" +Cohesion: 0.10 +Nodes (19): callsuper, ConnectivityManager, FlexiLog, LoggingLevel, SharedFlow, StateFlow, flowpreview, isconnected (+11 more) + +### Community 79 - "MockSerializationTest" +Cohesion: 0.18 +Nodes (7): int, JsonObject, OkHttpClient, Response, T, MockSerializationTest, TestUser + +### Community 80 - "MultipartUploadManager.kt" +Cohesion: 0.13 +Nodes (18): asrequestbody, async, atomiclong, cancelchildren, concurrenthashmap, connectexception, Deferred, inits3uploader (+10 more) + +### Community 81 - "OneShotBarcodeScanner.kt" +Cohesion: 0.11 +Nodes (17): atomicboolean, await, awaitUserCancellation(), OneShotCancellationTest, cancellationexception, connectionresult, currentcoroutinecontext, ensureactive (+9 more) + +### Community 82 - "ScannedBarcode" +Cohesion: 0.13 +Nodes (14): BarcodeTracker, Track, ScanMode, Multi, Single, ScannedBarcode, toScannedBarcode(), duration (+6 more) + +### Community 83 - "APIFlowState.kt" +Cohesion: 0.23 +Nodes (17): errorMessage(), isError(), isSuccess(), State, T, rememberSaveableSuccessData(), rememberSaveableSuccessDataAsState(), rememberSaveableSuccessList() (+9 more) + +### Community 84 - "AppolyBaseRepoTest" +Cohesion: 0.18 +Nodes (7): BaseRepo-AppolyJson README, AppolyBaseRepo, AppolyBaseRepoTest, ApiResponse, retrofit2, TestAppolyRepo, GenericBaseRepo + +### Community 85 - ".tracker" Cohesion: 0.18 -Nodes (6): Exception, HttpError, S3PartUploadResult, Success, GenericPagingSource, GenericPagingSourceTest +Nodes (3): ScanPolicy, BarcodeTrackerTest, kotlin + +### Community 86 - "ConnectivityMonitorApplicationTest" +Cohesion: 0.20 +Nodes (3): ConnectivityMonitorApplicationTest, ConnectivityManager, NetworkCapabilities -### Community 45 - "S3Uploader Standalone Core" +### Community 87 - "UploadResult" +Cohesion: 0.19 +Nodes (10): CoroutineDispatcher, DirectUploadResult, Error, Success, ApiResponse, MediaType, MutableStateFlow, Error (+2 more) + +### Community 88 - "nav3HostViewModelStoreOwner" +Cohesion: 0.14 +Nodes (10): localviewmodelstoreowner, ViewModelStoreOwner, nav3HostViewModelStoreOwner(), Nav3Screen, Nav3HostViewModelStoreOwnerTest, Nav3Screen, Nav3Screen, Nav3Screen (+2 more) + +### Community 89 - "MultipartApiService.kt" +Cohesion: 0.20 +Nodes (10): AbortMultipartRequest, CompletedPart, CompleteMultipartRequest, HttpError, S3PartUploadResult, Success, ApiResponse, OkHttpClient (+2 more) + +### Community 90 - "SerializableMutableState" +Cohesion: 0.18 +Nodes (10): assertthrows, bytearrayinputstream, bytearrayoutputstream, MutableState, Serializable, T, SerializableMutableState, notserializableexception (+2 more) + +### Community 91 - "RefreshableAPIFlow" +Cohesion: 0.18 +Nodes (6): callApiAsRefreshableFlow, SharedFlow, T, RefreshableAPIFlow, RefreshableAPIFlowTest, FlowCollector + +### Community 92 - "ConnectivityMonitorApplication" +Cohesion: 0.19 +Nodes (6): Connectivity debounce, ConnectivityMonitorApplication, ConnectivityRestoredEvent, Job, NetworkCapabilities, TestConnectivityMonitorApplication + +### Community 93 - "DateHelper" Cohesion: 0.17 -Nodes (17): Standalone (no BaseRepo dependency), Tolerate S3 header value as string or array, APIService, APIs, ErrorBody, S3Upload extensions, HeaderProvider, S3UploadLog (+9 more) +Nodes (3): Storage Format Details, Timezone Handling, DateHelper + +### Community 94 - "TestBackendRepository.kt" +Cohesion: 0.20 +Nodes (11): ApiResponse, BaseRetrofitClient, StateFlow, TestBackendRepository, AuthApi, ApiResponse, LoginRequest, LoginResponse (+3 more) -### Community 46 - "PagingData Bridge" +### Community 95 - "SegmentedControlDemoScreen" +Cohesion: 0.12 +Nodes (15): Nav3Screen, SegmentedControlDemoScreen, ViewMode, GRID, LIST, MAP, brush, FontFamily (+7 more) + +### Community 96 - "SegmentedControl" +Cohesion: 0.19 +Nodes (14): Density, SegmentedControl, Dividers(), Brush, Dp, SegmentedControlColors, SegmentedControlDefaults, SegmentedControlState (+6 more) + +### Community 97 - "MockRouteBuilder" Cohesion: 0.13 -Nodes (17): PagingData, LazyListPagingExtensions README, LazyGridPagingExtensions build.gradle.kts, LazyGrid loadingStateItem, APIFlowState, PagingExtensions module, asPagingData, emptyStateItem (+9 more) +Nodes (16): Retrofit annotation mocking (concept), java, KClass, KFunction, extractRetrofitRoute, MockRouteBuilder.mockApi, MockRouteBuilder, MockApiBuilder (+8 more) + +### Community 98 - "MockAppolyJsonTest" +Cohesion: 0.32 +Nodes (4): OkHttpClient, Response, MockAppolyJsonTest, TestUser + +### Community 99 - "RetrofitClient" +Cohesion: 0.17 +Nodes (9): S3Upload extensions, S3UploadLog, ErrorBody, Retrofit, T, RetrofitClient, FlexiLog, LogType (+1 more) + +### Community 100 - "MultipartApis" +Cohesion: 0.18 +Nodes (9): AbortMultipartResponse, CompleteMultipartData, CompleteMultipartResponse, InitiateMultipartRequest, InitiateMultipartData, InitiateMultipartResponse, ApiResponse, RequestBody (+1 more) + +### Community 101 - "AppSnackBar" +Cohesion: 0.12 +Nodes (16): API Reference, AppSnackBar, AppSnackBar Component, AppSnackBarColors, Basic Setup, Custom Colors, Dependencies, Different Snackbar Types (+8 more) + +### Community 102 - "AppolyBaseRepoS3MultipartExtensions.kt" +Cohesion: 0.27 +Nodes (14): cancelMultipartUpload(), enableMultipartUploadAutoRecovery(), Context, Flow, MultipartUploadSuccess, observeAllMultipartUploads(), observeMultipartUploadProgress(), pauseMultipartUpload() (+6 more) + +### Community 103 - "APIResult.kt" +Cohesion: 0.18 +Nodes (15): APIError, isError(), isNetworkError(), isServerUnreachable(), isSuccess(), R, T, map() (+7 more) + +### Community 104 - "Module Architecture" +Cohesion: 0.12 +Nodes (7): Module Architecture, Error, Idle, Loading, Success, UiState, UiStateTest + +### Community 105 - "BaseRetrofitClient" +Cohesion: 0.20 +Nodes (10): API, BaseRetrofitClient, BaseRetrofitClient, BaseService, C, Json, T, ServiceManager (+2 more) + +### Community 106 - "ErrorState.kt" +Cohesion: 0.10 +Nodes (24): Alignment, background, circularprogressindicator, compositionlocalof, compositionlocalprovider, FontWeight, localtextstyle, CompositionLocal state providers (+16 more) -### Community 47 - "DateHelper Extensions" +### Community 107 - "transientMutableStateOf" +Cohesion: 0.25 +Nodes (7): MutableState, Serializable, T, TransientMutableState, transientMutableStateOf(), T, TransientMutableStateTest + +### Community 108 - "DateHelperExtensions.kt" Cohesion: 0.17 Nodes (10): daysFromNow(), deviceToUTC(), isPassed(), isToday(), millisToLocalDate(), millisToLocalDateTime(), toDeviceZone(), toMillis() (+2 more) -### Community 48 - "APIResult Sealed Class" +### Community 109 - "MultipartRetrofitClient" Cohesion: 0.18 -Nodes (10): APIError, APIResult, Error, isError(), isNetworkError(), isSuccess(), mapSuccess(), onSuccess() (+2 more) +Nodes (8): MultipartUploadLog, Retrofit, T, MultipartRetrofitClient, FlexiLog, LogType, MultipartUploadLogger, S3Uploader -### Community 50 - "ServiceManager & Retrofit Client" -Cohesion: 0.19 -Nodes (5): API, BaseRetrofitClient, BaseService, getInstance(), ServiceManager +### Community 111 - "clear-local-publish.sh" +Cohesion: 0.17 +Nodes (14): fail(), GROUP, GROUP_DIR, info(), M2_REPO, clear-local-publish.sh script, warn(), scripts_publish_conf (+6 more) + +### Community 112 - "GenericInvalidatingPagingSourceFactory.kt" +Cohesion: 0.17 +Nodes (10): Pager, PagingSourceFactory, GenericInvalidatingPagingSourceFactory, PagingSource, PagingSourceFactory, reentrantlock, roundtoint, Value (+2 more) + +### Community 113 - "ConnectivityMonitorApplicationTest.kt" +Cohesion: 0.13 +Nodes (10): componentactivity, GetActivityTest, config, context, networkinfo, robolectric, shadownetwork, shadownetworkcapabilities (+2 more) + +### Community 114 - "DateHelper.kt" +Cohesion: 0.16 +Nodes (12): datehelperlog, FlexiLog, LoggingLevel, datetimeformatter, instant, offsetdatetime, server_pattern_date, server_pattern_full (+4 more) + +### Community 115 - "LazyListPagingEntryPointsTest" +Cohesion: 0.23 +Nodes (6): LoadParams, LoadResult, PagingSource, PagingState, LazyListPagingEntryPointsTest, PagingSource -### Community 51 - "ComposeExtensions" +### Community 116 - "TabsSceneStrategy" +Cohesion: 0.20 +Nodes (8): Tabs (`TabsNav3Navigator`), Why one `NavDisplay`, NavEntry, Scene, SceneStrategy, TabsScene, TabsSceneStrategy, scenestrategyscope + +### Community 117 - "BarcodeScanner-Camera" Cohesion: 0.16 -Nodes (4): getActiveActivity(), getActivity(), keyboardAsState(), ComposeExtensionsComposeTest +Nodes (14): BarcodeScanner-Camera, Custom overlay, Deciding what counts as a scan, Features, Feedback on a scan, Installation, Notes, On-device test suite (+6 more) + +### Community 118 - "APIFlowStatePagingExtensions.kt" +Cohesion: 0.22 +Nodes (12): asPagingData(), Flow, PagingData, T, mapToPagingData(), filter, loadstates, map (+4 more) + +### Community 119 - "NetworkTransportType" +Cohesion: 0.14 +Nodes (9): NetworkTransportType, CELLULAR, ETHERNET, NONE, OTHER, VPN, WIFI, NetworkTypeChangedEvent (+1 more) -### Community 52 - "UiState Sealed Class" +### Community 120 - ".centreStartTabs" +Cohesion: 0.14 +Nodes (4): SaverScope, SaverScope, SaverScope, SaverScope + +### Community 122 - "MockRequestContext" +Cohesion: 0.10 +Nodes (17): MockApiInterceptor, MockRequestContext, Custom Json Instance, Features, Installation, MockInterceptor-Serialization, PageSlice, Pagination (+9 more) + +### Community 123 - "PagingExtensionsTest" +Cohesion: 0.18 +Nodes (7): LoadState extensions, PagingErrorType, APPEND, PREPEND, REFRESH, LoadState, PagingExtensionsTest + +### Community 124 - ".`lifecycle hooks delegate to the configured callbacks`" +Cohesion: 0.37 +Nodes (3): Context, MultipartUploadWorkerLifecycleTest, UploadLifecycleCallbacks + +### Community 125 - "PagingDemoViewModel" +Cohesion: 0.17 +Nodes (11): Flow, PagingData, ViewModel, PagingDemoViewModel, StateFlow, ViewModel, S3UploaderDemoViewModel, cachedin (+3 more) + +### Community 126 - "RefreshableAPIFlow.kt" +Cohesion: 0.19 +Nodes (12): assharedflow, collectAsState(), CoroutineScope, Flow, SharingStarted, State, stateIn(), completabledeferred (+4 more) + +### Community 128 - "MainActivity.kt" +Cohesion: 0.26 +Nodes (10): AppPreview(), Bundle, ComponentActivity, MainActivity, AppNavigation(), AppolyDroidTheme, enableedgetoedge, preview (+2 more) + +### Community 130 - "APIFlowState" +Cohesion: 0.21 +Nodes (12): APIFlowState, asApiFlowState, cacheSuccessData, Error, CoroutineScope, R, SharingStarted, Loading (+4 more) + +### Community 131 - "DateHelperUtil-Room" Cohesion: 0.17 -Nodes (5): Error, Idle, Loading, Success, UiState +Nodes (12): API Reference, DateHelperUtil-Room, DBDateConverters, Dependencies, Features, Installation, Notes, Repository Implementation Example (+4 more) + +### Community 133 - "lazyPagingItemsIndexedStatesWithNeighbours" +Cohesion: 0.06 +Nodes (37): LazyGrid lazyPagingItems, LazyGrid lazyPagingItemsWithNeighbours, Composable, GridItemSpan, index, item, LazyGridItemScope, LazyGridItemSpanScope (+29 more) + +### Community 134 - "ZonedDateTime.toUTC" +Cohesion: 0.67 +Nodes (3): ZonedDateTime.toDeviceZone, ZonedDateTime.toUTC, DateHelper.nowAsUTC + +### Community 136 - "UpdateReadmeVersions" +Cohesion: 0.06 +Nodes (30): BuildConfig.TOOLBOX_VERSION, BuildConfig, MinSdk, Sdk, UpdateReadmeVersions, Build Commands, Key Patterns, Knowledge Graph (graphify) (+22 more) + +### Community 137 - "S3UploadWorkManager" +Cohesion: 0.67 +Nodes (3): GenericBaseRepo.enableMultipartUploadAutoRecovery, GenericBaseRepo.scheduleMultipartUploadWork, S3UploadWorkManager + +### Community 138 - "RecordingTestLogger" +Cohesion: 0.27 +Nodes (5): Entry, FlexiLog, LogType, RecordingTestLogger, FlexiLog + +### Community 139 - "LazyGridPagingItemsStatesTest" +Cohesion: 0.26 +Nodes (5): LoadParams, PagingSource, PagingState, LazyGridPagingItemsStatesTest, PagingSource + +### Community 140 - "lazyPagingItemsIndexedStatesWithNeighbours" +Cohesion: 0.24 +Nodes (11): Composable, index, item, LazyItemScope, LazyListScope, LazyPagingItems, nextItem, PaddingValues (+3 more) + +### Community 141 - "LazyPagingItemsStatesTest" +Cohesion: 0.26 +Nodes (5): LoadParams, PagingSource, PagingState, LazyPagingItemsStatesTest, PagingSource + +### Community 143 - "publish.sh" +Cohesion: 0.24 +Nodes (11): fail(), GRADLE_OPTS, GROUP, info(), read_field(), RELEASE_BRANCH, publish.sh script, SIGNING_VARS (+3 more) + +### Community 144 - "Usage" +Cohesion: 0.18 +Nodes (10): API Response Format, BaseRepo-Paging-AppolyJson, Features, Installation, Step 1: Create API Service Interface, Step 2: Add Repository Method to Fetch Pages, Step 3: Create a PagingSource Factory, Step 4: Use in ViewModel (+2 more) + +### Community 145 - "MultipartApis.kt" +Cohesion: 0.29 +Nodes (8): body, header, headermap, post, put, ApiResponse, RequestBody, url + +### Community 146 - "serializableMutableStateOf" +Cohesion: 0.35 +Nodes (3): serializableMutableStateOf(), T, SerializableMutableStateTest + +### Community 148 - "lazyPagingItemsStates" +Cohesion: 0.31 +Nodes (10): Composable, GridItemSpan, item, LazyGridItemScope, LazyGridItemSpanScope, LazyGridScope, LazyPagingItems, PaddingValues (+2 more) -### Community 59 - "Upload Lifecycle Callbacks" -Cohesion: 0.2 -Nodes (4): Abort, BeforeUploadResult, Continue, UploadLifecycleCallbacks +### Community 149 - "LazyListScope.lazyPagingItemsStates" +Cohesion: 0.20 +Nodes (7): LoadParams, LoadResult, PagingSource, PagingState, LazyListPagingLoadingTest, PagingSource, LazyListScope.lazyPagingItemsStates -### Community 60 - "Instant Serializer Tests" +### Community 150 - "AppolyDroid Toolbox" +Cohesion: 0.18 +Nodes (11): AppolyDroid Toolbox, Dependencies, Individual Module Installation, Installation, License, Overview, R8 / ProGuard, Using the BOM (Bill of Materials) (+3 more) + +### Community 151 - "GetPreSignedUrlResponse" +Cohesion: 0.36 +Nodes (7): APIs, APIService, ApiResponse, RequestBody, GetPreSignedUrlBody, GetPreSignedUrlResponse, s3uploadlog + +### Community 152 - "MultipartUploadManager" +Cohesion: 0.20 +Nodes (10): APIResult, GenericBaseRepo.cancelMultipartUpload, GenericBaseRepo.observeAllMultipartUploads, GenericBaseRepo.observeMultipartUploadProgress, GenericBaseRepo.pauseMultipartUpload, GenericBaseRepo.resumeMultipartUpload, GenericBaseRepo.startMultipartUpload, MultipartUploadResult.toAPIResult (+2 more) + +### Community 153 - "ScannerOverlayScope.kt" +Cohesion: 0.31 +Nodes (6): DetectedBarcode, Rect, ScannerOverlayScope, ScannerOverlayScopeImpl, BoxScope, stable + +### Community 154 - "EnumSerializersTest" +Cohesion: 0.20 +Nodes (4): ColorStringSerializer, EnumSerializersTest, Holder, EnumSerializers + +### Community 155 - "EnumAsStringSerializer" Cohesion: 0.33 -Nodes (3): InstantSerializerTest, NullableWrapper, Wrapper +Nodes (6): EnumAsStringSerializer, Decoder, Encoder, KSerializer, SerialDescriptor, T + +### Community 156 - "NullableEnumAsIntSerializer.kt" +Cohesion: 0.23 +Nodes (10): Decoder, Encoder, KSerializer, SerialDescriptor, T, NullableEnumAsIntSerializer, experimentalserializationapi, nullable (+2 more) + +### Community 159 - "serialname" +Cohesion: 0.24 +Nodes (4): PresignPartRequest, PresignPartData, PresignPartResponse, serialname + +### Community 160 - "EnumAsIntSerializer" +Cohesion: 0.33 +Nodes (6): EnumAsIntSerializer, Decoder, Encoder, KSerializer, SerialDescriptor, T + +### Community 161 - "ConnectivityLogger" +Cohesion: 0.29 +Nodes (5): ConnectivityLogger, FlexiLog, LogType, FlexiLogger, MockInterceptor + +### Community 162 - "DateHelperUtil-Serialization" +Cohesion: 0.20 +Nodes (10): 1. Enable Kotlin Serialization Plugin, 2. Use Serializers in Data Classes, 3. Serializing to JSON, DateHelperUtil-Serialization, Dependencies, Example: Custom JSON Configuration, Features, Installation (+2 more) + +### Community 163 - "PagingSource" +Cohesion: 0.22 +Nodes (6): LoadParams, LoadResult, PagingSource, PagingState, LazyGridPagingLoadingTest, PagingSource -### Community 61 - "Appoly BaseResponse Handling" +### Community 164 - "LazyListAllOverloadsTest" Cohesion: 0.22 -Nodes (10): APIResult, AppolyBaseRepo.doAPICallWithBaseResponse, AppolyBaseRepo.extractErrorMessage, BaseResponse, ErrorBody, GenericResponse, parseBody, ResponseModelsTest (+2 more) +Nodes (6): LoadParams, LoadResult, PagingSource, PagingState, LazyListAllOverloadsTest, PagingSource -### Community 65 - "Date Serializers Tests" +### Community 165 - "LazyPagingItemsStatesOverloadsTest" +Cohesion: 0.22 +Nodes (6): LoadParams, LoadResult, PagingSource, PagingState, LazyPagingItemsStatesOverloadsTest, PagingSource + +### Community 166 - "TestScreens.kt" +Cohesion: 0.27 +Nodes (5): HomeScreen, Nav3Screen, ListScreen, OtherTabScreen, SettingsScreen + +### Community 169 - "BaseRepoLogger" +Cohesion: 0.33 +Nodes (4): BaseRepoLogger, FlexiLog, LogType, loggerwithlevel + +### Community 170 - "Extensions.kt" Cohesion: 0.31 -Nodes (4): DateSerializersTest, DateTimeHolder, LocalDateHolder, ZonedHolder +Nodes (7): ifNullOrBlank, ifNullOrBlank2(), C, baserepolog, charset, standardcharsets, workerthread -### Community 69 - "Multipart Manager & DB Entities" +### Community 171 - "NullableEnumAsStringSerializer.kt" Cohesion: 0.39 -Nodes (9): MultipartUploadManager, MultipartUploadManagerPipelineTest, MultipartUploadWorkerTest, PartUploadStatus, S3UploaderDatabase, SessionWithParts, UploadSessionEntity, UploadSessionStatus (+1 more) +Nodes (6): Decoder, Encoder, KSerializer, SerialDescriptor, T, NullableEnumAsStringSerializer -### Community 74 - "Multipart Config Group" -Cohesion: 0.32 -Nodes (8): MultipartApiUrls, MultipartUploadConfig, MultipartUploadManager, MultipartUploadWorker, S3UploadWorkManager, UploadConstraints, UploadNetworkType, UploadRecoveryWorker +### Community 172 - ".successRoot" +Cohesion: 0.25 +Nodes (3): ApiResponse, retrofit2, TestRootJson + +### Community 173 - "FlowRepo" +Cohesion: 0.31 +Nodes (3): FlowRepo, ApiResponse, kotlinx -### Community 90 - "S3Uploader-Multipart (misc)" -Cohesion: 0.4 -Nodes (3): buildDatabase(), getInstance(), S3UploaderDatabase +### Community 174 - "getActivity" +Cohesion: 0.25 +Nodes (6): getActiveActivity(), getActivity(), keyboardAsState(), ComponentActivity, State, ComposeExtensionsComposeTest -### Community 92 - "S3Uploader-Multipart (misc)" +### Community 175 - "DateHelperLogAttributionTest" +Cohesion: 0.22 +Nodes (3): Recoverable fallback must not log ERROR, DateHelperLogAttributionTest, DateHelper + +### Community 178 - "InstantSerializerTest" +Cohesion: 0.39 +Nodes (3): InstantSerializerTest, NullableWrapper, Wrapper + +### Community 180 - "LazyGridLoadingStateItem.kt" Cohesion: 0.33 -Nodes (5): Cancelled, Error, MultipartUploadResult, Paused, Success +Nodes (7): GridItemSpan, LazyGridItemSpanScope, PaddingValues, loadingStateItem(), PaddingValues, loadingStateItem(), localloadingstate -### Community 97 - "MockInterceptor-Retrofit (misc)" -Cohesion: 0.4 -Nodes (6): Retrofit annotation mocking (concept), extractRetrofitRoute, MockRouteBuilder.mockApi, MockApiBuilder, MockRouteBuilder, KFunction references for compile-time mock safety +### Community 181 - "lazyPagingItemsStatesWithNeighbours" +Cohesion: 0.36 +Nodes (8): Composable, item, LazyItemScope, LazyListScope, LazyPagingItems, PaddingValues, T, lazyPagingItemsStatesWithNeighbours() -### Community 100 - "buildSrc (misc)" -Cohesion: 0.4 -Nodes (3): BuildConfig, MinSdk, Sdk +### Community 183 - "Log" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, Log -### Community 105 - "BaseRepo (misc)" -Cohesion: 0.5 -Nodes (5): parseBody, GenericBaseRepo.getServiceManager, BaseRetrofitClient, BaseService, ServiceManager +### Community 184 - "Nav3NavigationDemoScreen" +Cohesion: 0.29 +Nodes (4): Nav3Screen, Nav3NavigationDemoScreen, Nav3PickerScreen, Nav3StackProbeScreen -### Community 106 - "PagingExtensions (misc)" -Cohesion: 0.6 -Nodes (5): PagingStateComposablesTest, CompositionLocal state providers, EmptyStateTextProvider, ErrorStateProvider, LoadingStateProvider +### Community 185 - "BaseAppolyRepoLogger" +Cohesion: 0.39 +Nodes (3): BaseAppolyRepoLogger, FlexiLog, LogType -### Community 109 - "S3Uploader (misc)" -Cohesion: 0.5 -Nodes (3): Error, Success, UploadResult +### Community 186 - "SilentTestLogger" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, SilentTestLogger -### Community 110 - "S3Uploader (misc)" -Cohesion: 0.5 -Nodes (3): DirectUploadResult, Error, Success +### Community 187 - "SilentTestLogger" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, SilentTestLogger -### Community 122 - "MockInterceptor (misc)" -Cohesion: 0.67 -Nodes (4): MockApiInterceptor, MockApiInterceptorTest, MockRequestContext, MockResponseBuilder +### Community 188 - "SilentTestLogger" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, SilentTestLogger -### Community 123 - "PagingExtensions (misc)" -Cohesion: 0.67 -Nodes (4): PagingExtensionsTest, LoadState extensions, PagingExtensions, PagingErrorType +### Community 191 - "SilentTestLogger" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, SilentTestLogger -### Community 124 - "ComposeExtensions (misc)" -Cohesion: 0.5 -Nodes (4): navigationBarsOrIme, navigationBarsOrImePadding, navigationBarsOrNoneIfIme, navigationBarsOrNoneIfImePadding +### Community 192 - "SilentTestLogger" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, SilentTestLogger -### Community 133 - "LazyGridPagingExtensions (misc)" -Cohesion: 0.67 -Nodes (3): LazyGridAllOverloadsTest, LazyGrid lazyPagingItems, LazyGrid lazyPagingItemsWithNeighbours +### Community 193 - "DateHelperLogger" +Cohesion: 0.39 +Nodes (3): DateHelperLogger, FlexiLog, LogType -### Community 134 - "DateHelperUtil (misc)" -Cohesion: 0.67 -Nodes (3): DateHelper.nowAsUTC, ZonedDateTime.toDeviceZone, ZonedDateTime.toUTC +### Community 194 - "SilentTestLogger" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, SilentTestLogger -### Community 135 - "ComposeExtensions (misc)" -Cohesion: 0.67 -Nodes (3): ScrollIntoViewAnimatedVisibility, Modifier.hideWithIme, ComposeExtensions +### Community 195 - "MockInterceptorLogger" +Cohesion: 0.39 +Nodes (3): FlexiLog, LogType, MockInterceptorLogger -### Community 136 - "buildSrc (misc)" -Cohesion: 1.0 -Nodes (3): BuildConfig, BuildConfig.TOOLBOX_VERSION, UpdateReadmeVersions +### Community 196 - "Nav3RetentionScopeTest" +Cohesion: 0.29 +Nodes (3): ViewModel, Nav3RetentionScopeTest, ProbeViewModel -### Community 137 - "BaseRepo-S3Uploader-Multipart (misc)" -Cohesion: 0.67 -Nodes (3): GenericBaseRepo.enableMultipartUploadAutoRecovery, GenericBaseRepo.scheduleMultipartUploadWork, S3UploadWorkManager +### Community 197 - "S3UploadWorkManagerTest" +Cohesion: 0.13 +Nodes (9): Context, ListenableWorker, Result, Worker, WorkerFactory, WorkerParameters, S3UploadWorkManagerTest, WorkerFactory (+1 more) + +### Community 198 - "firstNotNullOrBlank" +Cohesion: 0.39 +Nodes (3): firstNotNullOrBlank(), C, ExtensionsTest + +### Community 199 - "APIResult" +Cohesion: 0.07 +Nodes (33): apiresponse, doNestedPagedAPICall(), T, doPagedAPICall(), T, `APIResult.Error.responseCode` values, Basic Repository Setup, Making API Calls (+25 more) + +### Community 200 - "S3Uploader" +Cohesion: 0.17 +Nodes (5): HeaderProvider, FlexiLog, LoggingLevel, LogType, S3Uploader + +### Community 202 - "APIFlowStatePagingExtensionsTest" +Cohesion: 0.33 +Nodes (5): PagingData, APIFlowStatePagingExtensionsTest, APIFlowState, asPagingData, mapToPagingData + +### Community 203 - "SilentTestLogger" +Cohesion: 0.43 +Nodes (3): FlexiLog, LogType, SilentTestLogger + +### Community 204 - "Features" +Cohesion: 0.43 +Nodes (7): Features, gradientTint(), hideWithIme(), Brush, Modifier, navigationBarsOrImePadding(), navigationBarsOrNoneIfImePadding() + +### Community 205 - "DateHelper.parseServerInstant" +Cohesion: 0.33 +Nodes (7): Carbon/Laravel short-format fallback (1.4.1 regression fix), Type-level UTC enforcement for server timestamps, DateHelper.formatServerTimestamp, DateHelper.parseServerInstant, DateHelper.parseServerZoneDateTime, SERVER_PATTERN_FULL (deprecated), SERVER_PATTERN_FULL_OFFSET + +### Community 210 - "AppolyBaseRepoS3Extensions.kt" +Cohesion: 0.53 +Nodes (5): MediaType, MutableStateFlow, T, uploadFileDirectToS3(), uploadFileToS3() + +### Community 218 - "gradlew" +Cohesion: 0.83 +Nodes (3): gradlew script, die(), warn() + +### Community 219 - "Q: How do UploadResult and APIResult relate? Should they converge or is the decoupling deliberate?" +Cohesion: 0.50 +Nodes (3): Answer, Q: How do UploadResult and APIResult relate? Should they converge or is the decoupling deliberate?, Source Nodes + +### Community 220 - "Q: Are LazyGridPagingExtensions and LazyListPagingExtensions duplicated code that should be DRYed up?" +Cohesion: 0.50 +Nodes (3): Answer, Q: Are LazyGridPagingExtensions and LazyListPagingExtensions duplicated code that should be DRYed up?, Source Nodes ## Knowledge Gaps -- **233 isolated node(s):** `UiState`, `Idle`, `Loading`, `Success`, `Error` (+228 more) - These have ≤1 connection - possible missing edges or undocumented components. -- **97 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. +- **284 isolated node(s):** `Info`, `Success`, `Error`, `Back`, `Front` (+279 more) + These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 1215 node(s) total have ≤1 connection when file, concept and rationale nodes are included.) +- **53 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes. ## Suggested Questions _Questions this graph is uniquely positioned to answer:_ -- **Why does `ResponseModelsTest` connect `Appoly Response & Lazy State Items` to `Appoly BaseResponse Tests`?** - _High betweenness centrality (0.044) - this node is a cross-community bridge._ -- **Why does `RefreshableAPIFlow` connect `RefreshableAPIFlow & Composables` to `Compose Animation & Mock Demo`?** - _High betweenness centrality (0.044) - this node is a cross-community bridge._ -- **What connects `UiState`, `Idle`, `Loading` to the rest of the system?** - _233 weakly-connected nodes found - possible documentation gaps or missing edges._ -- **Should `S3 Multipart API Models & Headers` be split into smaller, more focused modules?** - _Cohesion score 0.06 - nodes in this community are weakly interconnected._ -- **Should `Multipart DAO Constraint Tests` be split into smaller, more focused modules?** - _Cohesion score 0.06 - nodes in this community are weakly interconnected._ -- **Should `Demo App Screens & Navigation` be split into smaller, more focused modules?** - _Cohesion score 0.05 - nodes in this community are weakly interconnected._ -- **Should `APIResult / APIFlowState Core` be split into smaller, more focused modules?** - _Cohesion score 0.05 - nodes in this community are weakly interconnected._ \ No newline at end of file +- **Why does `APIResult` connect `APIResult` to `test`, `APIFlowState`, `androidjunit4`, `UpdateReadmeVersions`, `GenericBaseRepoMockWebServerTest`, `logginglevel`, `GenericPagingSource`, `AppolyJsonDemoViewModel.kt`, `MultipartUploadDemoViewModel`, `NoConnectivityException`, `BaseRepoDemoViewModel`, `.success`, `AppolyBaseRepo.kt`, `AppolyBaseRepoS3Extensions.kt`, `APIFlowState.kt`, `AppolyBaseRepoTest`, `UploadResult`, `RefreshableAPIFlow`, `TestBackendRepository.kt`, `AppolyBaseRepoS3MultipartExtensions.kt`, `APIResult.kt`, `GenericInvalidatingPagingSourceFactory.kt`, `BarcodeScanner-Camera`, `PagingDemoViewModel`, `RefreshableAPIFlow.kt`?** + _High betweenness centrality (0.060) - this node is a cross-community bridge._ +- **Why does `MultipartUploadDemoScreen` connect `MultipartUploadDemoScreen` to `serializable`, `androidjunit4`, `UploadSessionStatus`, `SegmentedControl.kt`, `MultipartUploadProgress`, `ErrorState.kt`, `composable`, `MultipartUploadDemoViewModel`, `TabsDemoScreen.kt`, `SegmentedControlDemoScreen`?** + _High betweenness centrality (0.059) - this node is a cross-community bridge._ +- **Why does `MockInterceptorDemoViewModel` connect `MockInterceptorDemoViewModel` to `serializable`, `androidjunit4`, `MockApiInterceptorTest`, `MultipartApis.kt`, `MultipartUploadWorker`, `logginglevel`, `MockApiInterceptor`, `AnimatedScanFrame.kt`, `ProgressRequestBody`, `pagedBody`, `Log`, `file`, `MockRetrofitTest`, `DemoDatabase.kt`, `DateSerializationRoomDemoViewModel.kt`, `MockSerializationTest`, `Log (FlexiLog)`, `MockRouteBuilder`, `PagingDemoViewModel`?** + _High betweenness centrality (0.047) - this node is a cross-community bridge._ +- **Are the 4 inferred relationships involving `APIResult` (e.g. with `Testing repositories built on BaseRepo` and `APIFlowState`) actually correct?** + _`APIResult` has 4 INFERRED edges - model-reasoned connections that need verification._ +- **Are the 8 inferred relationships involving `MockInterceptorDemoViewModel` (e.g. with `.mock()` and `.emptyBody()`) actually correct?** + _`MockInterceptorDemoViewModel` has 8 INFERRED edges - model-reasoned connections that need verification._ +- **What connects `Info`, `Success`, `Error` to the rest of the system?** + _284 weakly-connected nodes found - possible documentation gaps or missing edges._ +- **Should `test` be split into smaller, more focused modules?** + _Cohesion score 0.09076682316118936 - nodes in this community are weakly interconnected._ \ No newline at end of file From 90aba0149f7e2432ac06a493444351266e1bb65b Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:30:46 +0100 Subject: [PATCH 41/53] fix(BarcodeScanner-Camera): mirror overlay coordinates on the front camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../camera/BarcodeScannerCamera.kt | 12 +++++-- .../camera/ScanRegionResolver.kt | 11 +++++- .../camera/ScanRegionResolverTest.kt | 35 +++++++++++++++++++ .../ui/screens/BarcodeScannerDemoScreen.kt | 9 +++++ 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index 42db1b9..da7ed70 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -169,6 +169,7 @@ fun BarcodeScannerCamera( tracker = tracker, crop = crop, previewSize = previewSize, + mirrored = lensFacing == LensFacing.Front, ) }, onDetectionFailed = { currentOnError(it) }, @@ -332,20 +333,25 @@ private fun List.toDetections( tracker: BarcodeTracker, crop: android.graphics.Rect, previewSize: Size, + mirrored: Boolean, ): List { if (previewSize.width <= 0f || crop.width() <= 0 || crop.height() <= 0) return emptyList() return mapNotNull { barcode -> val box = barcode.boundingBox ?: return@mapNotNull null val scanned = barcode.toScannedBarcode() ?: return@mapNotNull null - val topLeft = mapToPreview(box.left, box.top, crop, previewSize) - val bottomRight = mapToPreview(box.right, box.bottom, crop, previewSize) + // Mirroring swaps which horizontal edge is "left", so build the Rect from the extremes + // rather than assuming the mapped corners keep their names. + val a = mapToPreview(box.left, box.top, crop, previewSize, mirrored) + val b = mapToPreview(box.right, box.bottom, crop, previewSize, mirrored) + val topLeft = androidx.compose.ui.geometry.Offset(minOf(a.x, b.x), minOf(a.y, b.y)) + val bottomRight = androidx.compose.ui.geometry.Offset(maxOf(a.x, b.x), maxOf(a.y, b.y)) DetectedBarcode( barcode = scanned, bounds = Rect(topLeft, bottomRight), // cornerPoints follow the code's own rotation, unlike boundingBox which is always // axis-aligned — they are the only way an overlay can outline a tilted barcode. corners = barcode.cornerPoints - ?.map { mapToPreview(it.x, it.y, crop, previewSize) } + ?.map { mapToPreview(it.x, it.y, crop, previewSize, mirrored) } .orEmpty(), dwellProgress = tracker.dwellProgress(scanned.rawValue), ) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt index 543e584..550b18f 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt @@ -115,6 +115,9 @@ internal fun AndroidRect.rotatedInto( /** * Maps a point from the rotation-corrected analyser space into preview pixels. * + * [mirrored] handles the front camera, whose preview is flipped for display while the analysed + * buffer is not. + * * Everything hangs off [crop] rather than the full image: with a `ViewPort` the preview shows * exactly the cropped region, so scaling by the whole image makes every box too small and * forgetting the crop's origin shifts them all toward the top-left. Both at once is what a barcode @@ -125,10 +128,16 @@ internal fun mapToPreview( y: Int, crop: AndroidRect, previewSize: Size, + mirrored: Boolean = false, ): androidx.compose.ui.geometry.Offset { if (crop.width() <= 0 || crop.height() <= 0) return androidx.compose.ui.geometry.Offset.Zero + val mappedX = (x - crop.left) * previewSize.width / crop.width() return androidx.compose.ui.geometry.Offset( - x = (x - crop.left) * previewSize.width / crop.width(), + // The front camera's preview is mirrored for display — you expect to move left and see + // yourself move left — but the analyser receives the unmirrored buffer, so ML Kit's + // coordinates are in the frame the user is NOT looking at. Without this every overlay on + // the front lens is drawn on the wrong side of the screen. + x = if (mirrored) previewSize.width - mappedX else mappedX, y = (y - crop.top) * previewSize.height / crop.height(), ) } diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt index bb606fb..386cfd3 100644 --- a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt @@ -126,6 +126,41 @@ class ScanRegionResolverTest { assertEquals(800f, farCorner.y, 0.01f) } + @Test + fun `the front camera mirrors x but leaves y alone`() { + // The preview is flipped for display while the analysed buffer is not, so a code on the + // user's left arrives with coordinates on the right. Without mirroring here, every overlay + // on the front lens lands on the wrong side of the screen. + val crop = Rect(0, 0, 100, 100) + val preview = Size(100f, 100f) + + val mirrored = mapToPreview(x = 10, y = 30, crop = crop, previewSize = preview, mirrored = true) + + assertEquals(90f, mirrored.x, 0.01f) + assertEquals("vertical must not flip — the mirror is horizontal only", 30f, mirrored.y, 0.01f) + } + + @Test + fun `mirroring twice returns the original x`() { + val crop = Rect(0, 0, 200, 200) + val preview = Size(200f, 200f) + + val once = mapToPreview(x = 40, y = 0, crop = crop, previewSize = preview, mirrored = true) + val back = mapToPreview(x = once.x.toInt(), y = 0, crop = crop, previewSize = preview, mirrored = true) + + assertEquals(40f, back.x, 0.01f) + } + + @Test + fun `the back camera is unmirrored`() { + val crop = Rect(0, 0, 100, 100) + val preview = Size(100f, 100f) + + val plain = mapToPreview(x = 10, y = 30, crop = crop, previewSize = preview, mirrored = false) + + assertEquals(10f, plain.x, 0.01f) + } + @Test fun `a degenerate crop maps to the origin rather than dividing by zero`() { val empty = Rect(0, 0, 0, 0) diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index 8a3db1a..5ccc34e 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -65,6 +65,7 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.drawscope.Stroke import uk.co.appoly.droid.barcodescanner.camera.AnimatedScanFrame +import uk.co.appoly.droid.barcodescanner.camera.LensFacing import uk.co.appoly.droid.barcodescanner.camera.DefaultScanFrame import uk.co.appoly.droid.barcodescanner.camera.ScannerOverlayScope import uk.co.appoly.droid.barcodescanner.camera.BarcodeScannerCamera @@ -260,6 +261,7 @@ data object BarcodeScannerDemoScreen : Nav3Screen { var paused by remember { mutableStateOf(false) } var overlayStyle by remember { mutableStateOf("Animated") } var haptics by remember { mutableStateOf(true) } + var lens by remember { mutableStateOf(LensFacing.Back) } val hapticFeedback = LocalHapticFeedback.current var lastScan by remember { mutableStateOf(null) } @@ -286,6 +288,12 @@ data object BarcodeScannerDemoScreen : Nav3Screen { selectedSegment = regionChoice, onSegmentSelected = { regionChoice = it }, ) + SegmentedControl( + segments = listOf(LensFacing.Back, LensFacing.Front), + selectedSegment = lens, + onSegmentSelected = { lens = it }, + segmentText = { if (it == LensFacing.Back) "Back cam" else "Front cam" }, + ) SegmentedControl( segments = listOf("Frame", "Animated", "Corners", "Bullseye"), selectedSegment = overlayStyle, @@ -323,6 +331,7 @@ data object BarcodeScannerDemoScreen : Nav3Screen { ) { BarcodeScannerCamera( modifier = Modifier.fillMaxSize(), + lensFacing = lens, torchEnabled = torchEnabled, scanningEnabled = !paused, policy = policy, From c8eb2a57dd4650c0649d0b40b87a417b61e8dcb8 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:38:21 +0100 Subject: [PATCH 42/53] demo(BarcodeScanner): lay the scanner sheet out side by side in landscape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ui/screens/BarcodeScannerDemoScreen.kt | 239 ++++++++++-------- 1 file changed, 134 insertions(+), 105 deletions(-) diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index 5ccc34e..a354ab9 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -1,5 +1,9 @@ package uk.co.appoly.droid.ui.screens +import android.content.res.Configuration +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.ColumnScope import android.Manifest import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult @@ -245,14 +249,12 @@ data object BarcodeScannerDemoScreen : Nav3Screen { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( sheetState = sheetState, + // Material caps a sheet at 640dp on wide screens, which wastes most of a + // landscape phone — and width is exactly what this layout wants. + sheetMaxWidth = Dp.Unspecified, onDismissRequest = { showSheet = false }, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + run { // Every ScanPolicy knob is driven live from here, so the sheet doubles as the // place to feel what each one does rather than reason about it. var mode by remember { mutableStateOf(ScanMode.Single) } @@ -277,108 +279,135 @@ data object BarcodeScannerDemoScreen : Nav3Screen { ) } - SegmentedControl( - segments = listOf(ScanMode.Single, ScanMode.Multi), - selectedSegment = mode, - onSegmentSelected = { mode = it }, - segmentText = { it.name }, - ) - SegmentedControl( - segments = listOf("Full", "Visible", "Reticle"), - selectedSegment = regionChoice, - onSegmentSelected = { regionChoice = it }, - ) - SegmentedControl( - segments = listOf(LensFacing.Back, LensFacing.Front), - selectedSegment = lens, - onSegmentSelected = { lens = it }, - segmentText = { if (it == LensFacing.Back) "Back cam" else "Front cam" }, - ) - SegmentedControl( - segments = listOf("Frame", "Animated", "Corners", "Bullseye"), - selectedSegment = overlayStyle, - onSegmentSelected = { overlayStyle = it }, - ) - SegmentedControl( - segments = listOf(0, 250, 500, 1000), - selectedSegment = dwellMs, - onSegmentSelected = { dwellMs = it }, - segmentText = { if (it == 0) "no dwell" else "${it}ms" }, - ) - - TorchToggleRow( - modifier = Modifier.fillMaxWidth(), - checked = torchEnabled, - onCheckedChange = { torchEnabled = it }, - ) - TorchToggleRow( - modifier = Modifier.fillMaxWidth(), - label = "Haptic on scan", - checked = haptics, - onCheckedChange = { haptics = it }, - ) - TorchToggleRow( - modifier = Modifier.fillMaxWidth(), - label = "Pause scanning", - checked = paused, - onCheckedChange = { paused = it }, - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(360.dp), - ) { - BarcodeScannerCamera( - modifier = Modifier.fillMaxSize(), - lensFacing = lens, - torchEnabled = torchEnabled, - scanningEnabled = !paused, - policy = policy, - overlay = { - when (overlayStyle) { - "Frame" -> DefaultScanFrame() - "Animated" -> AnimatedScanFrame() - // Written here rather than in the library, to show the scope - // gives a consumer everything needed to draw their own. - "Corners" -> CornerBracketOverlay() - else -> BullseyeOverlay() - } - }, - onError = { cameraError = it.message ?: it.toString() }, - onBarcodeScanned = { barcode -> - // Feedback (haptic, sound etc.) lives here rather than in the module, - // because only the app knows whether a scan was any *good*. - // Here "already in the list" stands in for the real thing — a - // code that is not on the manifest, or the wrong item — and gets - // the reject signal. - val isNew = scannedCodes.none { it.rawValue == barcode.rawValue } - if (haptics) { - hapticFeedback.performHapticFeedback( - if (isNew) HapticFeedbackType.Confirm - else HapticFeedbackType.Reject, - ) - } - lastScan = if (isNew) { - scannedCodes.add(barcode) - "${barcode.format}: ${barcode.rawValue}" - } else { - "Already scanned: ${barcode.rawValue}" - } - }, + // Landscape is short on height and flush with width, so the knobs sit beside the + // preview rather than above it — which also keeps the preview big enough to aim + // with, instead of pushing it off the bottom of the sheet. + val landscape = LocalConfiguration.current.orientation == + Configuration.ORIENTATION_LANDSCAPE + + val controls: @Composable ColumnScope.() -> Unit = { + SegmentedControl( + segments = listOf(ScanMode.Single, ScanMode.Multi), + selectedSegment = mode, + onSegmentSelected = { mode = it }, + segmentText = { it.name }, + ) + SegmentedControl( + segments = listOf("Full", "Visible", "Reticle"), + selectedSegment = regionChoice, + onSegmentSelected = { regionChoice = it }, + ) + SegmentedControl( + segments = listOf(LensFacing.Back, LensFacing.Front), + selectedSegment = lens, + onSegmentSelected = { lens = it }, + segmentText = { if (it == LensFacing.Back) "Back cam" else "Front cam" }, + ) + SegmentedControl( + segments = listOf("Frame", "Animated", "Corners", "Bullseye"), + selectedSegment = overlayStyle, + onSegmentSelected = { overlayStyle = it }, + ) + SegmentedControl( + segments = listOf(0, 250, 500, 1000), + selectedSegment = dwellMs, + onSegmentSelected = { dwellMs = it }, + segmentText = { if (it == 0) "no dwell" else "${it}ms" }, + ) + + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + checked = torchEnabled, + onCheckedChange = { torchEnabled = it }, + ) + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + label = "Haptic on scan", + checked = haptics, + onCheckedChange = { haptics = it }, + ) + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + label = "Pause scanning", + checked = paused, + onCheckedChange = { paused = it }, + ) + + Text( + text = lastScan?.let { "Last: $it" } + ?: "Hold a code inside the frame for ${dwellMs}ms", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "${scannedCodes.size} distinct code(s) scanned", + style = MaterialTheme.typography.bodyMedium, ) } + val preview: @Composable (Modifier) -> Unit = { previewModifier -> + Box(modifier = previewModifier) { + BarcodeScannerCamera( + modifier = Modifier.fillMaxSize(), + lensFacing = lens, + torchEnabled = torchEnabled, + scanningEnabled = !paused, + policy = policy, + overlay = { + when (overlayStyle) { + "Frame" -> DefaultScanFrame() + "Animated" -> AnimatedScanFrame() + // Written here rather than in the library, to show the scope + // gives a consumer everything needed to draw their own. + "Corners" -> CornerBracketOverlay() + else -> BullseyeOverlay() + } + }, + onError = { cameraError = it.message ?: it.toString() }, + onBarcodeScanned = { barcode -> + // Feedback (haptic, sound etc.) lives here rather than in the module, + // because only the app knows whether a scan was any *good*. + // Here "already in the list" stands in for the real thing — a + // code that is not on the manifest, or the wrong item — and gets + // the reject signal. + val isNew = scannedCodes.none { it.rawValue == barcode.rawValue } + if (haptics) { + hapticFeedback.performHapticFeedback( + if (isNew) HapticFeedbackType.Confirm + else HapticFeedbackType.Reject, + ) + } + lastScan = if (isNew) { + scannedCodes.add(barcode) + "${barcode.format}: ${barcode.rawValue}" + } else { + "Already scanned: ${barcode.rawValue}" + } + }, + ) + } + + } - Text( - text = lastScan?.let { "Last: $it" } - ?: "Hold a code inside the frame for ${dwellMs}ms", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = "${scannedCodes.size} distinct code(s) scanned", - style = MaterialTheme.typography.bodyMedium, - ) + if (landscape) { + Row( + modifier = Modifier.fillMaxWidth().height(340.dp).padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { controls() } + preview(Modifier.weight(1f).fillMaxHeight()) + } + } else { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + controls() + preview(Modifier.fillMaxWidth().height(360.dp)) + } + } } } } From fea1b979f79d1207dd28a8891864866439f38654 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:41:49 +0100 Subject: [PATCH 43/53] demo(BarcodeScanner): drop a pointless run {} wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ui/screens/BarcodeScannerDemoScreen.kt | 290 +++++++++--------- 1 file changed, 144 insertions(+), 146 deletions(-) diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt index a354ab9..4d9adb3 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -254,159 +254,157 @@ data object BarcodeScannerDemoScreen : Nav3Screen { sheetMaxWidth = Dp.Unspecified, onDismissRequest = { showSheet = false }, ) { - run { - // Every ScanPolicy knob is driven live from here, so the sheet doubles as the - // place to feel what each one does rather than reason about it. - var mode by remember { mutableStateOf(ScanMode.Single) } - var regionChoice by remember { mutableStateOf("Reticle") } - var dwellMs by remember { mutableIntStateOf(500) } - var paused by remember { mutableStateOf(false) } - var overlayStyle by remember { mutableStateOf("Animated") } - var haptics by remember { mutableStateOf(true) } - var lens by remember { mutableStateOf(LensFacing.Back) } - val hapticFeedback = LocalHapticFeedback.current - var lastScan by remember { mutableStateOf(null) } - - val policy = remember(mode, regionChoice, dwellMs) { - ScanPolicy( - mode = mode, - dwell = dwellMs.takeIf { it > 0 }?.milliseconds, - region = when (regionChoice) { - "Full" -> ScanRegion.Full - "Visible" -> ScanRegion.Visible - else -> ScanRegion.Reticle() - }, - ) - } - - // Landscape is short on height and flush with width, so the knobs sit beside the - // preview rather than above it — which also keeps the preview big enough to aim - // with, instead of pushing it off the bottom of the sheet. - val landscape = LocalConfiguration.current.orientation == - Configuration.ORIENTATION_LANDSCAPE - - val controls: @Composable ColumnScope.() -> Unit = { - SegmentedControl( - segments = listOf(ScanMode.Single, ScanMode.Multi), - selectedSegment = mode, - onSegmentSelected = { mode = it }, - segmentText = { it.name }, - ) - SegmentedControl( - segments = listOf("Full", "Visible", "Reticle"), - selectedSegment = regionChoice, - onSegmentSelected = { regionChoice = it }, - ) - SegmentedControl( - segments = listOf(LensFacing.Back, LensFacing.Front), - selectedSegment = lens, - onSegmentSelected = { lens = it }, - segmentText = { if (it == LensFacing.Back) "Back cam" else "Front cam" }, - ) - SegmentedControl( - segments = listOf("Frame", "Animated", "Corners", "Bullseye"), - selectedSegment = overlayStyle, - onSegmentSelected = { overlayStyle = it }, - ) - SegmentedControl( - segments = listOf(0, 250, 500, 1000), - selectedSegment = dwellMs, - onSegmentSelected = { dwellMs = it }, - segmentText = { if (it == 0) "no dwell" else "${it}ms" }, - ) - - TorchToggleRow( - modifier = Modifier.fillMaxWidth(), - checked = torchEnabled, - onCheckedChange = { torchEnabled = it }, - ) - TorchToggleRow( - modifier = Modifier.fillMaxWidth(), - label = "Haptic on scan", - checked = haptics, - onCheckedChange = { haptics = it }, - ) - TorchToggleRow( - modifier = Modifier.fillMaxWidth(), - label = "Pause scanning", - checked = paused, - onCheckedChange = { paused = it }, - ) + // Every ScanPolicy knob is driven live from here, so the sheet doubles as the + // place to feel what each one does rather than reason about it. + var mode by remember { mutableStateOf(ScanMode.Single) } + var regionChoice by remember { mutableStateOf("Reticle") } + var dwellMs by remember { mutableIntStateOf(500) } + var paused by remember { mutableStateOf(false) } + var overlayStyle by remember { mutableStateOf("Animated") } + var haptics by remember { mutableStateOf(true) } + var lens by remember { mutableStateOf(LensFacing.Back) } + val hapticFeedback = LocalHapticFeedback.current + var lastScan by remember { mutableStateOf(null) } + + val policy = remember(mode, regionChoice, dwellMs) { + ScanPolicy( + mode = mode, + dwell = dwellMs.takeIf { it > 0 }?.milliseconds, + region = when (regionChoice) { + "Full" -> ScanRegion.Full + "Visible" -> ScanRegion.Visible + else -> ScanRegion.Reticle() + }, + ) + } - Text( - text = lastScan?.let { "Last: $it" } - ?: "Hold a code inside the frame for ${dwellMs}ms", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Text( - text = "${scannedCodes.size} distinct code(s) scanned", - style = MaterialTheme.typography.bodyMedium, + // Landscape is short on height and flush with width, so the knobs sit beside the + // preview rather than above it — which also keeps the preview big enough to aim + // with, instead of pushing it off the bottom of the sheet. + val landscape = LocalConfiguration.current.orientation == + Configuration.ORIENTATION_LANDSCAPE + + val controls: @Composable ColumnScope.() -> Unit = { + SegmentedControl( + segments = listOf(ScanMode.Single, ScanMode.Multi), + selectedSegment = mode, + onSegmentSelected = { mode = it }, + segmentText = { it.name }, + ) + SegmentedControl( + segments = listOf("Full", "Visible", "Reticle"), + selectedSegment = regionChoice, + onSegmentSelected = { regionChoice = it }, + ) + SegmentedControl( + segments = listOf(LensFacing.Back, LensFacing.Front), + selectedSegment = lens, + onSegmentSelected = { lens = it }, + segmentText = { if (it == LensFacing.Back) "Back cam" else "Front cam" }, + ) + SegmentedControl( + segments = listOf("Frame", "Animated", "Corners", "Bullseye"), + selectedSegment = overlayStyle, + onSegmentSelected = { overlayStyle = it }, + ) + SegmentedControl( + segments = listOf(0, 250, 500, 1000), + selectedSegment = dwellMs, + onSegmentSelected = { dwellMs = it }, + segmentText = { if (it == 0) "no dwell" else "${it}ms" }, + ) + + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + checked = torchEnabled, + onCheckedChange = { torchEnabled = it }, + ) + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + label = "Haptic on scan", + checked = haptics, + onCheckedChange = { haptics = it }, + ) + TorchToggleRow( + modifier = Modifier.fillMaxWidth(), + label = "Pause scanning", + checked = paused, + onCheckedChange = { paused = it }, + ) + + Text( + text = lastScan?.let { "Last: $it" } + ?: "Hold a code inside the frame for ${dwellMs}ms", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "${scannedCodes.size} distinct code(s) scanned", + style = MaterialTheme.typography.bodyMedium, + ) + } + val preview: @Composable (Modifier) -> Unit = { previewModifier -> + Box(modifier = previewModifier) { + BarcodeScannerCamera( + modifier = Modifier.fillMaxSize(), + lensFacing = lens, + torchEnabled = torchEnabled, + scanningEnabled = !paused, + policy = policy, + overlay = { + when (overlayStyle) { + "Frame" -> DefaultScanFrame() + "Animated" -> AnimatedScanFrame() + // Written here rather than in the library, to show the scope + // gives a consumer everything needed to draw their own. + "Corners" -> CornerBracketOverlay() + else -> BullseyeOverlay() + } + }, + onError = { cameraError = it.message ?: it.toString() }, + onBarcodeScanned = { barcode -> + // Feedback (haptic, sound etc.) lives here rather than in the module, + // because only the app knows whether a scan was any *good*. + // Here "already in the list" stands in for the real thing — a + // code that is not on the manifest, or the wrong item — and gets + // the reject signal. + val isNew = scannedCodes.none { it.rawValue == barcode.rawValue } + if (haptics) { + hapticFeedback.performHapticFeedback( + if (isNew) HapticFeedbackType.Confirm + else HapticFeedbackType.Reject, + ) + } + lastScan = if (isNew) { + scannedCodes.add(barcode) + "${barcode.format}: ${barcode.rawValue}" + } else { + "Already scanned: ${barcode.rawValue}" + } + }, ) } - val preview: @Composable (Modifier) -> Unit = { previewModifier -> - Box(modifier = previewModifier) { - BarcodeScannerCamera( - modifier = Modifier.fillMaxSize(), - lensFacing = lens, - torchEnabled = torchEnabled, - scanningEnabled = !paused, - policy = policy, - overlay = { - when (overlayStyle) { - "Frame" -> DefaultScanFrame() - "Animated" -> AnimatedScanFrame() - // Written here rather than in the library, to show the scope - // gives a consumer everything needed to draw their own. - "Corners" -> CornerBracketOverlay() - else -> BullseyeOverlay() - } - }, - onError = { cameraError = it.message ?: it.toString() }, - onBarcodeScanned = { barcode -> - // Feedback (haptic, sound etc.) lives here rather than in the module, - // because only the app knows whether a scan was any *good*. - // Here "already in the list" stands in for the real thing — a - // code that is not on the manifest, or the wrong item — and gets - // the reject signal. - val isNew = scannedCodes.none { it.rawValue == barcode.rawValue } - if (haptics) { - hapticFeedback.performHapticFeedback( - if (isNew) HapticFeedbackType.Confirm - else HapticFeedbackType.Reject, - ) - } - lastScan = if (isNew) { - scannedCodes.add(barcode) - "${barcode.format}: ${barcode.rawValue}" - } else { - "Already scanned: ${barcode.rawValue}" - } - }, - ) - } - } + } - if (landscape) { - Row( - modifier = Modifier.fillMaxWidth().height(340.dp).padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - Column( - modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { controls() } - preview(Modifier.weight(1f).fillMaxHeight()) - } - } else { + if (landscape) { + Row( + modifier = Modifier.fillMaxWidth().height(340.dp).padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), + modifier = Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - controls() - preview(Modifier.fillMaxWidth().height(360.dp)) - } + ) { controls() } + preview(Modifier.weight(1f).fillMaxHeight()) + } + } else { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + controls() + preview(Modifier.fillMaxWidth().height(360.dp)) } } } From 0274f1502508e7201e6a07f7800a26b79e913d54 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 13:58:58 +0100 Subject: [PATCH 44/53] fix(BarcodeScanner-Camera): map overlay coordinates the way the viewfinder 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) --- .../camera/BarcodeScannerCamera.kt | 4 +- .../camera/ScanRegionResolver.kt | 86 ++++++++++++++---- .../camera/ScanRegionResolverTest.kt | 87 +++++++++++++++++++ 3 files changed, 158 insertions(+), 19 deletions(-) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index da7ed70..2a1c3a3 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -156,6 +156,7 @@ fun BarcodeScannerCamera( BarcodeAnalyzer( scanner = scanner, region = policy.region, + previewSize = { previewSize }, callbackExecutor = ContextCompat.getMainExecutor(context), onFrameAnalysed = { ranked, crop -> // Every frame ticks the tracker, including empty ones: absence is @@ -285,6 +286,7 @@ private fun Set.toScannerOptions(): BarcodeScannerOptions { private class BarcodeAnalyzer( private val scanner: BarcodeScanner, private val region: ScanRegion, + private val previewSize: () -> Size, private val callbackExecutor: Executor, private val onFrameAnalysed: (ranked: List, crop: android.graphics.Rect) -> Unit, private val onDetectionFailed: (Throwable) -> Unit, @@ -304,7 +306,7 @@ private class BarcodeAnalyzer( val width = if (upright) imageProxy.height else imageProxy.width val height = if (upright) imageProxy.width else imageProxy.height val crop = imageProxy.cropRect.rotatedInto(rotation, imageProxy.width, imageProxy.height) - val imageRegion = ScanRegionResolver.inImage(region, crop, width, height) + val imageRegion = ScanRegionResolver.inImage(region, crop, previewSize(), width, height) val inputImage = InputImage.fromMediaImage(mediaImage, rotation) scanner.process(inputImage) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt index 550b18f..090763b 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt @@ -1,11 +1,12 @@ package uk.co.appoly.droid.barcodescanner.camera import android.graphics.Rect as AndroidRect -import androidx.camera.core.ImageProxy +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import com.google.mlkit.vision.barcode.common.Barcode import kotlin.math.hypot +import kotlin.math.max import kotlin.math.roundToInt /** @@ -13,28 +14,34 @@ import kotlin.math.roundToInt * coordinates for deciding what counts, and one in preview pixels for drawing. * * The two are kept consistent by binding preview and analysis through a single `ViewPort`, which - * makes `ImageProxy.cropRect` the region the user can actually see. Without that the analyser's - * field of view is wider than the preview and the two rectangles describe different parts of the - * world — which is exactly how a scanner ends up reading a barcode that is not on screen. + * makes `ImageProxy.cropRect` the region the camera is sharing between them, and then by taking + * account of how the viewfinder fits that region into its bounds — see [visibleInImage]. Without + * the `ViewPort` the analyser's field of view is wider than the preview and the two rectangles + * describe different parts of the world, which is exactly how a scanner ends up reading a barcode + * that is not on screen. */ internal object ScanRegionResolver { /** * The acceptance region in the analysed image's coordinate space. * - * @param cropRect what the preview shows, as reported by CameraX for a view-ported binding. + * @param cropRect what the camera shares between preview and analysis, as reported by CameraX + * for a view-ported binding. + * @param previewSize the viewfinder's bounds in pixels, needed because it centre-crops + * [cropRect] rather than stretching it. * @param imageWidth the full analysed width, after rotation correction. * @param imageHeight the full analysed height, after rotation correction. */ fun inImage( region: ScanRegion, cropRect: AndroidRect, + previewSize: Size, imageWidth: Int, imageHeight: Int, ): AndroidRect = when (region) { ScanRegion.Full -> AndroidRect(0, 0, imageWidth, imageHeight) - ScanRegion.Visible -> cropRect - is ScanRegion.Reticle -> cropRect.centredSubRect(region) + ScanRegion.Visible -> visibleInImage(cropRect, previewSize) + is ScanRegion.Reticle -> visibleInImage(cropRect, previewSize).centredSubRect(region) } /** The same region in preview pixels, for an overlay to draw. */ @@ -47,7 +54,7 @@ internal object ScanRegionResolver { val width = previewSize.width * region.widthFraction val height = (width / region.aspectRatio).coerceAtMost(previewSize.height) Rect( - offset = androidx.compose.ui.geometry.Offset( + offset = Offset( x = (previewSize.width - width) / 2f, y = (previewSize.height - height) / 2f, ), @@ -112,16 +119,57 @@ internal fun AndroidRect.rotatedInto( else -> AndroidRect(this) } +/** + * The single scale factor the viewfinder applies to the camera's crop rectangle. + * + * The viewfinder **fills** its bounds and centre-crops the overflow, so the scale is the larger of + * the two ratios and the same on both axes. Scaling each axis independently — stretching the crop + * onto the bounds — is only equivalent while the crop and the bounds share an aspect ratio, which + * is why treating them as interchangeable survives a portrait phone and falls apart the moment the + * preview is any other shape. + */ +private fun fillCentreScale(crop: AndroidRect, previewSize: Size): Float = + max(previewSize.width / crop.width(), previewSize.height / crop.height()) + +/** + * The part of [crop] the viewfinder actually puts on screen, in image coordinates. + * + * The camera shares one crop rectangle between preview and analysis, but the viewfinder only shows + * the part of it that fits its bounds — everything beyond is scaled off the edges. That remainder + * is analysed and invisible at once, so accepting a barcode there means accepting one the user + * cannot see. This is the rectangle [ScanRegion.Visible] means, and the one a reticle is measured + * against. + * + * Falls back to the whole crop when the preview has not been laid out yet, so scanning still works + * for the frame or two before the first measurement arrives. + */ +internal fun visibleInImage(crop: AndroidRect, previewSize: Size): AndroidRect { + if (crop.width() <= 0 || crop.height() <= 0) return AndroidRect(crop) + if (previewSize.width <= 0f || previewSize.height <= 0f) return AndroidRect(crop) + val scale = fillCentreScale(crop, previewSize) + val halfWidth = previewSize.width / (2f * scale) + val halfHeight = previewSize.height / (2f * scale) + val centreX = crop.exactCenterX() + val centreY = crop.exactCenterY() + return AndroidRect( + (centreX - halfWidth).roundToInt(), + (centreY - halfHeight).roundToInt(), + (centreX + halfWidth).roundToInt(), + (centreY + halfHeight).roundToInt(), + ) +} + /** * Maps a point from the rotation-corrected analyser space into preview pixels. * * [mirrored] handles the front camera, whose preview is flipped for display while the analysed * buffer is not. * - * Everything hangs off [crop] rather than the full image: with a `ViewPort` the preview shows - * exactly the cropped region, so scaling by the whole image makes every box too small and - * forgetting the crop's origin shifts them all toward the top-left. Both at once is what a barcode - * outline that is undersized *and* offset looks like. + * Everything is measured from the centre of [crop] outwards at a single [fillCentreScale], because + * that is what the viewfinder does: the crop's centre lands on the preview's centre and both axes + * share one scale. Stretching the crop onto the preview instead makes every box wrong by the ratio + * of the two aspect ratios — invisible while they match, and a barcode outline that is the right + * width and half the height the moment they do not. */ internal fun mapToPreview( x: Int, @@ -129,15 +177,17 @@ internal fun mapToPreview( crop: AndroidRect, previewSize: Size, mirrored: Boolean = false, -): androidx.compose.ui.geometry.Offset { - if (crop.width() <= 0 || crop.height() <= 0) return androidx.compose.ui.geometry.Offset.Zero - val mappedX = (x - crop.left) * previewSize.width / crop.width() - return androidx.compose.ui.geometry.Offset( +): Offset { + if (crop.width() <= 0 || crop.height() <= 0) return Offset.Zero + val scale = fillCentreScale(crop, previewSize) + val fromCentreX = (x - crop.exactCenterX()) * scale + val fromCentreY = (y - crop.exactCenterY()) * scale + return Offset( // The front camera's preview is mirrored for display — you expect to move left and see // yourself move left — but the analyser receives the unmirrored buffer, so ML Kit's // coordinates are in the frame the user is NOT looking at. Without this every overlay on // the front lens is drawn on the wrong side of the screen. - x = if (mirrored) previewSize.width - mappedX else mappedX, - y = (y - crop.top) * previewSize.height / crop.height(), + x = previewSize.width / 2f + if (mirrored) -fromCentreX else fromCentreX, + y = previewSize.height / 2f + fromCentreY, ) } diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt index 386cfd3..7fc52f2 100644 --- a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt @@ -170,4 +170,91 @@ class ScanRegionResolverTest { assertEquals(0f, mapped.x, 0.01f) assertEquals(0f, mapped.y, 0.01f) } + + @Test + fun `a crop taller than the preview overflows it rather than being squashed`() { + // The landscape bug. The viewfinder fills its bounds at one scale and lets the rest run off + // the edges; stretching the crop onto the bounds instead keeps x right and makes y wrong by + // the ratio of the aspect ratios — here 0.5625, which is what "the right width and half the + // height" looks like on a phone. + val crop = Rect(0, 0, 300, 400) + val preview = Size(400f, 300f) + + val centre = mapToPreview(x = 150, y = 200, crop = crop, previewSize = preview) + val rightEdge = mapToPreview(x = 300, y = 200, crop = crop, previewSize = preview) + val cropBottom = mapToPreview(x = 150, y = 400, crop = crop, previewSize = preview) + + assertEquals(200f, centre.x, 0.01f) + assertEquals(150f, centre.y, 0.01f) + assertEquals("the wider axis fills the preview exactly", 400f, rightEdge.x, 0.01f) + assertEquals( + "the crop's bottom edge is off-screen, not on the preview's bottom edge", + 416.67f, + cropBottom.y, + 0.01f, + ) + } + + @Test + fun `a crop wider than the preview overflows sideways`() { + val crop = Rect(0, 0, 400, 300) + val preview = Size(300f, 400f) + + val cropRight = mapToPreview(x = 400, y = 150, crop = crop, previewSize = preview) + val bottom = mapToPreview(x = 200, y = 300, crop = crop, previewSize = preview) + + assertEquals(416.67f, cropRight.x, 0.01f) + assertEquals("the taller axis fills the preview exactly", 400f, bottom.y, 0.01f) + } + + @Test + fun `the visible region trims what the viewfinder crops away`() { + val crop = Rect(0, 0, 300, 400) + val preview = Size(400f, 300f) + + val visible = visibleInImage(crop, preview) + + assertEquals("nothing is lost across the filled axis", 300, visible.width()) + assertEquals(225, visible.height()) + assertEquals("it stays centred on the crop", 200, visible.centerY()) + } + + @Test + fun `the visible region is the whole crop when the aspects match`() { + val crop = Rect(20, 40, 320, 440) + val preview = Size(150f, 200f) + + assertEquals(crop, visibleInImage(crop, preview)) + } + + @Test + fun `the visible region falls back to the crop before the preview is measured`() { + // A frame or two arrive before the first layout pass. Returning an empty region there would + // stop the scanner dead rather than merely be imprecise. + val crop = Rect(0, 0, 300, 400) + + assertEquals(crop, visibleInImage(crop, Size.Zero)) + } + + @Test + fun `the reticle the analyser filters on is the reticle the overlay draws`() { + // The invariant the whole two-rectangle design exists to hold: a scanner that shows one box + // and accepts codes in a different one is worse than one that draws no box at all. Both are + // derived from the visible region, so a mismatched preview aspect must not pull them apart. + val crop = Rect(0, 0, 300, 400) + val preview = Size(400f, 300f) + val reticle = ScanRegion.Reticle(widthFraction = 0.7f, aspectRatio = 1f) + + val inImage = ScanRegionResolver.inImage(reticle, crop, preview, imageWidth = 300, imageHeight = 400) + val drawn = ScanRegionResolver.inPreview(reticle, preview) + + val mappedTopLeft = mapToPreview(inImage.left, inImage.top, crop, preview) + val mappedBottomRight = mapToPreview(inImage.right, inImage.bottom, crop, preview) + + // A pixel or so of slack: the image region is integer-rounded and the drawn one is not. + assertEquals(drawn.left, mappedTopLeft.x, 2f) + assertEquals(drawn.top, mappedTopLeft.y, 2f) + assertEquals(drawn.right, mappedBottomRight.x, 2f) + assertEquals(drawn.bottom, mappedBottomRight.y, 2f) + } } From 2f5491d8a1883c3dba53585ebcfc65df3a7f0b63 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 14:05:54 +0100 Subject: [PATCH 45/53] fix(BarcodeScanner-Camera): draw the preview inline inside a dialog window 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) --- .../camera/BarcodeScannerCamera.kt | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index 2a1c3a3..fac8d17 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -2,6 +2,7 @@ package uk.co.appoly.droid.barcodescanner.camera import androidx.annotation.OptIn import androidx.camera.compose.CameraXViewfinder +import androidx.camera.viewfinder.core.ImplementationMode import androidx.camera.core.AspectRatio import androidx.camera.core.Camera import androidx.camera.core.CameraSelector @@ -30,6 +31,8 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.IntSize import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.window.DialogWindowProvider import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.mlkit.vision.barcode.BarcodeScanner @@ -246,10 +249,29 @@ fun BarcodeScannerCamera( }, ) { surfaceRequest?.let { request -> - CameraXViewfinder( - modifier = Modifier.fillMaxSize(), - surfaceRequest = request, - ) + // The viewfinder's default is a SurfaceView wherever the device supports one: cheaper + // and lower-latency, but composited by the system outside the view hierarchy. In a + // window of its own — a ModalBottomSheet, a Dialog — that surface is positioned against + // the wrong window, so the preview spills outside its bounds and draws behind the sheet + // rather than inside it. A TextureView draws inline and therefore clips, scrolls, + // rounds and animates like anything else. + // + // Only in a dialog window, because a full-screen scanner is exactly where the cheaper + // path is worth keeping. Two call sites rather than a nullable argument: passing no + // mode is what leaves CameraX its own compatibility choice, which already downgrades to + // a TextureView on legacy camera hardware. + if (LocalView.current.parent is DialogWindowProvider) { + CameraXViewfinder( + modifier = Modifier.fillMaxSize(), + surfaceRequest = request, + implementationMode = ImplementationMode.EMBEDDED, + ) + } else { + CameraXViewfinder( + modifier = Modifier.fillMaxSize(), + surfaceRequest = request, + ) + } } ScannerOverlayScopeImpl( boxScope = this, From 9e10e7424567ee7070d7347500959cca99f47ac5 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 14:10:44 +0100 Subject: [PATCH 46/53] docs(BarcodeScanner-Camera): say what the viewfinder does to the shared 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) --- BarcodeScanner-Camera/README.md | 19 ++++++++++++++++++- .../camera/BarcodeScannerCamera.kt | 4 ++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index ea5d519..ed4a28c 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -9,7 +9,8 @@ module gives you the one-shot scanner for free. ## Features - One `@Composable`; no `AndroidView`, no `PreviewView` -- Binds to the ambient lifecycle, so it works inside a `ModalBottomSheet` and unbinds on exit +- Binds to the ambient lifecycle, so it works inside a `ModalBottomSheet` and unbinds on exit — + and draws inline there, so the preview clips to the sheet instead of spilling behind it - A dwell gate, so a code has to be held deliberately rather than glimpsed in passing - A centre-of-frame acceptance region that the drawn reticle actually matches - Single- or multi-code tracking, ranked nearest-the-centre first @@ -109,6 +110,16 @@ Preview and analysis are bound through one CameraX `ViewPort`, which is what mak of view agree — and what lets `DefaultScanFrame` draw the exact rectangle the analyser filters against, so the box on screen and the region that accepts codes cannot drift apart. +That shared region is still not quite what you see. The viewfinder scales it to **fill** the bounds +you give the composable and centre-crops whatever overflows, so on a preview that is not the same +shape as the region, part of it is analysed and off-screen at once. `Visible` and `Reticle` are +measured against what is genuinely displayed rather than against the shared region, which is what +makes the first row of the table above literally true. + +**A note on sizing.** The examples here fill the screen; a preview laid out narrow or short is +centre-cropped to fit, so it shows a zoomed-in slice of the camera rather than a letterboxed whole. +Give it as close to a 4:3 box as the layout allows if you want the full field of view. + ### Feedback on a scan **The module plays nothing — no haptic, no sound.** Deliberately: it knows a barcode was *read*, @@ -192,8 +203,14 @@ Silently ignored on a camera with no flash unit. | Type | Purpose | |---|---| | `BarcodeScannerCamera` | The scanning preview composable | +| `ScanPolicy` | What counts as a scan: dwell, tracking, region | +| `ScanMode` | `Single` / `Multi` | +| `ScanRegion` | `Full` / `Visible` / `Reticle(widthFraction, aspectRatio)` | | `LensFacing` | `Back` / `Front` | +| `ScannerOverlayScope` | What an overlay can see: `regionRect`, `detections` | +| `DetectedBarcode` | One visible code: bounds, corners, dwell progress | | `DefaultScanFrame` | The default overlay reticle; usable standalone | +| `AnimatedScanFrame` | A reticle that springs to the code and closes as the dwell fills | Results arrive as `ScannedBarcode` from the `BarcodeScanner` module. diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index fac8d17..c21d793 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -75,6 +75,10 @@ enum class LensFacing(internal val selector: CameraSelector) { * second, and one presentation produces one result however long it is held. A scanner that fires * at whatever drifts through the frame reads as broken to the person holding it. * + * The preview **fills** the bounds it is given and centre-crops the overflow, so a box that is not + * roughly 4:3 shows a zoomed-in slice of the camera rather than a letterboxed whole. What counts as + * a scan follows what is displayed, not what is analysed, so the two cannot disagree. + * * **This composable does not request the `CAMERA` permission.** Check it before composing this; * every app's permission flow differs, so the module deliberately owns none of it. Composing * without the permission granted reports a bind failure through [onError]. From f5e5d8306ff35893e99d9aec62006c4f737d68f9 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Fri, 18 Sep 2026 14:36:38 +0100 Subject: [PATCH 47/53] fix(BarcodeScanner-Camera): bind the ViewPort to the preview's real shape 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) --- BarcodeScanner-Camera/README.md | 23 +++++--- .../camera/BarcodeScannerCamera.kt | 58 ++++++++++++++++--- 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index ed4a28c..91dfe2d 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -110,15 +110,20 @@ Preview and analysis are bound through one CameraX `ViewPort`, which is what mak of view agree — and what lets `DefaultScanFrame` draw the exact rectangle the analyser filters against, so the box on screen and the region that accepts codes cannot drift apart. -That shared region is still not quite what you see. The viewfinder scales it to **fill** the bounds -you give the composable and centre-crops whatever overflows, so on a preview that is not the same -shape as the region, part of it is analysed and off-screen at once. `Visible` and `Reticle` are -measured against what is genuinely displayed rather than against the shared region, which is what -makes the first row of the table above literally true. - -**A note on sizing.** The examples here fill the screen; a preview laid out narrow or short is -centre-cropped to fit, so it shows a zoomed-in slice of the camera rather than a letterboxed whole. -Give it as close to a 4:3 box as the layout allows if you want the full field of view. +**The `ViewPort` takes the preview's own shape**, measured from the bounds you give the composable +rather than assumed. That matters because the shared region is cropped twice on its way to the +screen — the camera crops to the `ViewPort`, and the viewfinder then scales that to *fill* the +bounds and centre-crops whatever overflows. Asking for a shape the layout does not have pays that +toll twice: a fixed 4:3 against a landscape preview left under a third of the frame on screen. +Matching the two means a preview of any shape gets the whole field of view the camera can give it. + +What the camera offers is not infinitely divisible, so a little can still be cropped away. +`Visible` and `Reticle` are therefore measured against what is genuinely displayed rather than +against the shared region, which is what makes the first row of the table above literally true. + +**Changing the preview's shape rebinds the camera**, which is briefly visible. Rotations and pane +resizes are meant to do that; a preview whose size is *animated* is not, so give it its final size +and animate something else, or accept a rebind each time the shape moves more than about 2%. ### Feedback on a scan diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index c21d793..8907d3a 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -1,5 +1,7 @@ package uk.co.appoly.droid.barcodescanner.camera +import android.util.Rational +import android.view.Surface import androidx.annotation.OptIn import androidx.camera.compose.CameraXViewfinder import androidx.camera.viewfinder.core.ImplementationMode @@ -30,6 +32,7 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.unit.IntSize import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.window.DialogWindowProvider @@ -50,9 +53,16 @@ import uk.co.appoly.droid.barcodescanner.ScannedBarcode import uk.co.appoly.droid.barcodescanner.toScannedBarcode import java.util.concurrent.Executor import java.util.concurrent.Executors +import kotlin.math.abs import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +/** + * How far the preview's shape may drift before the camera is rebound to match it. Two percent is + * comfortably below a rotation or a pane resize and comfortably above layout noise. + */ +private const val ASPECT_TOLERANCE = 0.02f + /** Which camera the scanner binds to. */ enum class LensFacing(internal val selector: CameraSelector) { Back(CameraSelector.DEFAULT_BACK_CAMERA), @@ -75,9 +85,11 @@ enum class LensFacing(internal val selector: CameraSelector) { * second, and one presentation produces one result however long it is held. A scanner that fires * at whatever drifts through the frame reads as broken to the person holding it. * - * The preview **fills** the bounds it is given and centre-crops the overflow, so a box that is not - * roughly 4:3 shows a zoomed-in slice of the camera rather than a letterboxed whole. What counts as - * a scan follows what is displayed, not what is analysed, so the two cannot disagree. + * The camera is asked for the shape of the bounds it is given, so a preview of any shape gets the + * whole field of view rather than a centre-cropped slice of a fixed one. Changing that shape + * rebinds the camera and is briefly visible, which is what a rotation or a pane resize should cost + * and an animated size should not. What counts as a scan follows what is displayed, not what is + * analysed, so the two cannot disagree. * * **This composable does not request the `CAMERA` permission.** Check it before composing this; * every app's permission flow differs, so the module deliberately owns none of it. Composing @@ -132,7 +144,18 @@ fun BarcodeScannerCamera( var surfaceRequest by remember { mutableStateOf(null) } var camera by remember { mutableStateOf(null) } + // The rotation previewSize is measured in. Preview.targetRotation is NOT this -- its builder + // leaves it unset, so it reads back as ROTATION_0 on a landscape display, and a ViewPort built + // against it has its aspect ratio interpreted in portrait and silently inverted. + val configuration = LocalConfiguration.current + val view = LocalView.current + val displayRotation = remember(configuration) { view.display?.rotation ?: Surface.ROTATION_0 } + var previewSize by remember { mutableStateOf(Size.Zero) } + // The shape to ask the camera for, measured rather than assumed. Separate from previewSize + // because it keys the camera binding: it must change only when the preview genuinely changes + // shape, while previewSize tracks every pixel for the overlay's sake. + var viewPortAspect by remember { mutableStateOf(null) } var detections by remember { mutableStateOf>(emptyList()) } val currentScanningEnabled by rememberUpdatedState(scanningEnabled) @@ -141,7 +164,11 @@ fun BarcodeScannerCamera( // every recomposition and nothing would ever scan. val tracker = remember(policy) { BarcodeTracker(policy) } - LaunchedEffect(lifecycleOwner, formats, lensFacing, policy) { + LaunchedEffect(lifecycleOwner, formats, lensFacing, policy, viewPortAspect, displayRotation) { + // Nothing to bind until the preview has been measured. Binding to a guessed shape first + // and correcting later would mean a visible rebind every time the scanner opens, which is + // a worse trade than the frame or two of delay this costs. + val aspect = viewPortAspect ?: return@LaunchedEffect surfaceRequest = null camera = null val scanner = BarcodeScanning.getClient(formats.toScannerOptions()) @@ -150,12 +177,14 @@ fun BarcodeScannerCamera( val analysisExecutor = Executors.newSingleThreadExecutor() try { val preview = Preview.Builder() + .setTargetRotation(displayRotation) .build() .apply { setSurfaceProvider { request -> surfaceRequest = request } } val analysis = ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setTargetRotation(displayRotation) .build() .apply { setAnalyzer( @@ -207,8 +236,13 @@ fun BarcodeScannerCamera( // of view. Without it the analyser sees a wider image than the preview // shows, ImageProxy.cropRect means nothing, and the scanner can read a // barcode that is not on screen at all. + // + // The ViewPort takes the preview's own shape. A fixed 4:3 costs field of + // view twice over on any other shape: the camera crops to 4:3, and then the + // viewfinder crops that again to fill the bounds. On a landscape preview + // that left under a third of the frame on screen. val group = UseCaseGroup.Builder() - .setViewPort(ViewPort.Builder(android.util.Rational(4, 3), preview.targetRotation).build()) + .setViewPort(ViewPort.Builder(aspect, displayRotation).build()) .addUseCase(preview) .addUseCase(analysis) .build() @@ -248,8 +282,18 @@ fun BarcodeScannerCamera( } Box( - modifier = modifier.onSizeChanged { - previewSize = Size(it.width.toFloat(), it.height.toFloat()) + modifier = modifier.onSizeChanged { size -> + previewSize = Size(size.width.toFloat(), size.height.toFloat()) + if (size.width > 0 && size.height > 0) { + val ratio = size.width.toFloat() / size.height.toFloat() + val current = viewPortAspect + // Rebinding the camera is visible, so only a real change of shape counts — an + // orientation change, a pane resize — and not the pixel or two a layout pass or an + // animating inset can wobble by. + if (current == null || abs(current.toFloat() - ratio) > ratio * ASPECT_TOLERANCE) { + viewPortAspect = Rational(size.width, size.height) + } + } }, ) { surfaceRequest?.let { request -> From cc352613b201ded9d51a537dd28455fa5a8ea9b9 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 22 Sep 2026 13:30:00 +0100 Subject: [PATCH 48/53] - update AGP --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e131785..21a7072 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,7 @@ playServicesCodeScanner = "16.1.0" playServicesBase = "18.10.1" # ModuleInstallClient, for warmUp() # --- BUILD/TEST: toolchain + test-only, never published ---------------------- -agp = "9.4.0" +agp = "9.4.1" ksp = "2.3.11" vanniktechPublish = "0.37.0" # release tooling only junit = "4.13.2" From 8fac9f51e9df66dd29c019d9d783fdf8e7ec3b9e Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 22 Sep 2026 13:39:11 +0100 Subject: [PATCH 49/53] deps(BarcodeScanner): bump play-services-base to 18.11.0, minSdk to 24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- buildSrc/src/main/kotlin/BuildConfig.kt | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index 6535eb5..37ee404 100644 --- a/buildSrc/src/main/kotlin/BuildConfig.kt +++ b/buildSrc/src/main/kotlin/BuildConfig.kt @@ -55,7 +55,7 @@ object BuildConfig { const val NAV3_NAVIGATION = 23 /** BarcodeScanner and BarcodeScanner-Camera modules */ - const val BARCODE_SCANNER = 21 + const val BARCODE_SCANNER = 24 /** * Returns the highest minSdk version among all modules. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21a7072..c0ea858 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,7 +35,7 @@ cameraX = "1.6.2" # BarcodeScanner-Camera mlkitBarcodeCommon = "17.0.0" # exposed as `api` from BarcodeScanner (Barcode.FORMAT_* constants) mlkitBarcodeScanning = "18.3.1" # unbundled ML Kit detector, model served by Play services playServicesCodeScanner = "16.1.0" -playServicesBase = "18.10.1" # ModuleInstallClient, for warmUp() +playServicesBase = "18.11.0" # ModuleInstallClient, for warmUp() # --- BUILD/TEST: toolchain + test-only, never published ---------------------- agp = "9.4.1" From 97d622a36f7b7cf9f13d639455ed575276aaf67d Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 22 Sep 2026 13:39:17 +0100 Subject: [PATCH 50/53] docs: state each module's minSdk in its README 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) --- AppSnackBar-UiState/README.md | 4 ++++ AppSnackBar/README.md | 4 ++++ BarcodeScanner-Camera/README.md | 4 ++++ BarcodeScanner/README.md | 4 ++++ BaseRepo-AppolyJson/README.md | 4 ++++ BaseRepo-Paging-AppolyJson/README.md | 4 ++++ BaseRepo-Paging/README.md | 4 ++++ BaseRepo-S3Uploader-Multipart/README.md | 4 ++++ BaseRepo-S3Uploader/README.md | 4 ++++ BaseRepo/README.md | 4 ++++ ComposeExtensions/README.md | 4 ++++ ConnectivityMonitor/README.md | 4 ++++ DateHelperUtil-Room/README.md | 4 ++++ DateHelperUtil-Serialization/README.md | 4 ++++ DateHelperUtil/README.md | 4 ++++ LazyGridPagingExtensions/README.md | 4 ++++ LazyListPagingExtensions/README.md | 4 ++++ PagingExtensions/README.md | 4 ++++ README.md | 16 ++++++++++++++++ S3Uploader-Multipart/README.md | 4 ++++ S3Uploader/README.md | 4 ++++ SegmentedControl/README.md | 4 ++++ UiState/README.md | 4 ++++ 23 files changed, 104 insertions(+) diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index aaa4fb1..1a8b521 100644 --- a/AppSnackBar-UiState/README.md +++ b/AppSnackBar-UiState/README.md @@ -18,6 +18,10 @@ implementation("uk.co.appoly.droid:appsnackbar:1.10.0") implementation("uk.co.appoly.droid:appsnackbar-uistate:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Integration diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index 6345147..1413c4b 100644 --- a/AppSnackBar/README.md +++ b/AppSnackBar/README.md @@ -16,6 +16,10 @@ A customizable Jetpack Compose Snackbar implementation with support for differen implementation("uk.co.appoly.droid:appsnackbar:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Setup diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index 91dfe2d..b717279 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -25,6 +25,10 @@ module gives you the one-shot scanner for free. implementation("uk.co.appoly.droid:barcodescanner-camera:1.10.0") ``` +**Requirements** + +- `minSdk` **24** (play-services-base requirement) + ## Usage ```kotlin diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md index af19946..1f6adc3 100644 --- a/BarcodeScanner/README.md +++ b/BarcodeScanner/README.md @@ -22,6 +22,10 @@ For continuous in-app scanning with your own UI around it, add implementation("uk.co.appoly.droid:barcodescanner:1.10.0") ``` +**Requirements** + +- `minSdk` **24** (play-services-base requirement) + ## Usage ### A single scan diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index 7ef1f48..e760863 100644 --- a/BaseRepo-AppolyJson/README.md +++ b/BaseRepo-AppolyJson/README.md @@ -18,6 +18,10 @@ implementation("uk.co.appoly.droid:baserepo:1.10.0") implementation("uk.co.appoly.droid:baserepo-appolyjson:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## API Response Structure This module expects all API responses to follow Appoly's specific JSON structure. The API handling code requires all responses to use this structure as the root level of the JSON response, with the diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md index a69b951..cbf6f50 100644 --- a/BaseRepo-Paging-AppolyJson/README.md +++ b/BaseRepo-Paging-AppolyJson/README.md @@ -24,6 +24,10 @@ implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0") // For Lazy implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0") // For LazyGrid ``` +**Requirements** + +- `minSdk` **21** + ## API Response Format This module requires your paginated API responses to follow Appoly's specific nested structure as shown below: diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md index 7e38bf8..7f9c153 100644 --- a/BaseRepo-Paging/README.md +++ b/BaseRepo-Paging/README.md @@ -25,6 +25,10 @@ implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0") // For Lazy implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0") // For LazyGrid ``` +**Requirements** + +- `minSdk` **21** + ## Extensions For specific JSON paging response formats, use the following extension modules: diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md index 9d367ee..fdac36b 100644 --- a/BaseRepo-S3Uploader-Multipart/README.md +++ b/BaseRepo-S3Uploader-Multipart/README.md @@ -20,6 +20,10 @@ implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0") implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### 1. Initialize S3Uploader diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md index 49c7e16..e33ecb8 100644 --- a/BaseRepo-S3Uploader/README.md +++ b/BaseRepo-S3Uploader/README.md @@ -23,6 +23,10 @@ implementation("uk.co.appoly.droid:s3uploader:1.10.0") implementation("uk.co.appoly.droid:baserepo-s3uploader:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## How it Works This module acts as a bridge between: diff --git a/BaseRepo/README.md b/BaseRepo/README.md index a4580de..322dbe6 100644 --- a/BaseRepo/README.md +++ b/BaseRepo/README.md @@ -17,6 +17,10 @@ Foundation module for implementing the repository pattern with standardized API implementation("uk.co.appoly.droid:baserepo:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Extensions For specific JSON response formats, use the following extension modules: diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md index 0756abd..8bc266b 100644 --- a/ComposeExtensions/README.md +++ b/ComposeExtensions/README.md @@ -16,6 +16,10 @@ Compose utilities for insets/IME padding, padding arithmetic, serialization-safe implementation("uk.co.appoly.droid:composeextensions:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Insets and IME padding diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index 74d5276..3794f5d 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -12,6 +12,10 @@ Add the following dependency to your project's `build.gradle` file: implementation("uk.co.appoly.droid:connectivitymonitor:1.10.0") ``` +**Requirements** + +- `minSdk` **24** (newer network APIs) + ## Usage ### Option 1: Use provided Application class diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index 2515897..96abcb9 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -25,6 +25,10 @@ implementation("androidx.room:room-ktx:2.8.5") ksp("androidx.room:room-compiler:2.8.5") ``` +**Requirements** + +- `minSdk` **26** (Java 8 time APIs) + ## Usage ### Setting Up Room Type Converters diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md index 697a32a..573a354 100644 --- a/DateHelperUtil-Serialization/README.md +++ b/DateHelperUtil-Serialization/README.md @@ -24,6 +24,10 @@ implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") ``` +**Requirements** + +- `minSdk` **26** (Java 8 time APIs) + ## Usage ### 1. Enable Kotlin Serialization Plugin diff --git a/DateHelperUtil/README.md b/DateHelperUtil/README.md index ade3b82..4b41ee8 100644 --- a/DateHelperUtil/README.md +++ b/DateHelperUtil/README.md @@ -17,6 +17,10 @@ A utility module for standardized date and time operations in Android applicatio implementation("uk.co.appoly.droid:datehelperutil:1.10.0") ``` +**Requirements** + +- `minSdk` **26** (Java 8 time APIs) + ## 1.4.1 patch note `parseServerInstant` and `parseServerZoneDateTime` (and therefore the diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md index 1d0db72..ae3c26d 100644 --- a/LazyGridPagingExtensions/README.md +++ b/LazyGridPagingExtensions/README.md @@ -22,6 +22,10 @@ implementation("uk.co.appoly.droid:lazygridpagingextensions:1.10.0") implementation("androidx.paging:paging-compose:3.5.1") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Implementation diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md index 5353fcf..38fedbd 100644 --- a/LazyListPagingExtensions/README.md +++ b/LazyListPagingExtensions/README.md @@ -22,6 +22,10 @@ implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0") implementation("androidx.paging:paging-compose:3.5.1") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Implementation diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md index 0abde3a..2ac994a 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -15,6 +15,10 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo implementation("uk.co.appoly.droid:pagingextensions:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### De-duplicating paging streams diff --git a/README.md b/README.md index 7258263..880bf20 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,22 @@ dependencies { } ``` +## Minimum SDK + +Each module declares the lowest `minSdk` it can actually support, so your app's `minSdk` must be at +least as high as that of every module you depend on. A module whose floor is higher than your app's +fails the manifest merge at build time rather than at runtime. + +| `minSdk` | Modules | Why | +|---|---|---| +| **21** | BaseRepo (and all `BaseRepo-*` extensions), UiState, AppSnackBar, AppSnackBar-UiState, ComposeExtensions, SegmentedControl, PagingExtensions, LazyListPagingExtensions, LazyGridPagingExtensions, S3Uploader, S3Uploader-Multipart | — | +| **23** | Nav3Navigation | `androidx.navigation3` requirement | +| **24** | ConnectivityMonitor | Newer network APIs | +| **24** | BarcodeScanner, BarcodeScanner-Camera | `play-services-base` requirement | +| **26** | DateHelperUtil, DateHelperUtil-Room, DateHelperUtil-Serialization | Java 8 time APIs | + +The four `MockInterceptor` modules are pure Kotlin/JVM and have no `minSdk` of their own. + ## Modules ### BaseRepo Foundation for repository pattern implementation with API call handling. diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index 050f416..9a4ba91 100644 --- a/S3Uploader-Multipart/README.md +++ b/S3Uploader-Multipart/README.md @@ -21,6 +21,10 @@ implementation("uk.co.appoly.droid:s3uploader-multipart:1.10.0") This module depends on `S3Uploader` and includes it transitively. +**Requirements** + +- `minSdk` **21** + ## Backend API Specification Your backend must implement four endpoints that proxy requests to AWS S3's Multipart Upload API. This section provides the complete specification for external developers to implement these endpoints. diff --git a/S3Uploader/README.md b/S3Uploader/README.md index e7294fb..4d54ad0 100644 --- a/S3Uploader/README.md +++ b/S3Uploader/README.md @@ -19,6 +19,10 @@ Standalone module for Amazon S3 file uploading with progress tracking and error implementation("uk.co.appoly.droid:s3uploader:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Initializing the S3Uploader diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 4edca28..0a98a7d 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -22,6 +22,10 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot implementation("uk.co.appoly.droid:segmentedcontrol:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Usage with Strings diff --git a/UiState/README.md b/UiState/README.md index 4375501..e0ab7dd 100644 --- a/UiState/README.md +++ b/UiState/README.md @@ -16,6 +16,10 @@ A standardized UI state management library for Android applications, providing c implementation("uk.co.appoly.droid:uistate:1.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic UI State Management From dc4a0781bff1b1c684c8500fce48914b6d643227 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 22 Sep 2026 14:09:10 +0100 Subject: [PATCH 51/53] fix(BarcodeScanner-Camera): report a repeated code once per frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BarcodeScanner-Camera/README.md | 6 +++- .../barcodescanner/camera/BarcodeTracker.kt | 10 ++++-- .../droid/barcodescanner/camera/ScanPolicy.kt | 11 +++++- .../camera/BarcodeTrackerTest.kt | 36 +++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/BarcodeScanner-Camera/README.md b/BarcodeScanner-Camera/README.md index b717279..39c960f 100644 --- a/BarcodeScanner-Camera/README.md +++ b/BarcodeScanner-Camera/README.md @@ -94,7 +94,11 @@ it was last reported, which is a different and worse rule that re-fires a code y the case that matters on a label carrying both a 1D tracking code and a QR: picking whichever the detector happened to list first gets it wrong about half the time. -`ScanPolicy.Immediate` restores the old fire-on-sight behaviour if you want to do your own filtering. +**`ScanPolicy.Immediate` is one result per presentation, with no dwell and no region.** It is not a +fire-every-frame firehose — it keeps the default `debounceWindow`, so a code held in shot reports +once and then stays quiet until it has been absent that long. Lowering `debounceWindow` shortens +that wait but cannot remove it — re-arming is floored at `missTolerance` — so no policy reports the +same held code every frame, and there is nothing left for downstream dedup to do. ### Where a barcode has to be diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt index 94872b7..fbfd492 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt @@ -58,8 +58,9 @@ internal class BarcodeTracker( /** * How far through its dwell [rawValue] is, from 0f to 1f, for an overlay to draw. * - * 1f for a code with no track yet (nothing to wait for), for one already reported, and when - * the policy has no dwell — in every one of those cases there is no progress left to show. + * 1f for a code already reported and when the policy has no dwell — in both cases there is no + * progress left to show. 0f for a code with no track yet: in [ScanMode.Single] that is a code + * being held off while another holds the lock, which must not draw a full ring. */ fun dwellProgress(rawValue: String): Float { val dwell = policy.dwell ?: return 1f @@ -103,7 +104,10 @@ internal class BarcodeTracker( // 4. Report anything present that has now dwelled long enough. val dwell = policy.dwell ?: Duration.ZERO - return visible.filter { barcode -> + // distinctBy matters: filter on a List is eager, so without it two decodes sharing a + // rawValue both pass !reported before onEach flips it, and one code reports twice. It + // keeps the first occurrence, which is the one nearest the region centre. + return visible.distinctBy { it.rawValue }.filter { barcode -> val track = tracks[barcode.rawValue] ?: return@filter false !track.reported && track.firstSeen.elapsedNow() >= dwell }.onEach { tracks.getValue(it.rawValue).reported = true } diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt index e6289cf..2db542d 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt @@ -136,7 +136,16 @@ class ScanPolicy( /** Sensible behaviour for aiming at one code at a time. */ val Default = ScanPolicy() - /** Reports every code in the region as soon as it is decoded, with no dwell or region. */ + /** + * One result per presentation, with no dwell and no region — every code in frame reports + * the moment it is decoded. + * + * This is not a fire-every-frame firehose: [debounceWindow] keeps its default, so a code + * that stays in shot reports once and then stays quiet until it has been absent for that + * long. Lowering [debounceWindow] shortens that wait but cannot remove it — [rearm] is + * floored at [missTolerance], and a held code is never absent — so there is no policy that + * reports the same code every frame. Dedup downstream of this is unnecessary. + */ val Immediate = ScanPolicy( mode = ScanMode.Multi, dwell = null, diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt index 787ca6f..ed77fdd 100644 --- a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt @@ -227,4 +227,40 @@ class BarcodeTrackerTest { ScanPolicy(region = ScanRegion.Reticle(0.5f)), ) } + + @Test + fun `the same code twice in one frame reports once`() { + // ML Kit hands back one Barcode per decode, so a code physically in shot twice — a case + // on a warehouse pallet, a label repeated down a box — arrives as two entries sharing a + // rawValue. Both must collapse to one report, or "at most once per track" is a lie. + val time = TestTimeSource() + val tracker = tracker(time, mode = ScanMode.Multi, dwell = null) + + val reported = tracker.accept(listOf(a, a.copy())) + + assertEquals(listOf(a), reported) + } + + @Test + fun `the same code twice in one frame reports once after a dwell too`() { + // The dwell path is the one the defaults take, so pin it separately: the duplicate must + // not slip through on the frame the dwell completes. + val time = TestTimeSource() + val tracker = tracker(time, mode = ScanMode.Multi) + + assertTrue(tracker.accept(listOf(a, a.copy())).isEmpty()) + time += 500.milliseconds + + assertEquals(listOf(a), tracker.accept(listOf(a, a.copy()))) + } + + @Test + fun `dwell progress is zero for a code with no track yet`() { + // In Single mode a code held off while another holds the lock has no track, and must not + // draw a full ring — the overlay would promise a scan that is not coming. + val time = TestTimeSource() + val tracker = tracker(time) + + assertEquals(0f, tracker.dwellProgress("never-seen"), 0f) + } } From d50391c4f3a3dd17539012d83de4eb7e244d3804 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 22 Sep 2026 14:34:10 +0100 Subject: [PATCH 52/53] docs(BarcodeScanner-Camera): correct the off-centre crop test comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../droid/barcodescanner/camera/ScanRegionResolverTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt index 7fc52f2..cda3a0c 100644 --- a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt @@ -67,7 +67,8 @@ class ScanRegionResolverTest { @Test fun `an off-centre crop is not merely transposed`() { // The case the old transpose got wrong. A crop hugging the buffer's left edge must end up - // against the *bottom* of a 90-degree-rotated frame, not against its left edge. + // against the *top* of a 90-degree-rotated frame, not against its left edge — a clockwise + // turn carries the left edge to the top. val leftEdge = Rect(0, 100, 40, 300) val rotated = leftEdge.rotatedInto(90, bufferWidth = 640, bufferHeight = 480) From 983455f0fe1c140862227362fbbc719786345ed2 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 22 Sep 2026 14:38:31 +0100 Subject: [PATCH 53/53] fix(BarcodeScanner-Camera): keep corner winding clockwise on the front lens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../camera/BarcodeScannerCamera.kt | 1 + .../camera/ScanRegionResolver.kt | 16 +++++ .../camera/ScannerOverlayScope.kt | 7 ++- .../camera/ScanRegionResolverTest.kt | 58 +++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt index 8907d3a..ff15946 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -424,6 +424,7 @@ private fun List.toDetections( // axis-aligned — they are the only way an overlay can outline a tilted barcode. corners = barcode.cornerPoints ?.map { mapToPreview(it.x, it.y, crop, previewSize, mirrored) } + ?.clockwiseAfterMirror(mirrored) .orEmpty(), dwellProgress = tracker.dwellProgress(scanned.rawValue), ) diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt index 090763b..8a302f3 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt @@ -191,3 +191,19 @@ internal fun mapToPreview( y = previewSize.height / 2f + fromCentreY, ) } + + +/** + * Restores clockwise winding to corners that have been through a mirrored [mapToPreview]. + * + * [mapToPreview] negates x for the front lens but leaves the list order alone, so a clockwise set + * of corners comes out counter-clockwise. That matters because [AnimatedScanFrame] springs each + * corner to the corresponding index of its next target, and its fallbacks are always clockwise: an + * outline crossing from a clockwise rect to a counter-clockwise detection swaps two corners and + * visibly bow-ties mid-animation. + * + * Reversing all but the first entry flips the winding back while keeping index 0 on the same + * physical corner, so the starting corner stays the one the detector reported. + */ +internal fun List.clockwiseAfterMirror(mirrored: Boolean): List = + if (!mirrored || size < 3) this else listOf(first()) + drop(1).reversed() diff --git a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt index d22288a..ad18bb8 100644 --- a/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt @@ -21,9 +21,10 @@ import uk.co.appoly.droid.barcodescanner.ScannedBarcode * @property barcode the decoded barcode. * @property bounds its axis-aligned bounding box, in preview pixels. Simple to draw, but for a * barcode held at an angle it is the box *around* the code rather than the code's own outline. - * @property corners the code's four corners in its own orientation, in preview pixels, clockwise - * from the code's top-left. Use these to draw an outline that follows a rotated barcode. Empty if - * the detector did not report them. + * @property corners the code's four corners in its own orientation, in preview pixels, wound + * clockwise on either lens. Use these to draw an outline that follows a rotated barcode. Index 0 is + * the corner the detector reported first — the code's top-left as the analyser sees it, which on + * the mirrored front camera is drawn on the right. Empty if the detector did not report them. * @property dwellProgress how far through [ScanPolicy.dwell] this code is, from 0f to 1f. Already * 1f when the policy has no dwell. Useful for drawing a progress ring that fills as the user holds * steady. diff --git a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt index cda3a0c..08dc67c 100644 --- a/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt @@ -1,8 +1,10 @@ package uk.co.appoly.droid.barcodescanner.camera import android.graphics.Rect +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -258,4 +260,60 @@ class ScanRegionResolverTest { assertEquals(drawn.right, mappedBottomRight.x, 2f) assertEquals(drawn.bottom, mappedBottomRight.y, 2f) } + + /** + * Twice the signed area. Positive is clockwise in screen space: the shoelace sign is the + * usual counter-clockwise-positive one, and y growing downward flips it. Zero would mean the + * quad is degenerate or self-intersecting. + */ + private fun List.windingSign(): Float = + indices.sumOf { i -> + val p = this[i] + val q = this[(i + 1) % size] + (p.x * q.y - q.x * p.y).toDouble() + }.toFloat() + + @Test + fun `mirroring reverses corner winding`() { + // Pins the premise of the fix rather than the fix itself: if mapToPreview ever stops + // flipping the winding, clockwiseAfterMirror becomes the thing that breaks it. + val crop = Rect(0, 0, 640, 480) + val preview = Size(640f, 480f) + val corners = listOf(100 to 100, 300 to 100, 300 to 200, 100 to 200) + + val front = corners.map { (x, y) -> mapToPreview(x, y, crop, preview, mirrored = true) } + val back = corners.map { (x, y) -> mapToPreview(x, y, crop, preview, mirrored = false) } + + assertTrue("back lens should stay clockwise", back.windingSign() > 0f) + assertTrue("mirroring should reverse it", front.windingSign() < 0f) + } + + @Test + fun `clockwiseAfterMirror restores winding without moving the first corner`() { + // The bow-tie: AnimatedScanFrame springs corner i to corner i of the next target, and its + // fallbacks are always clockwise. A counter-clockwise detection swaps two corners on the + // way, and the outline crosses itself mid-spring. + val crop = Rect(0, 0, 640, 480) + val preview = Size(640f, 480f) + val corners = listOf(100 to 100, 300 to 100, 300 to 200, 100 to 200) + + val front = corners.map { (x, y) -> mapToPreview(x, y, crop, preview, mirrored = true) } + val fixed = front.clockwiseAfterMirror(mirrored = true) + + assertTrue("winding should be clockwise again", fixed.windingSign() > 0f) + assertEquals("index 0 must stay the detector's first corner", front[0], fixed[0]) + assertEquals(front.toSet(), fixed.toSet()) + } + + @Test + fun `clockwiseAfterMirror leaves the back lens alone`() { + val corners = listOf( + Offset(0f, 0f), + Offset(10f, 0f), + Offset(10f, 10f), + Offset(0f, 10f), + ) + + assertEquals(corners, corners.clockwiseAfterMirror(mirrored = false)) + } }