feat(blossom): app-level "Sync all" with floating progress; rename to "My Blossom Data"

Sync-all now runs like the PoW miner — an app-level job with a floating progress
banner — instead of a screen-bound loop, so it keeps going as the user navigates:

- BlossomMirrorQueue (app-level, on applicationIOScope via Amethyst.instance): runs
  the BUD-04 fan-out, exposes an aggregate BlossomSyncState for the banner and a
  results SharedFlow so an open manager flips each pill green/grey live. Servers that
  need payment are skipped (pay those per-row).
- DisplayBlossomSyncProgress: a bottom floating banner mounted at the navigation root
  (sibling of the mining/broadcast banners) with a determinate bar, "x / N · host",
  failed count, and cancel/dismiss.
- Manager: a "Sync all" banner appears when any file has gaps; tapping it enqueues the
  sweep and optimistically spins the affected pills.

Also, per request: the screen is renamed "My Blossom Data" and moved into the left
drawer's "You" section (just before My Emoji Packs); the standalone Settings-catalog
entry is removed and the Media Servers shortcut relabeled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
Claude
2026-07-18 03:57:58 +00:00
parent da633342bc
commit ecbe2c6704
11 changed files with 354 additions and 3 deletions
@@ -107,6 +107,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -795,6 +796,9 @@ class AppModules(
}
}
/** App-level BUD-04 mirror sweep, so "sync all" keeps running as the user navigates. */
val blossomMirrorQueue by lazy { BlossomMirrorQueue(applicationIOScope) }
val powJobRestorer by lazy {
PowJobRestorer(powPublishQueue, powJobStore, scheduledPostStore)
}
@@ -0,0 +1,137 @@
/*
* 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.service.uploads.blossom
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/** Aggregate progress of a running "sync all my blobs to all my servers" sweep. */
@Immutable
data class BlossomSyncState(
val total: Int,
val done: Int,
val failed: Int,
val running: Boolean,
val currentHost: String? = null,
) {
val succeeded get() = done - failed
val fraction get() = if (total == 0) 0f else done.toFloat() / total
}
/** One completed mirror step, streamed so an open manager screen can flip its pill live. */
@Immutable
data class BlossomMirrorResult(
val hash: HexKey,
val server: String,
val ok: Boolean,
)
/**
* App-level BUD-04 mirror queue, modeled on the PoW publish queue: it runs on the
* application IO scope (via [Amethyst.instance]) so a sweep keeps going while the
* user navigates the app, and exposes [state] for a floating progress banner mounted
* at the navigation root. Servers that require payment are skipped (counted as
* failed) those are paid for individually from the manager screen.
*/
class BlossomMirrorQueue(
private val scope: CoroutineScope,
) {
data class Task(
val hash: HexKey,
val sourceUrl: String,
val size: Long?,
val targets: List<String>,
)
private val _state = MutableStateFlow<BlossomSyncState?>(null)
val state: StateFlow<BlossomSyncState?> = _state.asStateFlow()
private val _results = MutableSharedFlow<BlossomMirrorResult>(extraBufferCapacity = 128)
val results: SharedFlow<BlossomMirrorResult> = _results.asSharedFlow()
private var job: Job? = null
val isRunning get() = _state.value?.running == true
/** Enqueue a sweep. No-op if one is already running or there's nothing to do. */
fun start(
account: Account,
tasks: List<Task>,
) {
if (isRunning) return
val work = tasks.flatMap { t -> t.targets.map { t to it } }
if (work.isEmpty()) return
job =
scope.launch {
var done = 0
var failed = 0
_state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true)
for ((task, target) in work) {
_state.value = _state.value?.copy(currentHost = BlossomServerUrl.domain(target))
val ok =
try {
val auth = account.createBlossomUploadAuth(task.hash, task.size ?: 0L, "Mirror ${task.hash}").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirror(task.sourceUrl, target, auth)
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("BlossomMirrorQueue", "mirror ${task.hash} -> $target failed", e)
false
}
_results.tryEmit(BlossomMirrorResult(task.hash, target, ok))
done++
if (!ok) failed++
_state.value = _state.value?.copy(done = done, failed = failed)
}
_state.value = _state.value?.copy(running = false, currentHost = null)
}
}
/** Cancel a running sweep and clear the banner. */
fun cancel() {
job?.cancel()
job = null
_state.value = null
}
/** Dismiss the finished-summary banner (no effect while still running). */
fun dismiss() {
if (!isRunning) _state.value = null
}
}
@@ -231,7 +231,7 @@ private fun UploadBehaviorSection(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.manage_stored_files),
text = stringRes(id = R.string.my_blossom_data),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
@@ -114,7 +114,7 @@ fun BlossomBlobManagerScreen(
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = { Text(stringRes(R.string.manage_stored_files)) },
title = { Text(stringRes(R.string.my_blossom_data)) },
showBackButton = nav.canPop(),
popBack = { nav.popBack() },
actions = {
@@ -166,6 +166,9 @@ fun BlossomBlobManagerScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (blobs.any { it.hasMissing }) {
item { SyncAllBanner(onSyncAll = { vm.syncAll() }) }
}
items(blobs, key = { it.hash }) { row ->
BlobCard(row, vm)
}
@@ -201,6 +204,28 @@ private fun StatusGlyph(
}
}
@Composable
private fun SyncAllBanner(onSyncAll: () -> Unit) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(stringRes(R.string.blossom_sync_gaps), style = MaterialTheme.typography.bodyMedium)
}
FilledTonalButton(onClick = onSyncAll) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_sync_all))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BlobCard(
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -121,8 +122,20 @@ class BlossomBlobManagerViewModel : ViewModel() {
private val _pendingPayment = MutableStateFlow<PendingMirrorPayment?>(null)
val pendingPayment = _pendingPayment.asStateFlow()
private var resultCollectorStarted = false
fun init(accountViewModel: AccountViewModel) {
this.account = accountViewModel.account
// Reflect the app-level sync sweep's per-server results onto the pills, so an
// open manager turns dots green live even though the work runs in the background.
if (!resultCollectorStarted) {
resultCollectorStarted = true
viewModelScope.launch {
Amethyst.instance.blossomMirrorQueue.results.collect { r ->
setServerState(r.hash, r.server, if (r.ok) PresenceState.PRESENT else PresenceState.MISSING)
}
}
}
}
private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server))
@@ -295,6 +308,31 @@ class BlossomBlobManagerViewModel : ViewModel() {
}
}
/**
* BUD-04 sweep: hand the whole "fill every gap" job to the app-level
* [BlossomMirrorQueue] so it keeps running (with a floating progress banner) as
* the user navigates away. The pills we're about to fill go straight to a spinner;
* the queue's [results] stream (collected in [init]) flips each to green/grey as it
* lands, so an open manager stays in sync with the background sweep.
*/
fun syncAll() {
val tasks =
_blobs.value
.filter { it.hasMissing && it.url != null }
.map { BlossomMirrorQueue.Task(it.hash, it.url!!, it.size, it.missingServers) }
if (tasks.isEmpty()) return
_blobs.value =
_blobs.value.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)
}
private suspend fun mirrorOne(
source: String,
hash: HexKey,
@@ -0,0 +1,130 @@
/*
* 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.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* App-wide floating banner for the BUD-04 "sync all" sweep, mounted at the navigation
* root (a sibling of the mining/broadcast banners) so it floats over every screen and
* survives navigation. Observes the app-level [Amethyst.instance.blossomMirrorQueue].
*/
@Composable
fun DisplayBlossomSyncProgress() {
val queue = Amethyst.instance.blossomMirrorQueue
val state by queue.state.collectAsStateWithLifecycle()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
AnimatedVisibility(
visible = state != null,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier =
Modifier
.navigationBarsPadding()
.padding(start = 12.dp, end = 12.dp, bottom = 116.dp)
.widthIn(max = 560.dp),
) {
val s = state ?: return@AnimatedVisibility
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = if (s.running) MaterialSymbols.CloudUpload else MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (s.running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = if (s.running) stringRes(R.string.blossom_syncing) else stringRes(R.string.blossom_sync_done),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 10.dp),
)
IconButton(
onClick = { if (s.running) queue.cancel() else queue.dismiss() },
modifier = Modifier.size(28.dp),
) {
Icon(symbol = MaterialSymbols.Close, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
LinearProgressIndicator(
progress = { s.fraction },
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
)
Text(
text =
buildString {
append("${s.done} / ${s.total}")
if (s.running && s.currentHost != null) append(" · ${s.currentHost}")
if (s.failed > 0) append(" · ${s.failed} failed")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
}
@@ -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.DisplayBlossomSyncProgress
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallActivity
@@ -351,6 +352,7 @@ fun AppNavigation(
DisplayCrashMessages(accountViewModel, nav)
DisplayResourceUsageAlert(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
DisplayBlossomSyncProgress()
ObserveIncomingCalls(accountViewModel)
}
@@ -46,6 +46,7 @@ enum class NavBarItem {
DRAFTS,
SCHEDULED_POSTS,
INTEREST_SETS,
BLOSSOM_DATA,
EMOJI_PACKS,
WALLET,
NOSTR_SIGNER,
@@ -182,6 +183,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.AutoAwesome,
resolveRoute = { Route.EditFavoriteAlgoFeeds },
),
NavBarItem.BLOSSOM_DATA to
NavBarItemDef(
id = NavBarItem.BLOSSOM_DATA,
labelRes = R.string.my_blossom_data,
icon = MaterialSymbols.Storage,
resolveRoute = { Route.ManageBlossomBlobs },
),
NavBarItem.EMOJI_PACKS to
NavBarItemDef(
id = NavBarItem.EMOJI_PACKS,
@@ -449,6 +457,7 @@ val DrawerYouItems: List<NavBarItem> =
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
@@ -502,6 +511,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
@@ -168,6 +168,7 @@ private fun PreloadFor(
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
@@ -66,7 +66,6 @@ fun buildSettingsCatalog(
symEntry(R.string.event_sync_title, MaterialSymbols.Sync, R.string.event_sync_search_keywords, Route.EventSync),
symEntry(R.string.route_import_follows, MaterialSymbols.GroupAdd, R.string.import_follows_search_keywords, Route.ImportFollowsSelectUser),
symEntry(R.string.media_servers, MaterialSymbols.CloudUpload, R.string.media_servers_search_keywords, Route.EditMediaServers),
symEntry(R.string.manage_stored_files, MaterialSymbols.Storage, R.string.media_servers_search_keywords, Route.ManageBlossomBlobs),
symEntry(R.string.nests_servers_title, MaterialSymbols.CloudUpload, R.string.nests_servers_search_keywords, Route.EditNestsServers),
symEntry(R.string.reactions, MaterialSymbols.FavoriteBorder, R.string.reactions_search_keywords, Route.UpdateReactionType),
symEntry(R.string.zaps, MaterialSymbols.Bolt, R.string.zaps_search_keywords, Route.UpdateZapAmount()),
+5
View File
@@ -1551,7 +1551,12 @@
<string name="blossom_optimize_media">Optimize media on the server</string>
<string name="blossom_optimize_media_caption">Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original.</string>
<string name="manage_stored_files">Manage stored files</string>
<string name="my_blossom_data">My Blossom Data</string>
<string name="blossom_refresh">Refresh</string>
<string name="blossom_sync_all">Sync all</string>
<string name="blossom_sync_gaps">Some of your files aren\'t on all your servers yet.</string>
<string name="blossom_syncing">Copying your files across servers…</string>
<string name="blossom_sync_done">Sync complete</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>
<string name="blossom_delete_from">Delete from…</string>