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 <