diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt index 37bfb93b5c..0414ff950c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt @@ -46,16 +46,6 @@ class BlossomSyncForegroundService : FlowProgressForegroundService allHashes.map { hash -> - async { (server to hash) to clientFor(server).has(hash, server) } + async { limiter.withPermit { (server to hash) to clientFor(server).has(hash, server) } } } }.awaitAll() }.toMap() @@ -248,14 +264,18 @@ class BlossomBlobManagerViewModel : ViewModel() { server: String, state: PresenceState, ) { - _blobs.value = - _blobs.value.map { row -> + // Atomic read-modify-write: the app-level sync results collector (Main) and the + // per-row delete/mirror actions (IO) both mutate _blobs concurrently, so a plain + // `_blobs.value = _blobs.value.map{}` would lose updates. + _blobs.update { list -> + list.map { row -> if (row.hash != hash) { row } else { row.copy(servers = row.servers.map { if (it.server == server) it.copy(state = state) else it }) } } + } } /** BUD-02 delete: remove [hash] from a single [server]; the pill spins then goes grey. */ @@ -275,9 +295,7 @@ class BlossomBlobManagerViewModel : ViewModel() { } setServerState(hash, server, if (ok) PresenceState.MISSING else PresenceState.PRESENT) // Drop the row entirely once it's gone from every server. - if (currentRow(hash)?.hasPresent == false) { - _blobs.value = _blobs.value.filter { it.hash != hash } - } + _blobs.update { list -> if (list.firstOrNull { it.hash == hash }?.hasPresent == false) list.filter { it.hash != hash } else list } } } @@ -322,14 +340,15 @@ class BlossomBlobManagerViewModel : ViewModel() { .map { BlossomMirrorQueue.Task(it.hash, it.url!!, it.size, it.missingServers) } if (tasks.isEmpty()) return - _blobs.value = - _blobs.value.map { row -> + _blobs.update { list -> + list.map { row -> if (!row.hasMissing) { row } else { row.copy(servers = row.servers.map { if (it.state == PresenceState.MISSING) it.copy(state = PresenceState.PENDING) else it }) } } + } Amethyst.instance.blossomMirrorQueue.start(account, tasks) } @@ -400,4 +419,9 @@ class BlossomBlobManagerViewModel : ViewModel() { val size: Long?, val type: String?, ) + + companion object { + /** Cap on concurrent HEAD probes during the /list backfill. */ + private const val MAX_HEAD_PROBES = 8 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt index 0ec725109d..8a9ad2a000 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt @@ -41,7 +41,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -51,6 +55,7 @@ import com.vitorpamplona.amethyst.Amethyst 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.service.uploads.blossom.BlossomSyncState import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.allGoodColor import com.vitorpamplona.amethyst.ui.theme.grayText @@ -65,6 +70,11 @@ fun DisplayBlossomSyncProgress() { val queue = Amethyst.instance.blossomMirrorQueue val state by queue.state.collectAsStateWithLifecycle() + // Retain the last non-null value so the slide-out exit still has content to draw when + // state clears to null on cancel/dismiss. + var lastShown by remember { mutableStateOf(null) } + LaunchedEffect(state) { state?.let { lastShown = it } } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { AnimatedVisibility( visible = state != null, @@ -76,7 +86,7 @@ fun DisplayBlossomSyncProgress() { .padding(start = 12.dp, end = 12.dp, bottom = 116.dp) .widthIn(max = 560.dp), ) { - val s = state ?: return@AnimatedVisibility + val s = lastShown ?: return@AnimatedVisibility Surface( shape = RoundedCornerShape(18.dp), color = MaterialTheme.colorScheme.surfaceContainer, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt index d550b087b4..67b78a4749 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -116,7 +116,12 @@ object BlossomCommands { val args = Args(rest) val server = args.flag("server") ?: return Output.error("bad_args", "blossom mirror requires --server URL") val sourceUrl = args.positional(0, "source-url") - val hash = sourceUrl.substringAfterLast('/').substringBefore('.') + val hash = + sourceUrl + .substringBefore('?') + .substringBefore('#') + .substringAfterLast('/') + .substringBefore('.') if (hash.length != 64 || hash.any { it !in "0123456789abcdef" }) { return Output.error("bad_args", "could not extract a sha256 from the source url '$sourceUrl'") } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt index 4bafe94247..f8fe9246b8 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -37,6 +37,7 @@ import okhttp3.Response import okio.BufferedSink import okio.source import java.io.File +import kotlin.coroutines.cancellation.CancellationException /** * Thrown when a Blossom server answers with `402 Payment Required` (BUD-07). The @@ -198,6 +199,8 @@ open class BlossomClient( .build() try { okHttpClient.newCall(request).execute().use { it.isSuccessful } + } catch (e: CancellationException) { + throw e } catch (_: Exception) { false }