Merge pull request #3703 from vitorpamplona/claude/blossom-file-import-xwcrzn

Add Blossom blob import flow to pull files from other servers
This commit is contained in:
Vitor Pamplona
2026-07-24 19:01:04 -04:00
committed by GitHub
8 changed files with 961 additions and 10 deletions
@@ -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<Task>,
) {
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(
@@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
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
@@ -176,12 +177,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) },
)
}
},
)
@@ -240,6 +244,51 @@ 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 = { OverflowMenuIcon(MaterialSymbols.Sync) },
onClick = {
open = false
onRefresh()
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_import_menu)) },
leadingIcon = { OverflowMenuIcon(MaterialSymbols.CloudDownload) },
onClick = {
open = false
onImport()
},
)
}
}
}
@Composable
private fun OverflowMenuIcon(symbol: MaterialSymbol) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
/** Whether a blob is an image or a video, i.e. it can be previewed and shown full-screen. */
private val BlobRow.isViewable: Boolean
get() = type?.let { it.startsWith("image/") || it.startsWith("video/") } == true
@@ -0,0 +1,423 @@
/*
* 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 android.widget.Toast
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.platform.LocalContext
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()
// 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 = {
TopBarWithBackButton(
caption = stringRes(R.string.blossom_import_title),
nav = nav,
)
},
) { padding ->
if (targetServers.isEmpty()) {
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 = {
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 -> {}
}
},
)
}
}
}
}
}
}
@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,428 @@
/*
* 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,
)
/** 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
* 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 seeded = 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) {
// 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()
}
/** The user's own servers — where imported blobs land. */
private fun targets(): List<String> =
account.blossomServers.flow.value
.distinct()
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 } }
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). */
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)
}
}
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))
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
// 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).
val meta = HashMap<HexKey, CandidateMeta>()
coroutineScope {
sources
.map { source ->
async {
val listed =
try {
clientFor(source).list(source, pubkey, listAuth)
} 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, listAuth)
_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>,
listAuth: String,
): Map<String, Set<HexKey>> {
val pubkey = account.signer.pubKey
val listed: List<Pair<String, Set<HexKey>?>> =
coroutineScope {
targets
.map { target ->
async {
target to
try {
clientFor(target).list(target, pubkey, listAuth).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".
*
* 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(): ImportStart {
val candidates = _candidates.value
val tasks = candidates.map { BlossomMirrorQueue.Task(it.hash, it.sourceUrl, it.size, it.missingTargets) }
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(
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
}
}
@@ -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<BlossomSyncState?>(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,
@@ -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
+27
View File
@@ -1694,6 +1694,33 @@
<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>
<string name="blossom_import_busy">A file sync is already running. Try importing again once it finishes.</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>