Merge branch 'main' into fix/tor-lifecycle-race

This commit is contained in:
Vitor Pamplona
2026-06-16 13:53:01 -04:00
committed by GitHub
19 changed files with 542 additions and 219 deletions
@@ -427,10 +427,19 @@ class AppModules(
// Connects the INostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
okHttpClientForRelays.getHttpClient(useTor)
}
OkHttpWebSocket.Builder(
httpClient = { url ->
val useTor = torEvaluatorFlow.shouldUseTorForRelay(url)
okHttpClientForRelays.getHttpClient(useTor)
},
// Don't dial Tor-routed relays until Tor's SOCKS port is up. Otherwise the
// whole Tor-routed relay set is hammered with doomed dials against the dead
// proxy during bootstrap. RelayProxyClientConnector reconnects them (with
// ignoreRetryDelays=true) the instant Tor flips to Active.
canDial = { url ->
!torEvaluatorFlow.shouldUseTorForRelay(url) || torManager.isSocksReady()
},
)
// Caches all events in Memory
val cache: LocalCache = LocalCache
@@ -133,12 +133,16 @@ class OkHttpWebSocket(
class Builder(
val httpClient: (NormalizedRelayUrl) -> OkHttpClient,
val canDial: (NormalizedRelayUrl) -> Boolean = { true },
) : WebsocketBuilder {
// Called when connecting.
override fun build(
url: NormalizedRelayUrl,
out: WebSocketListener,
) = OkHttpWebSocket(url, httpClient, out)
// Gates the dial — false skips it (e.g. a Tor-routed relay before Tor is ready).
override fun canConnect(url: NormalizedRelayUrl) = canDial(url)
}
override fun disconnect() {
@@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition
import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags
import com.vitorpamplona.amethyst.ui.note.nip22Comments.DisplayExternalId
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.PreloadThreadForReply
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.placeholderText
@@ -82,6 +83,10 @@ fun RenderTextEvent(
val noteEvent = note.event ?: return
if (unPackReply != ReplyRenderType.NONE) {
// Eagerly pull the rest of this reply's thread while it's on screen, so opening
// the conversation finds it already loaded. No-op when the note is a root itself.
PreloadThreadForReply(note, accountViewModel)
val canShowReply by
remember(note) {
derivedStateOf {
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -52,3 +53,24 @@ fun ThreadFilterAssemblerSubscription(
LifecycleAwareKeyDataSourceSubscription(state, filterAssembler)
}
/**
* Eagerly pre-loads the whole thread of a reply that is visible in a feed.
*
* When a reply shows up in `NoteCompose`, this opens the same root subscription the
* thread screen uses: `ThreadFilterSubAssembler` resolves the thread root from this
* id and subscribes to the root's `e`/`a` tag (covering NIP-10 and NIP-22 event /
* addressable roots), so tapping into the conversation finds it already loaded.
*
* Only replies are pre-loaded — a root post (empty `replyTo`) has no ancestor thread
* to pull, and pure quotes are excluded because citations don't populate `replyTo`.
*/
@Composable
fun PreloadThreadForReply(
note: Note,
accountViewModel: AccountViewModel,
) {
if (note.replyTo?.isNotEmpty() == true) {
ThreadFilterAssemblerSubscription(note.idHex, accountViewModel)
}
}
@@ -1,73 +0,0 @@
/*
* 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.ui.screen.loggedIn.workouts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ReactionsRow
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.UserCardHeader
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
@Composable
fun WorkoutCardCompose(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val event = (baseNote.event as? WorkoutRecordEvent) ?: return
Column(
modifier =
Modifier.fillMaxWidth().clickable {
routeFor(baseNote, accountViewModel.account)?.let { nav.nav(it) }
},
) {
UserCardHeader(baseNote, accountViewModel, nav)
WorkoutDisplay(baseNote)
if (event.content.isNotBlank()) {
Text(
text = event.content,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
ReactionsRow(
baseNote = baseNote,
showReactionDetail = true,
addPadding = true,
editState = null,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@@ -20,16 +20,18 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -37,6 +39,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
@@ -53,6 +56,7 @@ import com.vitorpamplona.quartz.experimental.fitness.workout.tags.Elevation
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.ExerciseType
import com.vitorpamplona.quartz.experimental.fitness.workout.tags.WeightTag
import kotlin.math.abs
import kotlin.math.round
fun ExerciseType?.symbol(): MaterialSymbol =
when (this) {
@@ -95,18 +99,39 @@ private fun paceMinPerUnit(
return "${secondsPerUnit / 60}:${(secondsPerUnit % 60).toString().padStart(2, '0')}"
}
/** Average speed, more natural than pace for wheeled/water sports. Returns e.g. `24.5 km/h` or `15.2 mph`. */
private fun speed(
durationSeconds: Long,
distance: DistanceTag,
): String {
val hours = durationSeconds / 3600.0
return if (distance.unit == DistanceTag.MILES) {
"${trimToOneDecimal(distance.value / hours)} mph"
} else {
"${trimToOneDecimal(distance.toKilometers() / hours)} km/h"
}
}
private fun trimToOneDecimal(value: Double): String {
val rounded = round(value * 10.0) / 10.0
return rounded.trimmed()
}
/** One-shot snapshot of the parsed workout tags, so the feed doesn't re-scan the tag array on every recomposition. */
@Immutable
class WorkoutInfo(
val title: String?,
val type: ExerciseType?,
val exerciseRaw: String?,
val source: String?,
val durationSeconds: Long?,
val distance: DistanceTag?,
val elevationGain: Elevation?,
val elevationLoss: Elevation?,
val calories: Int?,
val steps: Int?,
val avgHeartRate: Int?,
val maxHeartRate: Int?,
val sets: Int?,
val reps: Int?,
val weight: WeightTag?,
@@ -117,12 +142,15 @@ class WorkoutInfo(
title = event.title(),
type = event.exerciseType(),
exerciseRaw = event.exercise(),
source = event.workoutSource(),
durationSeconds = event.durationSeconds(),
distance = event.distance(),
elevationGain = event.elevationGain(),
elevationLoss = event.elevationLoss(),
calories = event.calories(),
steps = event.steps(),
avgHeartRate = event.avgHeartRate(),
maxHeartRate = event.maxHeartRate(),
sets = event.sets(),
reps = event.reps(),
weight = event.weight(),
@@ -130,6 +158,16 @@ class WorkoutInfo(
}
}
/** Which metric is promoted to the hero number, so the grid below can skip repeating it. */
private enum class HeroKind { DISTANCE, STEPS, DURATION, NONE }
/** A single secondary metric: a bold value over a muted label. */
@Immutable
private class Stat(
val value: String,
val label: String,
)
@Composable
fun WorkoutDisplay(baseNote: Note) {
val event = (baseNote.event as? WorkoutRecordEvent) ?: return
@@ -137,18 +175,58 @@ fun WorkoutDisplay(baseNote: Note) {
val info = remember(baseNote) { WorkoutInfo.from(event) }
val typeLabel = info.type?.let { stringRes(it.labelRes()) } ?: info.exerciseRaw ?: stringRes(R.string.workout)
val duration = info.durationSeconds
val distance = info.distance
val steps = info.steps
val heroKind =
when {
distance != null && distance.value > 0.0 -> HeroKind.DISTANCE
steps != null -> HeroKind.STEPS
duration != null -> HeroKind.DURATION
else -> HeroKind.NONE
}
val secondaryStats =
buildSecondaryStats(
info = info,
heroKind = heroKind,
durationLabel = stringRes(R.string.workout_duration),
distanceLabel = stringRes(R.string.workout_distance),
paceLabel = stringRes(R.string.workout_pace),
speedLabel = stringRes(R.string.workout_speed),
elevationGainLabel = stringRes(R.string.workout_elevation),
elevationLossLabel = stringRes(R.string.workout_elevation_loss),
caloriesLabel = stringRes(R.string.workout_calories),
stepsLabel = stringRes(R.string.workout_steps),
heartRateLabel = stringRes(R.string.workout_heart_rate),
maxHeartRateLabel = stringRes(R.string.workout_max_heart_rate),
setsLabel = stringRes(R.string.workout_sets),
repsLabel = stringRes(R.string.workout_reps),
weightLabel = stringRes(R.string.workout_weight),
)
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = info.type.symbol(),
contentDescription = typeLabel,
modifier = Modifier.size(28.dp),
tint = MaterialTheme.colorScheme.primary,
)
Box(
modifier =
Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = info.type.symbol(),
contentDescription = typeLabel,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(10.dp))
Column {
Column(modifier = Modifier.weight(1f)) {
Text(
text = info.title ?: typeLabel,
fontWeight = FontWeight.Bold,
@@ -162,73 +240,162 @@ fun WorkoutDisplay(baseNote: Note) {
)
}
}
info.source?.let {
Spacer(modifier = Modifier.width(8.dp))
WorkoutSourceBadge(it)
}
}
WorkoutStatsRow(info)
when (heroKind) {
HeroKind.DISTANCE ->
WorkoutHero(distance!!.value.trimmed(), distance.unit, stringRes(R.string.workout_distance))
HeroKind.STEPS ->
WorkoutHero(steps!!.toString(), null, stringRes(R.string.workout_steps))
HeroKind.DURATION ->
WorkoutHero(DurationTag.formatTime(duration!!), null, stringRes(R.string.workout_duration))
HeroKind.NONE -> {}
}
WorkoutStatsGrid(secondaryStats)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun WorkoutStatsRow(info: WorkoutInfo) {
/** Builds the ordered list of secondary metrics, skipping whichever one is shown as the hero. */
private fun buildSecondaryStats(
info: WorkoutInfo,
heroKind: HeroKind,
durationLabel: String,
distanceLabel: String,
paceLabel: String,
speedLabel: String,
elevationGainLabel: String,
elevationLossLabel: String,
caloriesLabel: String,
stepsLabel: String,
heartRateLabel: String,
maxHeartRateLabel: String,
setsLabel: String,
repsLabel: String,
weightLabel: String,
): List<Stat> {
val duration = info.durationSeconds
val distance = info.distance
FlowRow(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
) {
duration?.let {
WorkoutStat(DurationTag.formatTime(it), stringRes(R.string.workout_duration))
return buildList {
if (heroKind != HeroKind.DURATION) {
duration?.let { add(Stat(DurationTag.formatTime(it), durationLabel)) }
}
distance?.let {
WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_distance))
if (heroKind != HeroKind.DISTANCE) {
distance?.let { add(Stat("${it.value.trimmed()} ${it.unit}", distanceLabel)) }
}
if (duration != null && distance != null && distance.value > 0.0) {
WorkoutStat(
"${paceMinPerUnit(duration, distance.value)} /${distance.unit}",
stringRes(R.string.workout_pace),
// Cycling is conventionally reported as speed; running/walking/etc. as pace.
if (info.type == ExerciseType.CYCLING) {
add(Stat(speed(duration, distance), speedLabel))
} else {
add(Stat("${paceMinPerUnit(duration, distance.value)} /${distance.unit}", paceLabel))
}
}
info.elevationGain?.let { add(Stat("${it.value.trimmed()} ${it.unit}", elevationGainLabel)) }
info.elevationLoss?.let { add(Stat("${it.value.trimmed()} ${it.unit}", elevationLossLabel)) }
info.calories?.let { add(Stat("$it kcal", caloriesLabel)) }
if (heroKind != HeroKind.STEPS) {
info.steps?.let { add(Stat("$it", stepsLabel)) }
}
info.avgHeartRate?.let { add(Stat("$it bpm", heartRateLabel)) }
info.maxHeartRate?.let { add(Stat("$it bpm", maxHeartRateLabel)) }
info.sets?.let { add(Stat("$it", setsLabel)) }
info.reps?.let { add(Stat("$it", repsLabel)) }
info.weight?.let { add(Stat("${it.value.trimmed()} ${it.unit}", weightLabel)) }
}
}
/** The headline metric, shown large above the secondary grid (e.g. `5.2 km`). */
@Composable
private fun WorkoutHero(
value: String,
unit: String?,
label: String,
) {
Column(modifier = Modifier.padding(top = 10.dp)) {
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = value,
fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.primary,
)
unit?.let {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = it,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.placeholderText,
modifier = Modifier.padding(bottom = 6.dp),
)
}
}
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.placeholderText,
)
}
}
info.elevationGain?.let {
WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_elevation))
}
/** Fixed-column grid so secondary metrics line up in tidy columns instead of free-flowing. */
@Composable
private fun WorkoutStatsGrid(
stats: List<Stat>,
columns: Int = 3,
) {
if (stats.isEmpty()) return
info.calories?.let {
WorkoutStat("$it kcal", stringRes(R.string.workout_calories))
}
info.steps?.let {
WorkoutStat("$it", stringRes(R.string.workout_steps))
}
info.avgHeartRate?.let {
WorkoutStat("$it bpm", stringRes(R.string.workout_heart_rate))
}
info.sets?.let {
WorkoutStat("$it", stringRes(R.string.workout_sets))
}
info.reps?.let {
WorkoutStat("$it", stringRes(R.string.workout_reps))
}
info.weight?.let {
WorkoutStat("${it.value.trimmed()} ${it.unit}", stringRes(R.string.workout_weight))
Column(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
stats.chunked(columns).forEach { rowStats ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
rowStats.forEach { stat ->
WorkoutStat(stat.value, stat.label, Modifier.weight(1f))
}
// Pad the last row so columns stay aligned across rows.
repeat(columns - rowStats.size) {
Spacer(modifier = Modifier.weight(1f))
}
}
}
}
}
/** Small chip showing how the workout was recorded (e.g. GPS, RUNSTR, HEALTHKIT, MANUAL). */
@Composable
private fun WorkoutSourceBadge(source: String) {
Text(
text = source.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary,
modifier =
Modifier
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
@Composable
private fun WorkoutStat(
value: String,
label: String,
modifier: Modifier = Modifier,
) {
Column {
Column(modifier = modifier) {
Text(
text = value,
fontWeight = FontWeight.Bold,
@@ -1,75 +0,0 @@
/*
* 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.ui.screen.loggedIn.workouts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.ui.layouts.rememberFeedContentPadding
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
@Composable
fun WorkoutFeedLoaded(
loaded: FeedState.Loaded,
listState: LazyListState,
accountViewModel: AccountViewModel,
nav: INav,
) {
val items by loaded.feed.collectAsStateWithLifecycle()
LazyColumn(
contentPadding = rememberFeedContentPadding(FeedPadding),
state = listState,
) {
itemsIndexed(
items.list,
key = { _, item -> item.idHex },
contentType = { _, item -> item.event?.kind ?: -1 },
) { _, item ->
if (item.event is WorkoutRecordEvent) {
WorkoutCardCompose(
baseNote = item,
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider(
thickness = DividerThickness,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
}
}
@@ -89,14 +89,6 @@ fun WorkoutsScreen(
listState = listState,
nav = nav,
routeForLastRead = "WorkoutsFeed",
onLoaded = { loaded ->
WorkoutFeedLoaded(
loaded = loaded,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
},
)
}
}
@@ -3274,4 +3274,7 @@
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Umění</string>
<string name="nowhere_link_card_forum">Nowhere Fórum</string>
<string name="workout_elevation_loss">Klesání</string>
<string name="workout_max_heart_rate">Maximální tepová frekvence</string>
<string name="workout_speed">Rychlost</string>
</resources>
@@ -3213,4 +3213,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Kunst</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
<string name="workout_elevation_loss">Höhenverlust</string>
<string name="workout_max_heart_rate">Maximale Herzfrequenz</string>
<string name="workout_speed">Geschwindigkeit</string>
</resources>
@@ -3208,4 +3208,7 @@
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Arte</string>
<string name="nowhere_link_card_forum">Nowhere Fórum</string>
<string name="workout_elevation_loss">Perda de elevação</string>
<string name="workout_max_heart_rate">Frequência cardíaca máxima</string>
<string name="workout_speed">Velocidade</string>
</resources>
@@ -3207,4 +3207,7 @@
<string name="nowhere_link_card_drop">Nowhere Drop</string>
<string name="nowhere_link_card_art">Nowhere Konst</string>
<string name="nowhere_link_card_forum">Nowhere Forum</string>
<string name="workout_elevation_loss">Höjdminskning</string>
<string name="workout_max_heart_rate">Maxpuls</string>
<string name="workout_speed">Hastighet</string>
</resources>
+4 -1
View File
@@ -687,10 +687,13 @@
<string name="workout_duration">Duration</string>
<string name="workout_distance">Distance</string>
<string name="workout_pace">Pace</string>
<string name="workout_elevation">Elevation</string>
<string name="workout_speed">Speed</string>
<string name="workout_elevation">Elevation gain</string>
<string name="workout_elevation_loss">Elevation loss</string>
<string name="workout_calories">Calories</string>
<string name="workout_steps">Steps</string>
<string name="workout_heart_rate">Heart rate</string>
<string name="workout_max_heart_rate">Max heart rate</string>
<string name="workout_sets">Sets</string>
<string name="workout_reps">Reps</string>
<string name="workout_weight">Weight</string>
@@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.concurrent.Volatile
/**
* Per-account, durable record of relay liveness used to drive the "unhealthy relay" review UI.
@@ -55,6 +56,10 @@ class RelayHealthStore(
private val persistence: RelayHealthPersistence,
private val torEnabledProvider: () -> Boolean = { false },
parentScope: CoroutineScope? = null,
// Caller-supplied dispatcher used for both classification and persistence I/O.
// Default is `Dispatchers.Default` so commonMain stays iOS-compatible — JVM hosts
// (Android/Desktop) should pass `Dispatchers.IO` so `prefs.flush()` doesn't sit on
// a CPU-bound worker.
private val ioDispatcher: CoroutineDispatcher = Dispatchers.Default,
) {
companion object {
@@ -84,8 +89,11 @@ class RelayHealthStore(
private val _unhealthy = MutableStateFlow<PersistentList<UnhealthyRelay>>(persistentListOf())
val unhealthy: StateFlow<PersistentList<UnhealthyRelay>> = _unhealthy.asStateFlow()
private var persistJob: Job? = null
private var tickJob: Job? = null
@Volatile private var persistJob: Job? = null
@Volatile private var tickJob: Job? = null
@Volatile private var closed = false
init {
// Persist the firstScanAt seed if we just stamped it.
@@ -214,20 +222,38 @@ class RelayHealthStore(
}
private fun schedulePersist() {
if (closed) return
persistJob?.cancel()
persistJob =
scope.launch {
delay(PERSIST_DEBOUNCE_MS)
val snapshot = state.value
runCatching { persistence.save(snapshot) }
withContext(ioDispatcher) {
runCatching { persistence.save(snapshot) }
}
}
}
/**
* Tear down internal jobs and fire the final persist off-thread. Safe to call from
* the composition / Main thread: the blocking I/O is dispatched to [ioDispatcher]
* on a detached, self-cancelling scope so the last debounce window isn't lost when
* the parent composition scope is about to cancel.
*/
fun close() {
// Flush pending writes synchronously before tearing down.
if (closed) return
closed = true
persistJob?.cancel()
runCatching { persistence.save(state.value) }
tickJob?.cancel()
val finalSnapshot = state.value
val flushScope = CoroutineScope(SupervisorJob() + ioDispatcher)
flushScope.launch {
try {
runCatching { persistence.save(finalSnapshot) }
} finally {
flushScope.cancel()
}
}
if (ownsScope) scope.cancel()
}
}
@@ -0,0 +1,130 @@
/*
* 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.relays.health
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlin.concurrent.Volatile
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@OptIn(ExperimentalCoroutinesApi::class)
class RelayHealthStoreCloseTest {
private class CountingPersistence : RelayHealthPersistence {
@Volatile var saves: Int = 0
@Volatile var lastSnapshot: RelayHealthSnapshot? = null
override fun load(): RelayHealthSnapshot = RelayHealthSnapshot()
override fun save(snapshot: RelayHealthSnapshot) {
saves++
lastSnapshot = snapshot
}
}
private val url = RelayUrlNormalizer.normalizeOrNull("wss://example.com")!!
@Test
fun close_is_idempotent() =
runTest {
val persistence = CountingPersistence()
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val store =
RelayHealthStore(
persistence = persistence,
parentScope = scope,
ioDispatcher = dispatcher,
)
store.close()
store.close()
store.close()
advanceUntilIdle()
// First close runs a fire-and-forget final save; later closes are no-ops.
assertEquals(1, persistence.saves)
scope.cancel()
}
@Test
fun recordIncoming_after_close_does_not_schedule_persist() =
runTest {
val persistence = CountingPersistence()
val dispatcher = StandardTestDispatcher(testScheduler)
val scope = CoroutineScope(SupervisorJob() + dispatcher)
val store =
RelayHealthStore(
persistence = persistence,
parentScope = scope,
ioDispatcher = dispatcher,
)
// NB: the store's init launches an always-on `while(true){ reclassify(); delay(60s) }`
// ticker on this shared test scheduler. advanceUntilIdle() would chase that periodic
// delay forever (livelock). Advance just past the persist debounce instead so init's
// debounced save fires while the ticker stays parked at its 60s mark.
advanceTimeBy(RelayHealthStore.PERSIST_DEBOUNCE_MS + 1)
runCurrent()
val baseline = persistence.saves
store.close()
advanceUntilIdle()
val afterClose = persistence.saves
assertTrue(afterClose >= baseline + 1, "close should run the final save")
store.recordIncoming(url, atSeconds = 1_700_000_000L)
advanceUntilIdle()
// closed → schedulePersist short-circuits, no extra save.
assertEquals(afterClose, persistence.saves)
scope.cancel()
}
@Test
fun close_with_internal_scope_owns_its_lifecycle() =
runTest {
val persistence = CountingPersistence()
val store =
RelayHealthStore(
persistence = persistence,
parentScope = null, // store owns its scope
ioDispatcher = Dispatchers.Default,
)
// Sanity: schedulePersist on internal scope worked.
store.recordConnect(url, atSeconds = 1_700_000_000L)
store.close()
// Idempotent + does not throw.
store.close()
}
}
@@ -839,6 +839,18 @@ fun App(
relayManager.startMetricsSnapshot(this)
}
// Detect host-machine sleep/wake: after a long delay overshoot the OkHttp
// sockets we held are dead even though needsToReconnect() still reads false,
// so force a hard disconnect+connect. See SleepResumeMonitor.kt.
LaunchedEffect(relayManager) {
com.vitorpamplona.amethyst.desktop.network.runSleepResumeMonitor {
relayManager.client.reconnect(
onlyIfChanged = false,
ignoreRetryDelays = true,
)
}
}
// Subscriptions coordinator — uses default relay URLs for metadata indexing.
// Feed subscriptions (inside MainContent) drive actual relay pool connections.
val subscriptionsCoordinator =
@@ -1441,6 +1453,8 @@ fun MainContent(
torStateForHealth.settings.torType != com.vitorpamplona.amethyst.commons.tor.TorType.OFF
},
parentScope = scope,
// `prefs.flush()` is blocking — keep it off the composition scope's Main dispatcher.
ioDispatcher = kotlinx.coroutines.Dispatchers.IO,
)
}
DisposableEffect(relayHealthStore, relayManager) {
@@ -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.desktop.network
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlin.coroutines.coroutineContext
/**
* Detects host-machine sleep/wake transitions by watching wall-clock overshoot
* of a tight delay loop. When the OS suspends the JVM, [delay] returns far past
* its scheduled deadline; the OkHttp websocket connections we held are dead by
* then even though [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient]
* still reports `isConnected() == true` until the next ping fails so the
* standard keep-alive reconnect path is a no-op and the offline banner stays
* stuck until a manual reload.
*
* Lives here in the desktop app (per Vitor's review of #3221) instead of the
* cross-platform NostrClient because sleep/wake semantics differ across
* platforms Android has Doze + network change broadcasts, iOS has app
* lifecycle events, and macOS/Linux/Windows desktops can grow real OS-level
* sleep hooks here later (NSWorkspace notifications, D-Bus PrepareForSleep,
* WM_POWERBROADCAST) without touching Quartz.
*
* Real OS sleep events would be more precise, but the wall-clock heuristic
* needs zero native deps and catches the symptom for v1.
*/
suspend fun runSleepResumeMonitor(
intervalMs: Long = DEFAULT_INTERVAL_MS,
wakeThresholdMs: Long = DEFAULT_WAKE_THRESHOLD_MS,
nowMs: () -> Long = { System.currentTimeMillis() },
onWake: () -> Unit,
) {
var lastTickMs = nowMs()
while (coroutineContext.isActive) {
delay(intervalMs)
val now = nowMs()
val elapsed = now - lastTickMs
lastTickMs = now
if (elapsed > wakeThresholdMs) onWake()
}
}
private const val DEFAULT_INTERVAL_MS: Long = 60_000L
// 5x the tick — wide enough to ignore GC stalls / brief scheduler hiccups, tight
// enough to recover quickly after a real sleep.
private const val DEFAULT_WAKE_THRESHOLD_MS: Long = 5 * DEFAULT_INTERVAL_MS
@@ -103,6 +103,12 @@ open class BasicRelayClient(
override fun needsToReconnect() = socket?.needsReconnect() ?: true
override fun connect() {
// Transport gate: skip the dial when the builder reports this relay's transport
// isn't ready (e.g. a Tor-routed relay while Tor's SOCKS port isn't up). Returning
// here before the mutex/socket/onConnecting means no doomed dial and no backoff
// growth; a later reconnect pass (fired when the transport becomes ready) will dial.
if (!socketBuilder.canConnect(url)) return
// If there is a connection, don't wait.
if (connectingMutex.exchange(true)) {
return
@@ -27,4 +27,19 @@ interface WebsocketBuilder {
url: NormalizedRelayUrl,
out: WebSocketListener,
): WebSocket
/**
* Whether the transport for [url] is ready to dial right now. Returning false makes
* [com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient.connect]
* skip the dial entirely no socket, no backoff growth until a later reconnect pass
* finds it ready.
*
* The motivating case: a Tor-routed relay while Tor's SOCKS proxy isn't up yet. Without
* this gate the pool hammers the dead proxy with doomed dials during the whole Tor
* bootstrap window. The caller is responsible for re-triggering a reconnect once the
* transport becomes ready (e.g. on the Tor status flipping to Active).
*
* Defaults to true so non-proxied builders need no change.
*/
fun canConnect(url: NormalizedRelayUrl): Boolean = true
}