mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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.
|
||||
|
||||
+3
-1
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
+16
-9
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -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) {
|
||||
|
||||
+10
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
|
||||
+7
-1
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+20
-12
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+11
-8
@@ -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)
|
||||
}
|
||||
|
||||
+95
@@ -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())
|
||||
}
|
||||
}
|
||||
+61
@@ -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)
|
||||
}
|
||||
}
|
||||
+26
@@ -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 {
|
||||
|
||||
+66
@@ -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")))
|
||||
}
|
||||
}
|
||||
+58
-114
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
+12
-9
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user