From 29937335d3a2886c157422b5627a4b4136b8eecb Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:01:50 +1100 Subject: [PATCH 01/14] docs: document state-based currency input --- CHANGELOG.md | 7 ++++++ README.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 054fb26..13907be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the Kurrency library will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `CurrencyOutputTransformation` and `rememberCurrencyOutputTransformation` for currency + input in state-based Compose text fields, with a sample and cross-platform tests. (#23) + ## [0.5.0] - 2026-08-20 A correctness release for the options API. Formatted output changes for three cases diff --git a/README.md b/README.md index a8dfb9e..ec178f9 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,58 @@ val system = KurrencyLocale.systemLocale() Add `kurrency-compose` for Jetpack Compose Multiplatform support. +### Currency input with state-based text fields + +Use `rememberCurrencyOutputTransformation` with Foundation's state-based `BasicTextField`. +The state holds raw minor-unit digits: `"1234"` displays as `$12.34` for USD in the US locale. +Compose handles cursor and selection offsets for the formatted output. + +```kotlin +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.input.KeyboardType +import org.kimplify.kurrency.KurrencyLocale +import org.kimplify.kurrency.compose.rememberCurrencyOutputTransformation + +@Composable +fun CurrencyInput() { + val state = rememberTextFieldState(initialText = "1234") + val transformation = rememberCurrencyOutputTransformation( + currencyCode = "USD", + locale = KurrencyLocale.US + ) + + BasicTextField( + state = state, + outputTransformation = transformation, + inputTransformation = InputTransformation { + if (asCharSequence().any { it !in '0'..'9' }) revertAllChanges() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + lineLimits = TextFieldLineLimits.SingleLine + ) +} +``` + +The optional input filter rejects an entire edit containing anything outside ASCII `0`–`9`; +the number keyboard alone does not validate input. Store digits without signs, decimal +separators, or currency symbols. Output formatting only changes the display, leaving +`state.text` untouched. Empty text displays formatted zero while remaining empty in the state. + +The default input scale comes from the currency: `"1234"` means USD 12.34, JPY 1234, or +BHD 1.234. Override `fractionDigits` to change that scale; it does not set display precision. +The platform formatter still uses the currency's normal fraction digits. If formatting fails, +including for an invalid currency code, the display falls back to the synthesized decimal amount. + +Omit `locale` to follow the current Compose locale. Outside composition, construct +`CurrencyOutputTransformation` directly; its default is the system locale. For +`value`/`onValueChange` text fields, `CurrencyVisualTransformation` and +`rememberCurrencyVisualTransformation` remain available. + ### rememberCurrencyFormatter The formatter is recreated when the locale changes (key-based recomposition). Formatting is cheap, so call it directly during composition rather than caching it. @@ -290,6 +342,18 @@ val groupingSeparator: Char ### Compose extensions (`kurrency-compose`) ```kotlin +class CurrencyOutputTransformation( + currencyCode: String, + locale: KurrencyLocale = KurrencyLocale.systemLocale(), + fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0) +) : OutputTransformation + +@Composable fun rememberCurrencyOutputTransformation( + currencyCode: String, + locale: KurrencyLocale = KurrencyLocale.current(), + fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0) +): CurrencyOutputTransformation + @Composable fun rememberCurrencyFormatter(locale: KurrencyLocale = KurrencyLocale.current()): CurrencyFormat @Composable fun rememberSystemCurrencyFormatter(): CurrencyFormat From 92762dfc6d55976c779a883eced185f1fde9489b Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:03:06 +1100 Subject: [PATCH 02/14] feat(compose): add currency output transformation --- .../compose/CurrencyOutputTransformation.kt | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 kurrency-compose/src/commonMain/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformation.kt diff --git a/kurrency-compose/src/commonMain/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformation.kt b/kurrency-compose/src/commonMain/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformation.kt new file mode 100644 index 0000000..b8e5c1d --- /dev/null +++ b/kurrency-compose/src/commonMain/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformation.kt @@ -0,0 +1,93 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.kimplify.kurrency.CurrencyFormatter +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyOutputTransformation( + private val currencyCode: String, + private val locale: KurrencyLocale = KurrencyLocale.systemLocale(), + private val fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0), +) : OutputTransformation { + + init { + require(fractionDigits >= 0) { "fractionDigits must be nonnegative" } + } + + private val formatter = CurrencyFormatter(locale) + private val formattedZero by lazy { + formatter.formatCurrencyStyleResult(buildAmount(""), currencyCode) + } + private val displayFractionDigits by lazy { + formattedZero.getOrNull()?.count { it.isDigit() }?.minus(1)?.takeIf { it >= 0 } + } + + override fun TextFieldBuffer.transformOutput() { + val raw = asCharSequence().toString() + val digits = raw.filter { it.isDigit() } + val amount = buildAmount(digits) + val result = if (digits.isEmpty()) formattedZero else formatter.formatCurrencyStyleResult(amount, currencyCode) + val formatted = result.getOrDefault(amount) + val outputScale = if (result.isSuccess) displayFractionDigits else fractionDigits + + if (outputScale == null) { + replaceOutputRange(0, length, formatted) + } else { + applyCurrencyFormatting(raw, formatted, fractionDigits, outputScale) + } + } + + private fun buildAmount(digits: String): String { + if (fractionDigits == 0) return digits.ifEmpty { "0" } + val integerLength = digits.length - fractionDigits + return if (integerLength > 0) { + digits.take(integerLength) + "." + digits.drop(integerLength) + } else { + "0." + "0".repeat(-integerLength) + digits + } + } +} + +private fun TextFieldBuffer.applyCurrencyFormatting( + raw: String, + formatted: String, + inputScale: Int, + outputScale: Int, +) { + val sourceDigits = raw.indices.filter { raw[it].isDigit() } + val outputDigits = formatted.indices.filter { formatted[it].isDigit() } + val sourceExponent = sourceDigits.lastIndex - inputScale + val outputExponent = outputDigits.lastIndex - outputScale + val firstExponent = maxOf(-inputScale, -outputScale) + val lastExponent = minOf(sourceExponent, outputExponent) + var sourceEnd = raw.length + var outputEnd = formatted.length + + for (exponent in firstExponent..lastExponent) { + val sourceIndex = sourceDigits[sourceExponent - exponent] + val outputIndex = outputDigits[outputExponent - exponent] + replaceOutputRange(sourceIndex + 1, sourceEnd, formatted.substring(outputIndex + 1, outputEnd)) + replaceOutputRange(sourceIndex, sourceIndex + 1, formatted[outputIndex].toString()) + sourceEnd = sourceIndex + outputEnd = outputIndex + } + replaceOutputRange(0, sourceEnd, formatted.substring(0, outputEnd)) +} + +private fun TextFieldBuffer.replaceOutputRange(start: Int, end: Int, replacement: String) { + if (asCharSequence().subSequence(start, end).toString() != replacement) { + replace(start, end, replacement) + } +} + +@Composable +fun rememberCurrencyOutputTransformation( + currencyCode: String, + locale: KurrencyLocale = KurrencyLocale.current(), + fractionDigits: Int = CurrencyFormatter.getFractionDigitsOrDefault(currencyCode).coerceAtLeast(0), +): CurrencyOutputTransformation = remember(currencyCode, locale, fractionDigits) { + CurrencyOutputTransformation(currencyCode, locale, fractionDigits) +} From e057c0e69e98daeff0c89d12019dbc07935fe04a Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:03:55 +1100 Subject: [PATCH 03/14] test(compose): cover currency formatting and fallbacks --- .../CurrencyOutputTransformationTest.kt | 123 ++++++++++++++++++ .../CurrencyVisualTransformationTest.kt | 32 +++++ 2 files changed, 155 insertions(+) create mode 100644 kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationTest.kt create mode 100644 kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyVisualTransformationTest.kt diff --git a/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationTest.kt b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationTest.kt new file mode 100644 index 0000000..3746e88 --- /dev/null +++ b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationTest.kt @@ -0,0 +1,123 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.ui.text.AnnotatedString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyOutputTransformationTest { + + @Test + fun formatsMinorUnitDigits() { + val examples = listOf( + "" to "$0.00", + "0" to "$0.00", + "00" to "$0.00", + "1" to "$0.01", + "12" to "$0.12", + "123" to "$1.23", + "1234" to "$12.34", + "123456" to "$1,234.56", + "0001" to "$0.01", + "0001234" to "$12.34", + ) + + for ((raw, expected) in examples) { + assertEquals(expected, transform(raw), "Raw input: $raw") + } + } + + @Test + fun filtersNonDigitsFromDisplay() { + val examples = listOf( + "abc" to "$0.00", + "a1b2" to "$0.12", + "1.23" to "$1.23", + "12.3" to "$1.23", + "-1234" to "$12.34", + "1e3" to "$0.13", + " 1,234.56 " to "$1,234.56", + ) + + for ((raw, expected) in examples) { + assertEquals(expected, transform(raw), "Raw input: $raw") + } + } + + @Test + fun customScaleChangesInputUnitsRatherThanDisplayPrecision() { + assertEquals("$1,234.00", transform("1234", fractionDigits = 0)) + assertEquals("$123.40", transform("1234", fractionDigits = 1)) + assertEquals("$12.34", transform("1234", fractionDigits = 2)) + assertEquals("$1.23", transform("1234", fractionDigits = 3)) + assertEquals("$10.00", transform("9999", fractionDigits = 3)) + } + + @Test + fun preservesLegacyOutputAcrossCurrenciesAndLocales() { + val configurations = listOf( + "USD" to KurrencyLocale.US, + "EUR" to KurrencyLocale.GERMANY, + "EUR" to KurrencyLocale.FRANCE, + "JPY" to KurrencyLocale.JAPAN, + "KWD" to KurrencyLocale.US, + "INR" to KurrencyLocale.INDIA, + "SAR" to KurrencyLocale.SAUDI_ARABIA, + ) + val inputs = listOf("", "1", "1234", "123456789", "0001234", "a1b2", "١٢", "۱۲") + + for ((currencyCode, locale) in configurations) { + for (raw in inputs) { + val expected = CurrencyVisualTransformation(currencyCode, locale) + .filter(AnnotatedString(raw)).text.text + assertEquals(expected, transform(raw, currencyCode, locale), "$currencyCode ${locale.languageTag}: $raw") + } + } + } + + @Test + fun invalidCurrencyFallsBackToSynthesizedAmount() { + assertEquals("12.34", transform("1234", currencyCode = "INVALID")) + assertEquals("0.00", transform("", currencyCode = "INVALID")) + assertEquals("0.001", transform("1", currencyCode = "INVALID", fractionDigits = 3)) + } + + @Test + fun failedAmountFormattingUsesInputScaleDespiteSuccessfulZeroFormatting() { + assertEquals("9".repeat(397) + ".999", transform("9".repeat(400), fractionDigits = 3)) + } + + @Test + fun unicodeDigitsRetainExistingFormatterFallback() { + assertEquals("0.١٢", transform("١٢")) + assertEquals("0.۱۲", transform("۱۲")) + assertEquals("$0.12", transform("😀1😀2")) + } + + @Test + fun rejectsNegativeInputScale() { + assertFailsWith { + CurrencyOutputTransformation("USD", KurrencyLocale.US, fractionDigits = -1) + } + } + + private fun transform( + raw: String, + currencyCode: String = "USD", + locale: KurrencyLocale = KurrencyLocale.US, + fractionDigits: Int? = null, + ): String { + val transformation = if (fractionDigits == null) { + CurrencyOutputTransformation(currencyCode, locale) + } else { + CurrencyOutputTransformation(currencyCode, locale, fractionDigits) + } + val state = TextFieldState(raw) + state.edit { + with(transformation) { transformOutput() } + } + return state.text.toString() + } +} diff --git a/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyVisualTransformationTest.kt b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyVisualTransformationTest.kt new file mode 100644 index 0000000..ed88a77 --- /dev/null +++ b/kurrency-compose/src/commonTest/kotlin/org/kimplify/kurrency/compose/CurrencyVisualTransformationTest.kt @@ -0,0 +1,32 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.ui.text.AnnotatedString +import kotlin.test.Test +import kotlin.test.assertEquals +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyVisualTransformationTest { + + @Test + fun preservesExistingMinorUnitFormatting() { + val transformation = CurrencyVisualTransformation("USD", KurrencyLocale.US) + val examples = listOf( + "" to "$0.00", + "1" to "$0.01", + "1234" to "$12.34", + "0001234" to "$12.34", + "a1b2" to "$0.12", + ) + + for ((raw, expected) in examples) { + assertEquals(expected, transformation.filter(AnnotatedString(raw)).text.text) + } + } + + @Test + fun customScaleKeepsCurrencyDisplayPrecision() { + val transformation = CurrencyVisualTransformation("USD", KurrencyLocale.US, fractionDigits = 3) + + assertEquals("$1.23", transformation.filter(AnnotatedString("1234")).text.text) + } +} From 67ee35782b179760b1a4b4cc79376a9893062216 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:04:33 +1100 Subject: [PATCH 04/14] test(compose): verify state-based currency editing --- .../CurrencyOutputTransformationJvmTest.kt | 23 ++ .../CurrencyOutputTransformationUiTest.kt | 349 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationJvmTest.kt create mode 100644 kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt diff --git a/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationJvmTest.kt b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationJvmTest.kt new file mode 100644 index 0000000..7d797fa --- /dev/null +++ b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationJvmTest.kt @@ -0,0 +1,23 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.text.input.TextFieldState +import kotlin.test.Test +import kotlin.test.assertEquals +import org.kimplify.kurrency.CurrencyFormatter +import org.kimplify.kurrency.KurrencyLocale + +class CurrencyOutputTransformationJvmTest { + + @Test + fun pseudoCurrencyUsesZeroInputScaleWhenMetadataIsNegative() { + val state = TextFieldState("1234") + val transformation = CurrencyOutputTransformation("XDR", KurrencyLocale.US) + + state.edit { + with(transformation) { transformOutput() } + } + + val expected = CurrencyFormatter(KurrencyLocale.US).formatCurrencyStyle("1234", "XDR") + assertEquals(expected, state.text.toString()) + } +} diff --git a/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt new file mode 100644 index 0000000..3c0e0e2 --- /dev/null +++ b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt @@ -0,0 +1,349 @@ +package org.kimplify.kurrency.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.TextRange +import org.kimplify.kurrency.KurrencyLocale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +@OptIn(ExperimentalTestApi::class) +class CurrencyOutputTransformationUiTest { + + @Test + fun formatting_displaysMinorUnitsWithoutEditingState() = runComposeUiTest { + val state = TextFieldState("123456", TextRange(2, 5)) + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + onNodeWithTag("amount").assertTextEquals("$1,234.56") + runOnIdle { + assertEquals("123456", state.text.toString()) + assertEquals(TextRange(2, 5), state.selection) + } + } + + @Test + fun emptyState_displaysZeroWithoutInsertingDigits() = runComposeUiTest { + val state = TextFieldState() + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + onNodeWithTag("amount").assertTextEquals("$0.00") + runOnIdle { + assertEquals("", state.text.toString()) + assertEquals(TextRange.Zero, state.selection) + } + } + + @Test + fun insertionAtDisplayedInteriorOffset_editsCorrespondingRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$1,234.56") + field.performTextInputSelection(TextRange(5), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(3), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$12,394.56") + runOnIdle { + assertEquals("1239456", state.text.toString()) + assertEquals(TextRange(4), state.selection) + } + } + + @Test + fun eitherSideOfDisplayedDecimal_mapsToSameRawBoundary() = runComposeUiTest { + val beforeDecimal = TextFieldState("123456") + val afterDecimal = TextFieldState("123456") + + setContent { + val transformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + Column { + BasicTextField( + state = beforeDecimal, + modifier = Modifier.testTag("before"), + outputTransformation = transformation + ) + BasicTextField( + state = afterDecimal, + modifier = Modifier.testTag("after"), + outputTransformation = transformation + ) + } + } + + val before = onNodeWithTag("before") + val after = onNodeWithTag("after") + before.performTextInputSelection(TextRange(6), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(4), beforeDecimal.selection) } + before.performTextInput("9") + after.performTextInputSelection(TextRange(7), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(4), afterDecimal.selection) } + after.performTextInput("9") + + before.assertTextEquals("$12,349.56") + after.assertTextEquals("$12,349.56") + runOnIdle { + assertEquals("1234956", beforeDecimal.text.toString()) + assertEquals("1234956", afterDecimal.text.toString()) + } + } + + @Test + fun replacingDisplayedRangeAcrossSeparators_replacesOnlySelectedRawDigits() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(2, 8), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(1, 5), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$1.96") + runOnIdle { + assertEquals("196", state.text.toString()) + assertEquals(TextRange(2), state.selection) + } + } + + @Test + fun backspaceAfterDisplayedDecimal_deletesPreviousRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(7), relativeToOriginalText = false) + field.performKeyInput { pressKey(Key.Backspace) } + + field.assertTextEquals("$123.56") + runOnIdle { + assertEquals("12356", state.text.toString()) + assertEquals(TextRange(3), state.selection) + } + } + + @Test + fun deleteBeforeDisplayedDecimal_deletesNextRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(6), relativeToOriginalText = false) + field.performKeyInput { pressKey(Key.Delete) } + + field.assertTextEquals("$123.46") + runOnIdle { + assertEquals("12346", state.text.toString()) + assertEquals(TextRange(4), state.selection) + } + } + + @Test + fun shortRawAmount_displayPaddingDoesNotBecomeInput() = runComposeUiTest { + val state = TextFieldState("5") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$0.05") + runOnIdle { assertEquals("5", state.text.toString()) } + field.performTextInputSelection(TextRange(4), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange.Zero, state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$0.95") + runOnIdle { assertEquals("95", state.text.toString()) } + } + + @Test + fun leadingRawZeros_remainInStateAndKeepVisibleDigitsEditable() = runComposeUiTest { + val state = TextFieldState("001234") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$12.34") + runOnIdle { assertEquals("001234", state.text.toString()) } + field.performTextInputSelection(TextRange(2), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(3), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$192.34") + runOnIdle { assertEquals("0019234", state.text.toString()) } + } + + @Test + fun zeroScale_keepsIntegerZerosMappedBeforeDisplayedFraction() = runComposeUiTest { + val state = TextFieldState("100") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation( + "USD", KurrencyLocale.US, fractionDigits = 0 + ) + ) + } + + val field = onNodeWithTag("amount") + field.assertTextEquals("$100.00") + field.performTextInputSelection(TextRange(3), relativeToOriginalText = false) + runOnIdle { assertEquals(TextRange(2), state.selection) } + field.performTextInput("9") + + field.assertTextEquals("$1,090.00") + runOnIdle { assertEquals("1090", state.text.toString()) } + } + + @Test + fun sharedTransformation_andRecompositionKeepFieldStatesIndependent() = runComposeUiTest { + val firstState = TextFieldState("1234") + val secondState = TextFieldState("5678", TextRange(2)) + var recomposition by mutableStateOf(0) + var observedRecomposition = -1 + + setContent { + observedRecomposition = recomposition + val transformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US) + Column { + BasicTextField( + state = firstState, + modifier = Modifier.testTag("first"), + outputTransformation = transformation + ) + BasicTextField( + state = secondState, + modifier = Modifier.testTag("second"), + outputTransformation = transformation + ) + } + } + + val first = onNodeWithTag("first") + val second = onNodeWithTag("second") + first.assertTextEquals("$12.34") + second.assertTextEquals("$56.78") + first.performTextInputSelection(TextRange(2), relativeToOriginalText = false) + first.performTextInput("9") + runOnIdle { recomposition++ } + + first.assertTextEquals("$192.34") + second.assertTextEquals("$56.78") + runOnIdle { + assertEquals(1, observedRecomposition) + assertEquals("19234", firstState.text.toString()) + assertEquals(TextRange(2), firstState.selection) + assertEquals("5678", secondState.text.toString()) + assertEquals(TextRange(2), secondState.selection) + } + } + + @Test + fun rememberHelper_reusesEqualKeysAndRecreatesForEachChangedKey() = runComposeUiTest { + var currencyCode by mutableStateOf("USD") + var locale by mutableStateOf(KurrencyLocale.US) + var fractionDigits by mutableStateOf(2) + var recomposition by mutableStateOf(0) + var observedRecomposition = -1 + var remembered: CurrencyOutputTransformation? = null + + setContent { + observedRecomposition = recomposition + remembered = rememberCurrencyOutputTransformation(currencyCode, locale, fractionDigits) + } + + waitForIdle() + val initial = remembered + runOnIdle { recomposition++ } + waitForIdle() + assertEquals(1, observedRecomposition) + assertSame(initial, remembered) + + runOnIdle { currencyCode = "EUR" } + waitForIdle() + val changedCurrency = remembered + assertNotSame(initial, changedCurrency) + + runOnIdle { locale = KurrencyLocale.GERMANY } + waitForIdle() + val changedLocale = remembered + assertNotSame(changedCurrency, changedLocale) + + runOnIdle { fractionDigits = 3 } + waitForIdle() + assertNotSame(changedLocale, remembered) + } +} From fb813e1a44ee8f601a85e4f30cd4d2fe4599f94b Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:05:13 +1100 Subject: [PATCH 05/14] feat(sample): show state-based currency input --- .../kurrency/sample/KurrencySample.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt b/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt index 425f01d..8ec9588 100644 --- a/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt +++ b/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt @@ -2,6 +2,7 @@ package org.kimplify.kurrency.sample +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,6 +14,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -34,12 +40,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import org.kimplify.kurrency.CurrencyStyle import org.kimplify.kurrency.ExperimentalKurrency import org.kimplify.kurrency.Kurrency import org.kimplify.kurrency.KurrencyLocale import org.kimplify.kurrency.compose.current +import org.kimplify.kurrency.compose.rememberCurrencyOutputTransformation import org.kimplify.kurrency.compose.rememberCurrencyState @Composable @@ -62,6 +70,7 @@ fun KurrencySampleApp() { LocaleSeparatorsExample() CurrencyStateExample() MultiCurrencyExample() + CurrencyInputExample() InteractiveCurrencyConverter() CurrencyListSection() } @@ -323,6 +332,48 @@ private fun MultiCurrencyExample() { } } +@Composable +private fun CurrencyInputExample() { + SampleCard(title = "Currency Input") { + val state = rememberTextFieldState(initialText = "1234") + val outputTransformation = rememberCurrencyOutputTransformation("USD") + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = "Amount (USD)", + style = MaterialTheme.typography.labelLarge + ) + + BasicTextField( + state = state, + outputTransformation = outputTransformation, + inputTransformation = InputTransformation { + if (asCharSequence().any { it !in '0'..'9' }) revertAllChanges() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + lineLimits = TextFieldLineLimits.SingleLine, + textStyle = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(16.dp)) + .padding(16.dp) + ) + + Text( + text = "Stored minor units: ${state.text}", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "Type USD cents. Clearing keeps the stored text empty and displays zero.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + @Composable private fun InteractiveCurrencyConverter() { SampleCard(title = "Interactive Converter") { From aaac0458397764839d60f4eb5cdd71f5ee4f1be0 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:05:52 +1100 Subject: [PATCH 06/14] ci: run Compose tests alongside core tests --- .github/workflows/build.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e45cc60..e603f60 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,16 +31,16 @@ jobs: uses: gradle/actions/setup-gradle@v5 - name: Run JVM tests - run: ./gradlew :kurrency-core:jvmTest + run: ./gradlew :kurrency-core:jvmTest :kurrency-compose:jvmTest - name: Run iOS tests - run: ./gradlew :kurrency-core:iosSimulatorArm64Test + run: ./gradlew :kurrency-core:iosSimulatorArm64Test :kurrency-compose:iosSimulatorArm64Test - name: Run JS tests - run: ./gradlew :kurrency-core:jsTest + run: ./gradlew :kurrency-core:jsTest :kurrency-compose:jsTest - name: Run WasmJs tests - run: ./gradlew :kurrency-core:wasmJsTest + run: ./gradlew :kurrency-core:wasmJsTest :kurrency-compose:wasmJsTest - name: Upload test reports if: always() @@ -50,6 +50,8 @@ jobs: path: | kurrency-core/build/reports/tests/ kurrency-core/build/test-results/ + kurrency-compose/build/reports/tests/ + kurrency-compose/build/test-results/ retention-days: 30 build: From 4f45441c2b8711948705301d3876d48069d70dfc Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:25:03 +1100 Subject: [PATCH 07/14] test: cover Delete across generated decimal separators --- .../CurrencyOutputTransformationUiTest.kt | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt index 3c0e0e2..f70db0e 100644 --- a/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt +++ b/kurrency-compose/src/jvmTest/kotlin/org/kimplify/kurrency/compose/CurrencyOutputTransformationUiTest.kt @@ -176,7 +176,7 @@ class CurrencyOutputTransformationUiTest { } @Test - fun deleteBeforeDisplayedDecimal_deletesNextRawDigit() = runComposeUiTest { + fun deleteAfterCrossingDisplayedDecimal_deletesNextRawDigit() = runComposeUiTest { val state = TextFieldState("123456") setContent { @@ -189,7 +189,10 @@ class CurrencyOutputTransformationUiTest { val field = onNodeWithTag("amount") field.performTextInputSelection(TextRange(6), relativeToOriginalText = false) - field.performKeyInput { pressKey(Key.Delete) } + field.performKeyInput { + pressKey(Key.DirectionRight) + pressKey(Key.Delete) + } field.assertTextEquals("$123.46") runOnIdle { @@ -198,6 +201,29 @@ class CurrencyOutputTransformationUiTest { } } + @Test + fun deleteAtDisplayedDigitBoundary_deletesNextRawDigit() = runComposeUiTest { + val state = TextFieldState("123456") + + setContent { + BasicTextField( + state = state, + modifier = Modifier.testTag("amount"), + outputTransformation = rememberCurrencyOutputTransformation("USD", KurrencyLocale.US), + ) + } + + val field = onNodeWithTag("amount") + field.performTextInputSelection(TextRange(5), relativeToOriginalText = false) + field.performKeyInput { pressKey(Key.Delete) } + + field.assertTextEquals("$123.56") + runOnIdle { + assertEquals("12356", state.text.toString()) + assertEquals(TextRange(3), state.selection) + } + } + @Test fun shortRawAmount_displayPaddingDoesNotBecomeInput() = runComposeUiTest { val state = TextFieldState("5") From edddd4cd02b1c7412d7aff9d82c1d73ed6ebbc46 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:26:52 +1100 Subject: [PATCH 08/14] test: expose currency sample fields to UI tests --- .../kotlin/org/kimplify/kurrency/sample/KurrencySample.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt b/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt index 8ec9588..3311add 100644 --- a/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt +++ b/sample/shared/src/commonMain/kotlin/org/kimplify/kurrency/sample/KurrencySample.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp @@ -356,6 +357,7 @@ private fun CurrencyInputExample() { color = MaterialTheme.colorScheme.onSurface ), modifier = Modifier + .testTag("currencyInput") .fillMaxWidth() .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(16.dp)) .padding(16.dp) @@ -363,6 +365,7 @@ private fun CurrencyInputExample() { Text( text = "Stored minor units: ${state.text}", + modifier = Modifier.testTag("currencyRawAmount"), style = MaterialTheme.typography.bodyMedium ) Text( From d1cbe2bead518635250890c31d8f6aedeac1bb2c Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:29:22 +1100 Subject: [PATCH 09/14] test: configure Android sample instrumentation --- sample/androidApp/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sample/androidApp/build.gradle.kts b/sample/androidApp/build.gradle.kts index 77bf547..6018335 100644 --- a/sample/androidApp/build.gradle.kts +++ b/sample/androidApp/build.gradle.kts @@ -13,6 +13,7 @@ android { applicationId = "org.kimplify.sample" versionCode = 1 versionName = "1.0.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildFeatures { @@ -25,4 +26,7 @@ dependencies { implementation(libs.androidx.activity.compose) debugImplementation("androidx.compose.ui:ui-tooling:1.9.0-alpha04") implementation("androidx.compose.ui:ui-tooling-preview:1.9.0-alpha04") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.11.2") + androidTestImplementation(libs.androidx.runner) + androidTestImplementation(libs.androidx.testExt.junit) } From 0723e4a6f0458060768fdd76abbe78ee7636c305 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:33:11 +1100 Subject: [PATCH 10/14] test: verify currency input in the Android sample --- .../org/kimplify/sample/CurrencyInputTest.kt | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 sample/androidApp/src/androidTest/kotlin/org/kimplify/sample/CurrencyInputTest.kt diff --git a/sample/androidApp/src/androidTest/kotlin/org/kimplify/sample/CurrencyInputTest.kt b/sample/androidApp/src/androidTest/kotlin/org/kimplify/sample/CurrencyInputTest.kt new file mode 100644 index 0000000..8db0658 --- /dev/null +++ b/sample/androidApp/src/androidTest/kotlin/org/kimplify/sample/CurrencyInputTest.kt @@ -0,0 +1,133 @@ +package org.kimplify.sample + +import android.graphics.Bitmap +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.text.TextRange +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.io.File +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestName +import org.junit.runner.RunWith + +@OptIn(ExperimentalTestApi::class) +@RunWith(AndroidJUnit4::class) +class CurrencyInputTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @get:Rule + val testName = TestName() + + @Before + fun focusCurrencyInput() { + field().performScrollTo().performClick() + } + + @After + fun captureScreen() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val directory = File(requireNotNull( + InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + )) + directory.mkdirs() + val screenshot = requireNotNull(instrumentation.uiAutomation.takeScreenshot()) + try { + File(directory, "${testName.methodName}.png").outputStream().use { + check(screenshot.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + } finally { + screenshot.recycle() + } + } + + @Test + fun initialAmountDisplaysCurrencyAndKeepsMinorUnits() { + assertAmount("$12.34", "1234") + } + + @Test + fun enteringAndClearingDigitsKeepsZeroDisplaySeparateFromStoredText() { + field().performTextClearance() + assertAmount("$0.00", "") + + field().performTextInput("123456") + assertAmount("$1,234.56", "123456") + + field().performTextClearance() + assertAmount("$0.00", "") + + field().performTextInput("0") + assertAmount("$0.00", "0") + } + + @Test + fun insertionAtDisplayedInteriorOffsetEditsCorrespondingRawDigit() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(5), relativeToOriginalText = false) + field().performTextInput("9") + + assertAmount("$12,394.56", "1239456") + } + + @Test + fun replacingDisplayedSelectionAcrossSeparatorsReplacesSelectedRawDigits() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(2, 8), relativeToOriginalText = false) + field().performTextInput("9") + + assertAmount("$1.96", "196") + } + + @Test + fun backspaceAfterDisplayedDecimalDeletesPreviousRawDigit() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(7), relativeToOriginalText = false) + field().performKeyInput { pressKey(Key.Backspace) } + + assertAmount("$123.56", "12356") + } + + @Test + fun deleteAtDisplayedDigitBoundaryDeletesNextRawDigit() { + enterGroupedAmount() + + field().performTextInputSelection(TextRange(5), relativeToOriginalText = false) + field().performKeyInput { pressKey(Key.Delete) } + + assertAmount("$123.56", "12356") + } + + private fun enterGroupedAmount() { + field().performTextClearance() + field().performTextInput("123456") + assertAmount("$1,234.56", "123456") + } + + private fun field(): SemanticsNodeInteraction = composeRule.onNodeWithTag("currencyInput") + + private fun assertAmount(displayedAmount: String, rawAmount: String) { + field().assertTextEquals(displayedAmount) + composeRule.onNodeWithTag("currencyRawAmount") + .assertTextEquals("Stored minor units: $rawAmount") + } +} From 9c95389e81616dada058377e06a6b9bcd3186f17 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:34:32 +1100 Subject: [PATCH 11/14] ci: build APK and test currency input on Android --- .github/workflows/android-sample.yml | 84 ++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 .github/workflows/android-sample.yml diff --git a/.github/workflows/android-sample.yml b/.github/workflows/android-sample.yml new file mode 100644 index 0000000..c00b9c8 --- /dev/null +++ b/.github/workflows/android-sample.yml @@ -0,0 +1,84 @@ +name: Android Sample + +on: + pull_request: + branches: [ main, develop ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + android-sample: + name: Build APK and test Android sample + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Check out code + uses: actions/checkout@v5 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: zulu + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install compile SDK + run: sdkmanager "platforms;android-37" + + - name: Build sample and instrumentation APKs + run: ./gradlew :sample:androidApp:assembleDebug :sample:androidApp:assembleDebugAndroidTest --stacktrace + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Prepare emulator validation + run: | + cat > "$RUNNER_TEMP/run-android-sample.sh" <<'SCRIPT' + set -euo pipefail + mkdir -p build/android-validation + adb install -r sample/androidApp/build/outputs/apk/debug/androidApp-debug.apk + adb shell am start -W -n org.kimplify.sample/.MainActivity + adb exec-out screencap -p > build/android-validation/launch.png + test_exit_code=0 + ./gradlew :sample:androidApp:connectedDebugAndroidTest -Pandroid.enableAdditionalTestOutput=true --stacktrace || test_exit_code=$? + adb logcat -d > build/android-validation/logcat.txt || true + if [ "$test_exit_code" -eq 0 ]; then + find sample/androidApp/build/outputs/connected_android_test_additional_output -type f -name '*.png' -print -quit | grep -q . + fi + exit "$test_exit_code" + SCRIPT + + - name: Install, launch, and test on Android emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + target: google_apis + arch: x86_64 + profile: pixel_7_pro + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -prop persist.sys.language=en -prop persist.sys.country=US + script: bash "$RUNNER_TEMP/run-android-sample.sh" + + - name: Upload APKs, Android test results, and screenshots + if: always() + uses: actions/upload-artifact@v5 + with: + name: android-sample-validation + path: | + sample/androidApp/build/outputs/apk/debug/*.apk + sample/androidApp/build/outputs/apk/androidTest/debug/*.apk + sample/androidApp/build/reports/androidTests/connected/ + sample/androidApp/build/outputs/androidTest-results/connected/ + sample/androidApp/build/outputs/connected_android_test_additional_output/ + build/android-validation/ + retention-days: 30 From d4cf1e878bacfcad58fe637e987ef444d705fae1 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:38:55 +1100 Subject: [PATCH 12/14] ci: install supported Android SDK tools --- .github/workflows/android-sample.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/android-sample.yml b/.github/workflows/android-sample.yml index c00b9c8..2773139 100644 --- a/.github/workflows/android-sample.yml +++ b/.github/workflows/android-sample.yml @@ -28,6 +28,8 @@ jobs: - name: Set up Android SDK uses: android-actions/setup-android@v3 + with: + packages: platform-tools - name: Install compile SDK run: sdkmanager "platforms;android-37" From bbfce24d82443dfb6749e422f246e287589c5390 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:39:31 +1100 Subject: [PATCH 13/14] test: run Compose JS tests in a browser --- kurrency-compose/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/kurrency-compose/build.gradle.kts b/kurrency-compose/build.gradle.kts index fee5ae9..90a2491 100644 --- a/kurrency-compose/build.gradle.kts +++ b/kurrency-compose/build.gradle.kts @@ -33,7 +33,6 @@ kotlin { js(IR) { browser() - nodejs() } listOf( From 29009e01a9bd3b6673a80d244d81f5997a02a613 Mon Sep 17 00:00:00 2001 From: Konstantin Merenkov <56008710+Merkost@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:42:19 +1100 Subject: [PATCH 14/14] ci: use the full Android 37 SDK package name --- .github/workflows/android-sample.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android-sample.yml b/.github/workflows/android-sample.yml index 2773139..a32571d 100644 --- a/.github/workflows/android-sample.yml +++ b/.github/workflows/android-sample.yml @@ -32,7 +32,7 @@ jobs: packages: platform-tools - name: Install compile SDK - run: sdkmanager "platforms;android-37" + run: sdkmanager "platforms;android-37.0" - name: Build sample and instrumentation APKs run: ./gradlew :sample:androidApp:assembleDebug :sample:androidApp:assembleDebugAndroidTest --stacktrace