From f759ef6ca7e48b38a569b3e35a30b218db3d183e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:47:02 +0000 Subject: [PATCH] feat: modernize Media Servers settings with reorderable priority list Redesigns the Media Servers screen around the fact that Blossom uploads mirror in list order, plus a lightweight reachability check per server. - Split the screen into "Servers" / "Local cache" segmented tabs, moving the local-cache switches into their own tab to declutter the list. - Make the server list drag-to-reorder using the shared RelayDragState utility; list order is the upload/fallback priority (row #1 first). - Add a rank badge per row (accent-filled for the primary target) and a reorder hint. - Add a per-server health dot (Online / Slow / Offline / Checking) backed by a one-shot HEAD probe through the account's Tor-aware preview client. - Add moveServer() + health StateFlow to BlossomServersViewModel; probe on load and when a server is added. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA --- .../mediaServers/AllMediaServersLIstView.kt | 382 ++++++++++++------ .../mediaServers/AllMediaServersScreen.kt | 56 ++- .../mediaServers/BlossomServersViewModel.kt | 37 ++ .../actions/mediaServers/MediaServerHealth.kt | 98 +++++ amethyst/src/main/res/values/strings.xml | 8 + 5 files changed, 442 insertions(+), 139 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 956c24ff62..59de9d8df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -20,15 +20,20 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +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.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.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton @@ -37,6 +42,10 @@ 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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -44,6 +53,10 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding @@ -51,16 +64,28 @@ import com.vitorpamplona.amethyst.ui.theme.FeedPadding import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText +import com.vitorpamplona.amethyst.ui.theme.warningColor @Composable -fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { +fun AllMediaBody( + blossomServersViewModel: BlossomServersViewModel, + modifier: Modifier = Modifier, +) { val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle() + val healthState by blossomServersViewModel.health.collectAsStateWithLifecycle() + + val dragState = + rememberRelayDragState( + onMove = { from, to -> blossomServersViewModel.moveServer(from, to) }, + itemCount = { blossomServersState.size }, + ) LazyColumn( - verticalArrangement = Arrangement.SpaceAround, - horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, contentPadding = FeedPadding, + userScrollEnabled = !dragState.isDragging, ) { item { SettingsCategory( @@ -70,52 +95,70 @@ fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { ) } - renderMediaServerList( - mediaServersState = blossomServersState, - keyType = "blossom", - editLabel = R.string.add_a_blossom_server, - emptyLabel = R.string.no_blossom_server_message, - onAddServer = { server -> - blossomServersViewModel.addServer(server) - }, - onDeleteServer = { - blossomServersViewModel.removeServer(serverUrl = it) - }, - ) - - DEFAULT_MEDIA_SERVERS.let { + if (blossomServersState.isEmpty()) { item { - SettingsCategoryWithButton( - title = R.string.recommended_media_servers, - description = R.string.built_in_servers_description, - modifier = SettingsCategorySpacingModifier, + Text( + text = stringRes(id = R.string.no_blossom_server_message), + modifier = DoubleVertPadding, + ) + } + } else { + item { + Text( + text = stringRes(id = R.string.media_servers_reorder_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) + } + + itemsIndexed( + blossomServersState, + key = { _, server -> "blossom" + server.baseUrl }, + ) { index, entry -> + MediaServerRow( + index = index, + serverEntry = entry, + health = healthState[entry.baseUrl] ?: ServerHealth.Unknown, + dragState = dragState, + onDelete = { blossomServersViewModel.removeServer(serverUrl = it) }, + ) + } + } + + item { + Spacer(modifier = StdVertSpacer) + MediaServerEditField(R.string.add_a_blossom_server) { + blossomServersViewModel.addServer(it) + } + } + + item { + SettingsCategoryWithButton( + title = R.string.recommended_media_servers, + description = R.string.built_in_servers_description, + modifier = SettingsCategorySpacingModifier, + ) { + OutlinedButton( + onClick = { + blossomServersViewModel.addServerList( + DEFAULT_MEDIA_SERVERS.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, + ) + }, ) { - OutlinedButton( - onClick = { - blossomServersViewModel.addServerList( - it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, - ) - }, - ) { - Text(text = stringRes(id = R.string.use_default_servers)) - } + Text(text = stringRes(id = R.string.use_default_servers)) } } - itemsIndexed( - it, - key = { _: Int, server: ServerName -> - "Proposed" + server.baseUrl - }, - ) { _, server -> - MediaServerEntry( - serverEntry = server, - isAmethystDefault = true, - onAddOrDelete = { serverUrl -> - if (server.type == ServerType.Blossom) { - blossomServersViewModel.addServer(serverUrl) - } - }, - ) + } + + itemsIndexed( + DEFAULT_MEDIA_SERVERS, + key = { _, server -> "Proposed" + server.baseUrl }, + ) { _, server -> + RecommendedServerRow(serverEntry = server) { + if (server.type == ServerType.Blossom) { + blossomServersViewModel.addServer(server.baseUrl) + } } } @@ -125,97 +168,186 @@ fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { } } -fun LazyListScope.renderMediaServerList( - mediaServersState: List, - keyType: String, - editLabel: Int, - emptyLabel: Int, - onAddServer: (String) -> Unit, - onDeleteServer: (String) -> Unit, -) { - if (mediaServersState.isEmpty()) { - item { - Text( - text = stringRes(id = emptyLabel), - modifier = DoubleVertPadding, - ) - } - } else { - itemsIndexed( - mediaServersState, - key = { _: Int, server: ServerName -> - keyType + server.baseUrl - }, - ) { _, entry -> - MediaServerEntry( - serverEntry = entry, - onAddOrDelete = { - onDeleteServer(it) - }, - ) - } - } - - item { - Spacer(modifier = StdVertSpacer) - MediaServerEditField(editLabel) { - onAddServer(it) - } - } -} - +/** + * A draggable, ranked server row. Position in the list is the upload/fallback + * priority (row #1 is tried first), so each row carries a rank badge and a drag + * handle wired into the shared [RelayDragState]. + */ @Composable -fun MediaServerEntry( - modifier: Modifier = Modifier, +fun MediaServerRow( + index: Int, serverEntry: ServerName, - isAmethystDefault: Boolean = false, - onAddOrDelete: (serverUrl: String) -> Unit, + health: ServerHealth, + dragState: RelayDragState, + onDelete: (serverUrl: String) -> Unit, ) { Row( modifier = - modifier + Modifier .fillMaxWidth() - .padding(vertical = 10.dp), + .draggableRelayItem(index, dragState) + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceAround, ) { + Icon( + symbol = MaterialSymbols.DragIndicator, + contentDescription = stringRes(id = R.string.media_server_reorder), + modifier = Modifier.size(24.dp).relayDragHandle(index, dragState), + tint = MaterialTheme.colorScheme.grayText, + ) + + RankBadge(rank = index + 1) + Column( - modifier = - Modifier - .weight(1f), + modifier = Modifier.weight(1f).padding(start = 12.dp), ) { - serverEntry.let { - Text( - text = it.name.replaceFirstChar(Char::titlecase), - style = MaterialTheme.typography.bodyLarge, - ) - Spacer(modifier = StdVertSpacer) - Text( - text = it.baseUrl, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.grayText, - ) - } + Text( + text = serverEntry.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdVertSpacer) + Text( + text = serverEntry.baseUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } - Row( - horizontalArrangement = Arrangement.End, - ) { - IconButton( - onClick = { - onAddOrDelete(serverEntry.baseUrl) - }, - ) { - Icon( - symbol = if (isAmethystDefault) MaterialSymbols.Add else MaterialSymbols.Delete, - contentDescription = - if (isAmethystDefault) { - stringRes(id = R.string.add_media_server) - } else { - stringRes(id = R.string.delete_media_server) - }, - ) - } + HealthIndicator(health) + + IconButton(onClick = { onDelete(serverEntry.baseUrl) }) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(id = R.string.delete_media_server), + tint = MaterialTheme.colorScheme.grayText, + ) } } } + +/** A recommended default server, added on tap of the trailing "+". */ +@Composable +fun RecommendedServerRow( + serverEntry: ServerName, + onAdd: (serverUrl: String) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = serverEntry.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = StdVertSpacer) + Text( + text = serverEntry.baseUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + IconButton(onClick = { onAdd(serverEntry.baseUrl) }) { + Icon( + symbol = MaterialSymbols.Add, + contentDescription = stringRes(id = R.string.add_media_server), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +/** Rounded rank badge. The primary target (#1) is filled with the accent color. */ +@Composable +private fun RankBadge(rank: Int) { + val isPrimary = rank == 1 + Box( + modifier = + Modifier + .size(26.dp) + .clip(RoundedCornerShape(8.dp)) + .background( + if (isPrimary) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = rank.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = + if (isPrimary) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } +} + +/** Colored reachability dot + label, or a spinner while a probe is in flight. */ +@Composable +private fun HealthIndicator(health: ServerHealth) { + if (health == ServerHealth.Unknown) return + + if (health == ServerHealth.Checking) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.grayText, + ) + return + } + + val color: Color + val label: Int + when (health) { + ServerHealth.Online -> { + color = MaterialTheme.colorScheme.allGoodColor + label = R.string.media_server_status_online + } + ServerHealth.Slow -> { + color = MaterialTheme.colorScheme.warningColor + label = R.string.media_server_status_slow + } + else -> { + color = MaterialTheme.colorScheme.error + label = R.string.media_server_status_offline + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + modifier = + Modifier + .size(9.dp) + .clip(CircleShape) + .background(color), + ) + Text( + text = stringRes(id = label), + style = MaterialTheme.typography.labelSmall, + color = color, + maxLines = 1, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index 25bd2eeb09..9930d0738f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -28,18 +28,24 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -101,25 +107,47 @@ fun MediaServersScaffold( bottom = padding.calculateBottomPadding(), ).consumeWindowInsets(padding) .imePadding(), - verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top), - horizontalAlignment = Alignment.CenterHorizontally, ) { - Text( - text = stringRes(id = R.string.set_preferred_media_servers), - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 10.dp), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.grayText, - ) + var selectedTab by remember { mutableIntStateOf(TAB_SERVERS) } + val tabs = listOf(R.string.media_servers_tab_servers, R.string.media_servers_tab_cache) - LocalBlossomCacheToggle(accountViewModel) - HorizontalDivider() + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp), + ) { + tabs.forEachIndexed { index, labelRes -> + SegmentedButton( + selected = selectedTab == index, + onClick = { selectedTab = index }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = tabs.size), + ) { + Text(text = stringRes(id = labelRes)) + } + } + } - AllMediaBody(blossomServersViewModel) + when (selectedTab) { + TAB_SERVERS -> AllMediaBody(blossomServersViewModel, Modifier.weight(1f)) + else -> LocalBlossomCacheTab(accountViewModel, Modifier.weight(1f)) + } } } } +private const val TAB_SERVERS = 0 + +@Composable +private fun LocalBlossomCacheTab( + accountViewModel: AccountViewModel, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth().verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + LocalBlossomCacheToggle(accountViewModel) + } +} + @Composable private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) { val enabled by accountViewModel.account.settings.useLocalBlossomCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index aef785e87d..ec8f49b6a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -24,9 +24,11 @@ import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -36,18 +38,26 @@ import kotlinx.coroutines.launch class BlossomServersViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account + private var httpClientBuilder: IRoleBasedHttpClientBuilder? = null private val _fileServers = MutableStateFlow>(emptyList()) val fileServers = _fileServers.asStateFlow() + + /** Reachability status per server, keyed by [ServerName.baseUrl]. */ + private val _health = MutableStateFlow>(emptyMap()) + val health = _health.asStateFlow() + private var isModified = false fun init(accountViewModel: AccountViewModel) { this.accountViewModel = accountViewModel this.account = accountViewModel.account + this.httpClientBuilder = accountViewModel.httpClientBuilder } fun load() { refresh() + checkAllHealth() } fun refresh() { @@ -69,6 +79,32 @@ class BlossomServersViewModel : ViewModel() { } } + /** Moves a server to a new position; list order is the upload/fallback priority. */ + fun moveServer( + from: Int, + to: Int, + ) { + _fileServers.update { list -> + if (from !in list.indices || to !in list.indices) return@update list + list.toMutableList().apply { add(to, removeAt(from)) } + } + isModified = true + } + + /** Re-probes every server currently in the list. */ + fun checkAllHealth() { + _fileServers.value.forEach { probeServer(it.baseUrl) } + } + + private fun probeServer(serverUrl: String) { + val builder = httpClientBuilder ?: return + _health.update { it + (serverUrl to ServerHealth.Checking) } + viewModelScope.launch(Dispatchers.IO) { + val result = MediaServerHealthProbe.probe(serverUrl, builder::okHttpClientForPreview) + _health.update { it + (serverUrl to result) } + } + } + fun addServerList(serverList: List) { serverList.forEach { serverUrl -> addServer(serverUrl) @@ -100,6 +136,7 @@ class BlossomServersViewModel : ViewModel() { _fileServers.update { it.plus(serverRef) } + probeServer(serverRef.baseUrl) } isModified = true } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt new file mode 100644 index 0000000000..8a1444b1e1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/MediaServerHealth.kt @@ -0,0 +1,98 @@ +/* + * 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.actions.mediaServers + +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.coroutines.executeAsync +import java.util.concurrent.TimeUnit + +/** + * Reachability status of a media server, shown as a colored dot next to each + * entry in the Media Servers list. + */ +enum class ServerHealth { + /** Not probed yet. */ + Unknown, + + /** A probe is in flight. */ + Checking, + + /** Responded quickly. */ + Online, + + /** Responded, but slower than [MediaServerHealthProbe.SLOW_THRESHOLD_MS]. */ + Slow, + + /** Could not be reached (DNS, refused, timeout, TLS). */ + Offline, +} + +/** + * A one-shot, lightweight reachability check for a Blossom server. Issues a + * `HEAD /` to the server's base URL and classifies the outcome by round-trip + * time. Any HTTP response — even 404/405 — counts as reachable; only + * connection-level failures map to [ServerHealth.Offline]. + * + * Mirrors the timeout/short-circuit shape of + * [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe], + * but runs per-server and returns latency-classified status rather than a boolean. + */ +object MediaServerHealthProbe { + /** Round-trip time above which a reachable server is reported as [ServerHealth.Slow]. */ + const val SLOW_THRESHOLD_MS: Long = 1_000L + private const val PROBE_TIMEOUT_MS: Long = 5_000L + + suspend fun probe( + baseUrl: String, + clientForUrl: (String) -> OkHttpClient, + ): ServerHealth = + try { + val client = + clientForUrl(baseUrl) + .newBuilder() + .connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .build() + + val request = + Request + .Builder() + .url(baseUrl) + .head() + .build() + + val startedAt = TimeUtils.nowMillis() + client.newCall(request).executeAsync().use { + // The status code doesn't matter — a Blossom root often answers 404/405. + // Getting any response back proves the host is reachable. + val elapsed = TimeUtils.nowMillis() - startedAt + if (elapsed > SLOW_THRESHOLD_MS) ServerHealth.Slow else ServerHealth.Online + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ServerHealth.Offline + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 348b08fecc..77d8d148cf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1553,6 +1553,14 @@ Use Default List Add media server Delete media server + Servers + Local cache + Drag to reorder. Uploads try each server from the top down. + Reorder server + Online + Slow + Offline + Checking… Payment Targets Publish your payment addresses so others can send you funds directly. Add payment addresses for different networks (e.g. bitcoin, lightning, ethereum).