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.