diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00c4cebe..b5b3bd25 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 diff --git a/.run/Clear_Local_Maven_Publish.run.xml b/.run/Clear_Local_Maven_Publish.run.xml new file mode 100644 index 00000000..71deee6b --- /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 00000000..e4833ba7 --- /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 00000000..17658e16 --- /dev/null +++ b/.run/Publish_to_Maven_Local__signed_.run.xml @@ -0,0 +1,17 @@ + + + + diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index 5c7c84b2..1a8b521d 100644 --- a/AppSnackBar-UiState/README.md +++ b/AppSnackBar-UiState/README.md @@ -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 diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index 929aa99d..1413c4bf 100644 --- a/AppSnackBar/README.md +++ b/AppSnackBar/README.md @@ -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 diff --git a/BarcodeScanner-Camera/.gitignore b/BarcodeScanner-Camera/.gitignore new file mode 100644 index 00000000..796b96d1 --- /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 00000000..39c960ff --- /dev/null +++ b/BarcodeScanner-Camera/README.md @@ -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. diff --git a/BarcodeScanner-Camera/build.gradle.kts b/BarcodeScanner-Camera/build.gradle.kts new file mode 100644 index 00000000..e22ec180 --- /dev/null +++ b/BarcodeScanner-Camera/build.gradle.kts @@ -0,0 +1,90 @@ +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) + // 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 { + 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 00000000..500f15bb --- /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 00000000..f1b42451 --- /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/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 00000000..bfcf0140 --- /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/AndroidManifest.xml b/BarcodeScanner-Camera/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4ba9d348 --- /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/AnimatedScanFrame.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt new file mode 100644 index 00000000..2eaac0b7 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/AnimatedScanFrame.kt @@ -0,0 +1,115 @@ +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.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.PathMeasure +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 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, + scrimColor: Color = Color.Black.copy(alpha = 0.4f), +) { + val tracked = detections.firstOrNull() + val progress = tracked?.dwellProgress ?: 0f + + // 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) + // 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", + ) + + Canvas(modifier = modifier.fillMaxSize()) { + if (size.width <= 0f || size.height <= 0f) return@Canvas + 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 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)) + addPath(outline) + fillType = PathFillType.EvenOdd + }, + scrimColor, + ) + } + + 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, closing around the code as the dwell + // completes. + if (tracked != null && progress > 0f) { + 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 new file mode 100644 index 00000000..ff15946a --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeScannerCamera.kt @@ -0,0 +1,432 @@ +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 +import androidx.camera.core.AspectRatio +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.core.UseCaseGroup +import androidx.camera.core.ViewPort +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.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 +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.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 +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), + Front(CameraSelector.DEFAULT_FRONT_CAMERA), +} + +/** + * A live camera preview that reports the barcodes the user deliberately aims at. + * + * 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. + * + * **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. + * + * 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 + * without the permission granted reports a bind failure through [onError]. + * + * ```kotlin + * BarcodeScannerCamera( + * modifier = Modifier.fillMaxSize(), + * formats = BarcodeFormats.OneDimensional, + * onError = viewModel::onScannerFailed, + * 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 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 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( + modifier: Modifier = Modifier, + formats: Set = BarcodeFormats.All, + lensFacing: LensFacing = LensFacing.Back, + torchEnabled: Boolean = false, + scanningEnabled: Boolean = true, + policy: ScanPolicy = ScanPolicy.Default, + overlay: @Composable ScannerOverlayScope.() -> 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) } + + // 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) + + // 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, 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()) + // 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() + .setTargetRotation(displayRotation) + .build() + .apply { + setSurfaceProvider { request -> surfaceRequest = request } + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setTargetRotation(displayRotation) + .build() + .apply { + setAnalyzer( + analysisExecutor, + 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 + // 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, + crop = crop, + previewSize = previewSize, + mirrored = lensFacing == LensFacing.Front, + ) + }, + 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) { + // 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 { + // 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. + // + // 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(aspect, displayRotation).build()) + .addUseCase(preview) + .addUseCase(analysis) + .build() + camera = cameraProvider.bindToLifecycle( + lifecycleOwner, + lensFacing.selector, + group, + ) + 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.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 -> + // 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, + regionRect = ScanRegionResolver.inPreview(policy.region, previewSize), + detections = detections, + ).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, 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. + * + * [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 previewSize: () -> Size, + private val callbackExecutor: Executor, + private val onFrameAnalysed: (ranked: List, crop: android.graphics.Rect) -> 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 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 = imageProxy.cropRect.rotatedInto(rotation, imageProxy.width, imageProxy.height) + val imageRegion = ScanRegionResolver.inImage(region, crop, previewSize(), width, height) + + val inputImage = InputImage.fromMediaImage(mediaImage, rotation) + scanner.process(inputImage) + .addOnSuccessListener(callbackExecutor) { barcodes -> + val ranked = barcodes + .filter { it.isWithin(imageRegion) } + .sortedBy { it.distanceToCentreOf(imageRegion) } + onFrameAnalysed(ranked, crop) + } + .addOnFailureListener(callbackExecutor) { error -> + onDetectionFailed(error) + } + .addOnCompleteListener(callbackExecutor) { + imageProxy.close() + } + } +} + +/** + * 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, + 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 + // 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, mirrored) } + ?.clockwiseAfterMirror(mirrored) + .orEmpty(), + 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 new file mode 100644 index 00000000..fbfd4921 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTracker.kt @@ -0,0 +1,115 @@ +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 + + /** + * How far through its dwell [rawValue] is, from 0f to 1f, for an overlay to draw. + * + * 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 + 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. + * + * @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 + // 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/DefaultScanFrame.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt new file mode 100644 index 00000000..dd81c121 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/DefaultScanFrame.kt @@ -0,0 +1,66 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +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. + * + * **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 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 ScannerOverlayScope.DefaultScanFrame( + modifier: Modifier = Modifier, + color: Color = Color.White, + strokeWidth: Dp = 3.dp, + cornerRadius: Dp = 16.dp, + scrimColor: Color = Color.Black.copy(alpha = 0.4f), +) { + 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/ScanPolicy.kt b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt new file mode 100644 index 00000000..2db542d7 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanPolicy.kt @@ -0,0 +1,155 @@ +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() + + /** + * 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, + region = ScanRegion.Full, + ) + } +} 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 00000000..8a302f3d --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolver.kt @@ -0,0 +1,209 @@ +package uk.co.appoly.droid.barcodescanner.camera + +import android.graphics.Rect as AndroidRect +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 + +/** + * 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 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 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 -> visibleInImage(cropRect, previewSize) + is ScanRegion.Reticle -> visibleInImage(cropRect, previewSize).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 = 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(), + ) +} + +/** + * 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) +} + +/** + * 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 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, + y: Int, + crop: AndroidRect, + previewSize: Size, + mirrored: Boolean = false, +): 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 = previewSize.width / 2f + if (mirrored) -fromCentreX else fromCentreX, + 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 new file mode 100644 index 00000000..ad18bb82 --- /dev/null +++ b/BarcodeScanner-Camera/src/main/java/uk/co/appoly/droid/barcodescanner/camera/ScannerOverlayScope.kt @@ -0,0 +1,95 @@ +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 + +/** + * 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 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, 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. + */ +@Immutable +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, corners=$corners, 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/BarcodeTrackerTest.kt b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt new file mode 100644 index 00000000..ed77fdd5 --- /dev/null +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/BarcodeTrackerTest.kt @@ -0,0 +1,266 @@ +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)), + ) + } + + @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) + } +} 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 00000000..08dc67ce --- /dev/null +++ b/BarcodeScanner-Camera/src/test/java/uk/co/appoly/droid/barcodescanner/camera/ScanRegionResolverTest.kt @@ -0,0 +1,319 @@ +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 + +/** + * 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 *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) + + 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 `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) + + 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) + } + + @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) + } + + /** + * 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)) + } +} diff --git a/BarcodeScanner-Camera/src/test/resources/robolectric.properties b/BarcodeScanner-Camera/src/test/resources/robolectric.properties new file mode 100644 index 00000000..73b487ac --- /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 00000000..796b96d1 --- /dev/null +++ b/BarcodeScanner/.gitignore @@ -0,0 +1 @@ +/build diff --git a/BarcodeScanner/README.md b/BarcodeScanner/README.md new file mode 100644 index 00000000..1f6adc39 --- /dev/null +++ b/BarcodeScanner/README.md @@ -0,0 +1,177 @@ +# 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") +``` + +**Requirements** + +- `minSdk` **24** (play-services-base requirement) + +## 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 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 + +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. + +**`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. + +### "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 -> { + // Fall back to BarcodeScanner-Camera, which needs only CameraX and the CAMERA permission, + // or to typing the code in by hand. +} +``` + +## 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 +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 | +|---|---| +| `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 00000000..5841d861 --- /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 00000000..efab7eb8 --- /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 00000000..f1b42451 --- /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 00000000..8072ee00 --- /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 00000000..9675504f --- /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 00000000..c6b5a475 --- /dev/null +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/OneShotBarcodeScanner.kt @@ -0,0 +1,320 @@ +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.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 +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume + +/** + * 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. */ + 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, 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 + + /** + * 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 +} + +/** + * 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, 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 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. + * + * 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. + */ + private suspend fun ensureModuleInstalled(): Boolean { + val scannerClient = client + return try { + val moduleInstall = ModuleInstall.getClient(appContext) + 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 + // us. Only a genuinely cancelled caller propagates. + awaitUserCancellation() + false + } 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 { + // 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 classify( + IllegalStateException( + "The Play services barcode scanner module is not installed and could not be " + + "installed (no Play services, or no network).", + ), + ) + } + + // 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) + } + } + 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_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 + } +} 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 00000000..2bc0d1c0 --- /dev/null +++ b/BarcodeScanner/src/main/java/uk/co/appoly/droid/barcodescanner/ScannedBarcode.kt @@ -0,0 +1,41 @@ +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] — 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, + 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/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 00000000..d651c379 --- /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/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 00000000..e332c5bb --- /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, + ) + } +} diff --git a/BarcodeScanner/src/test/resources/robolectric.properties b/BarcodeScanner/src/test/resources/robolectric.properties new file mode 100644 index 00000000..73b487ac --- /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/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index ebacda98..e7608636 100644 --- a/BaseRepo-AppolyJson/README.md +++ b/BaseRepo-AppolyJson/README.md @@ -14,10 +14,14 @@ 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.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 2b2f8a73..cbf6f500 100644 --- a/BaseRepo-Paging-AppolyJson/README.md +++ b/BaseRepo-Paging-AppolyJson/README.md @@ -15,15 +15,19 @@ 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.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.9.0") // For LazyColumn -implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.0") // For LazyGrid +implementation("uk.co.appoly.droid:lazylistpagingextensions:1.10.0") // For LazyColumn +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 b8e37e43..7f9c1532 100644 --- a/BaseRepo-Paging/README.md +++ b/BaseRepo-Paging/README.md @@ -17,14 +17,18 @@ 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.10.0") +implementation("uk.co.appoly.droid:baserepo-paging:1.10.0") // 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.10.0") // For LazyColumn +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 0227437d..fdac36b4 100644 --- a/BaseRepo-S3Uploader-Multipart/README.md +++ b/BaseRepo-S3Uploader-Multipart/README.md @@ -15,11 +15,15 @@ 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.10.0") +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 cad0a2d4..e33ecb8f 100644 --- a/BaseRepo-S3Uploader/README.md +++ b/BaseRepo-S3Uploader/README.md @@ -18,11 +18,15 @@ 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.10.0") +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 8719440a..322dbe64 100644 --- a/BaseRepo/README.md +++ b/BaseRepo/README.md @@ -14,9 +14,13 @@ 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.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Extensions For specific JSON response formats, use the following extension modules: diff --git a/CLAUDE.md b/CLAUDE.md index 3c44c5e4..b863f69f 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`. `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) - `S3Uploader` - Direct S3 uploads with progress tracking @@ -103,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 @@ -125,6 +129,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 523fc7c9..33ff523f 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-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() + } +} +``` + +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 -./scripts/publish.sh --local +./gradlew --refresh-dependencies :app:assembleStagingDebug ``` -That publishes every module to `~/.m2`, signed. Add `mavenLocal()` to the consuming project's -repositories, ahead of `mavenCentral()`. +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,48 @@ 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 + +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 **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:** 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: + +- **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 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 + caveat in [Why one version for all modules](#why-one-version-for-all-modules). + +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 @@ -142,7 +265,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 diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md index c3b70afd..8bc266bf 100644 --- a/ComposeExtensions/README.md +++ b/ComposeExtensions/README.md @@ -13,9 +13,13 @@ 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.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Insets and IME padding diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index f1010396..3794f5d4 100644 --- a/ConnectivityMonitor/README.md +++ b/ConnectivityMonitor/README.md @@ -9,9 +9,13 @@ 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.10.0") ``` +**Requirements** + +- `minSdk` **24** (newer network APIs) + ## Usage ### Option 1: Use provided Application class @@ -50,4 +54,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/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index ad9a6880..96abcb9d 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -16,15 +16,19 @@ 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.10.0") +implementation("uk.co.appoly.droid:datehelperutil-room:1.10.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") ``` +**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 0a5de23a..573a3546 100644 --- a/DateHelperUtil-Serialization/README.md +++ b/DateHelperUtil-Serialization/README.md @@ -16,14 +16,18 @@ 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.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") 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 fce722aa..4b41ee8e 100644 --- a/DateHelperUtil/README.md +++ b/DateHelperUtil/README.md @@ -14,9 +14,13 @@ 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.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 3b7156de..ae3c26d0 100644 --- a/LazyGridPagingExtensions/README.md +++ b/LazyGridPagingExtensions/README.md @@ -15,13 +15,17 @@ 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.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") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Implementation diff --git a/LazyGridPagingExtensions/build.gradle.kts b/LazyGridPagingExtensions/build.gradle.kts index 09f9c4e0..6ac351de 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/README.md b/LazyListPagingExtensions/README.md index 26076ff1..38fedbd3 100644 --- a/LazyListPagingExtensions/README.md +++ b/LazyListPagingExtensions/README.md @@ -15,13 +15,17 @@ 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.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") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Implementation diff --git a/LazyListPagingExtensions/build.gradle.kts b/LazyListPagingExtensions/build.gradle.kts index 5d6620d4..df65e3c5 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/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md index e85ada7c..27695e10 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.10.0") ``` ## Usage diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md index 5572e722..dd1ceab6 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.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 59c2bb93..838a52a4 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.10.0") ``` ## Usage diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md index d37dbaed..c5fe975e 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.10.0") ``` ## Usage diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md index c99f4c22..22d3da6d 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 | @@ -32,20 +32,29 @@ 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.10.0") ``` 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.10.0")) 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-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: + + ```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,49 @@ 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). -// System back: pop tab stack, else switch tab / finish -BackHandler(enabled = navigator.canPop || currentTab != HomeTab) { - if (navigator.canPop) navigator.pop() else selectTab(HomeTab) +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: + +```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. + +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: @@ -175,11 +220,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 +239,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 +399,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 +411,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`. -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. +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. + +**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 @@ -465,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: @@ -540,10 +678,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 e26d5c27..e023ed5d 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 b6a8f5a8..3b14ed85 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]) + 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 e33562b1..5acd64be 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/PagingExtensions/README.md b/PagingExtensions/README.md index 4c8f4053..2ac994a6 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -12,9 +12,13 @@ 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.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### De-duplicating paging streams diff --git a/README.md b/README.md index bfcd3cb3..880bf204 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 @@ -54,7 +55,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.0" # 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" } @@ -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) @@ -129,7 +134,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.10.0")) // Now you can use AppolyDroid modules without specifying versions implementation("uk.co.appoly.droid:baserepo") @@ -166,7 +171,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.9.0" # Replace with the latest version +appolydroidToolbox = "1.10.0" # Replace with the latest version [libraries] #AppolyDroid-Toolbox @@ -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) @@ -234,7 +243,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { - val appolydroidToolbox = "1.9.0" // 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") @@ -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") @@ -263,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. @@ -338,6 +365,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) @@ -435,26 +472,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: +AppolyDroid Toolbox is released under the **GNU General Public License v3.0**. See +[LICENSE](LICENSE) for the full text. -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. -``` +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. diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index 59d62c78..9a4ba91a 100644 --- a/S3Uploader-Multipart/README.md +++ b/S3Uploader-Multipart/README.md @@ -16,11 +16,15 @@ 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.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 ef007763..4d54ad0b 100644 --- a/S3Uploader/README.md +++ b/S3Uploader/README.md @@ -16,9 +16,13 @@ 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.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Initializing the S3Uploader diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index f81df40a..0a98a7dc 100644 --- a/SegmentedControl/README.md +++ b/SegmentedControl/README.md @@ -4,6 +4,8 @@ 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 @@ -17,9 +19,13 @@ 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.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic Usage with Strings @@ -38,6 +44,61 @@ 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. + +### 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 9074e7c0..143427a1 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 @@ -54,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 @@ -168,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. */ @@ -398,9 +410,17 @@ 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 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. @@ -415,9 +435,10 @@ private const val NO_SEGMENT_INDEX = -1 @Composable fun SegmentedControl( segments: List, - selectedSegment: String, + selectedSegment: String?, onSegmentSelected: (String) -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, trackShape: Shape = RoundedCornerShape(8.dp), trackPadding: Dp = 2.dp, trackPressedPadding: Dp = 1.dp, @@ -434,6 +455,7 @@ fun SegmentedControl( selectedSegment = selectedSegment, onSegmentSelected = onSegmentSelected, modifier = modifier, + enabled = enabled, trackShape = trackShape, trackPadding = trackPadding, trackPressedPadding = trackPressedPadding, @@ -455,9 +477,17 @@ 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 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. @@ -473,9 +503,10 @@ fun SegmentedControl( @Composable fun SegmentedControl( segments: List, - selectedSegment: T, + selectedSegment: T?, onSegmentSelected: (T) -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, trackShape: Shape = RoundedCornerShape(8.dp), trackPadding: Dp = 2.dp, trackPressedPadding: Dp = 1.dp, @@ -493,6 +524,7 @@ fun SegmentedControl( selectedSegment = selectedSegment, onSegmentSelected = onSegmentSelected, modifier = modifier, + enabled = enabled, trackShape = trackShape, trackPadding = trackPadding, trackPressedPadding = trackPressedPadding, @@ -513,9 +545,17 @@ 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 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. @@ -532,9 +572,10 @@ fun SegmentedControl( @Composable fun SegmentedControl( segments: List, - selectedSegment: T, + selectedSegment: T?, onSegmentSelected: (T) -> Unit, modifier: Modifier = Modifier, + enabled: Boolean = true, trackShape: Shape = RoundedCornerShape(8.dp), trackPadding: Dp = 2.dp, trackPressedPadding: Dp = 1.dp, @@ -549,13 +590,37 @@ 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()) } + val thumbTracker = remember { ThumbSelectionTracker(hasSelection) } + LaunchedEffect(state.selectedSegment) { + val target = state.selectedSegment.toFloat() + 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 + + 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 +632,8 @@ fun SegmentedControl( Thumb( state = state, thumbShape = thumbShape, - colors = colors + colors = colors, + alpha = thumbAlpha ) Dividers( state = state, @@ -579,6 +645,7 @@ fun SegmentedControl( Segments( state = state, segments = segments, + enabled = enabled, trackPadding = trackPadding, segmentsPadding = segmentsPadding, content = content, @@ -590,7 +657,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 -> @@ -656,10 +727,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 +747,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) @@ -725,6 +802,7 @@ private fun Dividers( private fun Segments( state: SegmentedControlState, segments: List, + enabled: Boolean, trackPadding: Dp, segmentsPadding: Dp, colors: SegmentedControlColors, @@ -770,8 +848,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( @@ -810,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/SegmentedControlEnabledTest.kt b/SegmentedControl/src/test/java/uk/co/appoly/droid/ui/segmentedcontrol/SegmentedControlEnabledTest.kt new file mode 100644 index 00000000..29423903 --- /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/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 00000000..208d84d1 --- /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/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 00000000..6f15b80a --- /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) + } +} diff --git a/UiState/README.md b/UiState/README.md index 77a5319b..e0ab7dd5 100644 --- a/UiState/README.md +++ b/UiState/README.md @@ -13,9 +13,13 @@ 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.10.0") ``` +**Requirements** + +- `minSdk` **21** + ## Usage ### Basic UI State Management diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0e6d970..6c1cdda1 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 00000000..4d9adb30 --- /dev/null +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/BarcodeScannerDemoScreen.kt @@ -0,0 +1,553 @@ +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 +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 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 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.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 +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, + // 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 }, + ) { + // 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 }, + ) + + 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}" + } + }, + ) + } + + } + + 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)) + } + } + } + } + } +} + +@Composable +private fun TorchToggleRow( + modifier: Modifier = Modifier, + label: String = "Torch", + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + ) + } +} + +/** + * 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()), + ) + } + } +} + +/** + * 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 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 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 = "bracketColor", + ) + + Canvas(modifier = Modifier.fillMaxSize()) { + val base = restingArm.toPx() + val stroke = strokeWidth.toPx() + + // 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, + ) + } + } + } +} + 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 ed693f59..7412cf37 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/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 53c5bc2e..0dec9351 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,64 @@ 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 + ) + 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") + } + } + } + } + // Basic usage with strings Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index adf21390..c6eeb71f 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 fc7e0c93..37ee4048 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.10.0" /** * SDK version configuration for Android modules. @@ -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 = 24 + /** * 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.properties b/gradle.properties index 20e2a015..08c9b61d 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a3e6774..c0ea858e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,56 +1,80 @@ +# ============================================================================= +# 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.3.2" -kotlin = "2.4.10" -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" -composeBom = "2026.08.00" -flexiLoggerVersion = "2.1.4" +lifecycleRuntime = "2.11.0" +composeBom = "2026.09.00" +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.4" -nav3 = "1.2.0-alpha07" +roomVersion = "2.8.5" +nav3 = "1.2.0-rc01" # exposed as `api` from Nav3Navigation workManager = "2.11.2" -kover = "0.9.9" +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.11.0" # ModuleInstallClient, for warmUp() + +# --- BUILD/TEST: toolchain + test-only, never published ---------------------- +agp = "9.4.1" +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" +androidxTestRules = "1.7.0" # GrantPermissionRule, BarcodeScanner-Camera device suite 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" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", 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 +82,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 +110,63 @@ 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 +# ============================================================================= + +#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-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) +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/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index a44ed3bf..91e32491 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 diff --git a/scripts/clear-local-publish.sh b/scripts/clear-local-publish.sh new file mode 100755 index 00000000..d02c11b3 --- /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 00000000..8b0036b6 --- /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 <