Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
4330e02
docs: record how Central's publishing limits constrain release cadence
projectdelta6 Sep 9, 2026
7f50c06
ci: run tests when a draft PR is marked ready for review
projectdelta6 Sep 9, 2026
8907d65
docs: correct the Central limits to the ones actually applied to us
projectdelta6 Sep 9, 2026
b8b3897
build: AGP 9.3.2 -> 9.4.0
projectdelta6 Sep 9, 2026
91909ab
build: Kotlin 2.4.10 -> 2.4.20
projectdelta6 Sep 9, 2026
9ab405b
build: androidx Navigation 3 1.2.0-alpha07 -> 1.2.0-beta01
projectdelta6 Sep 9, 2026
2bf651e
docs(Nav3Navigation): stop recommending BackHandler for system back
projectdelta6 Sep 10, 2026
fdd3bd9
build: androidx Navigation 3 1.2.0-beta01 -> 1.2.0-rc01
projectdelta6 Sep 10, 2026
d1164e6
build: Compose BOM 2026.08.00 -> 2026.09.00
projectdelta6 Sep 10, 2026
db9187e
build: Room 2.8.4 -> 2.8.5
projectdelta6 Sep 10, 2026
faa0325
build: group the version catalog by publication blast radius
projectdelta6 Sep 11, 2026
4f53a3d
build: TOOLBOX_VERSION 1.9.0 -> 1.9.1-rc01
projectdelta6 Sep 15, 2026
51c3ddb
build: add local-publish and clear-local-publish scripts and run configs
projectdelta6 Sep 15, 2026
db1d347
docs(Nav3Navigation): how to deliver a pop result on system back
projectdelta6 Sep 15, 2026
c4a7e5c
build: TOOLBOX_VERSION 1.9.1-rc01 -> 1.9.1
projectdelta6 Sep 15, 2026
7c9069f
build: TOOLBOX_VERSION 1.9.1 -> 1.10.0-beta01
projectdelta6 Sep 16, 2026
ac7f730
feat(BarcodeScanner): add BarcodeScanner and BarcodeScanner-Camera mo…
projectdelta6 Sep 16, 2026
94c0005
test(BarcodeScanner): add unit, device and demo coverage for both mod…
projectdelta6 Sep 16, 2026
63cccb7
fix(BarcodeScanner): report user cancellation instead of cancelling t…
projectdelta6 Sep 16, 2026
0b7bb69
fix(BarcodeScanner): await the real module install before scanning
projectdelta6 Sep 16, 2026
dce4b4c
docs(BarcodeScanner): warn against branching on MlKitException error …
projectdelta6 Sep 16, 2026
3d01bce
fix(BarcodeScanner): stop a transient first-run race reporting as Una…
projectdelta6 Sep 16, 2026
fb23ea9
docs(BarcodeScanner): migration hazard, and when Unavailable is reach…
projectdelta6 Sep 16, 2026
1ecc406
feat(SegmentedControl): allow a null selection for unanswered state
projectdelta6 Sep 16, 2026
6c827c7
feat(SegmentedControl): add `enabled` for read-only forms
projectdelta6 Sep 16, 2026
27fbda4
fix(SegmentedControl): snap the thumb on every selection out of empty…
projectdelta6 Sep 17, 2026
4e1c20b
build: raise metaspace so Dokka stops failing a random module per pub…
projectdelta6 Sep 17, 2026
419613e
build: TEMPORARY version 1.10.0-formsupport-local03 — REVERT BEFORE M…
projectdelta6 Sep 17, 2026
b0d3ded
Merge pull request #117 from appoly/feature/SegmentedControl-Nullable…
projectdelta6 Sep 17, 2026
d2da32d
Merge release/2026-09 into feature/Barcode-Scanner
projectdelta6 Sep 17, 2026
4cdaed2
build: drop duplicate metaspace pinning from the publish scripts
projectdelta6 Sep 17, 2026
1839fe1
build: TOOLBOX_VERSION 1.10.0-beta01 -> 1.10.0
projectdelta6 Sep 17, 2026
f1b15c0
Merge pull request #116 from appoly/feature/Barcode-Scanner
projectdelta6 Sep 17, 2026
5b6510d
docs(CONTRIBUTING): file count is 548 at 1.10.0, not 508
projectdelta6 Sep 17, 2026
6b27134
feat(BarcodeScanner-Camera): add ScanPolicy and the scan state machine
projectdelta6 Sep 18, 2026
820227e
feat(BarcodeScanner-Camera): dwell, scan regions and a live overlay s…
projectdelta6 Sep 18, 2026
6fb1043
feat(BarcodeScanner-Camera): AnimatedScanFrame, and a custom overlay …
projectdelta6 Sep 18, 2026
3c717a2
feat(BarcodeScanner-Camera): haptic on scan, and a corner-bracket ove…
projectdelta6 Sep 18, 2026
91dcb71
refactor(BarcodeScanner-Camera): drop scanHaptic, feedback belongs to…
projectdelta6 Sep 18, 2026
a10fa44
fix(BarcodeScanner-Camera): correct the preview mapping and follow ro…
projectdelta6 Sep 18, 2026
d75985d
docs(CLAUDE): refresh the tech stack and the camera module's description
projectdelta6 Sep 18, 2026
969c823
docs: correct the licence to GPL-3.0 in the READMEs
projectdelta6 Sep 18, 2026
b9fa4e3
docs(graphify): regenerate the graph report
projectdelta6 Sep 18, 2026
90aba01
fix(BarcodeScanner-Camera): mirror overlay coordinates on the front c…
projectdelta6 Sep 18, 2026
c8eb2a5
demo(BarcodeScanner): lay the scanner sheet out side by side in lands…
projectdelta6 Sep 18, 2026
fea1b97
demo(BarcodeScanner): drop a pointless run {} wrapper
projectdelta6 Sep 18, 2026
0274f15
fix(BarcodeScanner-Camera): map overlay coordinates the way the viewf…
projectdelta6 Sep 18, 2026
2f5491d
fix(BarcodeScanner-Camera): draw the preview inline inside a dialog w…
projectdelta6 Sep 18, 2026
9e10e74
docs(BarcodeScanner-Camera): say what the viewfinder does to the shar…
projectdelta6 Sep 18, 2026
f5e5d83
fix(BarcodeScanner-Camera): bind the ViewPort to the preview's real s…
projectdelta6 Sep 18, 2026
cc35261
- update AGP
projectdelta6 Sep 22, 2026
8fac9f5
deps(BarcodeScanner): bump play-services-base to 18.11.0, minSdk to 24
projectdelta6 Sep 22, 2026
97d622a
docs: state each module's minSdk in its README
projectdelta6 Sep 22, 2026
dc4a078
fix(BarcodeScanner-Camera): report a repeated code once per frame
projectdelta6 Sep 22, 2026
d50391c
docs(BarcodeScanner-Camera): correct the off-centre crop test comment
projectdelta6 Sep 22, 2026
983455f
fix(BarcodeScanner-Camera): keep corner winding clockwise on the fron…
projectdelta6 Sep 22, 2026
46e193e
Merge pull request #119 from appoly/feature/Scanner-Dwell-And-Region
projectdelta6 Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]

Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions .run/Clear_Local_Maven_Publish.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Clear Local Maven Publish" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/clear-local-publish.sh" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>
</component>
17 changes: 17 additions & 0 deletions .run/Publish_to_Maven_Local.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Publish to Maven Local" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/publish-local.sh" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>
</component>
17 changes: 17 additions & 0 deletions .run/Publish_to_Maven_Local__signed_.run.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Publish to Maven Local (signed)" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/publish-local.sh" />
<option name="SCRIPT_OPTIONS" value="--signed" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/bash" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>
</component>
10 changes: 7 additions & 3 deletions AppSnackBar-UiState/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ 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.10.0")
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
Expand Down
6 changes: 5 additions & 1 deletion AppSnackBar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ 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.10.0")
```

**Requirements**

- `minSdk` **21**

## Usage

### Basic Setup
Expand Down
1 change: 1 addition & 0 deletions BarcodeScanner-Camera/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
266 changes: 266 additions & 0 deletions BarcodeScanner-Camera/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
# 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 —
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
- 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
- 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")
```

**Requirements**

- `minSdk` **24** (play-services-base requirement)

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

### Deciding what counts as a scan

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(
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` 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

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

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

**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(
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)
}
}
},
)
```

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

`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:

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

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

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

## 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
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.
Loading
Loading