feat(blossom): import files from other Blossom servers

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/<pubkey> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5G515Grhc4t7ACoza9eyN
This commit is contained in:
Claude
2026-07-24 21:22:04 +00:00
parent 5d72a0415c
commit 84a85ffaf7
6 changed files with 873 additions and 6 deletions
@@ -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(
@@ -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,
)
}
}
@@ -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<String>,
)
/**
* 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/<pubkey>`
* (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<List<ImportSource>>(emptyList())
val sources = _sources.asStateFlow()
private val _candidates = MutableStateFlow<List<ImportCandidate>>(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<String?>(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<String> =
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<String>,
targets: List<String>,
) {
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<HexKey, CandidateMeta>()
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<HexKey>,
targets: List<String>,
pubkey: HexKey,
): Map<String, Set<HexKey>> {
val listed: List<Pair<String, Set<HexKey>?>> =
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<String, MutableSet<HexKey>>()
val nonListTargets = ArrayList<String>()
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<String>,
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
}
}
@@ -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<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ManageBlossomBlobs> { BlossomBlobManagerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ImportBlossomBlobs> { BlossomImportScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditNestsServers> {
com.vitorpamplona.amethyst.ui.actions.nestsServers
.NestsServersScreen(accountViewModel, nav)
@@ -467,6 +467,8 @@ sealed class Route {
@Serializable object ManageBlossomBlobs : Route()
@Serializable object ImportBlossomBlobs : Route()
@Serializable object EditNestsServers : Route()
@Serializable
+26
View File
@@ -1688,6 +1688,32 @@
<string name="blossom_report_title">Report blob</string>
<string name="blossom_report_comment_hint">Reason (optional)</string>
<string name="blossom_send">Send</string>
<string name="blossom_more_actions">More actions</string>
<string name="blossom_import_menu">Import files…</string>
<string name="blossom_import_title">Import files</string>
<string name="blossom_import_intro">Check other Blossom servers for files you\'ve uploaded elsewhere and copy them into your own servers.</string>
<string name="blossom_import_sources_section">Servers to check</string>
<string name="blossom_import_enable_all">Enable all</string>
<string name="blossom_import_disable_all">Disable all</string>
<string name="blossom_import_add_url_label">Or paste a server address</string>
<string name="blossom_import_scan">Check servers</string>
<string name="blossom_import_scanning">Checking…</string>
<string name="blossom_import_source_failed">Couldn\'t reach this server</string>
<string name="blossom_import_none_found">No new files were found on the selected servers.</string>
<string name="blossom_import_no_targets">Add your own Blossom servers first, so there\'s somewhere to copy the imported files into.</string>
<string name="blossom_import_manage_servers">Manage my servers</string>
<plurals name="blossom_import_files_found">
<item quantity="one">%1$d file</item>
<item quantity="other">%1$d files</item>
</plurals>
<plurals name="blossom_import_found_files">
<item quantity="one">Found %1$d file to import into your servers.</item>
<item quantity="other">Found %1$d files to import into your servers.</item>
</plurals>
<plurals name="blossom_import_start_button">
<item quantity="one">Import %1$d file</item>
<item quantity="other">Import %1$d files</item>
</plurals>
<string name="recommended_media_servers">Recommended Media Servers</string>
<string name="built_in_servers_description">Amethyst\'s default list. You can add them individually or add the list.</string>