mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+9
-4
@@ -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(
|
||||
|
||||
+55
-6
@@ -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
|
||||
|
||||
+423
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+428
@@ -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
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user