diff --git a/commons/plans/2026-08-30-commons-migration-sweep.md b/commons/plans/2026-08-30-commons-migration-sweep.md index c67ba3f177..e5ee522cd6 100644 --- a/commons/plans/2026-08-30-commons-migration-sweep.md +++ b/commons/plans/2026-08-30-commons-migration-sweep.md @@ -1686,3 +1686,68 @@ The same sweep finds 46 more movable files outside `ui/`. The notable groups are (`InFlightInvoices`, `PaymentPromptLedger`, the `bud10` resolver trio), `service/playback` leaves (`HlsLivenessCache`, `SessionRegistry`, `WssDataStreamCollector`, the player control buttons), and `model/MediaAspectRatioCache`. + +### Audit of this round + +Three reviewers read the 69 moved files. I read the chess merge and the hand edits. The +edits kept behaviour: `codePointAtKmp`/`codePointCharCount` match the JDK calls, +`padStart` matches `%02d` for every Int, `toInt()` throws the same exception as +`parseInt`, and lifecycle's `ViewModelProvider` calls the `KClass` factory overload +first. None of the findings below came from the move; all are on `main`. + +**Fixed:** + +- **Zap amounts rounded half-even** (`showAmount`, `showAmountInteger`). Kotlin's + `BigDecimal.div` already rounds to the dividend's scale with HALF_EVEN, so the + `setScale(0, HALF_UP)` after it never ran: 12,500 read "12k" and 2,500,000 read "2M". + The unit was also picked before rounding, so 999,500 read "1000k". Both now use + `divide(_, 0, HALF_UP)` and pick the unit after rounding. The `BigDecimal(0.01)` built + on every call is now a constant. The `showAmount` KDoc examples were wrong and now match + what it returns. `ZapFormatterTest` covers this. +- **`AgentWorkBoard.merge` sorted every band by upvotes.** Its KDoc says upvotes order + only the queue. An old upvoted job sat above a fresh one in Shipped, and every job sat + above every workflow run in Working. Upvotes now count only in the queue. +- **`SuspendableConfirmation`** could resume its continuation twice: a button tap plus + an outside-tap dismiss before the dialog recomposed away threw "Already resumed". A + cancelled upload also left the dialog up with dead buttons. It now resumes only while + active, and clears the dialog on cancellation, but only if the dialog is still its own. + `SuspendableConfirmationTest` covers this. +- **`TorDialogViewModel.save`** parsed the SOCKS port even with the field hidden. Junk + typed while External was selected blocked saving Internal or Off. It also took 0 or + 70000. Now only External requires a port in 1..65535; otherwise the last saved port is + kept. `TorDialogViewModelTest` covers this. +- **`ExpandingCirclesAnimation`** built its infinite transition before checking + `isRecording`. The composer toolbar always shows the record button, so it asked for a + frame on every vsync while the composer was open. The transition now exists only while + recording. The circles and the stop dot read their animated values in `graphicsLayer`, + so a frame redraws a layer instead of recomposing. +- **`DebouncedPublisher.flush`** treated a job whose `publish()` was already running as + pending. Leaving the nav picker while a publish waited on a remote signer cancelled it + and started a second signing request. `flush` now acts only during the delay. A new case + in `DebouncedPublisherTest` covers this. +- **`RelayFeedViewModel`:** + - The DM list's first emission read `nip65RelayListNote` as a `DmRelayListEvent`, + which is always null. It now reads `dmRelayListNote`. + - `subscribeTo`/`unsubscribeTo` compared the `MutableStateFlow` itself to a `User`, + so `unsubscribeTo` never cleared it. They now compare `.value`. +- **`ScheduledFlag`** built a `DateFormat` on every composition of a feed card. It is + now remembered per start time. +- **`CashuWalletDiscovery`:** up to 50 relays finishing at once could write the crawl + progress out of order, so it stepped backwards. The write is now monotonic. +- **Desktop `ChessScreen`:** polling runs on the screen's scope. When `remember` replaced + the view model on an account switch, the old one kept polling. A `DisposableEffect` now + stops it. + +**Reported, not changed (need an owner's call):** + +- `FilterLastMessageFollowingPublicChats` asks each relay for kind 41 with `limit = 1` + across all followed channels, so only one channel's metadata comes back per relay. One + busy channel can also fill the kind-42 `limit = 100`. The fix (a limit per channel, or a + filter per channel) changes relay load. +- `PitchShifter`'s brute-force WSOLA search costs about 500 multiply-adds per output + sample, and the loop never checks for cancellation. Decimating the correlation changes + the audio, and a cancellation check changes its API. +- `RelayFeedViewModel.invalidateData` re-emits the same user into a `StateFlow`, which + drops it, so a refresh only spins for a second. +- `ScheduledFlag` still reads `TimeUtils.now()` once, so a card composed before the start + time keeps the date after it passes. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentWork.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentWork.kt index 98d2de1c56..15897a6b29 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentWork.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/buzz/AgentWork.kt @@ -132,7 +132,9 @@ object AgentWorkBoard { (runs.map { from(it) } + jobs.map { from(it) }) .sortedWith( compareBy { it.state.ordinal } - .thenByDescending { it.upvotes ?: 0 } + // Upvotes are the queue's priority signal only. Elsewhere they would put an old + // upvoted job above a fresh one, and every job above every workflow run. + .thenByDescending { if (it.state == AgentWorkState.QUEUED) it.upvotes ?: 0 else 0 } .thenByDescending { it.updatedAt }, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/SuspendableConfirmation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/SuspendableConfirmation.kt index 44a457a83c..3b4c1bac21 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/SuspendableConfirmation.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/SuspendableConfirmation.kt @@ -44,17 +44,24 @@ class SuspendableConfirmation { suspend fun awaitConfirmation(): Boolean = mutex.withLock { suspendCancellableCoroutine { continuation -> - state = + var callbacks: ConfirmationCallbacks? = null + + // The dialog can fire two of its callbacks (a button tap and an outside-tap + // dismiss) before the recomposition that removes it; only the first may resume. + fun answer(confirmed: Boolean) { + if (state === callbacks) state = null + if (continuation.isActive) continuation.resume(confirmed) + } + + callbacks = ConfirmationCallbacks( - onConfirm = { - state = null - continuation.resume(true) - }, - onCancel = { - state = null - continuation.resume(false) - }, + onConfirm = { answer(true) }, + onCancel = { answer(false) }, ) + state = callbacks + + // An upload cancelled while waiting must not leave a dialog with dead buttons. + continuation.invokeOnCancellation { if (state === callbacks) state = null } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorDialogViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorDialogViewModel.kt index 8158665f84..a01c9a2054 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorDialogViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorDialogViewModel.kt @@ -65,8 +65,12 @@ class TorDialogViewModel : ViewModel() { ) } + /** The last port that was actually saved, kept when the field is hidden and holds junk. */ + private var savedSocksPort = TorSettings().externalSocksPort + fun reset(torSettings: TorSettings) { torType.value = torSettings.torType + savedSocksPort = torSettings.externalSocksPort socksPortStr.value = torSettings.externalSocksPort.toString() onionRelaysViaTor.value = torSettings.onionRelaysViaTor dmRelaysViaTor.value = torSettings.dmRelaysViaTor @@ -81,10 +85,26 @@ class TorDialogViewModel : ViewModel() { mediaUploadsViaTor.value = torSettings.mediaUploadsViaTor } - fun save(): TorSettings = - TorSettings( + /** + * The port field is only shown for [TorType.EXTERNAL], so only then is a bad value the user's + * to fix: it throws, and the dialog shows its "invalid port" message. With the field hidden, a + * leftover unparsable value must not block saving Internal or Off; the last saved port stays. + */ + fun save(): TorSettings { + val typedPort = + socksPortStr.value + .trim() + .toIntOrNull() + ?.takeIf { it in 1..65535 } + val port = + when { + typedPort != null -> typedPort + torType.value == TorType.EXTERNAL -> throw IllegalArgumentException("Invalid SOCKS port: ${socksPortStr.value}") + else -> savedSocksPort + } + return TorSettings( torType = torType.value, - externalSocksPort = socksPortStr.value.toInt(), + externalSocksPort = port, onionRelaysViaTor = onionRelaysViaTor.value, dmRelaysViaTor = dmRelaysViaTor.value, newRelaysViaTor = newRelaysViaTor.value, @@ -97,6 +117,7 @@ class TorDialogViewModel : ViewModel() { nip05VerificationsViaTor = nip05VerificationsViaTor.value, mediaUploadsViaTor = mediaUploadsViaTor.value, ) + } fun setPreset(preset: TorPresetType) { when (preset) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisher.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisher.kt index eff052fcb0..c361099f9c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisher.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisher.kt @@ -45,6 +45,13 @@ class DebouncedPublisher( ) { private var pending: Job? = null + /** + * True only while [pending] is still waiting out its delay. Once [publish] has started, the + * job is still active but there is nothing left to hurry: flushing then would cancel a publish + * that may be parked on a remote signer and start a second one. + */ + private var waiting = false + /** Records an edit: restarts the wait, so a run of edits publishes once, after the last one. */ fun schedule() = start(debounceMs) @@ -53,15 +60,17 @@ class DebouncedPublisher( * pending, so a caller can flush on every exit path without publishing the same state twice. */ fun flush() { - if (pending?.isActive != true) return + if (!waiting || pending?.isActive != true) return start(0) } private fun start(delayMs: Long) { pending?.cancel() + waiting = true pending = launch { delay(delayMs) + waiting = false publish() } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RelayFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RelayFeedViewModel.kt index be771d2bf8..37845cb984 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RelayFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/RelayFeedViewModel.kt @@ -155,7 +155,7 @@ class RelayFeedViewModel : emit(emptyList()) } }.onStart { - emit(convert((currentUser.value?.nip65RelayListNote?.event as? DmRelayListEvent)?.relays()?.toSet(), currentUser.value?.relayState()?.data)) + emit(convert((currentUser.value?.dmRelayListNote?.event as? DmRelayListEvent)?.relays()?.toSet(), currentUser.value?.relayState()?.data)) }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -179,13 +179,13 @@ class RelayFeedViewModel : @OptIn(FlowPreview::class) fun subscribeTo(user: User) { - if (currentUser != user) { + if (currentUser.value != user) { currentUser.tryEmit(user) } } fun unsubscribeTo(user: User) { - if (currentUser == user) { + if (currentUser.value == user) { currentUser.tryEmit(null) invalidateData() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletDiscovery.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletDiscovery.kt index 54aae743e5..772e18e7fb 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletDiscovery.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/cashu/CashuWalletDiscovery.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -157,7 +158,12 @@ class CashuWalletDiscovery( } }, onRelayComplete = { - _state.value = State.Crawling(completed.incrementAndGet(), relays.size) + // Up to MAX_CONCURRENT_RELAYS relays finish at once: a coroutine preempted + // between the increment and the write would move the progress backwards. + val done = completed.incrementAndGet() + _state.update { current -> + if (current is State.Crawling && current.relaysCompleted >= done) current else State.Crawling(done, relays.size) + } }, ) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt index a0ad25666c..1e32ae89c6 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -31,6 +31,9 @@ val OneMega = BigDecimal(1_000_000) val TenKilo = BigDecimal(10_000) val OneKilo = BigDecimal(1_000) +/** Below this an amount rounds to nothing. Built once: it was a new `BigDecimal` per call. */ +internal val MinDisplayableAmount = BigDecimal(0.01) + private val dfGBig = ThreadLocal.withInitial { DecimalFormat("#.#G") } private val dfGSmall = ThreadLocal.withInitial { DecimalFormat("#.0G") } private val dfMBig = ThreadLocal.withInitial { DecimalFormat("#.#M") } @@ -42,23 +45,28 @@ private val dfN = ThreadLocal.withInitial { DecimalFormat("#") } * Formats a BigDecimal amount to human-readable format with G/M/K suffixes. * Returns empty string for null or very small amounts. * + * Amounts are rounded half-up to a whole number of the unit before the unit is picked, so + * 999,500 reads "1.0M" rather than "1000k". + * * Examples: - * - 1500 -> "1.5k" - * - 2500000 -> "2.5M" + * - 1500 -> "1500" + * - 12500 -> "13k" + * - 2500000 -> "3.0M" * - 10000000000 -> "10G" */ fun showAmount(amount: BigDecimal?): String { if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" + if (amount.abs() < MinDisplayableAmount) return "" + if (amount < TenKilo) return dfN.get()!!.format(amount) - return when { - amount >= TenGiga -> dfGBig.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= OneGiga -> dfGSmall.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) - amount >= TenMega -> dfMBig.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= OneMega -> dfMSmall.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) - amount >= TenKilo -> dfK.get()!!.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) - else -> dfN.get()!!.format(amount) - } + // `divide(_, 0, HALF_UP)`, not `div(_).setScale(0, HALF_UP)`: Kotlin's `div` already rounds + // to the dividend's scale with HALF_EVEN, so the HALF_UP never ran and 12,500 read "12k". + val kilos = amount.divide(OneKilo, 0, RoundingMode.HALF_UP) + if (kilos < OneKilo) return dfK.get()!!.format(kilos) + val megas = amount.divide(OneMega, 0, RoundingMode.HALF_UP) + if (megas < OneKilo) return (if (megas < BigDecimal.TEN) dfMSmall else dfMBig).get()!!.format(megas) + val gigas = amount.divide(OneGiga, 0, RoundingMode.HALF_UP) + return (if (gigas < BigDecimal.TEN) dfGSmall else dfGBig).get()!!.format(gigas) } /** @@ -67,7 +75,7 @@ fun showAmount(amount: BigDecimal?): String { */ fun showAmountWithZero(amount: BigDecimal?): String { if (amount == null) return "0" - if (amount.abs() < BigDecimal(0.01)) return "0" + if (amount.abs() < MinDisplayableAmount) return "0" return showAmount(amount) } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatterNoDecimals.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatterNoDecimals.kt index 5207240aef..8dc0ae07d1 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatterNoDecimals.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatterNoDecimals.kt @@ -46,14 +46,17 @@ private val dfN = fun showAmountInteger(amount: BigDecimal?): String { if (amount == null) return "" - if (amount.abs() < BigDecimal(0.01)) return "" + if (amount.abs() < MinDisplayableAmount) return "" + if (amount < TenKilo) return dfN.get()?.format(amount) ?: "" - return when { - amount >= OneGiga -> dfG.get()?.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) ?: "" - amount >= OneMega -> dfM.get()?.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) ?: "" - amount >= TenKilo -> dfK.get()?.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) ?: "" - else -> dfN.get()?.format(amount) ?: "" - } + // Round before picking the unit, so 999,500 reads "1M" rather than "1000k". + // `divide(_, 0, HALF_UP)` rather than `div(_).setScale(0, HALF_UP)`: Kotlin's `div` already + // rounds to the dividend's scale with HALF_EVEN, which left 12,500 as "12k". + val kilos = amount.divide(OneKilo, 0, RoundingMode.HALF_UP) + if (kilos < OneKilo) return dfK.get()?.format(kilos) ?: "" + val megas = amount.divide(OneMega, 0, RoundingMode.HALF_UP) + if (megas < OneKilo) return dfM.get()?.format(megas) ?: "" + return dfG.get()?.format(amount.divide(OneGiga, 0, RoundingMode.HALF_UP)) ?: "" } fun showAmountInteger(amount: Int?): String { @@ -64,7 +67,7 @@ fun showAmountInteger(amount: Int?): String { fun showAmountIntegerWithZero(amount: BigDecimal?): String { if (amount == null) return "0" - if (amount.abs() < BigDecimal(0.01)) return "0" + if (amount.abs() < MinDisplayableAmount) return "0" return showAmountInteger(amount) } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/SuspendableConfirmationTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/SuspendableConfirmationTest.kt new file mode 100644 index 0000000000..9befbfb67e --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/SuspendableConfirmationTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.service.upload + +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class SuspendableConfirmationTest { + @Test + fun confirmResumesTheWaiterAndClosesTheDialog() = + runTest { + val confirmation = SuspendableConfirmation() + val answer = async { confirmation.awaitConfirmation() } + runCurrent() + + assertNotNull(confirmation.state) + confirmation.state!!.onConfirm() + + assertEquals(true, answer.await()) + assertNull(confirmation.state) + } + + @Test + fun twoCallbacksFromTheSameDialogResumeOnlyOnce() = + runTest { + // A button tap and an outside-tap dismiss can both land before the dialog recomposes + // away. The second used to resume the continuation again and throw. + val confirmation = SuspendableConfirmation() + val answer = async { confirmation.awaitConfirmation() } + runCurrent() + + val callbacks = confirmation.state!! + callbacks.onConfirm() + callbacks.onCancel() + + assertEquals(true, answer.await()) + assertNull(confirmation.state) + } + + @Test + fun cancellingTheWaiterClosesTheDialog() = + runTest { + val confirmation = SuspendableConfirmation() + val answer = async { confirmation.awaitConfirmation() } + runCurrent() + assertNotNull(confirmation.state) + + answer.cancel() + runCurrent() + + assertNull(confirmation.state) + } + + @Test + fun aStaleCallbackDoesNotCloseTheNextDialog() = + runTest { + val confirmation = SuspendableConfirmation() + val first = async { confirmation.awaitConfirmation() } + runCurrent() + val firstCallbacks = confirmation.state!! + firstCallbacks.onCancel() + assertEquals(false, first.await()) + + val second = async { confirmation.awaitConfirmation() } + runCurrent() + firstCallbacks.onConfirm() + + assertNotNull(confirmation.state) + confirmation.state!!.onConfirm() + assertEquals(true, second.await()) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorDialogViewModelTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorDialogViewModelTest.kt new file mode 100644 index 0000000000..01092252ff --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/tor/TorDialogViewModelTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.tor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class TorDialogViewModelTest { + private fun dialogFor(settings: TorSettings) = TorDialogViewModel().apply { reset(settings) } + + @Test + fun externalSavesTheTypedPort() { + val dialog = dialogFor(TorSettings(torType = TorType.EXTERNAL, externalSocksPort = 9050)) + dialog.socksPortStr.value = "9150" + + assertEquals(9150, dialog.save().externalSocksPort) + } + + @Test + fun externalRejectsAPortThatIsNotANumberOrOutOfRange() { + val dialog = dialogFor(TorSettings(torType = TorType.EXTERNAL)) + + for (bad in listOf("90a0", "", "0", "70000", "-1")) { + dialog.socksPortStr.value = bad + assertThrows(IllegalArgumentException::class.java) { dialog.save() } + } + } + + @Test + fun aHiddenJunkPortDoesNotBlockSavingInternal() { + // The port field only shows for External. Typing junk there and then switching to + // Internal used to make every save fail with "invalid port", with the field hidden. + val dialog = dialogFor(TorSettings(torType = TorType.EXTERNAL, externalSocksPort = 9150)) + dialog.socksPortStr.value = "90a0" + dialog.torType.value = TorType.INTERNAL + + val saved = dialog.save() + + assertEquals(TorType.INTERNAL, saved.torType) + assertEquals(9150, saved.externalSocksPort) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisherTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisherTest.kt index d90d6a823a..40636cf461 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisherTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/DebouncedPublisherTest.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy @@ -103,6 +104,31 @@ class DebouncedPublisherTest { assertEquals(1, published) } + @Test + fun flushDuringAnInFlightPublishDoesNotRestartIt() = + runTest { + // A publish can sit for seconds waiting on a remote signer. Leaving the screen then + // flushes; that must not cancel the signing request and send a second one. + var started = 0 + var finished = 0 + val publisher = + publisher { + started++ + delay(5_000) + finished++ + } + + publisher.schedule() + advanceTimeBy(debounce + 100) + assertEquals(1, started) + + publisher.flush() + advanceUntilIdle() + + assertEquals(1, started) + assertEquals(1, finished) + } + @Test fun flushWithNothingPendingIsANoOp() = runTest { diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatterTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatterTest.kt new file mode 100644 index 0000000000..634aee9390 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatterTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigDecimal +import java.util.Locale + +class ZapFormatterTest { + private fun inEnglish(block: () -> T): T { + val previous = Locale.getDefault() + Locale.setDefault(Locale.US) + try { + return block() + } finally { + Locale.setDefault(previous) + } + } + + @Test + fun halvesRoundUpNotToEven() = + inEnglish { + // Kotlin's BigDecimal `div` rounds HALF_EVEN, so 12,500 used to read "12k". + assertEquals("13k", showAmountInteger(12_500)) + assertEquals("14k", showAmountInteger(13_500)) + assertEquals("3M", showAmountInteger(2_500_000)) + assertEquals("13k", showAmount(BigDecimal(12_500))) + } + + @Test + fun roundingUpToTheNextUnitSwitchesUnit() = + inEnglish { + assertEquals("999k", showAmountInteger(999_499)) + assertEquals("1M", showAmountInteger(999_500)) + assertEquals("1G", showAmountInteger(999_500_000)) + assertEquals("1.0M", showAmount(BigDecimal(999_500))) + } + + @Test + fun smallAmountsStayWhole() = + inEnglish { + assertEquals("0", showAmountInteger(0)) + assertEquals("9999", showAmountInteger(9_999)) + assertEquals("", showAmount(BigDecimal("0.001"))) + assertEquals("0", showAmountWithZero(BigDecimal("0.001"))) + } +} diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/audio/RecordingIndicators.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/audio/RecordingIndicators.kt index be2eb5eced..63b8a299b6 100644 --- a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/audio/RecordingIndicators.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/audio/RecordingIndicators.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.audio +import androidx.compose.animation.core.InfiniteTransition import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -29,6 +30,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -38,12 +40,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -63,113 +63,56 @@ fun ExpandingCirclesAnimation( modifier: Modifier = Modifier, isRecording: Boolean, primaryColor: Color = MaterialTheme.colorScheme.primary, +) { + // The composer shows this button all the time. An infinite transition asks for a frame on + // every vsync for as long as it is composed, so it only exists while recording. + if (isRecording) { + ExpandingCircles(modifier, primaryColor) + } +} + +@Composable +private fun ExpandingCircles( + modifier: Modifier, + primaryColor: Color, ) { val infiniteTransition = rememberInfiniteTransition(label = "expanding_circles") - // First circle animation - val scale1 by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 2.5f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "circle1_scale", - ) - - val alpha1 by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "circle1_alpha", - ) - - // Second circle animation (offset by 500ms) - val scale2 by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 2.5f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "circle2_scale", - ) - - val alpha2 by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, delayMillis = 500, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "circle2_alpha", - ) - - // Third circle animation (offset by 1000ms) - val scale3 by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = 2.5f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "circle3_scale", - ) - - val alpha3 by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1500, delayMillis = 1000, easing = LinearEasing), - repeatMode = RepeatMode.Restart, - ), - label = "circle3_alpha", - ) - - if (!isRecording) return - Box(modifier = modifier, contentAlignment = Alignment.Center) { - // Circle 1 - Box( - modifier = - Modifier - .matchParentSize() - .scale(scale1) - .alpha(alpha1) - .background(primaryColor.copy(alpha = 0.3f), CircleShape), - ) - - // Circle 2 - Box( - modifier = - Modifier - .matchParentSize() - .scale(scale2) - .alpha(alpha2) - .background(primaryColor.copy(alpha = 0.2f), CircleShape), - ) - - // Circle 3 - Box( - modifier = - Modifier - .matchParentSize() - .scale(scale3) - .alpha(alpha3) - .background(primaryColor.copy(alpha = 0.1f), CircleShape), - ) + ExpandingCircle(infiniteTransition, delayMillis = 0, color = primaryColor.copy(alpha = 0.3f), label = "circle1") + ExpandingCircle(infiniteTransition, delayMillis = 500, color = primaryColor.copy(alpha = 0.2f), label = "circle2") + ExpandingCircle(infiniteTransition, delayMillis = 1000, color = primaryColor.copy(alpha = 0.1f), label = "circle3") } } +@Composable +private fun BoxScope.ExpandingCircle( + transition: InfiniteTransition, + delayMillis: Int, + color: Color, + label: String, +) { + val spec = + infiniteRepeatable( + animation = tween(durationMillis = 1500, delayMillis = delayMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ) + val scale = transition.animateFloat(initialValue = 0f, targetValue = 2.5f, animationSpec = spec, label = "${label}_scale") + val alpha = transition.animateFloat(initialValue = 1f, targetValue = 0f, animationSpec = spec, label = "${label}_alpha") + + // Read in the draw phase so each frame redraws the layer instead of recomposing. + Box( + modifier = + Modifier + .matchParentSize() + .graphicsLayer { + scaleX = scale.value + scaleY = scale.value + this.alpha = alpha.value + }.background(color, CircleShape), + ) +} + /** * Floating recording indicator showing elapsed time */ @@ -218,16 +161,17 @@ fun FloatingRecordingIndicator( ) { // Pulsing stop square val infiniteTransition = rememberInfiniteTransition(label = "recording_stop") - val dotAlpha by infiniteTransition.animateFloat( - initialValue = 1f, - targetValue = 0.5f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 1000), - repeatMode = RepeatMode.Reverse, - ), - label = "dot_alpha", - ) + val dotAlpha = + infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 0.5f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 1000), + repeatMode = RepeatMode.Reverse, + ), + label = "dot_alpha", + ) Icon( symbol = MaterialSymbols.Stop, @@ -235,7 +179,7 @@ fun FloatingRecordingIndicator( tint = Color.White, modifier = Modifier - .alpha(dotAlpha) + .graphicsLayer { alpha = dotAlpha.value } .padding(end = 8.dp), ) diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamingStatusFlags.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamingStatusFlags.kt index 7528de89ad..cfc643fb71 100644 --- a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamingStatusFlags.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/nip53LiveActivities/ui/StreamingStatusFlags.kt @@ -112,16 +112,19 @@ fun OfflineFlag() { @Composable fun ScheduledFlag(starts: Long?) { + // Remembered: this sits on feed cards, and building a DateFormat loads locale data each time. val startsIn = - starts?.let { - if (it > TimeUtils.now()) { - SimpleDateFormat - .getDateTimeInstance( - DateFormat.SHORT, - DateFormat.SHORT, - ).format(Date(starts * 1000)) - } else { - null + remember(starts) { + starts?.let { + if (it > TimeUtils.now()) { + SimpleDateFormat + .getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT, + ).format(Date(starts * 1000)) + } else { + null + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 271312a224..4f35817288 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -116,6 +117,11 @@ fun ChessScreen( scope = scope, ) } + // The polling runs on this screen's scope, which outlives an account switch: stop the old + // account's view model when `remember` replaces it, or both keep polling. + DisposableEffect(viewModel) { + onDispose { viewModel.stopPolling() } + } val connectedRelays by relayManager.connectedRelays.collectAsState() val broadcastStatus by viewModel.broadcastStatus.collectAsState() val activeGames by viewModel.activeGames.collectAsState()