diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt index ce7582fc75..168d7ee9f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt @@ -89,14 +89,18 @@ class BlossomMirrorQueue( val isRunning get() = _state.value?.running == true - /** Enqueue a sweep. No-op if one is already running or there's nothing to do. */ + /** + * Enqueue a sweep. Returns true when this call actually started one; false (no-op) when a + * sweep is already running or there's nothing to do — the caller can tell the difference so + * it doesn't report an import/sync as started when the work was silently dropped. + */ fun start( account: Account, tasks: List, - ) { - if (isRunning) return + ): Boolean { + if (isRunning) return false val work = tasks.flatMap { t -> t.targets.map { t to it } } - if (work.isEmpty()) return + if (work.isEmpty()) return false // Publish the active state and start the foreground service synchronously (we're on the // foreground thread here, which is what dataSync FGS starts require) before the sweep runs. @@ -122,6 +126,7 @@ class BlossomMirrorQueue( }.awaitAll() _state.update { it?.copy(running = false) } } + return true } private suspend fun mirrorOne( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt index dc3ef3b8ca..25918c72f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -52,6 +53,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -97,6 +99,11 @@ fun BlossomImportScreen( val scanning by vm.isScanning.collectAsStateWithLifecycle() val scanned by vm.scanned.collectAsStateWithLifecycle() val error by vm.error.collectAsStateWithLifecycle() + // Collect the user's own server list so the empty-state ↔ picker switch recomposes if the + // kind-10063 list arrives from a relay just after the screen opens (common on cold start). + val targetServers by accountViewModel.account.blossomServers.flow + .collectAsStateWithLifecycle() + val context = LocalContext.current Scaffold( topBar = { @@ -106,7 +113,7 @@ fun BlossomImportScreen( ) }, ) { padding -> - if (vm.hasNoTargets) { + if (targetServers.isEmpty()) { NoTargetsState( modifier = Modifier @@ -224,8 +231,14 @@ fun BlossomImportScreen( ImportResultCard( count = candidates.size, onImport = { - vm.importSelected() - nav.popBack() + when (vm.importSelected()) { + is ImportStart.Started -> nav.popBack() + ImportStart.Busy -> + Toast + .makeText(context, stringRes(context, R.string.blossom_import_busy), Toast.LENGTH_LONG) + .show() + ImportStart.Empty -> {} + } }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt index 59c737bc3e..a2b6d758f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomImportViewModel.kt @@ -82,6 +82,17 @@ data class ImportSource( val scan: SourceScanState = SourceScanState.Idle, ) +/** Outcome of tapping "import": either the sweep started, the queue was busy, or nothing to do. */ +sealed interface ImportStart { + data class Started( + val count: Int, + ) : ImportStart + + data object Busy : ImportStart + + data object Empty : ImportStart +} + /** * A blob found on a source server that at least one of the user's own servers is * missing — i.e. something worth importing. [sourceUrl] is the absolute URL the @@ -109,7 +120,7 @@ data class ImportCandidate( @Stable class BlossomImportViewModel : ViewModel() { private lateinit var account: Account - private var initialized = false + private var seeded = false private val _sources = MutableStateFlow>(emptyList()) val sources = _sources.asStateFlow() @@ -128,9 +139,11 @@ class BlossomImportViewModel : ViewModel() { val error = _error.asStateFlow() fun init(accountViewModel: AccountViewModel) { - if (initialized) return - initialized = true + // Re-point at the current account every call (matches the sibling BlobManager VM), but + // seed the source list only once so we don't clobber the user's toggles on recomposition. this.account = accountViewModel.account + if (seeded) return + seeded = true seedSources() } @@ -139,9 +152,6 @@ class BlossomImportViewModel : ViewModel() { account.blossomServers.flow.value .distinct() - /** True when the user hasn't configured any of their own servers to import into. */ - val hasNoTargets get() = targets().isEmpty() - private fun seedSources() { val ownHosts = targets().mapTo(HashSet()) { BlossomServerUrl.domain(it) } _sources.value = @@ -162,10 +172,12 @@ class BlossomImportViewModel : ViewModel() { fun toggle(baseUrl: String) { _sources.update { list -> list.map { if (it.baseUrl == baseUrl) it.copy(enabled = !it.enabled) else it } } + invalidateResults() } fun setAll(enabled: Boolean) { _sources.update { list -> list.map { it.copy(enabled = enabled) } } + invalidateResults() } /** Add a hand-typed server. Ignores blanks and duplicates (matched by host). */ @@ -190,10 +202,27 @@ class BlossomImportViewModel : ViewModel() { list + ImportSource(normalized, host, host, enabled = true, custom = true) } } + invalidateResults() } fun remove(baseUrl: String) { _sources.update { list -> list.filterNot { it.baseUrl == baseUrl } } + invalidateResults() + } + + /** + * Drop the previous scan's results whenever the source selection changes — otherwise the + * "Import N files" button could mirror blobs sourced from a server the user just disabled + * or removed. Forces a fresh scan against the current selection. + */ + private fun invalidateResults() { + // Cancel an in-flight scan too, so its results (for the old selection) can't land after the change. + scanJob?.cancel() + _isScanning.value = false + _candidates.value = emptyList() + _scanned.value = false + _error.value = null + _sources.update { list -> list.map { if (it.scan == SourceScanState.Idle) it else it.copy(scan = SourceScanState.Idle) } } } private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server)) @@ -235,6 +264,10 @@ class BlossomImportViewModel : ViewModel() { targets: List, ) { val pubkey = account.signer.pubKey + // A BUD-02 `t=list` token with no `server` tag is generic, so one signature covers + // every source AND target list call. Signing once (instead of per server) avoids a + // round-trip storm with remote NIP-46 signers. + val listAuth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() // Phase 1 — /list each enabled source. Collect the user's blobs and remember the // first source that can serve each hash (its descriptor URL is the mirror source). @@ -245,8 +278,7 @@ class BlossomImportViewModel : ViewModel() { async { val listed = try { - val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() - clientFor(source).list(source, pubkey, auth) + clientFor(source).list(source, pubkey, listAuth) } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -273,7 +305,7 @@ class BlossomImportViewModel : ViewModel() { // Phase 2 — which of the user's own servers already hold each hash? /list where the // server supports it, HEAD-probe (bounded) the rest, so we only offer the true gaps. - val holders = targetHolders(allHashes, targets, pubkey) + val holders = targetHolders(allHashes, targets, listAuth) _candidates.value = allHashes @@ -289,8 +321,9 @@ class BlossomImportViewModel : ViewModel() { private suspend fun targetHolders( hashes: List, targets: List, - pubkey: HexKey, + listAuth: String, ): Map> { + val pubkey = account.signer.pubKey val listed: List?>> = coroutineScope { targets @@ -298,8 +331,7 @@ class BlossomImportViewModel : ViewModel() { async { target to try { - val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() - clientFor(target).list(target, pubkey, auth).mapNotNull { it.sha256 }.toSet() + clientFor(target).list(target, pubkey, listAuth).mapNotNull { it.sha256 }.toSet() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -337,15 +369,23 @@ class BlossomImportViewModel : ViewModel() { /** * Hand every discovered gap to the app-level mirror queue, which asks each of the * user's servers to fetch the blob from its source. Reuses the same queue (and - * floating progress banner) as the on-screen "sync all". Returns the file count so - * the caller can surface it. + * floating progress banner) as the on-screen "sync all". + * + * There is a single global queue, so if a sweep (or another import) is already in + * flight the queue would silently drop this one — [ImportStart.Busy] lets the caller + * keep the screen up and tell the user instead of navigating away to nothing. */ - fun importSelected(): Int { + fun importSelected(): ImportStart { val candidates = _candidates.value val tasks = candidates.map { BlossomMirrorQueue.Task(it.hash, it.sourceUrl, it.size, it.missingTargets) } - if (tasks.isEmpty()) return 0 - Amethyst.instance.blossomMirrorQueue.start(account, tasks) - return candidates.size + if (tasks.isEmpty()) return ImportStart.Empty + // start() itself atomically no-ops if a sweep is already running, so key off its return + // rather than a separate isRunning check that could race with a sweep starting. + return if (Amethyst.instance.blossomMirrorQueue.start(account, tasks)) { + ImportStart.Started(candidates.size) + } else { + ImportStart.Busy + } } private fun setScanState( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 591b0d14df..db99a0acfa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1706,6 +1706,7 @@ No new files were found on the selected servers. Add your own Blossom servers first, so there\'s somewhere to copy the imported files into. Manage my servers + A file sync is already running. Try importing again once it finishes. %1$d file %1$d files