diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 00c4ceb..b5b3bd2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,6 +3,10 @@ name: CI
on:
pull_request:
branches: [ main ]
+ # `ready_for_review` is NOT in the default type set (opened/synchronize/reopened).
+ # Without it a PR opened as a draft and later marked ready fires no event at all, so
+ # the draft guard below would skip every run and CI would never report on that PR.
+ types: [ opened, synchronize, reopened, ready_for_review ]
push:
branches: [ main ]
@@ -14,6 +18,11 @@ concurrency:
jobs:
test:
name: Tests & coverage gate
+ # Draft PRs can't be merged, so don't spend runner minutes on them. Marking a PR ready
+ # fires `ready_for_review` (see types above), which is when CI runs for a drafted PR.
+ # The event_name check keeps pushes to main running: on push there is no
+ # `event.pull_request`, and a null never equals false in GitHub expressions.
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
diff --git a/.run/Clear_Local_Maven_Publish.run.xml b/.run/Clear_Local_Maven_Publish.run.xml
new file mode 100644
index 0000000..71deee6
--- /dev/null
+++ b/.run/Clear_Local_Maven_Publish.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Publish_to_Maven_Local.run.xml b/.run/Publish_to_Maven_Local.run.xml
new file mode 100644
index 0000000..e4833ba
--- /dev/null
+++ b/.run/Publish_to_Maven_Local.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.run/Publish_to_Maven_Local__signed_.run.xml b/.run/Publish_to_Maven_Local__signed_.run.xml
new file mode 100644
index 0000000..17658e1
--- /dev/null
+++ b/.run/Publish_to_Maven_Local__signed_.run.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md
index 5c7c84b..66264e1 100644
--- a/AppSnackBar-UiState/README.md
+++ b/AppSnackBar-UiState/README.md
@@ -13,9 +13,9 @@ Integration module that bridges the AppSnackBar and UiState modules, providing a
```gradle.kts
// Requires both base modules
-implementation("uk.co.appoly.droid:uistate:1.9.0")
-implementation("uk.co.appoly.droid:appsnackbar:1.9.0")
-implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.0")
+implementation("uk.co.appoly.droid:uistate:1.9.1")
+implementation("uk.co.appoly.droid:appsnackbar:1.9.1")
+implementation("uk.co.appoly.droid:appsnackbar-uistate:1.9.1")
```
## Usage
diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md
index 929aa99..7db2e56 100644
--- a/AppSnackBar/README.md
+++ b/AppSnackBar/README.md
@@ -13,7 +13,7 @@ A customizable Jetpack Compose Snackbar implementation with support for differen
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:appsnackbar:1.9.0")
+implementation("uk.co.appoly.droid:appsnackbar:1.9.1")
```
## Usage
diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md
index ebacda9..b34b81d 100644
--- a/BaseRepo-AppolyJson/README.md
+++ b/BaseRepo-AppolyJson/README.md
@@ -14,8 +14,8 @@ Appoly's JSON format.
```gradle.kts
// Requires the base BaseRepo module
-implementation("uk.co.appoly.droid:baserepo:1.9.0")
-implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.0")
+implementation("uk.co.appoly.droid:baserepo:1.9.1")
+implementation("uk.co.appoly.droid:baserepo-appolyjson:1.9.1")
```
## API Response Structure
diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md
index 2b2f8a7..7890f83 100644
--- a/BaseRepo-Paging-AppolyJson/README.md
+++ b/BaseRepo-Paging-AppolyJson/README.md
@@ -15,13 +15,13 @@ follow Appoly's paging format.
```gradle.kts
// Requires the base modules
-implementation("uk.co.appoly.droid:baserepo:1.9.0")
-implementation("uk.co.appoly.droid:baserepo-paging:1.9.0")
-implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.0")
+implementation("uk.co.appoly.droid:baserepo:1.9.1")
+implementation("uk.co.appoly.droid:baserepo-paging:1.9.1")
+implementation("uk.co.appoly.droid:baserepo-paging-appolyjson:1.9.1")
// For Compose UI integration
-implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.0") // For LazyColumn
-implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.0") // For LazyGrid
+implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn
+implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid
```
## API Response Format
diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md
index b8e37e4..b8dd1c7 100644
--- a/BaseRepo-Paging/README.md
+++ b/BaseRepo-Paging/README.md
@@ -17,12 +17,12 @@ extended for specific JSON formats.
```gradle.kts
// Requires the base BaseRepo module
-implementation("uk.co.appoly.droid:baserepo:1.9.0")
-implementation("uk.co.appoly.droid:baserepo-paging:1.9.0")
+implementation("uk.co.appoly.droid:baserepo:1.9.1")
+implementation("uk.co.appoly.droid:baserepo-paging:1.9.1")
// For Compose UI integration
-implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.0") // For LazyColumn
-implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.0") // For LazyGrid
+implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1") // For LazyColumn
+implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1") // For LazyGrid
```
## Extensions
diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md
index 0227437..d3c1724 100644
--- a/BaseRepo-S3Uploader-Multipart/README.md
+++ b/BaseRepo-S3Uploader-Multipart/README.md
@@ -15,9 +15,9 @@ Extension module that bridges BaseRepo and S3Uploader-Multipart, enabling pausab
```gradle.kts
// Requires the base modules
-implementation("uk.co.appoly.droid:baserepo:1.9.0")
-implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.0")
-implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.0")
+implementation("uk.co.appoly.droid:baserepo:1.9.1")
+implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1")
+implementation("uk.co.appoly.droid:baserepo-s3uploader-multipart:1.9.1")
```
## Usage
diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md
index cad0a2d..fdb67ca 100644
--- a/BaseRepo-S3Uploader/README.md
+++ b/BaseRepo-S3Uploader/README.md
@@ -18,9 +18,9 @@ An extension module that bridges BaseRepo and S3Uploader, enabling seamless file
```gradle.kts
// Requires both the base modules
-implementation("uk.co.appoly.droid:baserepo:1.9.0")
-implementation("uk.co.appoly.droid:s3uploader:1.9.0")
-implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.0")
+implementation("uk.co.appoly.droid:baserepo:1.9.1")
+implementation("uk.co.appoly.droid:s3uploader:1.9.1")
+implementation("uk.co.appoly.droid:baserepo-s3uploader:1.9.1")
```
## How it Works
diff --git a/BaseRepo/README.md b/BaseRepo/README.md
index 8719440..1061378 100644
--- a/BaseRepo/README.md
+++ b/BaseRepo/README.md
@@ -14,7 +14,7 @@ Foundation module for implementing the repository pattern with standardized API
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:baserepo:1.9.0")
+implementation("uk.co.appoly.droid:baserepo:1.9.1")
```
## Extensions
diff --git a/CLAUDE.md b/CLAUDE.md
index 3c44c5e..e02b0d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -125,6 +125,9 @@ Published to **Maven Central** under `uk.co.appoly.droid`, with lowercase artifa
Gradle reads them only under the `ORG_GRADLE_PROJECT_` prefix with exact camelCase. The vault
item is set in the git-ignored `scripts/publish.conf` — this repo is public, so it is not
committed. See `scripts/publish.conf.example`.
+- `./scripts/publish-local.sh` installs to `~/.m2` unsigned with no credentials (the everyday
+ local-testing loop), and `./scripts/clear-local-publish.sh` removes that install again. Both are
+ also Android Studio run configurations in `.run/`.
- `./scripts/publish.sh --local` publishes signed artifacts to `~/.m2`; without `--local` it
releases to Central. **Releases are run manually and locally** — there is no release CI job and
no Maven Central secrets in the repo, so a version tag publishes nothing on its own.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 523fc7c..bad6619 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,23 +6,105 @@ covers building, testing and releasing it.
## Testing an unreleased change
Maven Central publishes only what is released, so there is no equivalent of JitPack's
-build-any-branch behaviour. Two options replace it.
+build-any-branch behaviour — and releases are immutable, so a mistake cannot be corrected in
+place. Test locally first. Two options replace JitPack.
-**Install locally.** From a checkout of the branch you want to test:
+### Install locally (the normal loop)
+
+From a checkout of the branch you want to test:
+
+```bash
+./scripts/publish-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/publish.sh --local
+./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
```
-That publishes every module to `~/.m2`, signed. Add `mavenLocal()` to the consuming project's
-repositories, ahead of `mavenCentral()`.
+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.
-> 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.
+`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.
-**Publish a snapshot.** Snapshot versions go to Central's snapshot repository rather than the main
-one, and need it adding explicitly:
+**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
+./gradlew --refresh-dependencies :app:assembleStagingDebug
+```
+
+Without it Gradle may serve a cached module for that version string — resolved earlier from Central
+— and never look in `~/.m2` at all. The same applies in reverse *after* clearing: a consumer that
+already resolved the local copy keeps serving it until refreshed.
+
+A narrower alternative, if you would rather `mavenLocal()` could not possibly shadow anything else,
+is to scope it to the toolbox group:
+
+```kotlin
+exclusiveContent {
+ forRepository { mavenLocal() }
+ filter { includeGroup("uk.co.appoly.droid") }
+}
+```
+
+> **Take `mavenLocal()` back out when you are done**, and run `clear-local-publish.sh`. A local
+> install carries the same version string as the real release, so leaving either in place means
+> resolving your own working tree while believing you are testing the published artifacts. A partial
+> install (`publish-local.sh BaseRepo`) is worse still: the other modules in `~/.m2` are whatever was
+> installed last, possibly a different build of the same version.
+
+The cleanest way to remove the ambiguity entirely is to bump `TOOLBOX_VERSION` in
+`buildSrc/src/main/kotlin/BuildConfig.kt` to something that does not and will not exist on Central —
+`1.9.1-local1` — and depend on that from the consuming project. Then there is no version string in
+play that could mean two different things, and the dependency-cache problem disappears with it.
+Revert the bump before committing.
+
+### Publish a snapshot
+
+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,36 @@ 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 **508 files, 11.55 MB, and one release event** — Central scores a multi-module
+deployment bundle as a single release, not one per artifact. So release count is a non-issue and size
+is nowhere near. **File count is the binding constraint:** 508 files is roughly half the monthly
+allowance, so a second release in the same calendar month lands at ~1,016 and a third cannot fit.
+
+Two consequences for release practice:
+
+- **Batch patch releases.** A flurry of same-month point releases — the 1.8.0 → 1.8.3 pattern of
+ August 2026 — would be ~2,540 files, over twice the allowance. Fold fixes into one version and
+ iterate through 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 +253,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 c3b70af..688eb43 100644
--- a/ComposeExtensions/README.md
+++ b/ComposeExtensions/README.md
@@ -13,7 +13,7 @@ Compose utilities for insets/IME padding, padding arithmetic, serialization-safe
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:composeextensions:1.9.0")
+implementation("uk.co.appoly.droid:composeextensions:1.9.1")
```
## Usage
diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md
index f101039..cf9418f 100644
--- a/ConnectivityMonitor/README.md
+++ b/ConnectivityMonitor/README.md
@@ -9,7 +9,7 @@
Add the following dependency to your project's `build.gradle` file:
```gradle.kts
-implementation("uk.co.appoly.droid:connectivitymonitor:1.9.0")
+implementation("uk.co.appoly.droid:connectivitymonitor:1.9.1")
```
## Usage
diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md
index ad9a688..a738f28 100644
--- a/DateHelperUtil-Room/README.md
+++ b/DateHelperUtil-Room/README.md
@@ -16,13 +16,13 @@ Extension module for DateHelperUtil that provides Room database integration for
```gradle.kts
// Requires base DateHelperUtil module
-implementation("uk.co.appoly.droid:datehelperutil:1.9.0")
-implementation("uk.co.appoly.droid:datehelperutil-room:1.9.0")
+implementation("uk.co.appoly.droid:datehelperutil:1.9.1")
+implementation("uk.co.appoly.droid:datehelperutil-room:1.9.1")
// Required Room dependencies
-implementation("androidx.room:room-runtime:2.8.4")
-implementation("androidx.room:room-ktx:2.8.4")
-ksp("androidx.room:room-compiler:2.8.4")
+implementation("androidx.room:room-runtime:2.8.5")
+implementation("androidx.room:room-ktx:2.8.5")
+ksp("androidx.room:room-compiler:2.8.5")
```
## Usage
diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md
index 0a5de23..7db5188 100644
--- a/DateHelperUtil-Serialization/README.md
+++ b/DateHelperUtil-Serialization/README.md
@@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides kotlinx.serialization integrat
```gradle.kts
// Requires base DateHelperUtil module
-implementation("uk.co.appoly.droid:datehelperutil:1.9.0")
-implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.0")
+implementation("uk.co.appoly.droid:datehelperutil:1.9.1")
+implementation("uk.co.appoly.droid:datehelperutil-serialization:1.9.1")
// Required kotlinx.serialization dependencies
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.11.0")
diff --git a/DateHelperUtil/README.md b/DateHelperUtil/README.md
index fce722a..37af5c7 100644
--- a/DateHelperUtil/README.md
+++ b/DateHelperUtil/README.md
@@ -14,7 +14,7 @@ A utility module for standardized date and time operations in Android applicatio
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:datehelperutil:1.9.0")
+implementation("uk.co.appoly.droid:datehelperutil:1.9.1")
```
## 1.4.1 patch note
diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md
index 3b7156d..54abf19 100644
--- a/LazyGridPagingExtensions/README.md
+++ b/LazyGridPagingExtensions/README.md
@@ -15,8 +15,8 @@ Extension functions for integrating Jetpack Paging 3 with Compose LazyVerticalGr
```gradle.kts
// Requires the base PagingExtensions module
-implementation("uk.co.appoly.droid:pagingextensions:1.9.0")
-implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.0")
+implementation("uk.co.appoly.droid:pagingextensions:1.9.1")
+implementation("uk.co.appoly.droid:lazygridpagingextensions:1.9.1")
// Make sure to include Jetpack Paging Compose
implementation("androidx.paging:paging-compose:3.5.1")
diff --git a/LazyGridPagingExtensions/build.gradle.kts b/LazyGridPagingExtensions/build.gradle.kts
index 09f9c4e..6ac351d 100644
--- a/LazyGridPagingExtensions/build.gradle.kts
+++ b/LazyGridPagingExtensions/build.gradle.kts
@@ -57,7 +57,6 @@ dependencies {
//Paging
implementation(libs.paging.runtime)
implementation(libs.paging.compose)
-// testImplementation(libs.paging.common)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md
index 26076ff..23738e9 100644
--- a/LazyListPagingExtensions/README.md
+++ b/LazyListPagingExtensions/README.md
@@ -15,8 +15,8 @@ Extension functions for easy integration of Jetpack Paging 3 with Compose LazyCo
```gradle.kts
// Requires the base PagingExtensions module
-implementation("uk.co.appoly.droid:pagingextensions:1.9.0")
-implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.0")
+implementation("uk.co.appoly.droid:pagingextensions:1.9.1")
+implementation("uk.co.appoly.droid:lazylistpagingextensions:1.9.1")
// Make sure to include Jetpack Paging Compose
implementation("androidx.paging:paging-compose:3.5.1")
diff --git a/LazyListPagingExtensions/build.gradle.kts b/LazyListPagingExtensions/build.gradle.kts
index 5d6620d..df65e3c 100644
--- a/LazyListPagingExtensions/build.gradle.kts
+++ b/LazyListPagingExtensions/build.gradle.kts
@@ -57,7 +57,6 @@ dependencies {
//Paging
implementation(libs.paging.runtime)
implementation(libs.paging.compose)
-// testImplementation(libs.paging.common)
testImplementation(libs.junit)
testImplementation(libs.robolectric)
diff --git a/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md
index e85ada7..3d52bef 100644
--- a/MockInterceptor-AppolyJson/README.md
+++ b/MockInterceptor-AppolyJson/README.md
@@ -14,7 +14,7 @@ Extension for [MockInterceptor-Serialization](../MockInterceptor-Serialization/)
```gradle.kts
// MockInterceptor and MockInterceptor-Serialization are included transitively
-implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.0")
+implementation("uk.co.appoly.droid:mockinterceptor-appolyjson:1.9.1")
```
## Usage
diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md
index 5572e72..2ee209f 100644
--- a/MockInterceptor-Retrofit/README.md
+++ b/MockInterceptor-Retrofit/README.md
@@ -13,7 +13,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that reads Retrofit HTTP an
```gradle.kts
// MockInterceptor is included transitively
-implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.0")
+implementation("uk.co.appoly.droid:mockinterceptor-retrofit:1.9.1")
```
> **Note:** Retrofit is a `compileOnly` dependency — your project must already depend on Retrofit.
diff --git a/MockInterceptor-Serialization/README.md b/MockInterceptor-Serialization/README.md
index 59c2bb9..d9cc6ec 100644
--- a/MockInterceptor-Serialization/README.md
+++ b/MockInterceptor-Serialization/README.md
@@ -12,7 +12,7 @@ Extension for [MockInterceptor](../MockInterceptor/) that adds type-safe JSON re
```gradle.kts
// MockInterceptor is included transitively
-implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.0")
+implementation("uk.co.appoly.droid:mockinterceptor-serialization:1.9.1")
```
## Usage
diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md
index d37dbae..9885716 100644
--- a/MockInterceptor/README.md
+++ b/MockInterceptor/README.md
@@ -16,7 +16,7 @@ An OkHttp interceptor with a route-matching DSL for mocking API responses during
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:mockinterceptor:1.9.0")
+implementation("uk.co.appoly.droid:mockinterceptor:1.9.1")
```
## Usage
diff --git a/Nav3Navigation/README.md b/Nav3Navigation/README.md
index c99f4c2..2ef77b7 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.9.1")
```
Or via the AppolyDroid BOM (version managed by the platform):
```gradle.kts
-implementation(platform("uk.co.appoly.droid:bom:1.9.0"))
+implementation(platform("uk.co.appoly.droid:bom:1.9.1"))
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 e26d5c2..e023ed5 100644
--- a/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/Nav3Navigator.kt
+++ b/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/Nav3Navigator.kt
@@ -140,12 +140,20 @@ interface Nav3Navigator {
*/
fun popUntilRoot()
- // --- stack introspection (bottom bar, BackHandler, deep-link reconcile) ---
+ // --- stack introspection (bottom bar, back-enablement, deep-link reconcile) ---
/**
* `true` when there is a previous screen to pop to (stack size > 1) — Voyager's `canPop`.
- * Use with system [androidx.activity.compose.BackHandler]: pop when `canPop`, otherwise
- * switch tab / finish the activity.
+ *
+ * Read this for UI decisions (an up arrow, a back-enabled check). **Do not** drive system back
+ * from it via [androidx.activity.compose.BackHandler]: [Nav3ScreenHost] already routes
+ * `NavDisplay.onBack` to [pop], and a handler above the host intercepts the gesture before
+ * `NavDisplay` sees it, defeating the predictive-back scrub. When `canPop` is `false` Nav3
+ * disables its back callback so the Activity finishes as usual.
+ *
+ * To intercept back on a single screen (e.g. an unsaved-changes prompt), use
+ * `NavigationBackHandler` from `androidx.navigationevent:navigationevent-compose` inside that
+ * screen's content — it shares `NavDisplay`'s dispatcher and keeps gesture progress.
*/
val canPop: Boolean
diff --git a/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt b/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt
index b6a8f5a..3b14ed8 100644
--- a/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt
+++ b/Nav3Navigation/src/main/java/uk/co/appoly/droid/nav3/TabsNav3Navigator.kt
@@ -41,17 +41,23 @@ enum class TabSlide {
val LocalTabsNavigator = staticCompositionLocalOf { null }
/**
- * Per-tab back stacks flattened into a single [backStack] for one [Nav3ScreenHost] /
- * [androidx.navigation3.ui.NavDisplay].
+ * One back stack per tab, projected into the single [backStack] that one [Nav3ScreenHost] /
+ * [androidx.navigation3.ui.NavDisplay] renders from.
*
* ## Model
*
- * - Each tab has its own stack. [startTab] is the launch tab and **exit-through-home** target
- * (defaults to the first entry of [tabOrder]; pass an explicit [startTab] when the home tab
- * is not first in the strip, e.g. a centre Home among Stations · Kerbside · Home · …).
- * - The display stack retains **every visited tab** so Nav3 keeps per-tab saveable state and
- * ViewModelStores across tab switches (entries are only torn down when their key leaves the
- * back stack). Flatten order:
+ * **The per-tab stacks are the source of truth.** Each tab owns its own stack, and every
+ * navigator operation ([push], [pop], [replace], [navigateToTab]) mutates exactly one tab's
+ * stack. [backStack] is a *derived projection* of those stacks, rebuilt after each mutation,
+ * because `NavDisplay` renders from a single list — it is a rendering adapter, not the model.
+ *
+ * - [startTab] is the launch tab and **exit-through-home** target (defaults to the first entry
+ * of [tabOrder]; pass an explicit [startTab] when the home tab is not first in the strip,
+ * e.g. a centre Home among Stations · Kerbside · Home · …).
+ * - The projection retains **every visited tab** so Nav3 keeps per-tab saveable state and
+ * ViewModelStores across tab switches. Nav3 clears an entry's state only when its key leaves
+ * the back stack, so stack membership is the only retention lever it offers. Projection
+ * order:
* `[other visited tabs in tabOrder] + startTabStack + (currentTabStack if not start)`.
* The current tab is always the suffix (`backStack.last()` is its top). Pair with
* [TabsSceneStrategy] (the [Nav3TabsHost] default) so only the current top is rendered.
@@ -73,7 +79,7 @@ val LocalTabsNavigator = staticCompositionLocalOf { null }
*
* ## Equal keys across tabs
*
- * Visited tabs share one flattened [backStack]. If the same equal key appears under more than
+ * Visited tabs share one projected [backStack]. If the same equal key appears under more than
* one tab (e.g. `DetailScreen(1)` on Home and on Rooms), Nav3 treats them as the same entry for
* saveable state / ViewModelStore — the same rule as duplicate keys on a single stack. Prefer
* distinguishing constructor args when the same destination can live under more than one tab.
@@ -101,7 +107,7 @@ val LocalTabsNavigator = staticCompositionLocalOf { null }
* @param tabOrder tab roots in strip order (used for [TabSlide] direction and bottom-bar order).
* Must be non-empty. Each root is kept as the first entry of that tab's stack and is never
* popped or replaced. Only tabs in this list may be passed to [switchTab] / [navigateToTab].
- * @param startTab the launch tab, exit-through-home target, and stack always flattened
+ * @param startTab the launch tab, exit-through-home target, and stack always projected
* underneath the current tab. Must be one of [tabOrder]. Defaults to the first entry of
* [tabOrder] so existing call sites stay source-compatible.
* @param parent the navigator that nested this tab shell (typically the root host), or `null`
@@ -120,7 +126,7 @@ class TabsNav3Navigator(
val tabOrder: List = tabOrder.toList()
/**
- * The launch tab, the exit-through-home target, and the stack always flattened underneath
+ * The launch tab, the exit-through-home target, and the stack always projected underneath
* the current tab's stack. Independent of [tabOrder] index — may sit mid-strip.
*/
val startTab: Nav3Screen = startTab
@@ -130,8 +136,10 @@ class TabsNav3Navigator(
)
/**
- * Flattened stack for [Nav3ScreenHost] / [androidx.navigation3.ui.NavDisplay].
- * Mutated only via this navigator — do not edit directly.
+ * The per-tab stacks projected into the single list [Nav3ScreenHost] /
+ * [androidx.navigation3.ui.NavDisplay] renders from. **Derived state**, rebuilt on every
+ * mutation — the per-tab stacks are the source of truth, so mutate only via this navigator
+ * and never edit this list directly.
*/
val backStack: NavBackStack = NavBackStack(this.startTab)
@@ -326,18 +334,18 @@ class TabsNav3Navigator(
/**
* Top of the current tab's stack (always `backStack.last()`, since the current tab is the
- * flattened suffix).
+ * projected suffix).
*/
override val lastItem: Nav3Screen?
get() = backStack.lastOrNull() as? Nav3Screen
/**
- * Entry immediately beneath the current top in the flattened [backStack].
+ * Entry immediately beneath the current top in the projected [backStack].
*
* - Deeper in a tab → that tab's previous screen.
* - At a non-start tab root → the top of [startTab]'s stack (what exit-through-home reveals).
* - At the start-tab root with retained visited tabs → another tab's entry may sit beneath
- * home in the flatten; [canPop] is still `false` and [TabsSceneStrategy] reports empty
+ * home in the projection; [canPop] is still `false` and [TabsSceneStrategy] reports empty
* `previousEntries`, so system back backgrounds the app rather than navigating there.
*/
override val previousItem: Nav3Screen?
@@ -345,11 +353,11 @@ class TabsNav3Navigator(
/**
* Screens on the **current tab's** stack only (root first) — Voyager-equivalent meaning of
- * “the stack”, not the full flattened multi-tab [backStack].
+ * “the stack”, not the full projected multi-tab [backStack].
*
* Deliberate behaviour: callers inspecting `items` for bottom-bar chrome, deep-link
* reconcile, or “am I on X?” want the active tab, not every retained visited tab.
- * Use [stackFor] or [backStack] when you need another tab or the display flatten.
+ * Use [stackFor] or [backStack] when you need another tab or the display projection.
*/
override val items: List
get() = stackFor(currentTab)
@@ -417,7 +425,7 @@ class TabsNav3Navigator(
}
/**
- * Rebuilds the flattened [backStack] so every **visited** tab keeps its entries (Nav3 only
+ * Rebuilds the projected [backStack] so every **visited** tab keeps its entries (Nav3 only
* tears down saveable / ViewModel state when a content key leaves the back stack).
*
* Order: other visited tabs in [tabOrder] (excluding [startTab] and [currentTab]) +
diff --git a/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt b/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt
index e33562b..5acd64b 100644
--- a/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt
+++ b/Nav3Navigation/src/test/java/uk/co/appoly/droid/nav3/TabsSceneStrategyTest.kt
@@ -69,9 +69,12 @@ class TabsSceneStrategyTest {
val scene = calculate(entries)
assertNotNull(scene)
assertEquals(entries.dropLast(1), scene!!.previousEntries)
- // dropLast(1) last entry is start tab top (DetailScreen(5))
+ // dropLast(1) last entry is start tab top, which the backStack assertion above pins as
+ // DetailScreen(5). Identity is compared contentKey-to-contentKey rather than against a
+ // literal: Nav3 1.2.0-beta01 changed the default contentKey to a composite of
+ // `key.toString()` and `key::class.toString()`, and `NavEntry.key` is private, so pinning
+ // the format here would only re-arm this trap on the next release.
assertEquals(entries[entries.lastIndex - 1].contentKey, scene.previousEntries.last().contentKey)
- assertEquals(DetailScreen(5).toString(), scene.previousEntries.last().contentKey)
assertEquals(listOf(entries.last()), scene.entries)
assertEquals(1, entries.size - scene.previousEntries.size)
}
diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md
index 4c8f405..effab19 100644
--- a/PagingExtensions/README.md
+++ b/PagingExtensions/README.md
@@ -12,7 +12,7 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:pagingextensions:1.9.0")
+implementation("uk.co.appoly.droid:pagingextensions:1.9.1")
```
## Usage
diff --git a/README.md b/README.md
index bfcd3cb..f842695 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@ In your `libs.versions.toml` file:
```toml
[versions]
-appolydroidToolbox = "1.9.0" # Replace with the latest version
+appolydroidToolbox = "1.9.1" # Replace with the latest version
[libraries]
appolydroid-toolbox-bom = { group = "uk.co.appoly.droid", name = "bom", version.ref = "appolydroidToolbox" }
@@ -129,7 +129,7 @@ In your module's `build.gradle.kts`:
```gradle.kts
dependencies {
// Import the BOM
- implementation(platform("uk.co.appoly.droid:bom:1.9.0"))
+ implementation(platform("uk.co.appoly.droid:bom:1.9.1"))
// Now you can use AppolyDroid modules without specifying versions
implementation("uk.co.appoly.droid:baserepo")
@@ -166,7 +166,7 @@ In your `libs.versions.toml` file:
```toml
[versions]
-appolydroidToolbox = "1.9.0" # Replace with the latest version
+appolydroidToolbox = "1.9.1" # Replace with the latest version
[libraries]
#AppolyDroid-Toolbox
@@ -234,7 +234,7 @@ In your module's `build.gradle.kts`:
```gradle.kts
dependencies {
- val appolydroidToolbox = "1.9.0" // Replace with the latest version
+ val appolydroidToolbox = "1.9.1" // Replace with the latest version
// Add only the modules you need
implementation("uk.co.appoly.droid:baserepo:$appolydroidToolbox")
implementation("uk.co.appoly.droid:baserepo-appolyjson:$appolydroidToolbox")
diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md
index 59d62c7..5147a01 100644
--- a/S3Uploader-Multipart/README.md
+++ b/S3Uploader-Multipart/README.md
@@ -16,7 +16,7 @@ Advanced S3 upload module with pause, resume, and recovery support using AWS S3
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.0")
+implementation("uk.co.appoly.droid:s3uploader-multipart:1.9.1")
```
This module depends on `S3Uploader` and includes it transitively.
diff --git a/S3Uploader/README.md b/S3Uploader/README.md
index ef00776..100276d 100644
--- a/S3Uploader/README.md
+++ b/S3Uploader/README.md
@@ -16,7 +16,7 @@ Standalone module for Amazon S3 file uploading with progress tracking and error
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:s3uploader:1.9.0")
+implementation("uk.co.appoly.droid:s3uploader:1.9.1")
```
## Usage
diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md
index f81df40..146b4c0 100644
--- a/SegmentedControl/README.md
+++ b/SegmentedControl/README.md
@@ -17,7 +17,7 @@ A highly customizable iOS-style segmented control for Jetpack Compose with smoot
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:segmentedcontrol:1.9.0")
+implementation("uk.co.appoly.droid:segmentedcontrol:1.9.1")
```
## Usage
diff --git a/UiState/README.md b/UiState/README.md
index 77a5319..b1e0c53 100644
--- a/UiState/README.md
+++ b/UiState/README.md
@@ -13,7 +13,7 @@ A standardized UI state management library for Android applications, providing c
## Installation
```gradle.kts
-implementation("uk.co.appoly.droid:uistate:1.9.0")
+implementation("uk.co.appoly.droid:uistate:1.9.1")
```
## Usage
diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt
index fc7e0c9..377aa1c 100644
--- a/buildSrc/src/main/kotlin/BuildConfig.kt
+++ b/buildSrc/src/main/kotlin/BuildConfig.kt
@@ -9,7 +9,7 @@ object BuildConfig {
* The current version of the AppolyDroid Toolbox library.
* This is used for maven publishing and README version updates.
*/
- const val TOOLBOX_VERSION = "1.9.0"
+ const val TOOLBOX_VERSION = "1.9.1"
/**
* SDK version configuration for Android modules.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 8a3e677..ae856c9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,56 +1,73 @@
+# =============================================================================
+# Version catalog
+#
+# Entries are grouped by blast radius:
+#
+# [PUBLISHED] Reaches consumers of the library. These end up on a consumer's
+# classpath (api/implementation => POM runtime scope) or are baked
+# into the published AAR by a code generator. Bumping one of these
+# is a consumer-visible change - mind binary compatibility, the
+# minimum versions consumers must tolerate, and the README tables.
+# [BUILD/TEST] Compiles and verifies the library modules but never leaves the
+# build - test and androidTest configurations, compilers, tooling.
+# Bump freely; only CI can be broken by it.
+# [DEMO APP] Used solely by the `app` demo module. Invisible to consumers.
+# =============================================================================
+
[versions]
-agp = "9.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"
+
+# --- BUILD/TEST: toolchain + test-only, never published ----------------------
+agp = "9.4.0"
+ksp = "2.3.11"
+vanniktechPublish = "0.37.0" # release tooling only
+junit = "4.13.2"
+junitVersion = "1.3.0"
+espressoCore = "3.7.0"
+androidxTestCore = "1.7.0"
robolectric = "4.16.1"
-coroutines = "1.11.0"
+activityCompose = "1.13.0" # demo app + Nav3Navigation androidTest only
[libraries]
+# =============================================================================
+# PUBLISHED - on consumers' classpaths
+# =============================================================================
+
+#AndroidX core
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
-junit = { group = "junit", name = "junit", version.ref = "junit" }
-androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
-androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
-androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxTestCore" }
-robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
-kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
-kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
-androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycleRuntime" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime", version.ref = "lifecycleRuntime" }
-#Compose
-androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+#Kotlin / coroutines
+kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" }
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
+
+#kotlinx serialization
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
+kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" }
+
+#Compose (BOM is imported by the Compose-facing modules and by the demo app)
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
-androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
-androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
-androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
-androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
-androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
-#FlexiLogger (Maven Central)
+#FlexiLogger (Maven Central) - exposed as `api`
flexiLogger = { group = "io.github.projectdelta6", name = "flexilogger", version.ref = "flexiLoggerVersion" }
flexiLogger-okhttp = { group = "io.github.projectdelta6", name = "flexilogger-okhttp", version.ref = "flexiLoggerVersion" }
@@ -58,37 +75,26 @@ flexiLogger-okhttp = { group = "io.github.projectdelta6", name = "flexilogger-ok
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp-urlconnection = { module = "com.squareup.okhttp3:okhttp-urlconnection", version.ref = "okhttp" }
-okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
#Retrofit
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-serializationConverter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
-#sandwich (versions aligned by the BOM — import platform(libs.sandwich.bom) alongside these)
+#sandwich (versions aligned by the BOM - import platform(libs.sandwich.bom) alongside these)
sandwich-bom = { group = "com.github.skydoves", name = "sandwich-bom", version.ref = "sandwichVersion" }
sandwich = { group = "com.github.skydoves", name = "sandwich" }
sandwich-retrofit = { group = "com.github.skydoves", name = "sandwich-retrofit" }
-#Kotlin
-kotlin-reflect = { group = "org.jetbrains.kotlin", name = "kotlin-reflect", version.ref = "kotlin" }
-
-#kotlinx serialization
-kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
-kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" }
-
#Paging
paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "paging" }
paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" }
-paging-testing = { group = "androidx.paging", name = "paging-testing", version.ref = "paging" }
-#Room
+#Room (the compiler is `ksp`-only, but its output is baked into the published AAR)
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomVersion" }
-androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomVersion" }
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomVersion" }
-androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomVersion" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomVersion" }
-#Navigation 3
+#Navigation 3 - exposed as `api` from Nav3Navigation
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "nav3" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "nav3" }
androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleRuntime" }
@@ -97,15 +103,48 @@ androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "l
#WorkManager
androidx-work-runtime = { group = "androidx.work", name = "work-runtime", version.ref = "workManager" }
+
+# =============================================================================
+# BUILD/TEST ONLY - test & androidTest configurations of the library modules
+# =============================================================================
+
+#Unit test
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
+kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
+okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
+paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" }
+paging-testing = { group = "androidx.paging", name = "paging-testing", version.ref = "paging" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "workManager" }
+#Instrumented test
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
+androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+androidx-test-core-ktx = { group = "androidx.test", name = "core-ktx", version.ref = "androidxTestCore" }
+androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "roomVersion" }
+
+#Compose test (versions from the Compose BOM)
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+
+# =============================================================================
+# DEMO APP ONLY - never referenced by a published module
+# =============================================================================
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+
[plugins]
-android-application = { id = "com.android.application", version.ref = "agp" }
+# --- Applied by the published library modules --------------------------------
android-library = { id = "com.android.library", version.ref = "agp" }
-kotlinKSP = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
-vanniktech-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechPublish" }
+kotlinKSP = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "roomVersion" }
-kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
+vanniktech-publish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechPublish" }
+
+# --- Local only --------------------------------------------------------------
+android-application = { id = "com.android.application", version.ref = "agp" } # demo app
diff --git a/scripts/clear-local-publish.sh b/scripts/clear-local-publish.sh
new file mode 100755
index 0000000..d02c11b
--- /dev/null
+++ b/scripts/clear-local-publish.sh
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+#
+# AppolyDroid Toolbox — remove a local install.
+#
+# Deletes the toolbox from the local Maven repository (~/.m2/repository), undoing
+# scripts/publish-local.sh or scripts/publish.sh --local.
+#
+# ./scripts/clear-local-publish.sh remove every locally installed version
+# ./scripts/clear-local-publish.sh 1.9.1-rc01 remove just that version
+# ./scripts/clear-local-publish.sh --dry-run list what would go, delete nothing
+# ./scripts/clear-local-publish.sh --yes skip the confirmation prompt
+#
+# WHY THIS MATTERS. A local install carries the same version string as the real release, and
+# mavenLocal() wins over mavenCentral(). Left in place it silently shadows the published artifacts:
+# the consuming project resolves your working tree while the version number says otherwise. Clearing
+# it is how you get back to testing what consumers actually receive.
+#
+# Only ever touches the toolbox's own group directory — nothing else in ~/.m2 is read or written.
+#
+set -euo pipefail
+
+cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# Shares fork-specific settings (PUBLISH_GROUP) with publish.sh. Git-ignored; see publish.conf.example.
+# shellcheck disable=SC1091
+[[ -f scripts/publish.conf ]] && source scripts/publish.conf
+
+readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}"
+readonly M2_REPO="${M2_REPO:-$HOME/.m2/repository}"
+readonly GROUP_DIR="$M2_REPO/${GROUP//.//}"
+
+RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; NC=$'\033[0m'
+info() { echo "${GREEN}[INFO]${NC} $1"; }
+warn() { echo "${YELLOW}[WARN]${NC} $1"; }
+fail() { echo "${RED}[ERROR]${NC} $1" >&2; }
+
+DRY_RUN=false
+ASSUME_YES=false
+VERSION=""
+for arg in "$@"; do
+ case "$arg" in
+ --dry-run|-n) DRY_RUN=true ;;
+ --yes|-y) ASSUME_YES=true ;;
+ -h|--help)
+ cat <<'USAGE'
+Usage: ./scripts/clear-local-publish.sh [--dry-run] [--yes] [VERSION]
+
+ (no args) Remove every locally installed toolbox version from ~/.m2.
+ VERSION Remove only that version (e.g. 1.9.1-rc01).
+ --dry-run, -n List what would be removed, delete nothing.
+ --yes, -y Do not ask for confirmation.
+
+Only the toolbox's own group directory is touched. Install again with
+./scripts/publish-local.sh.
+USAGE
+ exit 0 ;;
+ -*) fail "Unknown option: $arg"; echo "Try --help" >&2; exit 1 ;;
+ *)
+ [[ -z "$VERSION" ]] || { fail "Only one version can be given (got '$VERSION' and '$arg')."; exit 1; }
+ VERSION="$arg" ;;
+ esac
+done
+
+if [[ ! -d "$GROUP_DIR" ]]; then
+ info "Nothing to clear — $GROUP is not installed in $M2_REPO."
+ exit 0
+fi
+
+# Collect the artifact/version directories to delete, so the prompt shows exactly what goes.
+TARGETS=()
+while IFS= read -r dir; do
+ TARGETS+=("$dir")
+done < <(
+ if [[ -n "$VERSION" ]]; then
+ find "$GROUP_DIR" -mindepth 2 -maxdepth 2 -type d -name "$VERSION" | sort
+ else
+ find "$GROUP_DIR" -mindepth 1 -maxdepth 1 -type d | sort
+ fi
+)
+
+if [[ ${#TARGETS[@]} -eq 0 ]]; then
+ if [[ -n "$VERSION" ]]; then
+ info "Nothing to clear — no module of $GROUP is installed at version $VERSION."
+ else
+ info "Nothing to clear — $GROUP_DIR holds no module directories."
+ fi
+ exit 0
+fi
+
+SIZE=$(du -sh "$GROUP_DIR" 2>/dev/null | cut -f1 | tr -d "[:space:]" || echo "?")
+
+echo
+echo "================================================"
+if [[ -n "$VERSION" ]]; then
+ echo " Clearing ${BOLD}$GROUP${NC} ${BOLD}$VERSION${NC} from ~/.m2"
+else
+ echo " Clearing ${BOLD}$GROUP${NC} (all versions) from ~/.m2"
+fi
+echo "================================================"
+echo
+echo "${#TARGETS[@]} director$([[ ${#TARGETS[@]} -eq 1 ]] && echo "y" || echo "ies") under $GROUP_DIR:"
+for target in "${TARGETS[@]}"; do
+ echo " ${target#"$GROUP_DIR"/}"
+done
+echo
+info "Group directory currently uses $SIZE on disk."
+
+if [[ "$DRY_RUN" == true ]]; then
+ echo
+ info "Dry run — nothing was deleted."
+ exit 0
+fi
+
+if [[ "$ASSUME_YES" != true ]]; then
+ echo
+ read -rp "Delete these? (y/N) " -n 1 reply; echo
+ [[ $reply =~ ^[Yy]$ ]] || { info "Cancelled — nothing was deleted."; exit 0; }
+fi
+
+for target in "${TARGETS[@]}"; do
+ rm -rf "$target"
+done
+
+# With a version filter the artifact directories survive, holding other versions — or nothing, if
+# that was the only one installed. Prune the husks so a later run reports honestly instead of
+# listing empty directories.
+find "$GROUP_DIR" -mindepth 1 -type d -empty -delete
+rmdir "$GROUP_DIR" 2>/dev/null || true
+
+echo
+info "Cleared. The consuming project now resolves $GROUP from its remote repositories again."
+warn "Gradle caches resolved modules per project. If a consumer already resolved the local copy,"
+warn "it needs --refresh-dependencies to notice, or it keeps serving the build you just deleted."
diff --git a/scripts/publish-local.sh b/scripts/publish-local.sh
new file mode 100755
index 0000000..8b0036b
--- /dev/null
+++ b/scripts/publish-local.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+#
+# AppolyDroid Toolbox — local install.
+#
+# Publishes the toolbox to the local Maven repository (~/.m2/repository) so a consuming app can
+# resolve the current working tree through mavenLocal(). This is the loop for testing a change
+# BEFORE releasing it — Maven Central releases are immutable, so there is no fixing one afterwards.
+#
+# ./scripts/publish-local.sh every module, unsigned
+# ./scripts/publish-local.sh BaseRepo UiState only those modules (and their dependencies)
+# ./scripts/publish-local.sh --signed every module, signed — same as publish.sh --local
+#
+# WHY UNSIGNED BY DEFAULT. Signing needs the release key out of 1Password, which makes the quick
+# iteration loop wait on a vault unlock for a signature nothing local ever verifies. Gradle does not
+# check signatures on resolve, so an unsigned local install behaves identically to a signed one for
+# every purpose this script exists for. Use --signed when the thing being tested IS the signing or
+# the exact artifact set a release would upload; that path is scripts/publish.sh --local.
+#
+# Undo with ./scripts/clear-local-publish.sh — see CONTRIBUTING.md, "Testing an unreleased change".
+#
+set -euo pipefail
+
+cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# Shares fork-specific settings (PUBLISH_GROUP) with publish.sh. Git-ignored; see publish.conf.example.
+# shellcheck disable=SC1091
+[[ -f scripts/publish.conf ]] && source scripts/publish.conf
+
+readonly GROUP="${PUBLISH_GROUP:-uk.co.appoly.droid}"
+
+RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BOLD=$'\033[1m'; NC=$'\033[0m'
+info() { echo "${GREEN}[INFO]${NC} $1"; }
+warn() { echo "${YELLOW}[WARN]${NC} $1"; }
+fail() { echo "${RED}[ERROR]${NC} $1" >&2; }
+
+MODULES=()
+for arg in "$@"; do
+ case "$arg" in
+ --signed|-s)
+ info "Delegating to scripts/publish.sh --local for the signed install."
+ exec ./scripts/publish.sh --local
+ ;;
+ -h|--help)
+ cat <<'USAGE'
+Usage: ./scripts/publish-local.sh [--signed] [Module ...]
+
+ (no args) Publish every module to ~/.m2, unsigned. Needs no credentials.
+ Module ... Publish only the named modules, by Gradle project name
+ (e.g. BaseRepo UiState). Faster when iterating on one module.
+ --signed, -s Publish every module signed, via scripts/publish.sh --local.
+ Needs the release signing key.
+
+Remove a local install again with ./scripts/clear-local-publish.sh.
+USAGE
+ exit 0 ;;
+ -*) fail "Unknown option: $arg"; echo "Try --help" >&2; exit 1 ;;
+ *) MODULES+=("${arg#:}") ;;
+ esac
+done
+
+VERSION=$(sed -n 's/.*TOOLBOX_VERSION *= *"\([^"]*\)".*/\1/p' buildSrc/src/main/kotlin/BuildConfig.kt)
+[[ -n "$VERSION" ]] || { fail "Could not read TOOLBOX_VERSION from buildSrc/src/main/kotlin/BuildConfig.kt"; exit 1; }
+
+# Publishing drives Dokka across every module in one daemon, which needs more metaspace than a
+# typical personal ~/.gradle/gradle.properties allows — and user-level properties beat the repo's,
+# so the project cannot set this itself. Unpinned, this fails with a bare "Metaspace" error on an
+# arbitrary module. Same reasoning as publish.sh.
+export GRADLE_OPTS="-Dorg.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=2048m -Dfile.encoding=UTF-8"
+
+if [[ ${#MODULES[@]} -eq 0 ]]; then
+ info "Publishing all modules of ${BOLD}$GROUP${NC} at ${BOLD}$VERSION${NC} to ~/.m2 (unsigned)..."
+ TASKS=(publishToMavenLocal)
+else
+ # A partial install leaves ~/.m2 holding a mix of versions across modules. That is fine while
+ # iterating on one module, and wrong the moment you draw a conclusion about the set — hence
+ # the warning below and the whole-set default.
+ info "Publishing ${BOLD}${MODULES[*]}${NC} at ${BOLD}$VERSION${NC} to ~/.m2 (unsigned)..."
+ TASKS=()
+ for module in "${MODULES[@]}"; do
+ TASKS+=(":${module}:publishToMavenLocal")
+ done
+fi
+
+./gradlew "${TASKS[@]}"
+
+echo
+info "================================================"
+info " Installed to ~/.m2 — $GROUP at $VERSION"
+info "================================================"
+echo
+if [[ ${#MODULES[@]} -gt 0 ]]; then
+ warn "Partial install — only these were rebuilt: ${MODULES[*]}. Every other module in ~/.m2"
+ warn "is whatever was installed last, which may be a different build of the same version."
+fi
+cat <