fix: audit findings in the code moved to commons

Pre-existing bugs found while auditing the moved files, none introduced
by the move:

- showAmount/showAmountInteger rounded half-even (Kotlin's BigDecimal
  div) so 12,500 read "12k", and picked the unit before rounding so
  999,500 read "1000k".
- AgentWorkBoard.merge ordered every band by upvotes, not just the queue.
- SuspendableConfirmation could resume twice (tap + dismiss) and left a
  dead dialog when the upload was cancelled.
- TorDialogViewModel.save rejected Internal/Off over a hidden, invalid
  port, and accepted out-of-range ports for External.
- ExpandingCirclesAnimation ran an infinite transition whenever the
  composer was open; the circles now exist only while recording and
  animate in the draw phase.
- DebouncedPublisher.flush cancelled and restarted a publish already in
  flight (a second signer request).
- RelayFeedViewModel read the wrong note for the DM list's first
  emission, and compared the flow instead of its value.
- ScheduledFlag built a DateFormat per composition; Cashu discovery
  progress could step backwards; Desktop's chess view model kept
  polling after an account switch replaced it.

New tests: ZapFormatterTest, SuspendableConfirmationTest,
TorDialogViewModelTest, and a DebouncedPublisher case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkG4KTqmnprUK9BAcwAyc8
This commit is contained in:
Claude
2026-09-27 14:18:07 +00:00
parent 90e346a0e5
commit 8187028c35
16 changed files with 483 additions and 161 deletions
@@ -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.
@@ -132,7 +132,9 @@ object AgentWorkBoard {
(runs.map { from(it) } + jobs.map { from(it) })
.sortedWith(
compareBy<AgentWorkItem> { 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 },
)
}
@@ -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 }
}
}
}
@@ -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) {
@@ -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()
}
}
@@ -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()
}
@@ -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)
}
},
)
}
@@ -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)
}
@@ -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)
}
@@ -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())
}
}
@@ -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)
}
}
@@ -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 {
@@ -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 <T> 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")))
}
}
@@ -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<Float>(
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),
)
@@ -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
}
}
}
@@ -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()