From 84a85ffaf79030b7fb8961af0b3daa67ea37a054 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 21:22:04 +0000 Subject: [PATCH 1/3] feat(blossom): import files from other Blossom servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the "My Blossom Files" screen, replace the top-bar sync icon with a 3-dot overflow menu offering "Refresh" and "Import files…". The new Import flow lets the user pull files they've uploaded to other Blossom servers into their own. They pick source servers to check — seeded from the recommended bootstrap list (minus servers already in their own list, with an enable-all toggle) and/or hand-typed addresses. A scan fans a BUD-02 GET /list/ across the enabled sources, works out which of those blobs are missing from the user's own kind-10063 servers, and hands the gaps to the existing app-level BlossomMirrorQueue so each server fetches them via BUD-04 mirror — reusing the same floating progress banner as the on-screen sync. - BlossomImportViewModel: source list management, scan, gap detection, mirror hand-off. - BlossomImportScreen: server picker, custom-URL field, scan + results. - New Route.ImportBlossomBlobs wired into AppNavigation. - Strings + plurals for the import UI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN --- .../mediaServers/BlossomBlobManagerScreen.kt | 51 ++- .../mediaServers/BlossomImportScreen.kt | 410 ++++++++++++++++++ .../mediaServers/BlossomImportViewModel.kt | 388 +++++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + amethyst/src/main/res/values/strings.xml | 26 ++ 6 files changed, 873 insertions(+), 6 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 69afaddab3..b9344c702c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.components.util.setText import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -121,12 +122,15 @@ fun BlossomBlobManagerScreen( showBackButton = nav.canPop(), popBack = { nav.popBack() }, actions = { - IconButton(onClick = { vm.refresh() }, enabled = !loading) { - if (loading) { - CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) - } else { - Icon(symbol = MaterialSymbols.Sync, contentDescription = stringRes(R.string.blossom_refresh)) - } + if (loading) { + // Keep the spinner as immediate feedback that a refresh is in flight; the + // overflow menu returns once it settles. + CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp) + } else { + BlobManagerOverflowMenu( + onRefresh = { vm.refresh() }, + onImport = { nav.nav(Route.ImportBlossomBlobs) }, + ) } }, ) @@ -181,6 +185,41 @@ fun BlossomBlobManagerScreen( } } +/** + * The top-bar overflow: "Refresh" re-reads the presence matrix; "Import" opens the flow + * that pulls the user's files off other Blossom servers into their own. + */ +@Composable +private fun BlobManagerOverflowMenu( + onRefresh: () -> Unit, + onImport: () -> Unit, +) { + var open by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { open = true }) { + Icon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.blossom_more_actions)) + } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.blossom_refresh)) }, + leadingIcon = { MenuIcon(MaterialSymbols.Sync) }, + onClick = { + open = false + onRefresh() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.blossom_import_menu)) }, + leadingIcon = { MenuIcon(MaterialSymbols.CloudDownload) }, + onClick = { + open = false + onImport() + }, + ) + } + } +} + @Composable private fun CenteredState(content: @Composable () -> Unit) { Column( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt new file mode 100644 index 0000000000..dc3ef3b8ca --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt @@ -0,0 +1,410 @@ +/* + * 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 androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.grayText + +/** Vibrant palette for server monograms; picked deterministically from the host name. */ +private val ImportMonogramColors = + listOf( + Color(0xFF8B5CF6), + Color(0xFF0EA5A0), + Color(0xFFE07B00), + Color(0xFF4169E1), + Color(0xFFD16D8F), + Color(0xFF4F9D4F), + Color(0xFFB66605), + Color(0xFF7C6FE0), + ) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BlossomImportScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: BlossomImportViewModel = viewModel() + vm.init(accountViewModel) + + val sources by vm.sources.collectAsStateWithLifecycle() + val candidates by vm.candidates.collectAsStateWithLifecycle() + val scanning by vm.isScanning.collectAsStateWithLifecycle() + val scanned by vm.scanned.collectAsStateWithLifecycle() + val error by vm.error.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopBarWithBackButton( + caption = stringRes(R.string.blossom_import_title), + nav = nav, + ) + }, + ) { padding -> + if (vm.hasNoTargets) { + NoTargetsState( + modifier = + Modifier + .fillMaxSize() + .padding( + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + ), + onManageServers = { nav.nav(Route.EditMediaServers) }, + ) + return@Scaffold + } + + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding( + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + ), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + text = stringRes(R.string.blossom_import_intro), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + } + + item { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(R.string.blossom_import_sources_section), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) + val anyDisabled = sources.any { !it.enabled } + TextButton(onClick = { vm.setAll(anyDisabled) }, enabled = sources.isNotEmpty()) { + Text( + stringRes( + if (anyDisabled) R.string.blossom_import_enable_all else R.string.blossom_import_disable_all, + ), + ) + } + } + } + + items(sources, key = { it.baseUrl }) { source -> + ImportSourceRow( + source = source, + onToggle = { vm.toggle(source.baseUrl) }, + onRemove = { vm.remove(source.baseUrl) }, + ) + } + + item { + Text( + text = stringRes(R.string.blossom_import_add_url_label), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + ) + MediaServerEditField(R.string.add_a_blossom_server) { vm.addCustom(it) } + } + + item { + val enabledCount = sources.count { it.enabled } + FilledTonalButton( + onClick = { vm.scan() }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + enabled = !scanning && enabledCount > 0, + ) { + if (scanning) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.size(8.dp)) + Text(stringRes(R.string.blossom_import_scanning)) + } else { + Icon(symbol = MaterialSymbols.CloudSync, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringRes(R.string.blossom_import_scan)) + } + } + } + + error?.let { + item { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + if (scanned && !scanning) { + if (candidates.isEmpty()) { + item { + Text( + text = stringRes(R.string.blossom_import_none_found), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + modifier = Modifier.padding(top = 8.dp), + ) + } + } else { + item { + ImportResultCard( + count = candidates.size, + onImport = { + vm.importSelected() + nav.popBack() + }, + ) + } + } + } + } + } +} + +@Composable +private fun NoTargetsState( + modifier: Modifier, + onManageServers: () -> Unit, +) { + Column( + modifier = modifier.padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = + Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon( + symbol = MaterialSymbols.Storage, + contentDescription = null, + modifier = Modifier.size(34.dp), + tint = MaterialTheme.colorScheme.grayText, + ) + } + Spacer(Modifier.height(12.dp)) + Text( + text = stringRes(R.string.blossom_import_no_targets), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton(onClick = onManageServers) { + Text(stringRes(R.string.blossom_import_manage_servers)) + } + } +} + +@Composable +private fun ImportSourceRow( + source: ImportSource, + onToggle: () -> Unit, + onRemove: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable(onClick = onToggle) + .padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ServerMonogramTile(name = source.name, size = 36.dp) + + Column(modifier = Modifier.weight(1f).padding(start = 12.dp)) { + Text( + text = source.name.replaceFirstChar(Char::titlecase), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + ScanStatusLabel(source.scan, source.host) + } + + if (source.custom) { + IconButton(onClick = onRemove) { + Icon( + symbol = MaterialSymbols.Delete, + contentDescription = stringRes(R.string.delete_media_server), + tint = MaterialTheme.colorScheme.grayText, + modifier = Modifier.size(20.dp), + ) + } + } + + Switch(checked = source.enabled, onCheckedChange = { onToggle() }) + } +} + +@Composable +private fun ScanStatusLabel( + scan: SourceScanState, + host: String, +) { + when (scan) { + is SourceScanState.Idle -> + Text( + text = host, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.grayText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + is SourceScanState.Scanning -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + CircularProgressIndicator(modifier = Modifier.size(11.dp), strokeWidth = 1.5.dp) + Text( + text = stringRes(R.string.blossom_import_scanning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + is SourceScanState.Found -> { + val count = scan.count + Text( + text = pluralStringResource(R.plurals.blossom_import_files_found, count, count), + style = MaterialTheme.typography.bodySmall, + color = if (count > 0) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText, + ) + } + is SourceScanState.Failed -> + Text( + text = stringRes(R.string.blossom_import_source_failed), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ImportResultCard( + count: Int, + onImport: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = pluralStringResource(R.plurals.blossom_import_found_files, count, count), + style = MaterialTheme.typography.bodyMedium, + ) + FilledTonalButton(onClick = onImport, modifier = Modifier.fillMaxWidth()) { + Icon(symbol = MaterialSymbols.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(pluralStringResource(R.plurals.blossom_import_start_button, count, count)) + } + } +} + +/** A colored letter tile identifying a server, derived from its host name. */ +@Composable +private fun ServerMonogramTile( + name: String, + size: Dp, +) { + val letter = name.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "?" + val color = ImportMonogramColors[((name.hashCode() % ImportMonogramColors.size) + ImportMonogramColors.size) % ImportMonogramColors.size] + Box( + modifier = + Modifier + .size(size) + .clip(RoundedCornerShape(size / 3)) + .background(color), + contentAlignment = Alignment.Center, + ) { + Text( + text = letter, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt new file mode 100644 index 0000000000..59c737bc3e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt @@ -0,0 +1,388 @@ +/* + * 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 androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.Rfc3986 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlin.coroutines.cancellation.CancellationException + +/** Per-source outcome of the last scan, so each row can report what it found. */ +@Immutable +sealed interface SourceScanState { + /** Never scanned, or reset after the source list changed. */ + data object Idle : SourceScanState + + /** A `/list` request is in flight for this source. */ + data object Scanning : SourceScanState + + /** The source answered; [count] is how many of the user's blobs it holds. */ + data class Found( + val count: Int, + ) : SourceScanState + + /** The source could not be listed (unreachable, no `/list`, error). */ + data class Failed( + val reason: String, + ) : SourceScanState +} + +/** + * One server the user can pull their files FROM. Seeded from [DEFAULT_MEDIA_SERVERS] + * (minus the servers already in the user's own kind-10063 list) plus any address the + * user typed in. [custom] rows are removable; recommended rows are not. + */ +@Immutable +data class ImportSource( + val baseUrl: String, + val host: String, + val name: String, + val enabled: Boolean, + val custom: Boolean, + val scan: SourceScanState = SourceScanState.Idle, +) + +/** + * A blob found on a source server that at least one of the user's own servers is + * missing — i.e. something worth importing. [sourceUrl] is the absolute URL the + * user's servers will mirror (BUD-04) from. + */ +@Immutable +data class ImportCandidate( + val hash: HexKey, + val sourceUrl: String, + val sourceHost: String, + val url: String?, + val size: Long?, + val type: String?, + val missingTargets: List, +) + +/** + * Backs the "import files from other Blossom servers" screen. The user picks a set of + * source servers (recommended or hand-typed); [scan] fans a `GET /list/` + * (BUD-02) across the enabled ones, works out which of those blobs are absent from the + * user's own kind-10063 servers, and [importSelected] hands the gaps to the app-level + * [BlossomMirrorQueue] so the user's servers fetch them (BUD-04) with the same floating + * progress banner the "sync all" sweep uses. + */ +@Stable +class BlossomImportViewModel : ViewModel() { + private lateinit var account: Account + private var initialized = false + + private val _sources = MutableStateFlow>(emptyList()) + val sources = _sources.asStateFlow() + + private val _candidates = MutableStateFlow>(emptyList()) + val candidates = _candidates.asStateFlow() + + private val _isScanning = MutableStateFlow(false) + val isScanning = _isScanning.asStateFlow() + + /** True once a scan has completed at least once, so the UI can tell "not scanned yet" from "found nothing". */ + private val _scanned = MutableStateFlow(false) + val scanned = _scanned.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun init(accountViewModel: AccountViewModel) { + if (initialized) return + initialized = true + this.account = accountViewModel.account + seedSources() + } + + /** The user's own servers — where imported blobs land. */ + private fun targets(): List = + account.blossomServers.flow.value + .distinct() + + /** True when the user hasn't configured any of their own servers to import into. */ + val hasNoTargets get() = targets().isEmpty() + + private fun seedSources() { + val ownHosts = targets().mapTo(HashSet()) { BlossomServerUrl.domain(it) } + _sources.value = + DEFAULT_MEDIA_SERVERS + .filter { it.type == ServerType.Blossom } + // Importing from a server that's already yours is pointless — that's what "sync" covers. + .filter { BlossomServerUrl.domain(it.baseUrl) !in ownHosts } + .map { + ImportSource( + baseUrl = it.baseUrl, + host = BlossomServerUrl.domain(it.baseUrl), + name = it.name, + enabled = false, + custom = false, + ) + } + } + + fun toggle(baseUrl: String) { + _sources.update { list -> list.map { if (it.baseUrl == baseUrl) it.copy(enabled = !it.enabled) else it } } + } + + fun setAll(enabled: Boolean) { + _sources.update { list -> list.map { it.copy(enabled = enabled) } } + } + + /** Add a hand-typed server. Ignores blanks and duplicates (matched by host). */ + fun addCustom(rawUrl: String) { + val normalized = + try { + Rfc3986.normalize(rawUrl.trim()) + } catch (e: Exception) { + rawUrl.trim() + } + if (normalized.isBlank()) return + val host = + try { + BlossomServerUrl.domain(normalized) + } catch (e: Exception) { + normalized + } + _sources.update { list -> + if (list.any { it.host == host }) { + list + } else { + list + ImportSource(normalized, host, host, enabled = true, custom = true) + } + } + } + + fun remove(baseUrl: String) { + _sources.update { list -> list.filterNot { it.baseUrl == baseUrl } } + } + + private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server)) + + private var scanJob: Job? = null + + fun scan() { + val targets = targets() + if (targets.isEmpty()) { + _error.value = null + return + } + val enabled = _sources.value.filter { it.enabled } + if (enabled.isEmpty()) return + + scanJob?.cancel() + scanJob = + viewModelScope.launch(Dispatchers.IO) { + _isScanning.value = true + _error.value = null + _candidates.value = emptyList() + setScanStates(enabled.map { it.baseUrl }, SourceScanState.Scanning) + try { + scanSources(enabled.map { it.baseUrl }, targets) + _scanned.value = true + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("BlossomImport", "scan failed", e) + _error.value = e.message?.ifBlank { null } ?: e.javaClass.simpleName + } finally { + _isScanning.value = false + } + } + } + + private suspend fun scanSources( + sources: List, + targets: List, + ) { + val pubkey = account.signer.pubKey + + // Phase 1 — /list each enabled source. Collect the user's blobs and remember the + // first source that can serve each hash (its descriptor URL is the mirror source). + val meta = HashMap() + coroutineScope { + sources + .map { source -> + async { + val listed = + try { + val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() + clientFor(source).list(source, pubkey, auth) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("BlossomImport", "list failed on $source", e) + setScanState(source, SourceScanState.Failed(e.shortReason())) + return@async + } + setScanState(source, SourceScanState.Found(listed.count { it.sha256 != null })) + synchronized(meta) { + listed.forEach { d -> + val hash = d.sha256 ?: return@forEach + meta.putIfAbsent(hash, CandidateMeta(sourceUrlFor(source, d, hash), BlossomServerUrl.domain(source), d.url, d.size, d.type)) + } + } + } + }.awaitAll() + } + + val allHashes = meta.keys.toList() + if (allHashes.isEmpty()) { + _candidates.value = emptyList() + return + } + + // Phase 2 — which of the user's own servers already hold each hash? /list where the + // server supports it, HEAD-probe (bounded) the rest, so we only offer the true gaps. + val holders = targetHolders(allHashes, targets, pubkey) + + _candidates.value = + allHashes + .mapNotNull { hash -> + val missing = targets.filter { hash !in holders.getOrElse(it) { emptySet() } } + if (missing.isEmpty()) return@mapNotNull null + val m = meta.getValue(hash) + ImportCandidate(hash, m.sourceUrl, m.sourceHost, m.url, m.size, m.type, missing) + }.sortedByDescending { it.missingTargets.size } + } + + /** For each target server, the set of hashes it already holds. */ + private suspend fun targetHolders( + hashes: List, + targets: List, + pubkey: HexKey, + ): Map> { + val listed: List?>> = + coroutineScope { + targets + .map { target -> + async { + target to + try { + val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() + clientFor(target).list(target, pubkey, auth).mapNotNull { it.sha256 }.toSet() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + } + }.awaitAll() + } + + val holders = HashMap>() + val nonListTargets = ArrayList() + listed.forEach { (target, set) -> + if (set != null) holders[target] = set.toMutableSet() else nonListTargets.add(target) + } + + // Servers without /list: HEAD-probe each hash, bounded so a big library doesn't fan out unbounded. + if (nonListTargets.isNotEmpty()) { + val limiter = Semaphore(MAX_HEAD_PROBES) + val probes = + coroutineScope { + nonListTargets + .flatMap { target -> + hashes.map { hash -> + async { limiter.withPermit { Triple(target, hash, clientFor(target).has(hash, target)) } } + } + }.awaitAll() + } + probes.forEach { (target, hash, present) -> + if (present) holders.getOrPut(target) { HashSet() }.add(hash) + } + } + return holders + } + + /** + * Hand every discovered gap to the app-level mirror queue, which asks each of the + * user's servers to fetch the blob from its source. Reuses the same queue (and + * floating progress banner) as the on-screen "sync all". Returns the file count so + * the caller can surface it. + */ + fun importSelected(): Int { + val candidates = _candidates.value + val tasks = candidates.map { BlossomMirrorQueue.Task(it.hash, it.sourceUrl, it.size, it.missingTargets) } + if (tasks.isEmpty()) return 0 + Amethyst.instance.blossomMirrorQueue.start(account, tasks) + return candidates.size + } + + private fun setScanState( + server: String, + state: SourceScanState, + ) { + _sources.update { list -> list.map { if (it.baseUrl == server) it.copy(scan = state) else it } } + } + + private fun setScanStates( + servers: List, + state: SourceScanState, + ) { + val set = servers.toHashSet() + _sources.update { list -> list.map { if (it.baseUrl in set) it.copy(scan = state) else it } } + } + + fun hostOf(serverBaseUrl: String): String = BlossomServerUrl.domain(serverBaseUrl) + + private fun sourceUrlFor( + server: String, + descriptor: BlossomUploadResult, + hash: HexKey, + ): String = descriptor.url?.takeIf { it.isNotBlank() } ?: BlossomServerUrl.blob(server, hash) + + private fun Exception.shortReason(): String = message?.ifBlank { null } ?: javaClass.simpleName + + private data class CandidateMeta( + val sourceUrl: String, + val sourceHost: String, + val url: String?, + val size: Long?, + val type: String?, + ) + + companion object { + /** Cap on concurrent HEAD probes when checking non-/list target servers. */ + private const val MAX_HEAD_PROBES = 8 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 6811206223..fdcc71a148 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen +import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomImportScreen import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress @@ -585,6 +586,7 @@ fun BuildNavigation( composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } composableFromEndArgs { BlossomBlobManagerScreen(accountViewModel, nav) } + composableFromEndArgs { BlossomImportScreen(accountViewModel, nav) } composableFromEndArgs { com.vitorpamplona.amethyst.ui.actions.nestsServers .NestsServersScreen(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 9401c1916d..06551b616c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -467,6 +467,8 @@ sealed class Route { @Serializable object ManageBlossomBlobs : Route() + @Serializable object ImportBlossomBlobs : Route() + @Serializable object EditNestsServers : Route() @Serializable diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 2052e7b286..769879c3a7 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1688,6 +1688,32 @@ Report blob Reason (optional) Send + More actions + Import files… + Import files + Check other Blossom servers for files you\'ve uploaded elsewhere and copy them into your own servers. + Servers to check + Enable all + Disable all + Or paste a server address + Check servers + Checking… + Couldn\'t reach this server + No new files were found on the selected servers. + Add your own Blossom servers first, so there\'s somewhere to copy the imported files into. + Manage my servers + + %1$d file + %1$d files + + + Found %1$d file to import into your servers. + Found %1$d files to import into your servers. + + + Import %1$d file + Import %1$d files + Recommended Media Servers Amethyst\'s default list. You can add them individually or add the list. From 895bcc33817366cb401da0e25bc708c132dfa673 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:47:52 +0000 Subject: [PATCH 2/3] refactor(blossom): harden import flow after audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up fixes from an audit of the import feature: - Cancel an in-flight scan when the source selection changes (toggle/add/remove/enable-all). Without this a scan started against the old selection could land afterwards and offer blobs sourced from a server the user just de-selected — which importSelected() would then mirror from. - Sign the BUD-02 list token once per scan and reuse it across every source and target. The token carries no `server` scope tag, so it's valid everywhere; per-server signing was a round-trip storm with remote NIP-46 signers. - BlossomMirrorQueue.start() now returns whether it actually started a sweep. importSelected() keys the Started/Busy result off that instead of a separate isRunning check, closing a TOCTOU where the import would report "started" but the queue silently dropped the work. - The import screen's empty-state now collects the kind-10063 server list reactively, so the "add servers first" ↔ picker switch recomposes when the list arrives from a relay after the screen opens. - init() re-points at the current account each call (matching the sibling BlobManager VM) while still seeding the source list only once. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN --- .../uploads/blossom/BlossomMirrorQueue.kt | 13 +++- .../mediaServers/BlossomImportScreen.kt | 19 ++++- .../mediaServers/BlossomImportViewModel.kt | 76 ++++++++++++++----- amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 84 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt index ce7582fc75..168d7ee9f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt @@ -89,14 +89,18 @@ class BlossomMirrorQueue( val isRunning get() = _state.value?.running == true - /** Enqueue a sweep. No-op if one is already running or there's nothing to do. */ + /** + * Enqueue a sweep. Returns true when this call actually started one; false (no-op) when a + * sweep is already running or there's nothing to do — the caller can tell the difference so + * it doesn't report an import/sync as started when the work was silently dropped. + */ fun start( account: Account, tasks: List, - ) { - if (isRunning) return + ): Boolean { + if (isRunning) return false val work = tasks.flatMap { t -> t.targets.map { t to it } } - if (work.isEmpty()) return + if (work.isEmpty()) return false // Publish the active state and start the foreground service synchronously (we're on the // foreground thread here, which is what dataSync FGS starts require) before the sweep runs. @@ -122,6 +126,7 @@ class BlossomMirrorQueue( }.awaitAll() _state.update { it?.copy(running = false) } } + return true } private suspend fun mirrorOne( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt index dc3ef3b8ca..25918c72f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -52,6 +53,7 @@ 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.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -97,6 +99,11 @@ fun BlossomImportScreen( val scanning by vm.isScanning.collectAsStateWithLifecycle() val scanned by vm.scanned.collectAsStateWithLifecycle() val error by vm.error.collectAsStateWithLifecycle() + // Collect the user's own server list so the empty-state ↔ picker switch recomposes if the + // kind-10063 list arrives from a relay just after the screen opens (common on cold start). + val targetServers by accountViewModel.account.blossomServers.flow + .collectAsStateWithLifecycle() + val context = LocalContext.current Scaffold( topBar = { @@ -106,7 +113,7 @@ fun BlossomImportScreen( ) }, ) { padding -> - if (vm.hasNoTargets) { + if (targetServers.isEmpty()) { NoTargetsState( modifier = Modifier @@ -224,8 +231,14 @@ fun BlossomImportScreen( ImportResultCard( count = candidates.size, onImport = { - vm.importSelected() - nav.popBack() + when (vm.importSelected()) { + is ImportStart.Started -> nav.popBack() + ImportStart.Busy -> + Toast + .makeText(context, stringRes(context, R.string.blossom_import_busy), Toast.LENGTH_LONG) + .show() + ImportStart.Empty -> {} + } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt index 59c737bc3e..a2b6d758f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt @@ -82,6 +82,17 @@ data class ImportSource( val scan: SourceScanState = SourceScanState.Idle, ) +/** Outcome of tapping "import": either the sweep started, the queue was busy, or nothing to do. */ +sealed interface ImportStart { + data class Started( + val count: Int, + ) : ImportStart + + data object Busy : ImportStart + + data object Empty : ImportStart +} + /** * A blob found on a source server that at least one of the user's own servers is * missing — i.e. something worth importing. [sourceUrl] is the absolute URL the @@ -109,7 +120,7 @@ data class ImportCandidate( @Stable class BlossomImportViewModel : ViewModel() { private lateinit var account: Account - private var initialized = false + private var seeded = false private val _sources = MutableStateFlow>(emptyList()) val sources = _sources.asStateFlow() @@ -128,9 +139,11 @@ class BlossomImportViewModel : ViewModel() { val error = _error.asStateFlow() fun init(accountViewModel: AccountViewModel) { - if (initialized) return - initialized = true + // Re-point at the current account every call (matches the sibling BlobManager VM), but + // seed the source list only once so we don't clobber the user's toggles on recomposition. this.account = accountViewModel.account + if (seeded) return + seeded = true seedSources() } @@ -139,9 +152,6 @@ class BlossomImportViewModel : ViewModel() { account.blossomServers.flow.value .distinct() - /** True when the user hasn't configured any of their own servers to import into. */ - val hasNoTargets get() = targets().isEmpty() - private fun seedSources() { val ownHosts = targets().mapTo(HashSet()) { BlossomServerUrl.domain(it) } _sources.value = @@ -162,10 +172,12 @@ class BlossomImportViewModel : ViewModel() { fun toggle(baseUrl: String) { _sources.update { list -> list.map { if (it.baseUrl == baseUrl) it.copy(enabled = !it.enabled) else it } } + invalidateResults() } fun setAll(enabled: Boolean) { _sources.update { list -> list.map { it.copy(enabled = enabled) } } + invalidateResults() } /** Add a hand-typed server. Ignores blanks and duplicates (matched by host). */ @@ -190,10 +202,27 @@ class BlossomImportViewModel : ViewModel() { list + ImportSource(normalized, host, host, enabled = true, custom = true) } } + invalidateResults() } fun remove(baseUrl: String) { _sources.update { list -> list.filterNot { it.baseUrl == baseUrl } } + invalidateResults() + } + + /** + * Drop the previous scan's results whenever the source selection changes — otherwise the + * "Import N files" button could mirror blobs sourced from a server the user just disabled + * or removed. Forces a fresh scan against the current selection. + */ + private fun invalidateResults() { + // Cancel an in-flight scan too, so its results (for the old selection) can't land after the change. + scanJob?.cancel() + _isScanning.value = false + _candidates.value = emptyList() + _scanned.value = false + _error.value = null + _sources.update { list -> list.map { if (it.scan == SourceScanState.Idle) it else it.copy(scan = SourceScanState.Idle) } } } private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server)) @@ -235,6 +264,10 @@ class BlossomImportViewModel : ViewModel() { targets: List, ) { val pubkey = account.signer.pubKey + // A BUD-02 `t=list` token with no `server` tag is generic, so one signature covers + // every source AND target list call. Signing once (instead of per server) avoids a + // round-trip storm with remote NIP-46 signers. + val listAuth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() // Phase 1 — /list each enabled source. Collect the user's blobs and remember the // first source that can serve each hash (its descriptor URL is the mirror source). @@ -245,8 +278,7 @@ class BlossomImportViewModel : ViewModel() { async { val listed = try { - val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() - clientFor(source).list(source, pubkey, auth) + clientFor(source).list(source, pubkey, listAuth) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -273,7 +305,7 @@ class BlossomImportViewModel : ViewModel() { // Phase 2 — which of the user's own servers already hold each hash? /list where the // server supports it, HEAD-probe (bounded) the rest, so we only offer the true gaps. - val holders = targetHolders(allHashes, targets, pubkey) + val holders = targetHolders(allHashes, targets, listAuth) _candidates.value = allHashes @@ -289,8 +321,9 @@ class BlossomImportViewModel : ViewModel() { private suspend fun targetHolders( hashes: List, targets: List, - pubkey: HexKey, + listAuth: String, ): Map> { + val pubkey = account.signer.pubKey val listed: List?>> = coroutineScope { targets @@ -298,8 +331,7 @@ class BlossomImportViewModel : ViewModel() { async { target to try { - val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() - clientFor(target).list(target, pubkey, auth).mapNotNull { it.sha256 }.toSet() + clientFor(target).list(target, pubkey, listAuth).mapNotNull { it.sha256 }.toSet() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -337,15 +369,23 @@ class BlossomImportViewModel : ViewModel() { /** * Hand every discovered gap to the app-level mirror queue, which asks each of the * user's servers to fetch the blob from its source. Reuses the same queue (and - * floating progress banner) as the on-screen "sync all". Returns the file count so - * the caller can surface it. + * floating progress banner) as the on-screen "sync all". + * + * There is a single global queue, so if a sweep (or another import) is already in + * flight the queue would silently drop this one — [ImportStart.Busy] lets the caller + * keep the screen up and tell the user instead of navigating away to nothing. */ - fun importSelected(): Int { + fun importSelected(): ImportStart { val candidates = _candidates.value val tasks = candidates.map { BlossomMirrorQueue.Task(it.hash, it.sourceUrl, it.size, it.missingTargets) } - if (tasks.isEmpty()) return 0 - Amethyst.instance.blossomMirrorQueue.start(account, tasks) - return candidates.size + if (tasks.isEmpty()) return ImportStart.Empty + // start() itself atomically no-ops if a sweep is already running, so key off its return + // rather than a separate isRunning check that could race with a sweep starting. + return if (Amethyst.instance.blossomMirrorQueue.start(account, tasks)) { + ImportStart.Started(candidates.size) + } else { + ImportStart.Busy + } } private fun setScanState( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 591b0d14df..db99a0acfa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1706,6 +1706,7 @@ No new files were found on the selected servers. Add your own Blossom servers first, so there\'s somewhere to copy the imported files into. Manage my servers + A file sync is already running. Try importing again once it finishes. %1$d file %1$d files From 16c6acd78af3af38b65b567626070b9a47a65a58 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:51:46 +0000 Subject: [PATCH 3/3] fix(blossom): auto-dismiss the finished sync/import banner The app-level "sync all" / import progress banner switched to "Sync complete" when the sweep finished but then lingered until the user tapped X. Auto-dismiss it a few seconds after completion, keeping the X for dismissing early. Keyed on the running flag so a new sweep cancels the pending dismiss and tapping X re-keys it to a no-op. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN --- .../mediaServers/DisplayBlossomSyncProgress.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt index 8a9ad2a000..b383ccd1bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt @@ -59,6 +59,10 @@ import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText +import kotlinx.coroutines.delay + +/** How long the finished "Sync complete" banner lingers before it auto-dismisses. */ +private const val AUTO_DISMISS_DELAY_MS = 4000L /** * App-wide floating banner for the BUD-04 "sync all" sweep, mounted at the navigation @@ -75,6 +79,17 @@ fun DisplayBlossomSyncProgress() { var lastShown by remember { mutableStateOf(null) } LaunchedEffect(state) { state?.let { lastShown = it } } + // Once the sweep finishes, auto-dismiss the "Sync complete" banner after a short pause so + // the user doesn't have to tap X. Keyed on `running`: a new sweep flips it back to true and + // cancels the pending dismiss; tapping X clears state to null and re-keys this to a no-op. + val running = state?.running + LaunchedEffect(running) { + if (running == false) { + delay(AUTO_DISMISS_DELAY_MS) + queue.dismiss() + } + } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { AnimatedVisibility( visible = state != null,