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 9930d0738f..a955d457e2 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 @@ -42,7 +42,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -108,11 +108,18 @@ fun MediaServersScaffold( ).consumeWindowInsets(padding) .imePadding(), ) { - var selectedTab by remember { mutableIntStateOf(TAB_SERVERS) } + var selectedTab by rememberSaveable { mutableIntStateOf(TAB_SERVERS) } val tabs = listOf(R.string.media_servers_tab_servers, R.string.media_servers_tab_cache) + Text( + text = stringRes(id = R.string.set_preferred_media_servers), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = 10.dp), + ) + SingleChoiceSegmentedButtonRow( - modifier = Modifier.fillMaxWidth().padding(top = 12.dp, bottom = 8.dp), + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 8.dp), ) { tabs.forEachIndexed { index, labelRes -> SegmentedButton( 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 ec8f49b6a7..a5e5607a40 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 @@ -77,6 +77,7 @@ class BlossomServersViewModel : ViewModel() { } } } + pruneHealth() } /** Moves a server to a new position; list order is the upload/fallback priority. */ @@ -91,13 +92,23 @@ class BlossomServersViewModel : ViewModel() { isModified = true } - /** Re-probes every server currently in the list. */ + /** Re-probes every server currently in the list. Fresh cached results are reused. */ fun checkAllHealth() { _fileServers.value.forEach { probeServer(it.baseUrl) } } private fun probeServer(serverUrl: String) { val builder = httpClientBuilder ?: return + + // A probe is already in flight for this URL — don't launch a duplicate. + if (_health.value[serverUrl] == ServerHealth.Checking) return + + // Reuse a still-fresh cached status instead of hitting the network again. + MediaServerHealthProbe.cached(serverUrl)?.let { cachedStatus -> + _health.update { it + (serverUrl to cachedStatus) } + return + } + _health.update { it + (serverUrl to ServerHealth.Checking) } viewModelScope.launch(Dispatchers.IO) { val result = MediaServerHealthProbe.probe(serverUrl, builder::okHttpClientForPreview) @@ -105,6 +116,12 @@ class BlossomServersViewModel : ViewModel() { } } + /** Drops health entries for servers no longer in the list so the map can't grow unbounded. */ + private fun pruneHealth() { + val liveUrls = _fileServers.value.mapTo(HashSet()) { it.baseUrl } + _health.update { statuses -> statuses.filterKeys { it in liveUrls } } + } + fun addServerList(serverList: List) { serverList.forEach { serverUrl -> addServer(serverUrl) @@ -157,6 +174,7 @@ class BlossomServersViewModel : ViewModel() { ServerName(serverName, serverUrl, ServerType.Blossom), ) } + pruneHealth() 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 index 8a1444b1e1..c7dbb15d23 100644 --- 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 @@ -25,6 +25,7 @@ import kotlinx.coroutines.CancellationException import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.coroutines.executeAsync +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit /** @@ -63,9 +64,39 @@ object MediaServerHealthProbe { const val SLOW_THRESHOLD_MS: Long = 1_000L private const val PROBE_TIMEOUT_MS: Long = 5_000L + /** + * How long a probe result is reused before the server is re-checked. The cache is + * process-wide (this is a singleton) so results survive the screen's ViewModel being + * recreated on each open, mirroring [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe]. + */ + private const val CACHE_TTL_MS: Long = 60_000L + + private class CachedResult( + val status: ServerHealth, + val atMs: Long, + ) + + private val cache = ConcurrentHashMap() + + /** The cached status for [baseUrl] if still within [CACHE_TTL_MS], else null. */ + fun cached(baseUrl: String): ServerHealth? { + val entry = cache[baseUrl] ?: return null + return if (TimeUtils.nowMillis() - entry.atMs < CACHE_TTL_MS) entry.status else null + } + suspend fun probe( baseUrl: String, clientForUrl: (String) -> OkHttpClient, + ): ServerHealth { + cached(baseUrl)?.let { return it } + val result = runProbe(baseUrl, clientForUrl) + cache[baseUrl] = CachedResult(result, TimeUtils.nowMillis()) + return result + } + + private suspend fun runProbe( + baseUrl: String, + clientForUrl: (String) -> OkHttpClient, ): ServerHealth = try { val client =