mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
Merge branch 'main' into fix/tor-lifecycle-race
This commit is contained in:
@@ -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 {
|
||||
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
-73
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+220
-53
@@ -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,
|
||||
|
||||
-75
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-8
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+31
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+130
@@ -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) {
|
||||
|
||||
+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.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
|
||||
+6
@@ -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
|
||||
|
||||
+15
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user