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/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" }