From 4f7e3b8926e9dec060530e0921eaddb86560d4b3 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Tue, 21 Jul 2026 12:51:38 +0100 Subject: [PATCH 1/4] docs: TODO to promote WenWe paging de-dupe + serializable Compose state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture two generic utilities written in WenWe (fixing Sentry WENWE-ANDROID-5H and 5G) as a proposed-features TODO, ready to lift in: - Flow>.distinctBy → :PagingExtensions - SerializableMutableState / TransientMutableState → :ComposeExtensions Includes copy-paste-ready source (repackaged), placement, a note that the library must not rely on BuildConfig.DEBUG for the fail-fast guard, tests to add, and a checklist to migrate WenWe onto the lib versions once released. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...g-dedupe-and-serializable-compose-state.md | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 docs/proposed/paging-dedupe-and-serializable-compose-state.md diff --git a/docs/proposed/paging-dedupe-and-serializable-compose-state.md b/docs/proposed/paging-dedupe-and-serializable-compose-state.md new file mode 100644 index 00000000..12009d1d --- /dev/null +++ b/docs/proposed/paging-dedupe-and-serializable-compose-state.md @@ -0,0 +1,224 @@ +# Proposed: paging de-dupe + serialization-safe Compose state + +**Status:** TODO / proposed — not yet implemented. +**Origin:** WenWe Android, July 2026. Both utilities were written to fix production crashes +(Sentry `WENWE-ANDROID-5H` and `WENWE-ANDROID-5G`) and are fully generic — nothing WenWe-specific — +so they belong in the toolbox. This doc captures them ready to lift in. + +Two independent additions: +1. `Flow>.distinctBy { }` → **PagingExtensions** module. +2. `SerializableMutableState` / `TransientMutableState` (+ factories) → **ComposeExtensions** module. + +Once released, update WenWe to depend on the library versions and delete its local copies (see +[Migrate WenWe once released](#migrate-wenwe-once-released)). + +--- + +## 1. `Flow>.distinctBy` — PagingExtensions + +**Module:** `:PagingExtensions` · **Package:** `uk.co.appoly.droid.util.paging` +(alongside the existing `PagingExtensions.kt`; deps already include `androidx.paging`.) + +### Why +Offset/page-number pagination over data that can change between page loads can return the **same +item id on more than one page**. With `itemKey = { it.id }` on a `LazyColumn`/`LazyRow`, the second +occurrence throws `IllegalArgumentException: Key "…" was already used` and crashes the screen. This +extension de-dupes the stream by an arbitrary key, keeping the first occurrence per `PagingData` +generation. (It's a client-side guard; the real fix is stable/keyset pagination server-side, but the +guard prevents a hard crash regardless.) + +### Source (repackaged) +```kotlin +package uk.co.appoly.droid.util.paging + +import androidx.paging.PagingData +import androidx.paging.filter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * De-duplicates items in a paging stream by the key produced by [selector], keeping the first + * occurrence of each key and dropping any later duplicates. + * + * Guards against a paged endpoint returning the same item on more than one page — common with + * offset pagination over data that can change between page loads — which would otherwise crash a + * `LazyColumn`/`LazyRow` with `IllegalArgumentException: Key "…" was already used` when that item's + * key is used as the list `key`/`itemKey`. + * + * The `seen` set is scoped inside the [map] over each emitted [PagingData], so it resets naturally + * on every refresh/invalidation. Only safe when the `Pager` does **not** set `maxSize` (no page + * dropping): a dropped-then-reloaded page's items would otherwise be wrongly filtered as seen. + */ +fun Flow>.distinctBy( + selector: (T) -> K, +): Flow> = map { pagingData -> + val seen = mutableSetOf() + pagingData.filter { seen.add(selector(it)) } +} +``` + +### Tests to add +- Duplicate ids across a simulated multi-page `PagingData` → only first kept. +- Clean pages → unchanged, order preserved. +- Add to `PagingExtensionsTest.kt`. + +--- + +## 2. Serialization-safe Compose `MutableState` — ComposeExtensions + +**Module:** `:ComposeExtensions` · **Package:** `uk.co.appoly.droid.compose.extensions` +(or a new `uk.co.appoly.droid.compose.state` sub-package; deps: compose runtime + stdlib only.) + +### Why +Voyager `Screen`s implement `java.io.Serializable` and are Java-serialized to survive process death. +A plain `mutableStateOf(…)` isn't `Serializable`, so the common workaround is a `@Transient` field — +which returns **null** after a deserialization restore (the JVM doesn't run field initializers), +crashing on the next read with `NullPointerException: … State.getValue() on a null object reference` +(`WENWE-ANDROID-5G`). These two holders both implement `MutableState` (so they're drop-in for +`by`/`.value`/destructuring) and are `Serializable`: + +- **`SerializableMutableState`** — persists & restores the value. For one-shot guards where a reset + would re-fire an effect (`firstOpen`, `shouldLaunchPicker`, `didInitialScroll`, `wenWeLoaded`). +- **`TransientMutableState`** — resets to `initial` on restore. For ephemeral presentation/event + state (sheet visibility, "refresh now" pulses, overlays, deep-link/pending triggers, one-shot + snackbars). + +Rule of thumb: **persist if resetting would re-fire a one-shot effect or lose real progress; +otherwise reset.** For `derivedStateOf` on a Screen field (same null-after-restore bug, can't be +serialized), convert to a computed `get()` that recomputes from the now-restored sources. + +### ⚠️ One adaptation needed for the library +`SerializableMutableState` below uses `BuildConfig.DEBUG` to fail-fast when handed a non-null, +non-`Serializable` value (which would silently restore as null and recreate the NPE). In a **published +library**, `BuildConfig.DEBUG` is always `false` in consumers, so the guard would never fire. Pick one: +- **Recommended:** just `throw` unconditionally on a non-null non-`Serializable` value (it's a + programming error to pass one), dropping the `BuildConfig.DEBUG` gate; or +- gate on a library-level opt-in flag if you want it silenceable. + +The version below is shown as-is from WenWe; adjust the `writeObject` guard per the above. + +### Source (repackaged) — `SerializableMutableState` +```kotlin +package uk.co.appoly.droid.compose.extensions + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import java.io.NotSerializableException +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable + +/** + * A [Serializable] [MutableState] that persists & restores its value across process death. + * See the proposed-features doc / WENWE-ANDROID-5G for background. Implements [MutableState] by + * delegating to a `@Transient` `mutableStateOf`, so it's a drop-in replacement. + * + * The value is persisted only when it is itself [Serializable] (or null); a non-serializable value + * restores as null (safe only for a nullable T). Main-thread access only. + */ +class SerializableMutableState(initial: T) : MutableState, Serializable { + + @Transient + private var delegate: MutableState = mutableStateOf(initial) + + override var value: T + get() = delegate.value + set(value) { delegate.value = value } + + override fun component1(): T = delegate.value + override fun component2(): (T) -> Unit = { delegate.value = it } + + private fun writeObject(out: ObjectOutputStream) { + out.defaultWriteObject() + val current = delegate.value + // TODO(lib): BuildConfig.DEBUG is meaningless in a published lib — throw unconditionally + // on a non-null non-Serializable value instead (see doc). WenWe original: + // if (BuildConfig.DEBUG && current != null && current !is Serializable) throw ... + if (current != null && current !is Serializable) { + throw NotSerializableException( + "serializableMutableStateOf value is not Serializable: ${current::class.java.name}. " + + "Use a Serializable type, or transientMutableStateOf if it need not survive process death.", + ) + } + out.writeObject(current as? Serializable) + } + + private fun readObject(input: ObjectInputStream) { + input.defaultReadObject() + @Suppress("UNCHECKED_CAST") + delegate = mutableStateOf(input.readObject() as T) + } + + companion object { private const val serialVersionUID: Long = 1L } +} + +/** Creates a [Serializable] [MutableState] that survives process death. */ +fun serializableMutableStateOf(initial: T): SerializableMutableState = SerializableMutableState(initial) +``` + +### Source (repackaged) — `TransientMutableState` +```kotlin +package uk.co.appoly.droid.compose.extensions + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import java.io.Serializable + +/** + * A [Serializable] [MutableState] whose value resets to [initial] after a process-death restore, + * rather than being persisted. Sibling of [SerializableMutableState]; for ephemeral state that + * should NOT survive process death. Only [initial] is persisted (must be Serializable or null). + * Main-thread access only. + */ +class TransientMutableState(private val initial: T) : MutableState, Serializable { + init { + require(initial == null || initial is Serializable) { + "transientMutableStateOf initial value must be Serializable or null, was: $initial" + } + } + + @Transient + private var delegate: MutableState? = null + + private fun delegate(): MutableState = + delegate ?: mutableStateOf(initial).also { delegate = it } + + override var value: T + get() = delegate().value + set(value) { delegate().value = value } + + override fun component1(): T = delegate().value + override fun component2(): (T) -> Unit = { delegate().value = it } + + companion object { private const val serialVersionUID: Long = 1L } +} + +/** Creates a [Serializable] [MutableState] that resets to [initial] on process-death restore. */ +fun transientMutableStateOf(initial: T): TransientMutableState = TransientMutableState(initial) +``` + +### Tests to add +- Round-trip `SerializableMutableState` through `ObjectOutputStream`/`ObjectInputStream`: value + preserved; non-null non-Serializable value → throws (per chosen guard); null value survives. +- Round-trip `TransientMutableState`: value resets to `initial`. +- Both: `.value` get/set and `by` delegation behave like `mutableStateOf`. + +--- + +## Migrate WenWe once released + +Once these ship in a toolbox release and WenWe bumps to it: + +1. Bump the AppolyDroid dependency in WenWe. +2. Delete the local copies: + - `app/src/main/java/uk/co/wenwe/util/PagingExt.kt` + - `app/src/main/java/uk/co/wenwe/util/SerializableMutableState.kt` + - `app/src/main/java/uk/co/wenwe/util/TransientMutableState.kt` +3. Repoint imports: + - `uk.co.wenwe.util.distinctBy` → `uk.co.appoly.droid.util.paging.distinctBy` + - `uk.co.wenwe.util.{serializable,transient}MutableStateOf` → `uk.co.appoly.droid.compose.extensions.*` +4. Call sites that use `distinctBy`: `WenWeContributorsScreenModel`, `ViewContributorsBottomSheet` + (both currently reverted/optional — check current state). State delegates: ~19 Screen files + swept in the WENWE-76 line (grep `serializableMutableStateOf` / `transientMutableStateOf`). +5. Rebuild + the same on-device process-death smoke test (Developer Options → "Don't keep + activities", background/foreground a Screen, confirm no NPE). From f4e5dcff5c96ec99ce6e25a6058f028641668f66 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 22 Jul 2026 13:00:18 +0100 Subject: [PATCH 2/4] - add clipboard copier to proposals --- ...g-dedupe-and-serializable-compose-state.md | 211 +++++++++++++++++- 1 file changed, 204 insertions(+), 7 deletions(-) diff --git a/docs/proposed/paging-dedupe-and-serializable-compose-state.md b/docs/proposed/paging-dedupe-and-serializable-compose-state.md index 12009d1d..79a80f5d 100644 --- a/docs/proposed/paging-dedupe-and-serializable-compose-state.md +++ b/docs/proposed/paging-dedupe-and-serializable-compose-state.md @@ -1,16 +1,20 @@ -# Proposed: paging de-dupe + serialization-safe Compose state +# Proposed: paging de-dupe + serialization-safe Compose state + clipboard copier **Status:** TODO / proposed — not yet implemented. -**Origin:** WenWe Android, July 2026. Both utilities were written to fix production crashes -(Sentry `WENWE-ANDROID-5H` and `WENWE-ANDROID-5G`) and are fully generic — nothing WenWe-specific — -so they belong in the toolbox. This doc captures them ready to lift in. +**Origin:** WenWe Android (#1, #2) and Accelerate Android (#3), July 2026. The WenWe utilities were +written to fix production crashes (Sentry `WENWE-ANDROID-5H` and `WENWE-ANDROID-5G`); the clipboard +copier came out of the Accelerate migration off the deprecated `LocalClipboardManager`. All are +fully generic — nothing app-specific — so they belong in the toolbox. This doc captures them ready +to lift in. -Two independent additions: +Three independent additions: 1. `Flow>.distinctBy { }` → **PagingExtensions** module. 2. `SerializableMutableState` / `TransientMutableState` (+ factories) → **ComposeExtensions** module. +3. `ClipboardCopier` + `rememberClipboardCopier()` + `copyX` extensions → **ComposeExtensions** module. -Once released, update WenWe to depend on the library versions and delete its local copies (see -[Migrate WenWe once released](#migrate-wenwe-once-released)). +Once released, update the originating apps to depend on the library versions and delete their local +copies (see [Migrate WenWe once released](#migrate-wenwe-once-released) and +[Migrate Accelerate once released](#migrate-accelerate-once-released)). --- @@ -205,6 +209,182 @@ fun transientMutableStateOf(initial: T): TransientMutableState = Transien --- +## 3. Clipboard copier — ComposeExtensions + +**Module:** `:ComposeExtensions` · **Package:** `uk.co.appoly.droid.compose.extensions` +(deps: compose runtime + compose ui + coroutines; all already on the module.) + +### Why + +`androidx.compose.ui.platform.LocalClipboardManager` is deprecated in favour of `LocalClipboard`, +whose `Clipboard.setClipEntry(…)` is a **suspend** function. That turns a one-line copy into a small +pile of plumbing every call site has to repeat correctly: grab `LocalClipboard` + a +`rememberCoroutineScope()`, `launch`, build a `ClipEntry`, and — because a "copied!" confirmation +should only fire once the write actually returns — order the toast after the await. There's also an +Android-13 wrinkle: API 33+ shows its own clipboard confirmation, so an app's own toast must be +gated to `< TIRAMISU` to avoid a double confirmation. This centralises all of it. + +`ClipboardCopier` takes a raw `ClipEntry`, so it handles any payload (text, HTML, URIs, intents, +multi-item); the `copyX` extensions mirror the `ClipData.newX` factories for ergonomics. `label` is +kept **required** to match `ClipData.newX` exactly (it's read by clipboard managers / a11y even +though it isn't shown in the modern paste UI); `confirmationMessage` is the library's own addition +and defaults to `null` (no toast). + +### Source (repackaged) + +```kotlin +package uk.co.appoly.droid.compose.extensions + +import android.content.ClipData +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.toClipEntry +import kotlinx.coroutines.launch + +/** + * Copies a [ClipEntry] to the system clipboard and, on Android < 13, shows a confirmation Toast. + * Android 13+ surfaces its own clipboard confirmation, so we suppress ours to avoid a double toast. + * + * Obtain one with [rememberClipboardCopier]. It centralises the [LocalClipboard] plumbing: + * `setClipEntry` is a suspend function, so each copy runs on a remembered scope and the confirmation + * only fires once the write has returned. The payload is a raw [ClipEntry], so it handles text, a + * URI, multiple items, etc.; use [copyPlainText] for the common plain-text case. + */ +fun interface ClipboardCopier { + /** + * @param clipEntry the payload to copy. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ + fun copy(clipEntry: ClipEntry, confirmationMessage: String?) +} + +/** + * Copies plain [text] to the clipboard — the common case (wraps [ClipData.newPlainText]). + * + * @param text the text to copy. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyPlainText( + text: CharSequence, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newPlainText(label, text).toClipEntry(), confirmationMessage) + +/** + * Copies styled [htmlText] to the clipboard, with [text] as the plain-text fallback for consumers + * that can't render HTML (wraps [ClipData.newHtmlText]). + * + * @param text the plain-text representation. + * @param htmlText the HTML-markup representation. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyHtmlText( + text: CharSequence, + htmlText: String, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newHtmlText(label, text, htmlText).toClipEntry(), confirmationMessage) + +/** + * Copies a raw [uri] to the clipboard without resolving it through a [ContentResolver] + * (wraps [ClipData.newRawUri]). Use for URIs that aren't `content://` provider URIs — e.g. an + * `http`/`https` link or a `mailto:` address; for content URIs use [copyUri] instead. + * + * @param uri the URI to copy verbatim. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyRawUri( + uri: Uri, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newRawUri(label, uri).toClipEntry(), confirmationMessage) + +/** + * Copies a `content://` [uri] to the clipboard, querying its available MIME types from [resolver] + * so pasting apps receive the right type (wraps [ClipData.newUri]). For plain web/mail URIs prefer + * [copyRawUri]. + * + * @param resolver resolves the URI's MIME types. + * @param uri the content URI to copy. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyUri( + resolver: ContentResolver, + uri: Uri, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newUri(resolver, label, uri).toClipEntry(), confirmationMessage) + +/** + * Copies an [intent] to the clipboard (wraps [ClipData.newIntent]) — e.g. a launcher shortcut. + * + * @param intent the Intent to copy. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyIntent( + intent: Intent, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newIntent(label, intent).toClipEntry(), confirmationMessage) + +/** + * Remembers a [ClipboardCopier] bound to the current [LocalClipboard] / [LocalContext] and a + * composition-scoped coroutine scope. Call one of the `copyX` extensions (e.g. [copyPlainText]) on + * the result to copy — see [ClipboardCopier] for the confirmation-Toast behaviour. + */ +@Composable +fun rememberClipboardCopier(): ClipboardCopier { + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + return remember(clipboard, context, scope) { + ClipboardCopier { clipEntry, confirmationMessage -> + scope.launch { + clipboard.setClipEntry(clipEntry) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && confirmationMessage != null) { + Toast.makeText(context, confirmationMessage, Toast.LENGTH_SHORT).show() + } + } + } + } +} +``` + +### Tests to add + +- `copyPlainText` builds a plain-text `ClipEntry` and calls `copy` with the given confirmation. +- Android < 13 shows the toast; API 33+ suppresses it (Robolectric `@Config(sdk = …)` on both sides + of `TIRAMISU`). +- Confirmation fires only after `setClipEntry` returns (ordering), and not at all when `null`. +- Consider a Compose test that `rememberClipboardCopier()` copies via `LocalClipboard`. + +--- + ## Migrate WenWe once released Once these ship in a toolbox release and WenWe bumps to it: @@ -222,3 +402,20 @@ Once these ship in a toolbox release and WenWe bumps to it: swept in the WENWE-76 line (grep `serializableMutableStateOf` / `transientMutableStateOf`). 5. Rebuild + the same on-device process-death smoke test (Developer Options → "Don't keep activities", background/foreground a Screen, confirm no NPE). + +--- + +## Migrate Accelerate once released + +Once the clipboard copier (#3) ships in a toolbox release and Accelerate bumps to it: + +1. Bump the AppolyDroid dependency in Accelerate. +2. Delete the local copy: + - `app/src/main/java/uk/co/accelerate/ui/extensions/ClipboardCopy.kt` +3. Repoint imports: + - `uk.co.accelerate.ui.extensions.{rememberClipboardCopier, copyPlainText, …}` → + `uk.co.appoly.droid.compose.extensions.*` +4. Call sites (grep `rememberClipboardCopier` / `copyPlainText`): `KerbsideScreen` (perk promo-code + redeem) and `WalletCodeScreen` (gift-card code copy). +5. Rebuild + tap-to-copy smoke test on an Android < 13 device/emulator (confirm the toast) and on + API 33+ (confirm the single system confirmation, no double toast). From f7bd09acc10f6266d3236e40c49121d9669757e8 Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 22 Jul 2026 13:35:58 +0100 Subject: [PATCH 3/4] feat: paging de-dupe + serialization-safe Compose state + clipboard copier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote three generic utilities out of WenWe and Accelerate into the toolbox (see docs/proposed/paging-dedupe-and-serializable-compose-state.md): - :PagingExtensions — Flow>.distinctBy(selector) guards LazyColumn/ LazyRow against duplicate keys when an offset-paged endpoint returns the same item id on more than one page. - :ComposeExtensions — SerializableMutableState / TransientMutableState (+ factories), Serializable MutableState holders for Voyager Screens. Fixed the release-only NPE the WenWe originals risked: consumer R8 stripped the reflective writeObject/readObject so the value restored as null. Added scoped consumer keep rules, plus fail-fast require() on construction and assignment (writeObject throw kept as a backstop). - :ComposeExtensions — ClipboardCopier + rememberClipboardCopier() + copyX extensions, wrapping the suspend LocalClipboard API and gating the confirmation toast to Android < 13 (API 33+ shows its own). Tests: 21 new unit/Robolectric tests (all green). The consumer keep rules are regression-guarded by :app:verifyConsumerKeepRules via a new ComposeExtensionsDemoScreen that exercises the holders in the minified demo app (27 -> 29 protected classes). Version bumped to 1.6.3; README versions synced. Co-Authored-By: Claude Opus 4.8 (1M context) --- AppSnackBar-UiState/README.md | 6 +- AppSnackBar/README.md | 2 +- BaseRepo-AppolyJson/README.md | 4 +- BaseRepo-Paging-AppolyJson/README.md | 10 +- BaseRepo-Paging/README.md | 8 +- BaseRepo-S3Uploader-Multipart/README.md | 6 +- BaseRepo-S3Uploader/README.md | 6 +- BaseRepo/README.md | 2 +- ComposeExtensions/README.md | 94 +++++++++++ ComposeExtensions/build.gradle.kts | 3 + ComposeExtensions/consumer-rules.pro | 20 ++- .../compose/extensions/ClipboardCopier.kt | 140 ++++++++++++++++ .../extensions/SerializableMutableState.kt | 78 +++++++++ .../extensions/TransientMutableState.kt | 39 +++++ .../compose/extensions/ClipboardCopierTest.kt | 158 ++++++++++++++++++ .../SerializableMutableStateTest.kt | 78 +++++++++ .../extensions/TransientMutableStateTest.kt | 59 +++++++ ConnectivityMonitor/README.md | 2 +- DateHelperUtil-Room/README.md | 4 +- DateHelperUtil-Serialization/README.md | 4 +- DateHelperUtil/README.md | 2 +- LazyGridPagingExtensions/README.md | 4 +- LazyListPagingExtensions/README.md | 4 +- MockInterceptor-AppolyJson/README.md | 2 +- MockInterceptor-Retrofit/README.md | 2 +- MockInterceptor-Serialization/README.md | 2 +- MockInterceptor/README.md | 2 +- PagingExtensions/README.md | 17 +- PagingExtensions/build.gradle.kts | 2 + .../paging/PagingDataDistinctExtensions.kt | 28 ++++ .../PagingDataDistinctExtensionsTest.kt | 83 +++++++++ README.md | 14 +- S3Uploader-Multipart/README.md | 2 +- S3Uploader/README.md | 2 +- SegmentedControl/README.md | 2 +- UiState/README.md | 2 +- app/build.gradle.kts | 5 + .../droid/ui/navigation/AppNavigation.kt | 4 + .../ui/screens/ComposeExtensionsDemoScreen.kt | 102 +++++++++++ .../co/appoly/droid/ui/screens/HomeScreen.kt | 6 + buildSrc/src/main/kotlin/BuildConfig.kt | 2 +- ...g-dedupe-and-serializable-compose-state.md | 5 +- gradle/libs.versions.toml | 2 + 43 files changed, 967 insertions(+), 52 deletions(-) create mode 100644 ComposeExtensions/README.md create mode 100644 ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/ClipboardCopier.kt create mode 100644 ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/SerializableMutableState.kt create mode 100644 ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/TransientMutableState.kt create mode 100644 ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/ClipboardCopierTest.kt create mode 100644 ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/SerializableMutableStateTest.kt create mode 100644 ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/TransientMutableStateTest.kt create mode 100644 PagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensions.kt create mode 100644 PagingExtensions/src/test/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensionsTest.kt create mode 100644 app/src/main/java/uk/co/appoly/droid/ui/screens/ComposeExtensionsDemoScreen.kt diff --git a/AppSnackBar-UiState/README.md b/AppSnackBar-UiState/README.md index fe219a53..ec6c0b4a 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("com.github.appoly.AppolyDroid-Toolbox:UiState:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:AppSnackBar:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:AppSnackBar-UiState:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:UiState:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:AppSnackBar:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:AppSnackBar-UiState:1.6.3") ``` ## Usage diff --git a/AppSnackBar/README.md b/AppSnackBar/README.md index bc235309..fad7cc6b 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("com.github.appoly.AppolyDroid-Toolbox:AppSnackBar:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:AppSnackBar:1.6.3") ``` ## Usage diff --git a/BaseRepo-AppolyJson/README.md b/BaseRepo-AppolyJson/README.md index fc4cbdb3..975fa13f 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("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-AppolyJson:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-AppolyJson:1.6.3") ``` ## API Response Structure diff --git a/BaseRepo-Paging-AppolyJson/README.md b/BaseRepo-Paging-AppolyJson/README.md index 4efc24da..a6c7b43c 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("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-Paging:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-Paging-AppolyJson:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-Paging:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-Paging-AppolyJson:1.6.3") // For Compose UI integration -implementation("com.github.appoly.AppolyDroid-Toolbox:LazyListPagingExtensions:1.6.2") // For LazyColumn -implementation("com.github.appoly.AppolyDroid-Toolbox:LazyGridPagingExtensions:1.6.2") // For LazyGrid +implementation("com.github.appoly.AppolyDroid-Toolbox:LazyListPagingExtensions:1.6.3") // For LazyColumn +implementation("com.github.appoly.AppolyDroid-Toolbox:LazyGridPagingExtensions:1.6.3") // For LazyGrid ``` ## API Response Format diff --git a/BaseRepo-Paging/README.md b/BaseRepo-Paging/README.md index 02810499..e79b2173 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("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-Paging:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-Paging:1.6.3") // For Compose UI integration -implementation("com.github.appoly.AppolyDroid-Toolbox:LazyListPagingExtensions:1.6.2") // For LazyColumn -implementation("com.github.appoly.AppolyDroid-Toolbox:LazyGridPagingExtensions:1.6.2") // For LazyGrid +implementation("com.github.appoly.AppolyDroid-Toolbox:LazyListPagingExtensions:1.6.3") // For LazyColumn +implementation("com.github.appoly.AppolyDroid-Toolbox:LazyGridPagingExtensions:1.6.3") // For LazyGrid ``` ## Extensions diff --git a/BaseRepo-S3Uploader-Multipart/README.md b/BaseRepo-S3Uploader-Multipart/README.md index ecc9b540..9d1dd585 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("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:S3Uploader-Multipart:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-S3Uploader-Multipart:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:S3Uploader-Multipart:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-S3Uploader-Multipart:1.6.3") ``` ## Usage diff --git a/BaseRepo-S3Uploader/README.md b/BaseRepo-S3Uploader/README.md index a1ed41a4..ad982f3e 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("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:S3Uploader:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-S3Uploader:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:S3Uploader:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-S3Uploader:1.6.3") ``` ## How it Works diff --git a/BaseRepo/README.md b/BaseRepo/README.md index 04bd332a..71215341 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("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:1.6.3") ``` ## Extensions diff --git a/ComposeExtensions/README.md b/ComposeExtensions/README.md new file mode 100644 index 00000000..4ba7a0d5 --- /dev/null +++ b/ComposeExtensions/README.md @@ -0,0 +1,94 @@ +# ComposeExtensions + +Compose utilities for insets/IME padding, padding arithmetic, serialization-safe `MutableState`, and clipboard copying. + +## Features + +- Navigation-bar / IME padding helpers (`navigationBarsOrImePadding`, `navigationBarsOrNoneIfImePadding`) +- Keyboard visibility as state (`keyboardAsState`) +- `PaddingValues` addition operators and `hideWithIme` / `gradientTint` modifiers +- Serialization-safe `MutableState` holders for Voyager Screens (`serializableMutableStateOf`, `transientMutableStateOf`) +- Clipboard copier that wraps the suspend `LocalClipboard` API with optional pre-Android-13 toasts + +## Installation + +```gradle.kts +implementation("com.github.appoly.AppolyDroid-Toolbox:ComposeExtensions:1.6.3") +``` + +## Usage + +### Insets and IME padding + +Pad content by whichever is larger of the navigation bars or the keyboard: + +```kotlin +Column(Modifier.navigationBarsOrImePadding()) { /* … */ } +``` + +For bottom bars that should lose nav-bar padding when the keyboard is up: + +```kotlin +BottomBar(Modifier.navigationBarsOrNoneIfImePadding()) +``` + +Observe keyboard visibility: + +```kotlin +val keyboardVisible by keyboardAsState() +``` + +### Padding helpers + +Add two `PaddingValues` (or a `PaddingValues` and `WindowInsets`) together: + +```kotlin +val combined = contentPadding + WindowInsets.navigationBars +``` + +### Serialization-safe Compose state + +Voyager `Screen`s are Java-serialized across process death. A plain `mutableStateOf` is not +`Serializable`, and a `@Transient` field restores as `null` (JVM does not run field initializers), +crashing on the next read. Use these drop-in holders instead: + +```kotlin +// Persist & restore — one-shot guards where a reset would re-fire an effect +var firstOpen by serializableMutableStateOf(true) + +// Reset to initial on restore — ephemeral presentation/event state +var sheetVisible by transientMutableStateOf(false) +``` + +The value (or initial, for transient) must be `Serializable` or `null`; construction and assignment +fail fast with `IllegalArgumentException` otherwise. + +### Clipboard copier + +`LocalClipboardManager` is deprecated in favour of `LocalClipboard`, whose `setClipEntry` is suspend. +`rememberClipboardCopier` centralises the scope/await/toast plumbing (and suppresses the app toast on +API 33+ where the system already shows one): + +```kotlin +@Composable +fun PromoCodeRow(code: String) { + val copier = rememberClipboardCopier() + TextButton(onClick = { + copier.copyPlainText( + text = code, + label = "Promo code", + confirmationMessage = "Copied!", // toast only on Android < 13 + ) + }) { + Text("Copy") + } +} +``` + +Also available: `copyHtmlText`, `copyRawUri`, `copyUri`, `copyIntent`, or pass a raw `ClipEntry` to +`ClipboardCopier.copy`. + +## Dependencies + +- Jetpack Compose +- kotlinx-coroutines-android diff --git a/ComposeExtensions/build.gradle.kts b/ComposeExtensions/build.gradle.kts index 53a91c05..d34495a2 100644 --- a/ComposeExtensions/build.gradle.kts +++ b/ComposeExtensions/build.gradle.kts @@ -60,12 +60,15 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.material3) + implementation(libs.kotlinx.coroutines.android) + testImplementation(libs.junit) testImplementation(libs.robolectric) testImplementation(libs.androidx.junit) testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.ui.test.junit4) testImplementation(libs.androidx.ui.test.manifest) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/ComposeExtensions/consumer-rules.pro b/ComposeExtensions/consumer-rules.pro index 3bf304b7..cfe43b4e 100644 --- a/ComposeExtensions/consumer-rules.pro +++ b/ComposeExtensions/consumer-rules.pro @@ -1,5 +1,15 @@ -# No consumer R8/ProGuard rules required for this module. -# -# It ships no @Serializable models, Room entities/converters, reflection, or JNI — nothing -# R8 would strip that a consumer relies on at runtime. This file is intentionally rule-free -# (kept so the absence of keeps is a deliberate, reviewed decision rather than an oversight). +# ComposeExtensions ships Serializable MutableState holders (SerializableMutableState, +# TransientMutableState) that are Java-serialized inside Voyager Screens to survive process +# death. Java serialization invokes writeObject/readObject/serialVersionUID reflectively, so R8 +# must be told to keep them — otherwise the value silently restores as null (NPE on next read) +# in minified consumer builds. Scoped to this package so we don't impose keeps on the consumer's +# own Serializable classes. +-keepclassmembers class uk.co.appoly.droid.compose.extensions.** implements java.io.Serializable { + static final long serialVersionUID; + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + private void readObjectNoData(); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} diff --git a/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/ClipboardCopier.kt b/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/ClipboardCopier.kt new file mode 100644 index 00000000..b52aeace --- /dev/null +++ b/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/ClipboardCopier.kt @@ -0,0 +1,140 @@ +package uk.co.appoly.droid.compose.extensions + +import android.content.ClipData +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.toClipEntry +import kotlinx.coroutines.launch + +/** + * Copies a [ClipEntry] to the system clipboard and, on Android < 13, shows a confirmation Toast. + * Android 13+ surfaces its own clipboard confirmation, so we suppress ours to avoid a double toast. + * + * Obtain one with [rememberClipboardCopier]. It centralises the [LocalClipboard] plumbing: + * `setClipEntry` is a suspend function, so each copy runs on a remembered scope and the confirmation + * only fires once the write has returned. The payload is a raw [ClipEntry], so it handles text, a + * URI, multiple items, etc.; use [copyPlainText] for the common plain-text case. + */ +fun interface ClipboardCopier { + /** + * @param clipEntry the payload to copy. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ + fun copy(clipEntry: ClipEntry, confirmationMessage: String?) +} + +/** + * Copies plain [text] to the clipboard — the common case (wraps [ClipData.newPlainText]). + * + * @param text the text to copy. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyPlainText( + text: CharSequence, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newPlainText(label, text).toClipEntry(), confirmationMessage) + +/** + * Copies styled [htmlText] to the clipboard, with [text] as the plain-text fallback for consumers + * that can't render HTML (wraps [ClipData.newHtmlText]). + * + * @param text the plain-text representation. + * @param htmlText the HTML-markup representation. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyHtmlText( + text: CharSequence, + htmlText: String, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newHtmlText(label, text, htmlText).toClipEntry(), confirmationMessage) + +/** + * Copies a raw [uri] to the clipboard without resolving it through a [ContentResolver] + * (wraps [ClipData.newRawUri]). Use for URIs that aren't `content://` provider URIs — e.g. an + * `http`/`https` link or a `mailto:` address; for content URIs use [copyUri] instead. + * + * @param uri the URI to copy verbatim. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyRawUri( + uri: Uri, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newRawUri(label, uri).toClipEntry(), confirmationMessage) + +/** + * Copies a `content://` [uri] to the clipboard, querying its available MIME types from [resolver] + * so pasting apps receive the right type (wraps [ClipData.newUri]). For plain web/mail URIs prefer + * [copyRawUri]. + * + * @param resolver resolves the URI's MIME types. + * @param uri the content URI to copy. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyUri( + resolver: ContentResolver, + uri: Uri, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newUri(resolver, label, uri).toClipEntry(), confirmationMessage) + +/** + * Copies an [intent] to the clipboard (wraps [ClipData.newIntent]) — e.g. a launcher shortcut. + * + * @param intent the Intent to copy. + * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by + * clipboard managers and accessibility services, though not shown in the modern paste UI. + * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in + * composition (e.g. `stringResource`) so a configuration change re-reads it. + */ +fun ClipboardCopier.copyIntent( + intent: Intent, + label: CharSequence, + confirmationMessage: String? = null, +) = copy(ClipData.newIntent(label, intent).toClipEntry(), confirmationMessage) + +/** + * Remembers a [ClipboardCopier] bound to the current [LocalClipboard] / [LocalContext] and a + * composition-scoped coroutine scope. Call one of the `copyX` extensions (e.g. [copyPlainText]) on + * the result to copy — see [ClipboardCopier] for the confirmation-Toast behaviour. + */ +@Composable +fun rememberClipboardCopier(): ClipboardCopier { + val clipboard = LocalClipboard.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + return remember(clipboard, context, scope) { + ClipboardCopier { clipEntry, confirmationMessage -> + scope.launch { + clipboard.setClipEntry(clipEntry) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && confirmationMessage != null) { + Toast.makeText(context, confirmationMessage, Toast.LENGTH_SHORT).show() + } + } + } + } +} diff --git a/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/SerializableMutableState.kt b/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/SerializableMutableState.kt new file mode 100644 index 00000000..c623e2b1 --- /dev/null +++ b/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/SerializableMutableState.kt @@ -0,0 +1,78 @@ +package uk.co.appoly.droid.compose.extensions + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import java.io.NotSerializableException +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.Serializable + +/** + * A [Serializable] [MutableState] that persists and restores its value across process death. + * + * Voyager `Screen`s are Java-serialized to survive process death; a plain `mutableStateOf` is not + * [Serializable], and the usual `@Transient` workaround restores as `null` (the JVM does not run + * field initializers on deserialization), crashing on the next read. This holder implements + * [MutableState] by delegating to a `@Transient` `mutableStateOf`, so it is a drop-in replacement + * for `by`/`.value`/destructuring, while persisting and restoring the value itself. + * + * Use this for one-shot guards where a reset would re-fire an effect or lose real progress. For + * ephemeral state that should reset on restore, use [TransientMutableState] instead. + * + * The value must be [Serializable] or `null`; this is enforced at construction and on assignment. + * Main-thread access only. + */ +class SerializableMutableState(initial: T) : MutableState, Serializable { + + init { + requireSerializableOrNull(initial) + } + + @Transient + private var delegate: MutableState = mutableStateOf(initial) + + override var value: T + get() = delegate.value + set(value) { + requireSerializableOrNull(value) + delegate.value = value + } + + override fun component1(): T = value + override fun component2(): (T) -> Unit = { value = it } + + private fun writeObject(out: ObjectOutputStream) { + out.defaultWriteObject() + val current = delegate.value + // Backstop: the setter/constructor already reject non-Serializable values, but guard here + // too so a value slipped in by other means fails loudly rather than restoring as null. + if (current != null && current !is Serializable) { + throw NotSerializableException( + "serializableMutableStateOf value is not Serializable: ${current::class.java.name}. " + + "Use a Serializable type, or transientMutableStateOf if it need not survive process death.", + ) + } + out.writeObject(current as? Serializable) + } + + private fun readObject(input: ObjectInputStream) { + input.defaultReadObject() + @Suppress("UNCHECKED_CAST") + delegate = mutableStateOf(input.readObject() as T) + } + + companion object { + private const val serialVersionUID: Long = 1L + + private fun requireSerializableOrNull(value: Any?) { + require(value == null || value is Serializable) { + "serializableMutableStateOf value must be Serializable or null, was: " + + "${value!!::class.java.name}. Use a Serializable type, or transientMutableStateOf " + + "if it need not survive process death." + } + } + } +} + +/** Creates a [Serializable] [MutableState] that survives process death. */ +fun serializableMutableStateOf(initial: T): SerializableMutableState = SerializableMutableState(initial) diff --git a/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/TransientMutableState.kt b/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/TransientMutableState.kt new file mode 100644 index 00000000..33548657 --- /dev/null +++ b/ComposeExtensions/src/main/java/uk/co/appoly/droid/compose/extensions/TransientMutableState.kt @@ -0,0 +1,39 @@ +package uk.co.appoly.droid.compose.extensions + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import java.io.Serializable + +/** + * A [Serializable] [MutableState] whose value resets to [initial] after a process-death restore, + * rather than being persisted. Sibling of [SerializableMutableState]; for ephemeral presentation + * or event state (sheet visibility, one-shot pulses, overlays) that should NOT survive process + * death. Only [initial] is persisted (it must be [Serializable] or `null`). + * + * The backing delegate is created lazily and is not thread-safe; main-thread access only. + */ +class TransientMutableState(private val initial: T) : MutableState, Serializable { + init { + require(initial == null || initial is Serializable) { + "transientMutableStateOf initial value must be Serializable or null, was: $initial" + } + } + + @Transient + private var delegate: MutableState? = null + + private fun delegate(): MutableState = + delegate ?: mutableStateOf(initial).also { delegate = it } + + override var value: T + get() = delegate().value + set(value) { delegate().value = value } + + override fun component1(): T = delegate().value + override fun component2(): (T) -> Unit = { delegate().value = it } + + companion object { private const val serialVersionUID: Long = 1L } +} + +/** Creates a [Serializable] [MutableState] that resets to [initial] on process-death restore. */ +fun transientMutableStateOf(initial: T): TransientMutableState = TransientMutableState(initial) diff --git a/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/ClipboardCopierTest.kt b/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/ClipboardCopierTest.kt new file mode 100644 index 00000000..6360d57c --- /dev/null +++ b/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/ClipboardCopierTest.kt @@ -0,0 +1,158 @@ +package uk.co.appoly.droid.compose.extensions + +import android.net.Uri +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.Clipboard +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.NativeClipboard +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CompletableDeferred +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowToast + +/** + * Robolectric tests for [ClipboardCopier], the `copyX` extensions, and [rememberClipboardCopier]. + */ +@RunWith(AndroidJUnit4::class) +class ClipboardCopierTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `copyPlainText builds plain-text ClipEntry and passes confirmation`() { + var capturedEntry: ClipEntry? = null + var capturedMsg: String? = null + val copier = ClipboardCopier { entry, msg -> + capturedEntry = entry + capturedMsg = msg + } + + copier.copyPlainText("body", label = "lbl", confirmationMessage = "Copied!") + + assertEquals("Copied!", capturedMsg) + assertEquals("body", capturedEntry!!.clipData.getItemAt(0).text.toString()) + } + + @Test + fun `copyHtmlText builds html ClipEntry and passes confirmation`() { + var capturedEntry: ClipEntry? = null + var capturedMsg: String? = null + val copier = ClipboardCopier { entry, msg -> + capturedEntry = entry + capturedMsg = msg + } + + copier.copyHtmlText( + text = "plain", + htmlText = "bold", + label = "lbl", + confirmationMessage = "Html!", + ) + + assertEquals("Html!", capturedMsg) + val item = capturedEntry!!.clipData.getItemAt(0) + assertEquals("plain", item.text.toString()) + assertEquals("bold", item.htmlText) + } + + @Test + fun `copyRawUri builds uri ClipEntry and passes confirmation`() { + var capturedEntry: ClipEntry? = null + var capturedMsg: String? = null + val copier = ClipboardCopier { entry, msg -> + capturedEntry = entry + capturedMsg = msg + } + val uri = Uri.parse("https://example.com") + + copier.copyRawUri(uri, label = "link", confirmationMessage = "Uri!") + + assertEquals("Uri!", capturedMsg) + assertEquals(uri, capturedEntry!!.clipData.getItemAt(0).uri) + } + + @Test + @Config(sdk = [32]) + fun `rememberClipboardCopier shows toast on sdk below TIRAMISU`() { + ShadowToast.reset() + composeRule.setContent { + val copier = rememberClipboardCopier() + LaunchedEffect(Unit) { + copier.copyPlainText("body", label = "lbl", confirmationMessage = "Copied!") + } + } + composeRule.waitForIdle() + assertEquals("Copied!", ShadowToast.getTextOfLatestToast()) + } + + @Test + @Config(sdk = [33]) + fun `rememberClipboardCopier suppresses toast on TIRAMISU`() { + ShadowToast.reset() + composeRule.setContent { + val copier = rememberClipboardCopier() + LaunchedEffect(Unit) { + copier.copyPlainText("body", label = "lbl", confirmationMessage = "Copied!") + } + } + composeRule.waitForIdle() + assertNull(ShadowToast.getLatestToast()) + } + + @Test + @Config(sdk = [32]) + fun `null confirmation shows no toast`() { + ShadowToast.reset() + composeRule.setContent { + val copier = rememberClipboardCopier() + LaunchedEffect(Unit) { + copier.copyPlainText("body", label = "lbl", confirmationMessage = null) + } + } + composeRule.waitForIdle() + assertNull(ShadowToast.getLatestToast()) + } + + @Test + @Config(sdk = [32]) + fun `confirmation toast fires only after setClipEntry returns`() { + ShadowToast.reset() + val gate = CompletableDeferred() + val fakeClipboard = object : Clipboard { + override val nativeClipboard: NativeClipboard + get() = error("unused") + + override suspend fun getClipEntry(): ClipEntry? = null + + override suspend fun setClipEntry(clipEntry: ClipEntry?) { + gate.await() + } + } + + lateinit var copier: ClipboardCopier + composeRule.setContent { + CompositionLocalProvider(LocalClipboard provides fakeClipboard) { + copier = rememberClipboardCopier() + } + } + composeRule.runOnIdle { + copier.copyPlainText("body", label = "lbl", confirmationMessage = "Copied!") + } + // setClipEntry is still suspended — toast must not have fired yet + composeRule.runOnIdle { + assertNull(ShadowToast.getLatestToast()) + } + gate.complete(Unit) + composeRule.waitForIdle() + assertEquals("Copied!", ShadowToast.getTextOfLatestToast()) + } +} diff --git a/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/SerializableMutableStateTest.kt b/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/SerializableMutableStateTest.kt new file mode 100644 index 00000000..d941f4c9 --- /dev/null +++ b/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/SerializableMutableStateTest.kt @@ -0,0 +1,78 @@ +package uk.co.appoly.droid.compose.extensions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +/** + * Unit tests for [SerializableMutableState] / [serializableMutableStateOf]. + */ +class SerializableMutableStateTest { + + @Suppress("UNCHECKED_CAST") + private fun roundTrip(state: SerializableMutableState): SerializableMutableState { + val bytes = ByteArrayOutputStream().use { baos -> + ObjectOutputStream(baos).use { it.writeObject(state) } + baos.toByteArray() + } + return ObjectInputStream(ByteArrayInputStream(bytes)).use { it.readObject() as SerializableMutableState } + } + + @Test + fun `round-trip preserves initial value`() { + val restored = roundTrip(serializableMutableStateOf("hello")) + assertEquals("hello", restored.value) + } + + @Test + fun `round-trip persists mutated value`() { + val state = serializableMutableStateOf("hello") + state.value = "world" + val restored = roundTrip(state) + assertEquals("world", restored.value) + } + + @Test + fun `round-trip preserves null value`() { + val restored = roundTrip(serializableMutableStateOf(null)) + assertNull(restored.value) + } + + @Test + fun `constructing non-Serializable value throws`() { + assertThrows(IllegalArgumentException::class.java) { + serializableMutableStateOf(Any()) + } + } + + @Test + fun `assigning non-Serializable value throws`() { + val state = serializableMutableStateOf(null) + assertThrows(IllegalArgumentException::class.java) { + state.value = Any() + } + } + + @Test + fun `by delegation works`() { + var v by serializableMutableStateOf("x") + assertEquals("x", v) + v = "y" + assertEquals("y", v) + } + + @Test + fun `destructuring set routes through validating setter`() { + val state = serializableMutableStateOf("x") + val (_, set) = state + set("z") + assertEquals("z", state.value) + } +} diff --git a/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/TransientMutableStateTest.kt b/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/TransientMutableStateTest.kt new file mode 100644 index 00000000..9aab738b --- /dev/null +++ b/ComposeExtensions/src/test/java/uk/co/appoly/droid/compose/extensions/TransientMutableStateTest.kt @@ -0,0 +1,59 @@ +package uk.co.appoly.droid.compose.extensions + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +/** + * Unit tests for [TransientMutableState] / [transientMutableStateOf]. + */ +class TransientMutableStateTest { + + @Suppress("UNCHECKED_CAST") + private fun roundTrip(state: TransientMutableState): TransientMutableState { + val bytes = ByteArrayOutputStream().use { baos -> + ObjectOutputStream(baos).use { it.writeObject(state) } + baos.toByteArray() + } + return ObjectInputStream(ByteArrayInputStream(bytes)).use { it.readObject() as TransientMutableState } + } + + @Test + fun `value resets to initial on restore`() { + val state = transientMutableStateOf("init") + state.value = "changed" + assertEquals("changed", state.value) + + val restored = roundTrip(state) + assertEquals("init", restored.value) + } + + @Test + fun `non-Serializable initial throws`() { + assertThrows(IllegalArgumentException::class.java) { + transientMutableStateOf(Any()) + } + } + + @Test + fun `value get and set work`() { + val state = transientMutableStateOf("a") + assertEquals("a", state.value) + state.value = "b" + assertEquals("b", state.value) + } + + @Test + fun `by delegation works`() { + var v by transientMutableStateOf("x") + assertEquals("x", v) + v = "y" + assertEquals("y", v) + } +} diff --git a/ConnectivityMonitor/README.md b/ConnectivityMonitor/README.md index 4cbfc808..2c16f059 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("com.github.appoly.AppolyDroid-Toolbox:connectivitymonitor:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:connectivitymonitor:1.6.3") ``` ## Usage diff --git a/DateHelperUtil-Room/README.md b/DateHelperUtil-Room/README.md index 77427d2f..a50627f2 100644 --- a/DateHelperUtil-Room/README.md +++ b/DateHelperUtil-Room/README.md @@ -16,8 +16,8 @@ Extension module for DateHelperUtil that provides Room database integration for ```gradle.kts // Requires base DateHelperUtil module -implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil-Room:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil-Room:1.6.3") // Required Room dependencies implementation("androidx.room:room-runtime:2.8.4") diff --git a/DateHelperUtil-Serialization/README.md b/DateHelperUtil-Serialization/README.md index e670eded..03ad11f7 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("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil-Serialization:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil-Serialization:1.6.3") // 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 55ab65da..c6541dc9 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("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:DateHelperUtil:1.6.3") ``` ## 1.4.1 patch note diff --git a/LazyGridPagingExtensions/README.md b/LazyGridPagingExtensions/README.md index e3c967de..76464c5d 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("com.github.appoly.AppolyDroid-Toolbox:PagingExtensions:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:LazyGridPagingExtensions:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:PagingExtensions:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:LazyGridPagingExtensions:1.6.3") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.0") diff --git a/LazyListPagingExtensions/README.md b/LazyListPagingExtensions/README.md index 2a64f48e..d2d48940 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("com.github.appoly.AppolyDroid-Toolbox:PagingExtensions:1.6.2") -implementation("com.github.appoly.AppolyDroid-Toolbox:LazyListPagingExtensions:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:PagingExtensions:1.6.3") +implementation("com.github.appoly.AppolyDroid-Toolbox:LazyListPagingExtensions:1.6.3") // Make sure to include Jetpack Paging Compose implementation("androidx.paging:paging-compose:3.5.0") diff --git a/MockInterceptor-AppolyJson/README.md b/MockInterceptor-AppolyJson/README.md index 7c75ca57..c213c712 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("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor-AppolyJson:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor-AppolyJson:1.6.3") ``` ## Usage diff --git a/MockInterceptor-Retrofit/README.md b/MockInterceptor-Retrofit/README.md index 366fba2a..d6438c1a 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("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor-Retrofit:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor-Retrofit:1.6.3") ``` > **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 9d2210c8..bf64bbd9 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("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor-Serialization:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor-Serialization:1.6.3") ``` ## Usage diff --git a/MockInterceptor/README.md b/MockInterceptor/README.md index 43c08719..13029ab0 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("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:MockInterceptor:1.6.3") ``` ## Usage diff --git a/PagingExtensions/README.md b/PagingExtensions/README.md index 2cca8c92..e0eefdca 100644 --- a/PagingExtensions/README.md +++ b/PagingExtensions/README.md @@ -12,11 +12,26 @@ Core utilities and extensions for Jetpack Paging 3 integration, providing the fo ## Installation ```gradle.kts -implementation("com.github.appoly.AppolyDroid-Toolbox:PagingExtensions:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:PagingExtensions:1.6.3") ``` ## Usage +### De-duplicating paging streams + +Offset pagination over data that can change between page loads can return the **same item id on more +than one page**. With `itemKey = { it.id }` on a `LazyColumn`/`LazyRow`, the second occurrence throws +`IllegalArgumentException: Key "…" was already used`. Guard against that by de-duping the stream: + +```kotlin +val items: Flow> = repo.pagedUsers() + .distinctBy { it.id } // guard against the same id arriving on two pages + .cachedIn(viewModelScope) +``` + +Keeps the first occurrence of each key per `PagingData` generation (the `seen` set resets on every +refresh/invalidation). Only safe when the `Pager` does **not** set `maxSize`. + ### Extension Functions The module provides extension functions to check LoadState types with Kotlin contracts for smart casting: diff --git a/PagingExtensions/build.gradle.kts b/PagingExtensions/build.gradle.kts index ab79ac10..0145e0c5 100644 --- a/PagingExtensions/build.gradle.kts +++ b/PagingExtensions/build.gradle.kts @@ -69,6 +69,8 @@ dependencies { testImplementation(platform(libs.androidx.compose.bom)) testImplementation(libs.androidx.ui.test.junit4) testImplementation(libs.androidx.ui.test.manifest) + testImplementation(libs.paging.testing) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/PagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensions.kt b/PagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensions.kt new file mode 100644 index 00000000..32bcaa9b --- /dev/null +++ b/PagingExtensions/src/main/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensions.kt @@ -0,0 +1,28 @@ +package uk.co.appoly.droid.util.paging + +import androidx.paging.PagingData +import androidx.paging.filter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * De-duplicates items in a paging stream by the key produced by [selector], keeping the first + * occurrence of each key and dropping any later duplicates. + * + * Guards against a paged endpoint returning the same item on more than one page — common with + * offset pagination over data that can change between page loads — which would otherwise crash a + * `LazyColumn`/`LazyRow` with `IllegalArgumentException: Key "…" was already used` when that item's + * key is used as the list `key`/`itemKey`. + * + * The `seen` set is scoped inside the [map] over each emitted [PagingData], so it resets naturally + * on every refresh/invalidation. Only safe when the `Pager` does **not** set `maxSize` (no page + * dropping): a dropped-then-reloaded page's items would otherwise be wrongly filtered as seen. + * + * @param selector produces the de-duplication key for an item. + */ +fun Flow>.distinctBy( + selector: (T) -> K, +): Flow> = map { pagingData -> + val seen = mutableSetOf() + pagingData.filter { seen.add(selector(it)) } +} diff --git a/PagingExtensions/src/test/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensionsTest.kt b/PagingExtensions/src/test/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensionsTest.kt new file mode 100644 index 00000000..10c276dc --- /dev/null +++ b/PagingExtensions/src/test/java/uk/co/appoly/droid/util/paging/PagingDataDistinctExtensionsTest.kt @@ -0,0 +1,83 @@ +package uk.co.appoly.droid.util.paging + +import androidx.paging.PagingData +import androidx.paging.testing.asSnapshot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [Flow][kotlinx.coroutines.flow.Flow]`>`.[distinctBy]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class PagingDataDistinctExtensionsTest { + + private data class Item(val id: Int, val name: String) + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `duplicates dropped, first kept, order preserved`() = runTest { + val pagingData = PagingData.from( + listOf( + Item(1, "a"), + Item(2, "b"), + Item(1, "a-dup"), + Item(3, "c"), + Item(2, "b-dup"), + ), + ) + + val snapshot = flowOf(pagingData).distinctBy { it.id }.asSnapshot() + + assertEquals(listOf(1, 2, 3), snapshot.map { it.id }) + assertEquals("a", snapshot.first { it.id == 1 }.name) + assertEquals("b", snapshot.first { it.id == 2 }.name) + } + + @Test + fun `clean data unchanged`() = runTest { + val items = listOf( + Item(1, "a"), + Item(2, "b"), + Item(3, "c"), + ) + val snapshot = flowOf(PagingData.from(items)).distinctBy { it.id }.asSnapshot() + + assertEquals(items, snapshot) + } + + @Test + fun `seen set resets per generation`() = runTest { + val dupData = listOf(Item(1, "a"), Item(1, "a-dup"), Item(2, "b")) + // Present each transformed PagingData exactly once — presenting the same one twice would + // dedupe the second presentation to empty because its closure-captured seen is already full. + val generations = flowOf(PagingData.from(dupData), PagingData.from(dupData)) + .distinctBy { it.id } + .toList() + + val snap1 = flowOf(generations[0]).asSnapshot() + val snap2 = flowOf(generations[1]).asSnapshot() + + // Each generation de-dupes from scratch — if seen leaked, snap2 would be empty. + assertEquals(listOf(1, 2), snap1.map { it.id }) + assertEquals(listOf(1, 2), snap2.map { it.id }) + } +} diff --git a/README.md b/README.md index a835c4af..ee2e47ad 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.6.2" # Replace with the latest version +appolydroidToolbox = "1.6.3" # Replace with the latest version [libraries] appolydroid-toolbox-bom = { group = "com.github.appoly.AppolyDroid-Toolbox", name = "AppolyDroid-Toolbox-bom", version.ref = "appolydroidToolbox" } @@ -126,7 +126,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { // Import the BOM - implementation(platform("com.github.appoly.AppolyDroid-Toolbox:AppolyDroid-Toolbox-bom:1.6.2")) + implementation(platform("com.github.appoly.AppolyDroid-Toolbox:AppolyDroid-Toolbox-bom:1.6.3")) // Now you can use AppolyDroid modules without specifying versions implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo") @@ -162,7 +162,7 @@ In your `libs.versions.toml` file: ```toml [versions] -appolydroidToolbox = "1.6.2" # Replace with the latest version +appolydroidToolbox = "1.6.3" # Replace with the latest version [libraries] #AppolyDroid-Toolbox @@ -228,7 +228,7 @@ In your module's `build.gradle.kts`: ```gradle.kts dependencies { - val appolydroidToolbox = "1.6.2" // Replace with the latest version + val appolydroidToolbox = "1.6.3" // Replace with the latest version // Add only the modules you need implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo:$appolydroidToolbox") implementation("com.github.appoly.AppolyDroid-Toolbox:BaseRepo-AppolyJson:$appolydroidToolbox") @@ -297,6 +297,12 @@ Integration of AppSnackBar with UiState. ### SegmentedControl iOS-style segmented control with smooth animations and customizable styling. [Learn more](SegmentedControl/README.md) +### ComposeExtensions +Compose utilities: insets/IME padding, padding arithmetic, serialization-safe `MutableState` holders for Voyager Screens, and a clipboard copier. +[Learn more](ComposeExtensions/README.md) +### PagingExtensions +Core Jetpack Paging 3 utilities: `LoadState` predicates, paging-stream de-duplication, and shared loading/error/empty state components. +[Learn more](PagingExtensions/README.md) ### LazyListPagingExtensions Extensions for Jetpack Compose LazyList with paging support. [Learn more](LazyListPagingExtensions/README.md) diff --git a/S3Uploader-Multipart/README.md b/S3Uploader-Multipart/README.md index f8ce3fe3..02aae62f 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("com.github.appoly.AppolyDroid-Toolbox:S3Uploader-Multipart:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:S3Uploader-Multipart:1.6.3") ``` This module depends on `S3Uploader` and includes it transitively. diff --git a/S3Uploader/README.md b/S3Uploader/README.md index 62283c95..35a88628 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("com.github.appoly.AppolyDroid-Toolbox:S3Uploader:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:S3Uploader:1.6.3") ``` ## Usage diff --git a/SegmentedControl/README.md b/SegmentedControl/README.md index 5890f1a5..56f5cf45 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("com.github.appoly.AppolyDroid-Toolbox:SegmentedControl:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:SegmentedControl:1.6.3") ``` ## Usage diff --git a/UiState/README.md b/UiState/README.md index 2aac56a6..e28e89d5 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("com.github.appoly.AppolyDroid-Toolbox:UiState:1.6.2") +implementation("com.github.appoly.AppolyDroid-Toolbox:UiState:1.6.3") ``` ## Usage diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5809f85d..ea8f9d1c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { implementation(project(":AppSnackBar")) implementation(project(":AppSnackBar-UiState")) implementation(project(":SegmentedControl")) + implementation(project(":ComposeExtensions")) implementation(project(":PagingExtensions")) implementation(project(":LazyListPagingExtensions")) implementation(project(":LazyGridPagingExtensions")) @@ -175,6 +176,10 @@ val consumerKeepSentinels = listOf( "uk.co.appoly.droid.util.NullableEnumAsIntSerializer", // DateHelperUtil-Room — Room TypeConverter carrier "uk.co.appoly.droid.util.DBDateConverters", + // ComposeExtensions — Serializable MutableState holders (writeObject/readObject kept for + // process-death restore); demo app uses them via ComposeExtensionsDemoScreen so R8 retains them. + "uk.co.appoly.droid.compose.extensions.SerializableMutableState", + "uk.co.appoly.droid.compose.extensions.TransientMutableState", ) tasks.register("verifyConsumerKeepRules") { diff --git a/app/src/main/java/uk/co/appoly/droid/ui/navigation/AppNavigation.kt b/app/src/main/java/uk/co/appoly/droid/ui/navigation/AppNavigation.kt index 0810a835..dab59813 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/navigation/AppNavigation.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/navigation/AppNavigation.kt @@ -6,6 +6,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import uk.co.appoly.droid.ui.screens.AppolyJsonDemoScreen import uk.co.appoly.droid.ui.screens.BaseRepoDemoScreen +import uk.co.appoly.droid.ui.screens.ComposeExtensionsDemoScreen import uk.co.appoly.droid.ui.screens.DateHelperDemoScreen import uk.co.appoly.droid.ui.screens.DateSerializationRoomDemoScreen import uk.co.appoly.droid.ui.screens.HomeScreen @@ -58,5 +59,8 @@ fun AppNavigation() { composable("mock_interceptor") { MockInterceptorDemoScreen(navController = navController) } + composable("compose_extensions") { + ComposeExtensionsDemoScreen(navController = navController) + } } } \ No newline at end of file diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/ComposeExtensionsDemoScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/ComposeExtensionsDemoScreen.kt new file mode 100644 index 00000000..6989f42c --- /dev/null +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/ComposeExtensionsDemoScreen.kt @@ -0,0 +1,102 @@ +package uk.co.appoly.droid.ui.screens + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import uk.co.appoly.droid.compose.extensions.copyPlainText +import uk.co.appoly.droid.compose.extensions.rememberClipboardCopier +import uk.co.appoly.droid.compose.extensions.serializableMutableStateOf +import uk.co.appoly.droid.compose.extensions.transientMutableStateOf + +/** + * Demonstrates the ComposeExtensions module: the serialization-safe [serializableMutableStateOf] / + * [transientMutableStateOf] state holders and the [rememberClipboardCopier] clipboard copier. + * + * Beyond the showcase, this screen exists so R8 retains `SerializableMutableState` / + * `TransientMutableState` in the minified demo app, which is what lets `verifyConsumerKeepRules` + * prove ComposeExtensions' consumer keep rules for their `writeObject`/`readObject` members fire. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ComposeExtensionsDemoScreen(navController: NavController) { + // Persisted across process death — a real one-shot guard would use this. + var tapCount by serializableMutableStateOf(0) + // Ephemeral — resets to the initial value on a process-death restore. + var lastCopied by transientMutableStateOf(null) + val clipboardCopier = rememberClipboardCopier() + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Compose Extensions") }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "serializableMutableStateOf survives process death; " + + "transientMutableStateOf resets to its initial value on restore.", + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = "Persisted tap count: $tapCount", + style = MaterialTheme.typography.titleMedium, + ) + + Button( + onClick = { tapCount++ }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Increment persisted count") + } + + Text( + text = "Last copied (transient): ${lastCopied ?: "nothing yet"}", + style = MaterialTheme.typography.bodyMedium, + ) + + Button( + onClick = { + val text = "Tap count is $tapCount" + clipboardCopier.copyPlainText( + text = text, + label = "Tap count", + confirmationMessage = "Copied!", + ) + lastCopied = text + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Copy tap count to clipboard") + } + + Button( + onClick = { navController.popBackStack() }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Back") + } + } + } +} diff --git a/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt b/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt index 2e46198d..27c33dc4 100644 --- a/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt +++ b/app/src/main/java/uk/co/appoly/droid/ui/screens/HomeScreen.kt @@ -122,6 +122,12 @@ fun HomeScreen(navController: NavController) { description = "OkHttp interceptor DSL for mocking API responses with typed bodies and pagination", onClick = { navController.navigate("mock_interceptor") } ) + + FeatureButton( + title = "Compose Extensions", + description = "Serialization-safe MutableState holders and the clipboard copier", + onClick = { navController.navigate("compose_extensions") } + ) } } } diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt index de9611a6..27c88fc6 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.6.2" + const val TOOLBOX_VERSION = "1.6.3" /** * SDK version configuration for Android modules. diff --git a/docs/proposed/paging-dedupe-and-serializable-compose-state.md b/docs/proposed/paging-dedupe-and-serializable-compose-state.md index 79a80f5d..46a624a0 100644 --- a/docs/proposed/paging-dedupe-and-serializable-compose-state.md +++ b/docs/proposed/paging-dedupe-and-serializable-compose-state.md @@ -1,6 +1,9 @@ # Proposed: paging de-dupe + serialization-safe Compose state + clipboard copier -**Status:** TODO / proposed — not yet implemented. +**Status:** ✅ Implemented in AppolyDroid Toolbox 1.6.3. All three additions shipped with unit + +Robolectric tests and consumer ProGuard rules (guarded by `:app:verifyConsumerKeepRules`). The +app-side migrations remain TODO — see [Migrate WenWe once released](#migrate-wenwe-once-released) +and [Migrate Accelerate once released](#migrate-accelerate-once-released). **Origin:** WenWe Android (#1, #2) and Accelerate Android (#3), July 2026. The WenWe utilities were written to fix production crashes (Sentry `WENWE-ANDROID-5H` and `WENWE-ANDROID-5G`); the clipboard copier came out of the Accelerate migration off the deprecated `LocalClipboardManager`. All are diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 27949f0a..62732361 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ 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" } 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" } @@ -74,6 +75,7 @@ kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-seria 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 androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomVersion" } From 5f784b5687d74ce735012cd2a5bdcf4b5115c72e Mon Sep 17 00:00:00 2001 From: Bradley Duck Date: Wed, 22 Jul 2026 13:48:13 +0100 Subject: [PATCH 4/4] docs: remove implemented proposal doc The three utilities shipped in 1.6.3 and WenWe + Accelerate have been migrated to the library versions per the doc's instructions, so the proposal scaffolding is no longer needed. History remains on develop (commits 4f7e3b8, f4e5dcf). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...g-dedupe-and-serializable-compose-state.md | 424 ------------------ 1 file changed, 424 deletions(-) delete mode 100644 docs/proposed/paging-dedupe-and-serializable-compose-state.md diff --git a/docs/proposed/paging-dedupe-and-serializable-compose-state.md b/docs/proposed/paging-dedupe-and-serializable-compose-state.md deleted file mode 100644 index 46a624a0..00000000 --- a/docs/proposed/paging-dedupe-and-serializable-compose-state.md +++ /dev/null @@ -1,424 +0,0 @@ -# Proposed: paging de-dupe + serialization-safe Compose state + clipboard copier - -**Status:** ✅ Implemented in AppolyDroid Toolbox 1.6.3. All three additions shipped with unit + -Robolectric tests and consumer ProGuard rules (guarded by `:app:verifyConsumerKeepRules`). The -app-side migrations remain TODO — see [Migrate WenWe once released](#migrate-wenwe-once-released) -and [Migrate Accelerate once released](#migrate-accelerate-once-released). -**Origin:** WenWe Android (#1, #2) and Accelerate Android (#3), July 2026. The WenWe utilities were -written to fix production crashes (Sentry `WENWE-ANDROID-5H` and `WENWE-ANDROID-5G`); the clipboard -copier came out of the Accelerate migration off the deprecated `LocalClipboardManager`. All are -fully generic — nothing app-specific — so they belong in the toolbox. This doc captures them ready -to lift in. - -Three independent additions: -1. `Flow>.distinctBy { }` → **PagingExtensions** module. -2. `SerializableMutableState` / `TransientMutableState` (+ factories) → **ComposeExtensions** module. -3. `ClipboardCopier` + `rememberClipboardCopier()` + `copyX` extensions → **ComposeExtensions** module. - -Once released, update the originating apps to depend on the library versions and delete their local -copies (see [Migrate WenWe once released](#migrate-wenwe-once-released) and -[Migrate Accelerate once released](#migrate-accelerate-once-released)). - ---- - -## 1. `Flow>.distinctBy` — PagingExtensions - -**Module:** `:PagingExtensions` · **Package:** `uk.co.appoly.droid.util.paging` -(alongside the existing `PagingExtensions.kt`; deps already include `androidx.paging`.) - -### Why -Offset/page-number pagination over data that can change between page loads can return the **same -item id on more than one page**. With `itemKey = { it.id }` on a `LazyColumn`/`LazyRow`, the second -occurrence throws `IllegalArgumentException: Key "…" was already used` and crashes the screen. This -extension de-dupes the stream by an arbitrary key, keeping the first occurrence per `PagingData` -generation. (It's a client-side guard; the real fix is stable/keyset pagination server-side, but the -guard prevents a hard crash regardless.) - -### Source (repackaged) -```kotlin -package uk.co.appoly.droid.util.paging - -import androidx.paging.PagingData -import androidx.paging.filter -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -/** - * De-duplicates items in a paging stream by the key produced by [selector], keeping the first - * occurrence of each key and dropping any later duplicates. - * - * Guards against a paged endpoint returning the same item on more than one page — common with - * offset pagination over data that can change between page loads — which would otherwise crash a - * `LazyColumn`/`LazyRow` with `IllegalArgumentException: Key "…" was already used` when that item's - * key is used as the list `key`/`itemKey`. - * - * The `seen` set is scoped inside the [map] over each emitted [PagingData], so it resets naturally - * on every refresh/invalidation. Only safe when the `Pager` does **not** set `maxSize` (no page - * dropping): a dropped-then-reloaded page's items would otherwise be wrongly filtered as seen. - */ -fun Flow>.distinctBy( - selector: (T) -> K, -): Flow> = map { pagingData -> - val seen = mutableSetOf() - pagingData.filter { seen.add(selector(it)) } -} -``` - -### Tests to add -- Duplicate ids across a simulated multi-page `PagingData` → only first kept. -- Clean pages → unchanged, order preserved. -- Add to `PagingExtensionsTest.kt`. - ---- - -## 2. Serialization-safe Compose `MutableState` — ComposeExtensions - -**Module:** `:ComposeExtensions` · **Package:** `uk.co.appoly.droid.compose.extensions` -(or a new `uk.co.appoly.droid.compose.state` sub-package; deps: compose runtime + stdlib only.) - -### Why -Voyager `Screen`s implement `java.io.Serializable` and are Java-serialized to survive process death. -A plain `mutableStateOf(…)` isn't `Serializable`, so the common workaround is a `@Transient` field — -which returns **null** after a deserialization restore (the JVM doesn't run field initializers), -crashing on the next read with `NullPointerException: … State.getValue() on a null object reference` -(`WENWE-ANDROID-5G`). These two holders both implement `MutableState` (so they're drop-in for -`by`/`.value`/destructuring) and are `Serializable`: - -- **`SerializableMutableState`** — persists & restores the value. For one-shot guards where a reset - would re-fire an effect (`firstOpen`, `shouldLaunchPicker`, `didInitialScroll`, `wenWeLoaded`). -- **`TransientMutableState`** — resets to `initial` on restore. For ephemeral presentation/event - state (sheet visibility, "refresh now" pulses, overlays, deep-link/pending triggers, one-shot - snackbars). - -Rule of thumb: **persist if resetting would re-fire a one-shot effect or lose real progress; -otherwise reset.** For `derivedStateOf` on a Screen field (same null-after-restore bug, can't be -serialized), convert to a computed `get()` that recomputes from the now-restored sources. - -### ⚠️ One adaptation needed for the library -`SerializableMutableState` below uses `BuildConfig.DEBUG` to fail-fast when handed a non-null, -non-`Serializable` value (which would silently restore as null and recreate the NPE). In a **published -library**, `BuildConfig.DEBUG` is always `false` in consumers, so the guard would never fire. Pick one: -- **Recommended:** just `throw` unconditionally on a non-null non-`Serializable` value (it's a - programming error to pass one), dropping the `BuildConfig.DEBUG` gate; or -- gate on a library-level opt-in flag if you want it silenceable. - -The version below is shown as-is from WenWe; adjust the `writeObject` guard per the above. - -### Source (repackaged) — `SerializableMutableState` -```kotlin -package uk.co.appoly.droid.compose.extensions - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import java.io.NotSerializableException -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.io.Serializable - -/** - * A [Serializable] [MutableState] that persists & restores its value across process death. - * See the proposed-features doc / WENWE-ANDROID-5G for background. Implements [MutableState] by - * delegating to a `@Transient` `mutableStateOf`, so it's a drop-in replacement. - * - * The value is persisted only when it is itself [Serializable] (or null); a non-serializable value - * restores as null (safe only for a nullable T). Main-thread access only. - */ -class SerializableMutableState(initial: T) : MutableState, Serializable { - - @Transient - private var delegate: MutableState = mutableStateOf(initial) - - override var value: T - get() = delegate.value - set(value) { delegate.value = value } - - override fun component1(): T = delegate.value - override fun component2(): (T) -> Unit = { delegate.value = it } - - private fun writeObject(out: ObjectOutputStream) { - out.defaultWriteObject() - val current = delegate.value - // TODO(lib): BuildConfig.DEBUG is meaningless in a published lib — throw unconditionally - // on a non-null non-Serializable value instead (see doc). WenWe original: - // if (BuildConfig.DEBUG && current != null && current !is Serializable) throw ... - if (current != null && current !is Serializable) { - throw NotSerializableException( - "serializableMutableStateOf value is not Serializable: ${current::class.java.name}. " + - "Use a Serializable type, or transientMutableStateOf if it need not survive process death.", - ) - } - out.writeObject(current as? Serializable) - } - - private fun readObject(input: ObjectInputStream) { - input.defaultReadObject() - @Suppress("UNCHECKED_CAST") - delegate = mutableStateOf(input.readObject() as T) - } - - companion object { private const val serialVersionUID: Long = 1L } -} - -/** Creates a [Serializable] [MutableState] that survives process death. */ -fun serializableMutableStateOf(initial: T): SerializableMutableState = SerializableMutableState(initial) -``` - -### Source (repackaged) — `TransientMutableState` -```kotlin -package uk.co.appoly.droid.compose.extensions - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import java.io.Serializable - -/** - * A [Serializable] [MutableState] whose value resets to [initial] after a process-death restore, - * rather than being persisted. Sibling of [SerializableMutableState]; for ephemeral state that - * should NOT survive process death. Only [initial] is persisted (must be Serializable or null). - * Main-thread access only. - */ -class TransientMutableState(private val initial: T) : MutableState, Serializable { - init { - require(initial == null || initial is Serializable) { - "transientMutableStateOf initial value must be Serializable or null, was: $initial" - } - } - - @Transient - private var delegate: MutableState? = null - - private fun delegate(): MutableState = - delegate ?: mutableStateOf(initial).also { delegate = it } - - override var value: T - get() = delegate().value - set(value) { delegate().value = value } - - override fun component1(): T = delegate().value - override fun component2(): (T) -> Unit = { delegate().value = it } - - companion object { private const val serialVersionUID: Long = 1L } -} - -/** Creates a [Serializable] [MutableState] that resets to [initial] on process-death restore. */ -fun transientMutableStateOf(initial: T): TransientMutableState = TransientMutableState(initial) -``` - -### Tests to add -- Round-trip `SerializableMutableState` through `ObjectOutputStream`/`ObjectInputStream`: value - preserved; non-null non-Serializable value → throws (per chosen guard); null value survives. -- Round-trip `TransientMutableState`: value resets to `initial`. -- Both: `.value` get/set and `by` delegation behave like `mutableStateOf`. - ---- - -## 3. Clipboard copier — ComposeExtensions - -**Module:** `:ComposeExtensions` · **Package:** `uk.co.appoly.droid.compose.extensions` -(deps: compose runtime + compose ui + coroutines; all already on the module.) - -### Why - -`androidx.compose.ui.platform.LocalClipboardManager` is deprecated in favour of `LocalClipboard`, -whose `Clipboard.setClipEntry(…)` is a **suspend** function. That turns a one-line copy into a small -pile of plumbing every call site has to repeat correctly: grab `LocalClipboard` + a -`rememberCoroutineScope()`, `launch`, build a `ClipEntry`, and — because a "copied!" confirmation -should only fire once the write actually returns — order the toast after the await. There's also an -Android-13 wrinkle: API 33+ shows its own clipboard confirmation, so an app's own toast must be -gated to `< TIRAMISU` to avoid a double confirmation. This centralises all of it. - -`ClipboardCopier` takes a raw `ClipEntry`, so it handles any payload (text, HTML, URIs, intents, -multi-item); the `copyX` extensions mirror the `ClipData.newX` factories for ergonomics. `label` is -kept **required** to match `ClipData.newX` exactly (it's read by clipboard managers / a11y even -though it isn't shown in the modern paste UI); `confirmationMessage` is the library's own addition -and defaults to `null` (no toast). - -### Source (repackaged) - -```kotlin -package uk.co.appoly.droid.compose.extensions - -import android.content.ClipData -import android.content.ContentResolver -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.widget.Toast -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.ClipEntry -import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.toClipEntry -import kotlinx.coroutines.launch - -/** - * Copies a [ClipEntry] to the system clipboard and, on Android < 13, shows a confirmation Toast. - * Android 13+ surfaces its own clipboard confirmation, so we suppress ours to avoid a double toast. - * - * Obtain one with [rememberClipboardCopier]. It centralises the [LocalClipboard] plumbing: - * `setClipEntry` is a suspend function, so each copy runs on a remembered scope and the confirmation - * only fires once the write has returned. The payload is a raw [ClipEntry], so it handles text, a - * URI, multiple items, etc.; use [copyPlainText] for the common plain-text case. - */ -fun interface ClipboardCopier { - /** - * @param clipEntry the payload to copy. - * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in - * composition (e.g. `stringResource`) so a configuration change re-reads it. - */ - fun copy(clipEntry: ClipEntry, confirmationMessage: String?) -} - -/** - * Copies plain [text] to the clipboard — the common case (wraps [ClipData.newPlainText]). - * - * @param text the text to copy. - * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by - * clipboard managers and accessibility services, though not shown in the modern paste UI. - * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in - * composition (e.g. `stringResource`) so a configuration change re-reads it. - */ -fun ClipboardCopier.copyPlainText( - text: CharSequence, - label: CharSequence, - confirmationMessage: String? = null, -) = copy(ClipData.newPlainText(label, text).toClipEntry(), confirmationMessage) - -/** - * Copies styled [htmlText] to the clipboard, with [text] as the plain-text fallback for consumers - * that can't render HTML (wraps [ClipData.newHtmlText]). - * - * @param text the plain-text representation. - * @param htmlText the HTML-markup representation. - * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by - * clipboard managers and accessibility services, though not shown in the modern paste UI. - * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in - * composition (e.g. `stringResource`) so a configuration change re-reads it. - */ -fun ClipboardCopier.copyHtmlText( - text: CharSequence, - htmlText: String, - label: CharSequence, - confirmationMessage: String? = null, -) = copy(ClipData.newHtmlText(label, text, htmlText).toClipEntry(), confirmationMessage) - -/** - * Copies a raw [uri] to the clipboard without resolving it through a [ContentResolver] - * (wraps [ClipData.newRawUri]). Use for URIs that aren't `content://` provider URIs — e.g. an - * `http`/`https` link or a `mailto:` address; for content URIs use [copyUri] instead. - * - * @param uri the URI to copy verbatim. - * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by - * clipboard managers and accessibility services, though not shown in the modern paste UI. - * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in - * composition (e.g. `stringResource`) so a configuration change re-reads it. - */ -fun ClipboardCopier.copyRawUri( - uri: Uri, - label: CharSequence, - confirmationMessage: String? = null, -) = copy(ClipData.newRawUri(label, uri).toClipEntry(), confirmationMessage) - -/** - * Copies a `content://` [uri] to the clipboard, querying its available MIME types from [resolver] - * so pasting apps receive the right type (wraps [ClipData.newUri]). For plain web/mail URIs prefer - * [copyRawUri]. - * - * @param resolver resolves the URI's MIME types. - * @param uri the content URI to copy. - * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by - * clipboard managers and accessibility services, though not shown in the modern paste UI. - * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in - * composition (e.g. `stringResource`) so a configuration change re-reads it. - */ -fun ClipboardCopier.copyUri( - resolver: ContentResolver, - uri: Uri, - label: CharSequence, - confirmationMessage: String? = null, -) = copy(ClipData.newUri(resolver, label, uri).toClipEntry(), confirmationMessage) - -/** - * Copies an [intent] to the clipboard (wraps [ClipData.newIntent]) — e.g. a launcher shortcut. - * - * @param intent the Intent to copy. - * @param label human-readable [ClipData] label (required, mirroring `ClipData.newX`); read by - * clipboard managers and accessibility services, though not shown in the modern paste UI. - * @param confirmationMessage pre-Android-13 Toast text; pass `null` to skip it. Resolve it in - * composition (e.g. `stringResource`) so a configuration change re-reads it. - */ -fun ClipboardCopier.copyIntent( - intent: Intent, - label: CharSequence, - confirmationMessage: String? = null, -) = copy(ClipData.newIntent(label, intent).toClipEntry(), confirmationMessage) - -/** - * Remembers a [ClipboardCopier] bound to the current [LocalClipboard] / [LocalContext] and a - * composition-scoped coroutine scope. Call one of the `copyX` extensions (e.g. [copyPlainText]) on - * the result to copy — see [ClipboardCopier] for the confirmation-Toast behaviour. - */ -@Composable -fun rememberClipboardCopier(): ClipboardCopier { - val clipboard = LocalClipboard.current - val context = LocalContext.current - val scope = rememberCoroutineScope() - return remember(clipboard, context, scope) { - ClipboardCopier { clipEntry, confirmationMessage -> - scope.launch { - clipboard.setClipEntry(clipEntry) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && confirmationMessage != null) { - Toast.makeText(context, confirmationMessage, Toast.LENGTH_SHORT).show() - } - } - } - } -} -``` - -### Tests to add - -- `copyPlainText` builds a plain-text `ClipEntry` and calls `copy` with the given confirmation. -- Android < 13 shows the toast; API 33+ suppresses it (Robolectric `@Config(sdk = …)` on both sides - of `TIRAMISU`). -- Confirmation fires only after `setClipEntry` returns (ordering), and not at all when `null`. -- Consider a Compose test that `rememberClipboardCopier()` copies via `LocalClipboard`. - ---- - -## Migrate WenWe once released - -Once these ship in a toolbox release and WenWe bumps to it: - -1. Bump the AppolyDroid dependency in WenWe. -2. Delete the local copies: - - `app/src/main/java/uk/co/wenwe/util/PagingExt.kt` - - `app/src/main/java/uk/co/wenwe/util/SerializableMutableState.kt` - - `app/src/main/java/uk/co/wenwe/util/TransientMutableState.kt` -3. Repoint imports: - - `uk.co.wenwe.util.distinctBy` → `uk.co.appoly.droid.util.paging.distinctBy` - - `uk.co.wenwe.util.{serializable,transient}MutableStateOf` → `uk.co.appoly.droid.compose.extensions.*` -4. Call sites that use `distinctBy`: `WenWeContributorsScreenModel`, `ViewContributorsBottomSheet` - (both currently reverted/optional — check current state). State delegates: ~19 Screen files - swept in the WENWE-76 line (grep `serializableMutableStateOf` / `transientMutableStateOf`). -5. Rebuild + the same on-device process-death smoke test (Developer Options → "Don't keep - activities", background/foreground a Screen, confirm no NPE). - ---- - -## Migrate Accelerate once released - -Once the clipboard copier (#3) ships in a toolbox release and Accelerate bumps to it: - -1. Bump the AppolyDroid dependency in Accelerate. -2. Delete the local copy: - - `app/src/main/java/uk/co/accelerate/ui/extensions/ClipboardCopy.kt` -3. Repoint imports: - - `uk.co.accelerate.ui.extensions.{rememberClipboardCopier, copyPlainText, …}` → - `uk.co.appoly.droid.compose.extensions.*` -4. Call sites (grep `rememberClipboardCopier` / `copyPlainText`): `KerbsideScreen` (perk promo-code - redeem) and `WalletCodeScreen` (gift-card code copy). -5. Rebuild + tap-to-copy smoke test on an Android < 13 device/emulator (confirm the toast) and on - API 33+ (confirm the single system confirmation, no double toast).