From bb03cd2a3c6e2cb50adaadee2a0373aacf3bcdd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:35:01 +0000 Subject: [PATCH 01/14] feat(blossom): full-client protocol support across quartz, commons, CLI and Android Extends Blossom support toward a full client on both the CLI and the mobile app. Quartz (protocol): - BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11 `server` domain scoping on every factory (stops replayable upload/delete tokens) - BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and BUD-07 payment header constants, and a lowercase bare-domain helper - BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08) - BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning) - BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders Commons (shared JVM client, now in jvmAndroid so Android shares it too): - BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05), preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling - BlossomAuth: media/list/delete passthroughs with server scoping CLI (first-class): - amy blossom now routes all HTTP through the shared client and adds `media` and `report` verbs; auth tokens are scoped to --server Android (first-class): - uploads mirror to the user's other Blossom servers (BUD-04) best-effort - new "Manage stored files" screen: per-server presence matrix (BUD-02 list + BUD-01 HEAD), delete, mirror-to-missing, and report actions Tests: quartz URL/auth/descriptor/payment parsing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../vitorpamplona/amethyst/model/Account.kt | 18 +- .../nipB7Blossom/BlossomServerListState.kt | 18 +- .../service/uploads/UploadOrchestrator.kt | 68 +++- .../mediaServers/BlossomBlobManagerScreen.kt | 286 +++++++++++++++ .../BlossomBlobManagerViewModel.kt | 227 ++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../settings/SettingsCatalogBuilder.kt | 1 + amethyst/src/main/res/values/strings.xml | 9 + .../amethyst/cli/commands/BlossomCommands.kt | 200 ++++++----- .../commons/service/upload/BlossomAuth.kt | 24 +- .../commons/service/upload/BlossomClient.kt | 336 ++++++++++++++++++ .../commons/service/upload/BlossomClient.kt | 140 -------- .../nipB7Blossom/BlossomAuthorizationEvent.kt | 39 +- .../nipB7Blossom/BlossomPaymentRequired.kt | 58 +++ .../quartz/nipB7Blossom/BlossomReport.kt | 51 +++ .../quartz/nipB7Blossom/BlossomServerUrl.kt | 55 ++- .../nipB7Blossom/BlossomUploadResult.kt | 8 + .../BlossomAuthorizationEventTest.kt | 102 ++++++ .../nipB7Blossom/BlossomServerUrlTest.kt | 59 +++ .../nipB7Blossom/BlossomUploadResultTest.kt | 108 ++++++ 21 files changed, 1557 insertions(+), 254 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt rename commons/src/{jvmMain => jvmAndroid}/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt (65%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt delete mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomReport.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrlTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 109e5737bf..9ea8909821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1594,12 +1594,26 @@ class Account( hash: HexKey, size: Long, alt: String, - ) = blossomServers.createBlossomUploadAuth(hash, size, alt) + servers: List = emptyList(), + ) = blossomServers.createBlossomUploadAuth(hash, size, alt, servers) + + suspend fun createBlossomMediaAuth( + hash: HexKey, + size: Long, + alt: String, + servers: List = emptyList(), + ) = blossomServers.createBlossomMediaAuth(hash, size, alt, servers) suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ) = blossomServers.createBlossomDeleteAuth(hash, alt) + servers: List = emptyList(), + ) = blossomServers.createBlossomDeleteAuth(hash, alt, servers) + + suspend fun createBlossomListAuth( + alt: String, + servers: List = emptyList(), + ) = blossomServers.createBlossomListAuth(alt, servers) suspend fun boost(note: Note) { val powDifficulty = powDifficultyFor(RepostEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index e4533b8f0b..88c5ea3e3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -120,12 +120,26 @@ class BlossomServerListState( hash: HexKey, size: Long, alt: String, - ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers) + + suspend fun createBlossomMediaAuth( + hash: HexKey, + size: Long, + alt: String, + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers) suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers) + + suspend fun createBlossomListAuth( + alt: String, + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createListAuth(signer, alt, servers) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index ed5b3e7b84..fe264d3f0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -24,6 +24,7 @@ import android.content.Context import android.net.Uri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.NostrCipher import kotlinx.coroutines.flow.MutableStateFlow @@ -211,22 +213,32 @@ class UploadOrchestrator { sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + // Scope the upload token to the target server (BUD-11) so it can't be replayed elsewhere. httpAuth = if (forcedSigner != null) { - { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } + { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) } } else { - account::createBlossomUploadAuth + { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) } }, context = context, ) - verifyHeader( - uploadResult = result, - localContentType = contentType, - okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - originalHash = originalHash, - originalContentType = contentTypeForResult, - ) + val finalState = + verifyHeader( + uploadResult = result, + localContentType = contentType, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + originalHash = originalHash, + originalContentType = contentTypeForResult, + ) + + // BUD-04: replicate the blob to the user's other Blossom servers for redundancy. + // Best-effort — a mirror failure never fails the upload the user already completed. + if (finalState is UploadingState.Finished && forcedSigner == null) { + mirrorToOtherServers(result, serverBaseUrl, account) + } + + finalState } catch (_: SignerExceptions.ReadOnlyException) { error(R.string.login_with_a_private_key_to_be_able_to_upload) } catch (e: Exception) { @@ -235,6 +247,44 @@ class UploadOrchestrator { } } + /** + * BUD-04 mirror fan-out: asks every *other* Blossom server in the account's + * kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs + * after the primary upload is confirmed, so the user's post is never delayed by + * a slow/offline mirror; failures are swallowed per-server. Requires the blob's + * sha256 (to scope the mirror auth and let server B verify the download). + */ + private suspend fun mirrorToOtherServers( + result: MediaUploadResult, + primaryServerBaseUrl: String, + account: Account, + ) { + val sourceUrl = result.url ?: return + val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.') + if (hash.length != 64) return + + val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl) + val targets = + account.blossomServers.hostNameFlow.value + .filter { it.type == ServerType.Blossom && BlossomServerUrl.domain(it.baseUrl) != primaryDomain } + .map { it.baseUrl } + .distinct() + + if (targets.isEmpty()) return + + updateState(0.95, UploadingState.ServerProcessing) + targets.forEach { target -> + try { + val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash", listOf(target)).toAuthorizationHeader() + BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target)) + .mirror(sourceUrl, target, auth) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e) + } + } + } + private suspend fun verifyHeader( uploadResult: MediaUploadResult, localContentType: String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt new file mode 100644 index 0000000000..7b290f1ce4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -0,0 +1,286 @@ +/* + * 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.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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 +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.ui.navigation.navs.INav +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.grayText +import com.vitorpamplona.quartz.nip56Reports.ReportType + +@Composable +fun BlossomBlobManagerScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val vm: BlossomBlobManagerViewModel = viewModel() + vm.init(accountViewModel) + + LaunchedEffect(accountViewModel) { vm.refresh() } + + val blobs by vm.blobs.collectAsStateWithLifecycle() + val loading by vm.isLoading.collectAsStateWithLifecycle() + val error by vm.error.collectAsStateWithLifecycle() + + Scaffold( + topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding( + start = 12.dp, + end = 12.dp, + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + ), + ) { + when { + loading && blobs.isEmpty() -> + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { CircularProgressIndicator() } + + error != null && blobs.isEmpty() -> + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(error ?: "", color = MaterialTheme.colorScheme.error) + TextButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) } + } + + blobs.isEmpty() -> + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { Text(stringRes(R.string.manage_stored_files_empty), color = MaterialTheme.colorScheme.grayText) } + + else -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(blobs, key = { it.hash }) { row -> + BlobCard(row, vm) + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun BlobCard( + row: BlobRow, + vm: BlossomBlobManagerViewModel, +) { + var deleteMenuOpen by remember { mutableStateOf(false) } + var reportOpen by remember { mutableStateOf(false) } + + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = row.hash.take(16) + "…", + style = MaterialTheme.typography.titleSmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + row.serversPresent.forEach { server -> + AssistChip( + onClick = {}, + label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) }, + colors = + AssistChipDefaults.assistChipColors( + labelColor = MaterialTheme.colorScheme.primary, + ), + ) + } + row.serversMissing.forEach { server -> + AssistChip( + onClick = {}, + label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) }, + colors = + AssistChipDefaults.assistChipColors( + labelColor = MaterialTheme.colorScheme.grayText, + ), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (row.serversMissing.isNotEmpty() && row.url != null) { + OutlinedButton(onClick = { vm.mirrorToMissing(row) }) { + Text(stringRes(R.string.blossom_mirror_to_missing), style = MaterialTheme.typography.labelMedium) + } + } + + if (row.serversPresent.isNotEmpty()) { + Column { + OutlinedButton(onClick = { deleteMenuOpen = true }) { + Text(stringRes(R.string.blossom_delete_from), style = MaterialTheme.typography.labelMedium) + } + DropdownMenu(expanded = deleteMenuOpen, onDismissRequest = { deleteMenuOpen = false }) { + row.serversPresent.forEach { server -> + DropdownMenuItem( + text = { Text(vm.hostOf(server)) }, + onClick = { + deleteMenuOpen = false + vm.delete(row.hash, server) + }, + ) + } + } + } + } + + if (row.serversPresent.isNotEmpty()) { + TextButton(onClick = { reportOpen = true }) { + Text(stringRes(R.string.blossom_report), style = MaterialTheme.typography.labelMedium) + } + } + } + } + } + + if (reportOpen) { + BlossomReportDialog( + row = row, + vm = vm, + onDismiss = { reportOpen = false }, + ) + } +} + +private fun humanBytes(bytes: Long): String = + when { + bytes >= 1_000_000 -> "${bytes / 1_000_000} MB" + bytes >= 1_000 -> "${bytes / 1_000} KB" + else -> "$bytes B" + } + +@Composable +private fun BlossomReportDialog( + row: BlobRow, + vm: BlossomBlobManagerViewModel, + onDismiss: () -> Unit, +) { + var comment by remember { mutableStateOf("") } + var typeMenuOpen by remember { mutableStateOf(false) } + var type by remember { mutableStateOf(ReportType.OTHER) } + // Report to the first server that actually holds the blob. + val server = row.serversPresent.firstOrNull() ?: return + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.blossom_report_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column { + OutlinedButton(onClick = { typeMenuOpen = true }) { Text(type.code) } + DropdownMenu(expanded = typeMenuOpen, onDismissRequest = { typeMenuOpen = false }) { + ReportType.entries.forEach { rt -> + DropdownMenuItem( + text = { Text(rt.code) }, + onClick = { + type = rt + typeMenuOpen = false + }, + ) + } + } + } + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text(stringRes(R.string.blossom_report_comment_hint)) }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button(onClick = { + vm.report(row.hash, server, type, comment) + onDismiss() + }) { Text(stringRes(R.string.blossom_send)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt new file mode 100644 index 0000000000..baf6f84481 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -0,0 +1,227 @@ +/* + * 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.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip56Reports.ReportType +import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * One stored blob, plus which of the user's Blossom servers currently hold it — + * the "seen files per server" view (BUD-01 HEAD / BUD-02 list). [serversPresent] + * and [serversMissing] are server base URLs from the user's kind-10063 list. + */ +@Immutable +data class BlobRow( + val hash: HexKey, + val url: String?, + val size: Long?, + val type: String?, + val serversPresent: List, + val serversMissing: List, +) + +/** + * Backs the Blossom blob-manager screen. For the active account it fans a + * `GET /list/` (BUD-02) across every server in the user's kind-10063 list, + * inverts the results into a per-blob presence matrix, and backfills servers that + * don't implement `/list` with cheap `HEAD /` probes (BUD-01). Exposes + * delete (BUD-02), mirror-to-missing (BUD-04), and report (BUD-09) actions. + */ +@Stable +class BlossomBlobManagerViewModel : ViewModel() { + private lateinit var account: Account + + private val _blobs = MutableStateFlow>(emptyList()) + val blobs = _blobs.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error = _error.asStateFlow() + + fun init(accountViewModel: AccountViewModel) { + this.account = accountViewModel.account + } + + private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server)) + + private fun servers(): List = + account.blossomServers.hostNameFlow.value + .filter { it.type == ServerType.Blossom } + .map { it.baseUrl } + .distinct() + + fun refresh() { + viewModelScope.launch(Dispatchers.IO) { + _isLoading.value = true + _error.value = null + try { + _blobs.value = loadMatrix() + } catch (e: Exception) { + Log.w("BlossomBlobManager", "Failed to load blob list", e) + _error.value = e.message ?: e.javaClass.simpleName + } finally { + _isLoading.value = false + } + } + } + + private suspend fun loadMatrix(): List { + val pubkey = account.signer.pubKey + val servers = servers() + if (servers.isEmpty()) return emptyList() + + // Per-server /list results, keyed by hash. Servers that don't implement + // /list (or error) contribute an empty holding and get HEAD-backfilled below. + val presence = mutableMapOf>() + val meta = mutableMapOf() + val listCapable = mutableSetOf() + + servers.forEach { server -> + try { + val auth = account.createBlossomListAuth("List blobs", listOf(server)).toAuthorizationHeader() + val blobs = clientFor(server).list(server, pubkey, auth) + listCapable.add(server) + blobs.forEach { d -> + val hash = d.sha256 ?: return@forEach + presence.getOrPut(hash) { mutableSetOf() }.add(server) + meta.putIfAbsent(hash, BlobMeta(d.url, d.size, d.type)) + } + } catch (e: Exception) { + Log.w("BlossomBlobManager", "list failed on $server", e) + } + } + + // BUD-01 backfill: for every known hash, HEAD-probe the servers that + // didn't (or couldn't) list it, so the presence matrix is complete. + val allHashes = presence.keys.toList() + servers.forEach { server -> + allHashes.forEach { hash -> + if (server !in presence[hash].orEmpty()) { + if (clientFor(server).has(hash, server)) { + presence.getOrPut(hash) { mutableSetOf() }.add(server) + } + } + } + } + + return presence + .map { (hash, present) -> + val m = meta[hash] + BlobRow( + hash = hash, + url = m?.url, + size = m?.size, + type = m?.type, + serversPresent = servers.filter { it in present }, + serversMissing = servers.filter { it !in present }, + ) + }.sortedByDescending { it.serversPresent.size } + } + + /** BUD-02 delete: remove [hash] from a single [server]. */ + fun delete( + hash: HexKey, + server: String, + onDone: (Boolean) -> Unit = {}, + ) { + viewModelScope.launch(Dispatchers.IO) { + val ok = + try { + val auth = account.createBlossomDeleteAuth(hash, "Delete blob", listOf(server)).toAuthorizationHeader() + clientFor(server).delete(hash, server, auth) + } catch (e: Exception) { + Log.w("BlossomBlobManager", "delete failed on $server", e) + false + } + if (ok) refresh() + withContext(Dispatchers.Main) { onDone(ok) } + } + } + + /** BUD-04: mirror a blob to every server in the user's list that doesn't have it yet. */ + fun mirrorToMissing( + row: BlobRow, + onDone: (Int) -> Unit = {}, + ) { + val source = row.url ?: return onDone(0) + viewModelScope.launch(Dispatchers.IO) { + var mirrored = 0 + row.serversMissing.forEach { target -> + try { + val auth = account.createBlossomUploadAuth(row.hash, row.size ?: 0L, "Mirror ${row.hash}", listOf(target)).toAuthorizationHeader() + clientFor(target).mirror(source, target, auth) + mirrored++ + } catch (e: Exception) { + Log.w("BlossomBlobManager", "mirror to $target failed", e) + } + } + if (mirrored > 0) refresh() + withContext(Dispatchers.Main) { onDone(mirrored) } + } + } + + /** BUD-09: report a blob to a server as problematic content. */ + fun report( + hash: HexKey, + server: String, + type: ReportType, + comment: String, + onDone: (Boolean) -> Unit = {}, + ) { + viewModelScope.launch(Dispatchers.IO) { + val ok = + try { + val event = account.signer.sign(BlossomReport.build(hash, type, account.signer.pubKey, comment)) + clientFor(server).report(server, event.toJson()) + } catch (e: Exception) { + Log.w("BlossomBlobManager", "report failed on $server", e) + false + } + withContext(Dispatchers.Main) { onDone(ok) } + } + } + + fun hostOf(serverBaseUrl: String): String = BlossomServerUrl.domain(serverBaseUrl) + + private data class BlobMeta( + val url: String?, + val size: Long?, + val type: String?, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c60fbefdca..6165aaaef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAler 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.paymentTargets.PaymentTargetsScreen import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress import com.vitorpamplona.amethyst.ui.call.CallActivity @@ -561,6 +562,7 @@ fun BuildNavigation( composableFromEnd { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEndArgs { BlossomBlobManagerScreen(accountViewModel, nav) } composableFromEndArgs { com.vitorpamplona.amethyst.ui.actions.nestsServers .NestsServersScreen(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index c2c39eb3ac..d6f6780592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -465,6 +465,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object ManageBlossomBlobs : Route() + @Serializable object EditNestsServers : Route() @Serializable object EditFavoriteAlgoFeeds : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 5e85e88c94..a2fdee8592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -66,6 +66,7 @@ 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()), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 67af627ae0..68f323a66b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1545,6 +1545,15 @@ You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ + Manage stored files + No stored files found on your Blossom servers. + Mirror to missing + Delete from… + Report + Report blob + Reason (optional) + Send + Recommended Media Servers Amethyst\'s default list. You can add them individually or add the list. Use Default List 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 56d1847c30..d550b087b4 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 @@ -26,31 +26,34 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient +import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType +import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.utils.sha256.sha256 -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody.Companion.toRequestBody import java.io.File import java.nio.file.Files /** - * `amy blossom ` — Blossom blob storage - * (nak's `blossom`). Uploads/lists/deletes are authed with the active - * account (BUD-01/02/04 kind:24242 events); downloads are public. + * `amy blossom ` — Blossom + * blob storage (nak's `blossom`, but fuller). Auth'd operations use the active + * account (BUD-01/02/04/05/09 kind:24242 events); downloads and HEAD checks are + * public. * * upload --server URL FILE [--mime-type M] - * download URL [--out FILE] (or: download HASH --server URL) - * list --server URL [USER] (USER defaults to the active account) + * media --server URL FILE [--mime-type M] (BUD-05 optimize on upload) + * download URL [--out FILE] (or: download HASH --server URL) + * list --server URL [USER] (USER defaults to the active account) * delete HASH --server URL + * check --server URL HASH[,HASH] (BUD-01 HEAD probe) + * mirror --server URL SOURCE-URL (BUD-04) + * report --server URL HASH [--type T] [--comment C] [--uploader HEX] * - * Thin assembly only: HTTP + auth live in commons `BlossomClient` / - * `BlossomAuth` and quartz `BlossomAuthorizationEvent`; this file wires - * flags and shapes output. List/delete use OkHttp directly (no client - * method exists) with the quartz-built auth header. + * Thin assembly only: all HTTP + auth live in commons `BlossomClient` / + * `BlossomAuth` and quartz `BlossomAuthorizationEvent` / `BlossomReport`; this + * file wires flags and shapes output. Auth tokens are scoped to `--server` so + * they can't be replayed elsewhere (BUD-11). */ object BlossomCommands { suspend fun dispatch( @@ -60,14 +63,16 @@ object BlossomCommands { route( "blossom", tail, - "blossom ", + "blossom ", mapOf( - "upload" to { rest -> upload(dataDir, rest) }, + "upload" to { rest -> upload(dataDir, rest, media = false) }, + "media" to { rest -> upload(dataDir, rest, media = true) }, "download" to { rest -> download(dataDir, rest) }, "list" to { rest -> list(dataDir, rest) }, "delete" to { rest -> delete(dataDir, rest) }, "check" to { rest -> check(dataDir, rest) }, "mirror" to { rest -> mirror(dataDir, rest) }, + "report" to { rest -> report(dataDir, rest) }, ), ) @@ -87,22 +92,11 @@ object BlossomCommands { // Read-only HEAD probe — no auth, so it runs anonymously without an account. Context.openOrAnonymous(dataDir).use { _ -> - val http = OkHttpClient() + val client = BlossomClient() val results = hashes.map { hash -> - val req = - Request - .Builder() - .url(BlossomServerUrl.blob(server, hash)) - .head() - .build() - val (found, status) = - try { - http.newCall(req).execute().use { it.isSuccessful to it.code } - } catch (e: Exception) { - false to -1 - } - mapOf("sha256" to hash, "found" to found, "status" to status) + val found = client.has(hash, server) + mapOf("sha256" to hash, "found" to found) } val allFound = results.all { it["found"] == true } Output.emit(mapOf("server" to server, "all_found" to allFound, "results" to results)) @@ -129,32 +123,23 @@ object BlossomCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val auth = BlossomAuthorizationEvent.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer).toAuthorizationHeader() - val body = """{"url":${Output.mapper.writeValueAsString(sourceUrl)}}""".toRequestBody("application/json".toMediaType()) - val req = - Request - .Builder() - .url(server.removeSuffix("/") + "/mirror") - .header("Authorization", auth) - .put(body) - .build() - OkHttpClient().newCall(req).execute().use { response -> - if (!response.isSuccessful) { - return Output.error("http_error", "mirror failed: HTTP ${response.code} ${response.headers[BlossomServerUrl.REASON_HEADER] ?: ""}") - } - val node = Output.mapper.readTree(response.body.string()) + val auth = BlossomAuth.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer, servers = listOf(server)) + return withPayment(server) { + val node = BlossomClient().mirror(sourceUrl, server, auth) Output.emit(mapOf("server" to server, "sha256" to hash, "blob" to node)) + 0 } - return 0 } } private suspend fun upload( dataDir: DataDir, rest: Array, + media: Boolean, ): Int { val args = Args(rest) - val server = args.flag("server") ?: return Output.error("bad_args", "blossom upload requires --server URL") + val verb = if (media) "media" else "upload" + val server = args.flag("server") ?: return Output.error("bad_args", "blossom $verb requires --server URL") val path = args.positional(0, "file") val file = File(path) if (!file.isFile) return Output.error("bad_args", "no such file: $path") @@ -165,18 +150,27 @@ object BlossomCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val auth = BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer) - val result = BlossomClient().upload(file, mime, server, auth) - Output.emit( - mapOf( - "url" to result.url, - "sha256" to (result.sha256 ?: hash), - "size" to (result.size ?: file.length()), - "type" to (result.type ?: mime), - "server" to server, - ), - ) - return 0 + val client = BlossomClient() + val auth = + if (media) { + BlossomAuth.createMediaAuth(hash, file.length(), "Optimize ${file.name}", ctx.signer, servers = listOf(server)) + } else { + BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer, servers = listOf(server)) + } + return withPayment(server) { + val result = if (media) client.media(file, mime, server, auth) else client.upload(file, mime, server, auth) + Output.emit( + mapOf( + "url" to result.url, + "sha256" to (result.sha256 ?: hash), + "ox" to result.ox, + "size" to (result.size ?: file.length()), + "type" to (result.type ?: mime), + "server" to server, + ), + ) + 0 + } } } @@ -190,7 +184,7 @@ object BlossomCommands { val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target // Public download — no auth, so it runs anonymously without an account. - Context.openOrAnonymous(dataDir).use { ctx -> + Context.openOrAnonymous(dataDir).use { _ -> val bytes = BlossomClient().download(url) ?: return Output.error("not_found", "server returned no blob for $url") @@ -220,21 +214,9 @@ object BlossomCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val pubkey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex - val auth = BlossomAuthorizationEvent.createListAuth(ctx.signer, "List blobs").toAuthorizationHeader() - val listUrl = server.removeSuffix("/") + "/list/" + pubkey - - val request = - Request - .Builder() - .url(listUrl) - .header("Authorization", auth) - .get() - .build() - OkHttpClient().newCall(request).execute().use { response -> - if (!response.isSuccessful) return Output.error("http_error", "server returned HTTP ${response.code} for $listUrl") - val node = Output.mapper.readTree(response.body.string()) - Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to node.size(), "blobs" to node)) - } + val auth = BlossomAuth.createListAuth("List blobs", ctx.signer, servers = listOf(server)) + val blobs = BlossomClient().list(server, pubkey, auth) + Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to blobs.size, "blobs" to blobs)) return 0 } } @@ -249,26 +231,56 @@ object BlossomCommands { Context.open(dataDir).use { ctx -> ctx.prepare() - val auth = BlossomAuthorizationEvent.createDeleteAuth(hash, "Delete blob", ctx.signer).toAuthorizationHeader() - val blobUrl = BlossomServerUrl.blob(server, hash) - val request = - Request - .Builder() - .url(blobUrl) - .header("Authorization", auth) - .delete() - .build() - OkHttpClient().newCall(request).execute().use { response -> - Output.emit( - mapOf( - "sha256" to hash, - "server" to server, - "deleted" to response.isSuccessful, - "status" to response.code, - ), - ) - return if (response.isSuccessful) 0 else 1 - } + val auth = BlossomAuth.createDeleteAuth(hash, "Delete blob", ctx.signer, servers = listOf(server)) + val deleted = BlossomClient().delete(hash, server, auth) + Output.emit(mapOf("sha256" to hash, "server" to server, "deleted" to deleted)) + return if (deleted) 0 else 1 } } + + /** + * `blossom report --server URL HASH [--type T] [--comment C] [--uploader HEX]` + * — PUT a signed NIP-56 (kind 1984) blob report to the server's /report + * endpoint (BUD-09). [type] is a NIP-56 report code (spam, illegal, nudity, + * malware, …), defaulting to `other`. + */ + private suspend fun report( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom report requires --server URL") + val hash = args.positional(0, "sha256") + val type = + args.flag("type")?.let { code -> + ReportType.entries.firstOrNull { it.code.equals(code, ignoreCase = true) } + ?: return Output.error("bad_args", "unknown --type '$code' (use ${ReportType.entries.joinToString("|") { it.code }})") + } ?: ReportType.OTHER + val comment = args.flag("comment") ?: "" + val uploader = args.flag("uploader") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val event = ctx.signer.sign(BlossomReport.build(hash, type, uploader, comment)) + val ok = BlossomClient().report(server, event.toJson()) + Output.emit(mapOf("server" to server, "sha256" to hash, "type" to type.code, "reported" to ok)) + return if (ok) 0 else 1 + } + } + + /** Runs [block], turning a BUD-07 402 into a clean payment-required error. */ + private inline fun withPayment( + server: String, + block: () -> Int, + ): Int = + try { + block() + } catch (e: BlossomPaymentException) { + Output.error( + "payment_required", + "server $server requires payment: ${e.payment.reason ?: "402"}" + + (e.payment.cashu?.let { " (cashu available)" } ?: "") + + (e.payment.lightning?.let { " (lightning invoice available)" } ?: ""), + ) + } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt similarity index 65% rename from commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt index 0bda515f8a..7c1597f992 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt @@ -30,7 +30,29 @@ object BlossomAuth { size: Long, alt: String, signer: NostrSigner, - ): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer).toAuthorizationHeader() + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers).toAuthorizationHeader() + + suspend fun createMediaAuth( + hash: HexKey, + size: Long, + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers).toAuthorizationHeader() + + suspend fun createListAuth( + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createListAuth(signer, alt, servers).toAuthorizationHeader() + + suspend fun createDeleteAuth( + hash: HexKey, + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers).toAuthorizationHeader() fun encodeAuthHeader(event: BlossomAuthorizationEvent): String = event.toAuthorizationHeader() } 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 new file mode 100644 index 0000000000..510631c6d2 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -0,0 +1,336 @@ +/* + * 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.commons.service.upload + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.BufferedSink +import okio.source +import java.io.File + +/** + * Thrown when a Blossom server answers with `402 Payment Required` (BUD-07). The + * caller pays [payment] (Cashu or Lightning) and retries the request with the + * proof attached. + */ +class BlossomPaymentException( + val server: String, + val payment: BlossomPaymentRequired, +) : RuntimeException("Payment required by $server: ${payment.reason ?: "402 Payment Required"}") + +/** Result of a BUD-06 `HEAD /upload` or `HEAD /media` preflight. */ +data class BlossomPreflightResult( + val accepted: Boolean, + val status: Int, + val reason: String? = null, +) + +/** + * Blossom HTTP client for JVM consumers (desktop + CLI + Android's + * shared logic). Owns no global state — pass a configured [OkHttpClient] (e.g. + * desktop's Tor-aware `DesktopHttpClient.currentClient()`) for proxying / + * connection pooling. The default constructor uses a fresh OkHttpClient — fine + * for one-shot uses such as the CLI. + * + * Covers BUD-01 (download), BUD-02 (upload/list/delete), BUD-04 (mirror), + * BUD-05 (media), BUD-06 (preflight), BUD-07 (402), and BUD-09 (report). Every + * optional endpoint degrades gracefully so callers can fan out across servers of + * varying capability. + */ +open class BlossomClient( + private val okHttpClient: OkHttpClient = OkHttpClient(), +) { + open suspend fun upload( + file: File, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader) + + /** + * Upload raw bytes (e.g. encrypted blobs) to a Blossom server. + */ + open suspend fun upload( + bytes: ByteArray, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader) + + /** + * BUD-05 media-optimization upload: `PUT /media`. The server MAY transform the + * blob, so the returned descriptor's `sha256` is the *optimized* hash and `ox` + * the original. Requires a `t=media` auth token. + */ + open suspend fun media( + file: File, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader) + + open suspend fun media( + bytes: ByteArray, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader) + + /** + * BUD-04 mirror: ask [serverBaseUrl] to fetch and store the blob already at + * [sourceUrl]. The server verifies the downloaded bytes hash to the `x` tag in + * the (upload) auth token. Returns the mirrored blob's descriptor. + */ + open suspend fun mirror( + sourceUrl: String, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = + withContext(Dispatchers.IO) { + val body = JsonMapper.toJson(MirrorRequest(sourceUrl)).toRequestBody("application/json".toMediaType()) + val request = + Request + .Builder() + .url(BlossomServerUrl.mirror(serverBaseUrl)) + .apply { authHeader?.let { addHeader("Authorization", it) } } + .put(body) + .build() + okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) } + } + + /** + * BUD-02 list: `GET /list/`. Returns the pubkey's blob descriptors on + * this server (may be empty; servers MAY not implement it). [authHeader] is a + * `t=list` token — some servers require it, others allow anonymous listing. + */ + open suspend fun list( + serverBaseUrl: String, + pubkey: HexKey, + authHeader: String?, + ): List = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(BlossomServerUrl.list(serverBaseUrl, pubkey)) + .apply { authHeader?.let { addHeader("Authorization", it) } } + .get() + .build() + okHttpClient.newCall(request).execute().use { response -> + check402(response, serverBaseUrl) + if (!response.isSuccessful) { + val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString() + throw RuntimeException("List failed ($serverBaseUrl): $reason") + } + val body = response.body.string().ifBlank { "[]" } + JsonMapper.fromJson>(body) + } + } + + /** + * BUD-02 delete: `DELETE /[.ext]`. [authHeader] is a `t=delete` token + * scoped to the hash (and ideally to this server). Returns true on 2xx. + */ + open suspend fun delete( + hash: HexKey, + serverBaseUrl: String, + authHeader: String?, + extension: String = "", + ): Boolean = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(BlossomServerUrl.blob(serverBaseUrl, hash, extension)) + .apply { authHeader?.let { addHeader("Authorization", it) } } + .delete() + .build() + okHttpClient.newCall(request).execute().use { it.isSuccessful } + } + + /** + * BUD-01 HEAD probe: does [serverBaseUrl] hold [hash]? A cheap "which server + * has which blob" check that needs no auth on most servers. + */ + open suspend fun has( + hash: HexKey, + serverBaseUrl: String, + ): Boolean = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(BlossomServerUrl.blob(serverBaseUrl, hash)) + .head() + .build() + try { + okHttpClient.newCall(request).execute().use { it.isSuccessful } + } catch (_: Exception) { + false + } + } + + /** + * BUD-06 preflight: `HEAD /upload` (or `/media` when [media] is true). A 200 + * means the server would accept the blob; any other status carries an optional + * `X-Reason`. Per spec this is only a hint — never gate an upload hard on it. + */ + open suspend fun preflight( + hash: HexKey, + size: Long, + contentType: String, + serverBaseUrl: String, + authHeader: String?, + media: Boolean = false, + ): BlossomPreflightResult = + withContext(Dispatchers.IO) { + val endpoint = if (media) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl) + val request = + Request + .Builder() + .url(endpoint) + .head() + .addHeader(BlossomServerUrl.X_SHA_256_HEADER, hash) + .addHeader(BlossomServerUrl.X_CONTENT_LENGTH_HEADER, size.toString()) + .addHeader(BlossomServerUrl.X_CONTENT_TYPE_HEADER, contentType) + .apply { authHeader?.let { addHeader("Authorization", it) } } + .build() + okHttpClient.newCall(request).execute().use { response -> + BlossomPreflightResult( + accepted = response.isSuccessful, + status = response.code, + reason = response.headers[BlossomServerUrl.REASON_HEADER], + ) + } + } + + /** + * BUD-09 report: `PUT /report` with a signed NIP-56 (kind 1984) report event as + * the JSON body. Returns true on 2xx. + */ + open suspend fun report( + serverBaseUrl: String, + reportEventJson: String, + ): Boolean = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(BlossomServerUrl.report(serverBaseUrl)) + .put(reportEventJson.toRequestBody("application/json".toMediaType())) + .build() + okHttpClient.newCall(request).execute().use { it.isSuccessful } + } + + /** + * Download a blob from an absolute URL — typically a Blossom GET endpoint + * `/`. Returns the raw bytes, or `null` when the server + * responds with a non-2xx status. Connection-level failures (DNS, refused, + * timeout) propagate as [java.io.IOException] so the caller can try the next + * server. + * + * This does NOT verify the blob's hash — content-addressed verification is + * the caller's responsibility (see quartz `StaticSiteResolver.verify`), since + * a Blossom server is untrusted and may return a substituted blob. + */ + open suspend fun download(url: String): ByteArray? = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(url) + .get() + .build() + okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) response.body.bytes() else null + } + } + + private fun fileBody( + file: File, + contentType: String, + ): RequestBody = + object : RequestBody() { + override fun contentType() = contentType.toMediaType() + + override fun contentLength() = file.length() + + override fun writeTo(sink: BufferedSink) { + file.inputStream().source().use(sink::writeAll) + } + } + + private suspend fun putBlob( + endpoint: String, + body: RequestBody, + serverBaseUrl: String, + authHeader: String?, + ): BlossomUploadResult = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(endpoint) + .apply { authHeader?.let { addHeader("Authorization", it) } } + .put(body) + .build() + okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) } + } + + private fun parseDescriptor( + response: Response, + serverBaseUrl: String, + ): BlossomUploadResult { + check402(response, serverBaseUrl) + if (!response.isSuccessful) { + val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString() + throw RuntimeException("Request failed ($serverBaseUrl): $reason") + } + val body = response.body.string().ifBlank { throw RuntimeException("$serverBaseUrl returned no body") } + return JsonMapper.fromJson(body) + } + + /** Surfaces a BUD-07 `402 Payment Required` as a typed exception the caller can act on. */ + private fun check402( + response: Response, + serverBaseUrl: String, + ) { + if (response.code == 402) { + throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] }) + } + } + + @kotlinx.serialization.Serializable + private data class MirrorRequest( + val url: String, + ) +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt deleted file mode 100644 index edc9702643..0000000000 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ /dev/null @@ -1,140 +0,0 @@ -/* - * 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.commons.service.upload - -import com.vitorpamplona.quartz.nip01Core.core.JsonMapper -import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl -import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.toRequestBody -import okio.BufferedSink -import okio.source -import java.io.File - -/** - * Blossom HTTP client for JVM consumers (desktop + CLI). Owns no global - * state — pass a configured [OkHttpClient] (e.g. desktop's Tor-aware - * `DesktopHttpClient.currentClient()`) for proxying / connection pooling. - * The default constructor uses a fresh OkHttpClient — fine for one-shot - * uses such as the CLI. - */ -open class BlossomClient( - private val okHttpClient: OkHttpClient = OkHttpClient(), -) { - open suspend fun upload( - file: File, - contentType: String, - serverBaseUrl: String, - authHeader: String?, - ): BlossomUploadResult = - withContext(Dispatchers.IO) { - val apiUrl = BlossomServerUrl.upload(serverBaseUrl) - val requestBody = - object : RequestBody() { - override fun contentType() = contentType.toMediaType() - - override fun contentLength() = file.length() - - override fun writeTo(sink: BufferedSink) { - file.inputStream().source().use(sink::writeAll) - } - } - - val requestBuilder = - Request - .Builder() - .url(apiUrl) - .put(requestBody) - - authHeader?.let { requestBuilder.addHeader("Authorization", it) } - - val response = okHttpClient.newCall(requestBuilder.build()).execute() - response.use { - if (!it.isSuccessful) { - val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString() - throw RuntimeException("Upload failed ($serverBaseUrl): $reason") - } - val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") } - JsonMapper.fromJson(body) - } - } - - /** - * Download a blob from an absolute URL — typically a Blossom GET endpoint - * `/`. Returns the raw bytes, or `null` when the server - * responds with a non-2xx status. Connection-level failures (DNS, refused, - * timeout) propagate as [java.io.IOException] so the caller can try the next - * server. - * - * This does NOT verify the blob's hash — content-addressed verification is - * the caller's responsibility (see quartz `StaticSiteResolver.verify`), since - * a Blossom server is untrusted and may return a substituted blob. - */ - open suspend fun download(url: String): ByteArray? = - withContext(Dispatchers.IO) { - val request = - Request - .Builder() - .url(url) - .get() - .build() - okHttpClient.newCall(request).execute().use { response -> - if (response.isSuccessful) response.body.bytes() else null - } - } - - /** - * Upload raw bytes (e.g. encrypted blobs) to a Blossom server. - */ - open suspend fun upload( - bytes: ByteArray, - contentType: String, - serverBaseUrl: String, - authHeader: String?, - ): BlossomUploadResult = - withContext(Dispatchers.IO) { - val apiUrl = BlossomServerUrl.upload(serverBaseUrl) - val requestBody = bytes.toRequestBody(contentType.toMediaType()) - - val requestBuilder = - Request - .Builder() - .url(apiUrl) - .put(requestBody) - - authHeader?.let { requestBuilder.addHeader("Authorization", it) } - - val response = okHttpClient.newCall(requestBuilder.build()).execute() - response.use { - if (!it.isSuccessful) { - val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString() - throw RuntimeException("Upload failed ($serverBaseUrl): $reason") - } - val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") } - JsonMapper.fromJson(body) - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index dcf9fe9e85..9cabfaa20e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -57,29 +57,47 @@ class BlossomAuthorizationEvent( hash: HexKey, alt: String, signer: NostrSigner, + servers: List = emptyList(), createdAt: Long = TimeUtils.now(), - ) = createAuth("get", hash, null, alt, signer, createdAt) + ) = createAuth("get", hash, null, alt, signer, servers, createdAt) suspend fun createListAuth( signer: NostrSigner, alt: String, + servers: List = emptyList(), createdAt: Long = TimeUtils.now(), - ) = createAuth("list", null, null, alt, signer, createdAt) + ) = createAuth("list", null, null, alt, signer, servers, createdAt) suspend fun createDeleteAuth( hash: HexKey, alt: String, signer: NostrSigner, + servers: List = emptyList(), createdAt: Long = TimeUtils.now(), - ) = createAuth("delete", hash, null, alt, signer, createdAt) + ) = createAuth("delete", hash, null, alt, signer, servers, createdAt) suspend fun createUploadAuth( hash: HexKey, size: Long, alt: String, signer: NostrSigner, + servers: List = emptyList(), createdAt: Long = TimeUtils.now(), - ) = createAuth("upload", hash, size, alt, signer, createdAt) + ) = createAuth("upload", hash, size, alt, signer, servers, createdAt) + + /** + * BUD-05 media-optimization auth (`t=media`). The [hash] is the sha256 of + * the *original* bytes the client sends to `PUT /media`; the server returns + * a descriptor whose hash is the optimized blob's. + */ + suspend fun createMediaAuth( + hash: HexKey, + size: Long, + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + createdAt: Long = TimeUtils.now(), + ) = createAuth("media", hash, size, alt, signer, servers, createdAt) private suspend fun createAuth( type: String, @@ -87,15 +105,26 @@ class BlossomAuthorizationEvent( fileSize: Long?, alt: String, signer: NostrSigner, + servers: List = emptyList(), createdAt: Long = TimeUtils.now(), ): BlossomAuthorizationEvent { + // BUD-11 `server` tags scope the token to specific domains so an upload + // or delete token can't be replayed against another server. The value + // MUST be the lowercase bare domain (no scheme/port/path). + val serverTags = + servers + .map { BlossomServerUrl.domain(it) } + .filter { it.isNotBlank() } + .distinct() + .map { arrayOf("server", it) } + val tags = listOfNotNull( arrayOf("t", type), arrayOf("expiration", TimeUtils.oneHourAhead().toString()), fileSize?.let { arrayOf("size", it.toString()) }, hash?.let { arrayOf("x", it) }, - ) + ) + serverTags return signer.sign(createdAt, KIND, tags.toTypedArray(), alt) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt new file mode 100644 index 0000000000..7b13a75332 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt @@ -0,0 +1,58 @@ +/* + * 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.quartz.nipB7Blossom + +/** + * BUD-07 payment challenge parsed from a `402 Payment Required` response. A paid + * Blossom server answers upload/mirror/media requests with a 402 and one or both + * of the payment headers; the client pays, then retries the same request with the + * proof attached. + * + * - [cashu] — a NUT-24 Cashu token request string from the `X-Cashu` header. The + * client pays it (e.g. via a NIP-60 wallet) and retries with the settled token. + * - [lightning] — a BOLT-11 invoice string from the `X-Lightning` header. The + * client pays it and retries; the preimage is the proof. + * - [reason] — the optional human-readable `X-Reason` message. + * + * Kept transport-agnostic (no OkHttp) so it can live in `commonMain`: build it + * from any per-name header lookup via [fromHeaders]. + */ +data class BlossomPaymentRequired( + val cashu: String? = null, + val lightning: String? = null, + val reason: String? = null, +) { + /** True when the server offered at least one payment method we could attempt. */ + fun hasPaymentOption(): Boolean = !cashu.isNullOrBlank() || !lightning.isNullOrBlank() + + companion object { + /** + * Reads the BUD-07 headers from a 402 response. [header] returns the value + * for a header name (case-insensitive at the transport layer), or null. + */ + inline fun fromHeaders(header: (String) -> String?): BlossomPaymentRequired = + BlossomPaymentRequired( + cashu = header(BlossomServerUrl.X_CASHU_HEADER)?.trim('"', ' ')?.ifBlank { null }, + lightning = header(BlossomServerUrl.X_LIGHTNING_HEADER)?.trim('"', ' ')?.ifBlank { null }, + reason = header(BlossomServerUrl.REASON_HEADER)?.ifBlank { null }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomReport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomReport.kt new file mode 100644 index 0000000000..7f1e239ab1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomReport.kt @@ -0,0 +1,51 @@ +/* + * 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.quartz.nipB7Blossom + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip56Reports.ReportType +import com.vitorpamplona.quartz.nip56Reports.hash +import com.vitorpamplona.quartz.nip56Reports.user +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * BUD-09 blob report: a NIP-56 (kind 1984) report event scoped to a blob by its + * sha256 (`x` tag) rather than to a nostr event. The signed event is PUT to a + * server's `/report` endpoint so the operator can review problematic content. + * + * Reuses the shared NIP-56 tag builders ([hash], [user]) so a blob report is a + * regular [ReportEvent] — clients that already parse kind 1984 pick up the + * reported hash via `HashSha256Tag`. + */ +object BlossomReport { + fun build( + blobHash: HexKey, + type: ReportType, + uploader: HexKey? = null, + comment: String = "", + createdAt: Long = TimeUtils.now(), + ) = eventTemplate(ReportEvent.KIND, comment, createdAt) { + hash(blobHash, type) + uploader?.let { user(it, type) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt index 3444b78052..05cd6f8180 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt @@ -21,9 +21,10 @@ package com.vitorpamplona.quartz.nipB7Blossom import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.utils.Rfc3986 /** - * Endpoint helpers for the Blossom HTTP API (BUD-01 / BUD-02). Centralizes the + * Endpoint helpers for the Blossom HTTP API (BUD-01 … BUD-12). Centralizes the * protocol's URL shapes and well-known header names so every transport — the * commons JVM `BlossomClient`, the Android uploader, the CLI — builds them the * same way instead of re-deriving `/upload` and `X-Reason` by hand. @@ -32,15 +33,51 @@ object BlossomServerUrl { /** BUD-01 upload endpoint path: `PUT /upload`. */ const val UPLOAD_PATH = "/upload" + /** BUD-04 mirror endpoint path: `PUT /mirror`. */ + const val MIRROR_PATH = "/mirror" + + /** BUD-05 media-optimization endpoint path: `PUT /media`. */ + const val MEDIA_PATH = "/media" + + /** BUD-02 list endpoint path prefix: `GET /list/`. */ + const val LIST_PATH = "/list/" + + /** BUD-09 report endpoint path: `PUT /report`. */ + const val REPORT_PATH = "/report" + /** * Header a Blossom server SHOULD set with a human-readable failure reason on * a non-2xx response (BUD-01). */ const val REASON_HEADER = "X-Reason" + /** BUD-06 preflight request headers for `HEAD /upload` and `HEAD /media`. */ + const val X_SHA_256_HEADER = "X-SHA-256" + const val X_CONTENT_TYPE_HEADER = "X-Content-Type" + const val X_CONTENT_LENGTH_HEADER = "X-Content-Length" + + /** BUD-07 payment headers carried on a `402 Payment Required` response. */ + const val X_CASHU_HEADER = "X-Cashu" + const val X_LIGHTNING_HEADER = "X-Lightning" + /** `/upload`, collapsing any trailing slash on [serverBaseUrl]. */ fun upload(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + UPLOAD_PATH + /** BUD-04 `/mirror`, collapsing any trailing slash on [serverBaseUrl]. */ + fun mirror(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MIRROR_PATH + + /** BUD-05 `/media`, collapsing any trailing slash on [serverBaseUrl]. */ + fun media(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MEDIA_PATH + + /** BUD-02 `/list/`, collapsing any trailing slash on [serverBaseUrl]. */ + fun list( + serverBaseUrl: String, + pubkey: HexKey, + ): String = serverBaseUrl.removeSuffix("/") + LIST_PATH + pubkey + + /** BUD-09 `/report`, collapsing any trailing slash on [serverBaseUrl]. */ + fun report(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + REPORT_PATH + /** * BUD-01 blob endpoint `/[.]`, used for GET and DELETE. * A blank [extension] omits the suffix. @@ -53,4 +90,20 @@ object BlossomServerUrl { val suffix = if (extension.isBlank()) "" else ".$extension" return serverBaseUrl.removeSuffix("/") + "/" + hash + suffix } + + /** + * The lowercase bare domain of [serverBaseUrl], as required by the BUD-11 + * `server` authorization tag ("lowercase domain name only", no scheme/port). + * Falls back to a best-effort strip when the URL can't be parsed. + */ + fun domain(serverBaseUrl: String): String = + try { + Rfc3986.host(serverBaseUrl).substringBefore(":").lowercase() + } catch (_: Exception) { + serverBaseUrl + .substringAfter("://") + .substringBefore("/") + .substringBefore(":") + .lowercase() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt index 674e734097..611e3e9e94 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt @@ -35,10 +35,18 @@ data class BlossomUploadResult( val type: String? = null, // upload time val uploaded: Long? = null, + // (BUD-05) The sha256 hash of the *original* blob, before server-side + // optimization. Only returned by the `/media` endpoint when the server + // transforms the file (so sha256 != ox). + val ox: HexKey? = null, // magnet link val magnet: String? = null, // info hash val infohash: String? = null, // ipfs link val ipfs: String? = null, + // (BUD-08) Optional NIP-94 file-metadata tags describing this blob, so a + // client gets standardized metadata (dimensions, blurhash, alt, …) without + // a separate request. Each entry is a NIP-94 tag: [name, value, …]. + val nip94: List>? = null, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt new file mode 100644 index 0000000000..6d82a3913a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt @@ -0,0 +1,102 @@ +/* + * 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.quartz.nipB7Blossom + +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.io.encoding.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BlossomAuthorizationEventTest { + private val signer = NostrSignerInternal(KeyPair()) + private val hash = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + + @Test + fun uploadAuthHasRequiredTags() = + runTest { + val event = BlossomAuthorizationEvent.createUploadAuth(hash, 184292, "Uploading cat.png", signer) + + assertEquals(BlossomAuthorizationEvent.KIND, event.kind) + assertEquals("upload", event.tags.first { it[0] == "t" }[1]) + assertEquals(hash, event.tags.first { it[0] == "x" }[1]) + assertEquals("184292", event.tags.first { it[0] == "size" }[1]) + // NIP-40 expiration must be in the future. + val expiration = event.tags.first { it[0] == "expiration" }[1].toLong() + assertTrue(expiration > event.createdAt) + } + + @Test + fun mediaAuthUsesMediaVerb() = + runTest { + val event = BlossomAuthorizationEvent.createMediaAuth(hash, 100, "Optimizing", signer) + assertEquals("media", event.tags.first { it[0] == "t" }[1]) + } + + @Test + fun serverScopeEmitsLowercaseBareDomainTags() = + runTest { + val event = + BlossomAuthorizationEvent.createDeleteAuth( + hash, + "Delete blob", + signer, + servers = listOf("https://CDN.Example.com/", "https://blossom.band:443/upload"), + ) + + val serverTags = event.tags.filter { it[0] == "server" }.map { it[1] } + assertEquals(listOf("cdn.example.com", "blossom.band"), serverTags) + } + + @Test + fun deduplicatesServerScopeByDomain() = + runTest { + val event = + BlossomAuthorizationEvent.createUploadAuth( + hash, + 1, + "Upload", + signer, + servers = listOf("https://cdn.example.com/a", "https://cdn.example.com/b"), + ) + assertEquals(1, event.tags.count { it[0] == "server" }) + } + + @Test + fun noServerScopeWhenListEmpty() = + runTest { + val event = BlossomAuthorizationEvent.createUploadAuth(hash, 1, "Upload", signer) + assertTrue(event.tags.none { it[0] == "server" }) + } + + @Test + fun authorizationHeaderIsNostrPrefixedBase64OfTheEvent() = + runTest { + val event = BlossomAuthorizationEvent.createListAuth(signer, "List blobs") + val header = event.toAuthorizationHeader() + + assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)) + val decoded = Base64.decode(header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)).decodeToString() + assertEquals(event.toJson(), decoded) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrlTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrlTest.kt new file mode 100644 index 0000000000..f27d8b4ace --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrlTest.kt @@ -0,0 +1,59 @@ +/* + * 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.quartz.nipB7Blossom + +import kotlin.test.Test +import kotlin.test.assertEquals + +class BlossomServerUrlTest { + private val sha256 = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + private val pubkey = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e0" + + @Test + fun buildsEndpointPaths() { + assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com")) + assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com")) + assertEquals("https://cdn.example.com/media", BlossomServerUrl.media("https://cdn.example.com")) + assertEquals("https://cdn.example.com/report", BlossomServerUrl.report("https://cdn.example.com")) + assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com", pubkey)) + } + + @Test + fun collapsesTrailingSlash() { + assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com/")) + assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com/")) + assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com/", pubkey)) + } + + @Test + fun buildsBlobUrlWithOptionalExtension() { + assertEquals("https://cdn.example.com/$sha256", BlossomServerUrl.blob("https://cdn.example.com", sha256)) + assertEquals("https://cdn.example.com/$sha256.png", BlossomServerUrl.blob("https://cdn.example.com", sha256, "png")) + } + + @Test + fun extractsLowercaseBareDomainForServerScope() { + assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com")) + assertEquals("cdn.example.com", BlossomServerUrl.domain("https://CDN.Example.com/")) + assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com:8443/upload")) + assertEquals("blossom.band", BlossomServerUrl.domain("https://blossom.band")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt new file mode 100644 index 0000000000..be732c00fd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt @@ -0,0 +1,108 @@ +/* + * 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.quartz.nipB7Blossom + +import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class BlossomUploadResultTest { + @Test + fun parsesMinimalDescriptor() { + val json = + """ + { + "url": "https://cdn.example.com/b167.png", + "sha256": "b167", + "size": 184292, + "type": "image/png", + "uploaded": 1725105921 + } + """.trimIndent() + + val result = JsonMapper.fromJson(json) + assertEquals("https://cdn.example.com/b167.png", result.url) + assertEquals("b167", result.sha256) + assertEquals(184292, result.size) + assertEquals("image/png", result.type) + assertNull(result.ox) + assertNull(result.nip94) + } + + @Test + fun parsesMediaDescriptorWithOriginalHashAndNip94() { + // BUD-05 /media returns the optimized blob's hash in `sha256` and the + // original in `ox`; BUD-08 adds the `nip94` tag array. + val json = + """ + { + "url": "https://cdn.example.com/opt.png", + "sha256": "optimizedhash", + "ox": "originalhash", + "size": 123, + "type": "image/png", + "uploaded": 1725105921, + "nip94": [ + ["url", "https://cdn.example.com/opt.png"], + ["m", "image/png"], + ["x", "optimizedhash"], + ["size", "123"] + ] + } + """.trimIndent() + + val result = JsonMapper.fromJson(json) + assertEquals("optimizedhash", result.sha256) + assertEquals("originalhash", result.ox) + assertEquals(4, result.nip94?.size) + assertEquals(listOf("m", "image/png"), result.nip94?.get(1)) + } + + @Test + fun ignoresUnknownFields() { + val json = """{"url":"https://x/y","sha256":"a","serverSpecific":{"foo":1},"extra":"z"}""" + val result = JsonMapper.fromJson(json) + assertEquals("a", result.sha256) + } + + @Test + fun readsBud07PaymentHeaders() { + val headers = + mapOf( + BlossomServerUrl.X_CASHU_HEADER to "\"cashuBToken...\"", + BlossomServerUrl.X_LIGHTNING_HEADER to "lnbc10n1...", + BlossomServerUrl.REASON_HEADER to "Payment required: 10 sats", + ) + val payment = BlossomPaymentRequired.fromHeaders { headers[it] } + + assertEquals("cashuBToken...", payment.cashu) + assertEquals("lnbc10n1...", payment.lightning) + assertEquals("Payment required: 10 sats", payment.reason) + assertEquals(true, payment.hasPaymentOption()) + } + + @Test + fun paymentWithNoMethodsIsNotPayable() { + val payment = BlossomPaymentRequired.fromHeaders { null } + assertEquals(false, payment.hasPaymentOption()) + } +} From d0f2f08e19601840943fce4f8bf61df26a16546a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 23:50:53 +0000 Subject: [PATCH 02/14] fix(blossom): mirror and blob-manager use only the user's configured servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the mirror-on-upload fan-out and the blob-manager matrix read the raw kind-10063 list (BlossomServerListState.flow) instead of hostNameFlow, which injects the 10 public DEFAULT_MEDIA_SERVERS when the user has none set. This avoids fanning uploads out to — and listing/HEAD-probing across — public servers the user never opted into. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../amethyst/service/uploads/UploadOrchestrator.kt | 8 +++++--- .../actions/mediaServers/BlossomBlobManagerViewModel.kt | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index fe264d3f0d..a10a63d66a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -263,11 +263,13 @@ class UploadOrchestrator { val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.') if (hash.length != 64) return + // Only the user's *explicitly configured* kind-10063 servers (flow), NOT the + // DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan + // uploads out to public defaults the user never opted into. val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl) val targets = - account.blossomServers.hostNameFlow.value - .filter { it.type == ServerType.Blossom && BlossomServerUrl.domain(it.baseUrl) != primaryDomain } - .map { it.baseUrl } + account.blossomServers.flow.value + .filter { BlossomServerUrl.domain(it) != primaryDomain } .distinct() if (targets.isEmpty()) return diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt index baf6f84481..39acd1ee82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -80,10 +80,11 @@ class BlossomBlobManagerViewModel : ViewModel() { private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server)) + // The user's explicitly configured kind-10063 servers (empty if they never set + // a list) — not the DEFAULT_MEDIA_SERVERS fallback, so the matrix reflects the + // servers the user actually chose. private fun servers(): List = - account.blossomServers.hostNameFlow.value - .filter { it.type == ServerType.Blossom } - .map { it.baseUrl } + account.blossomServers.flow.value .distinct() fun refresh() { From 6c01ad2b104baa56c3d8f8380451be839bc67005 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 00:04:19 +0000 Subject: [PATCH 03/14] =?UTF-8?q?feat(blossom):=20mobile=20upload=20contro?= =?UTF-8?q?ls=20=E2=80=94=20mirror=20+=20/media=20toggles,=20manager=20ref?= =?UTF-8?q?resh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds first-class Blossom upload controls to the Media Servers screen and rounds out the blob manager: - New persisted account settings (LocalPreferences + AccountSettings): `mirrorUploadsToAllServers` (BUD-04, default on) and `optimizeMediaOnUpload` (BUD-05, default off), each with a change fn. - UploadOrchestrator honours both: uploads via /media with a t=media token when optimize is on, and only mirrors when the mirror toggle is on. - BlossomUploader gains a useMediaEndpoint flag to target /media vs /upload. - "Upload behaviour" section on the Media Servers screen: mirror toggle, optimize-via-/media toggle, and a shortcut into the blob manager. - Blob manager gains a refresh FAB. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../amethyst/LocalPreferences.kt | 8 ++ .../amethyst/model/AccountSettings.kt | 24 +++++ .../service/uploads/UploadOrchestrator.kt | 17 +-- .../uploads/blossom/BlossomUploader.kt | 6 +- .../mediaServers/AllMediaServersLIstView.kt | 101 ++++++++++++++++++ .../mediaServers/AllMediaServersScreen.kt | 1 + .../mediaServers/BlossomBlobManagerScreen.kt | 13 +++ amethyst/src/main/res/values/strings.xml | 5 + 8 files changed, 168 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 001acbd3c4..8c5c6f9480 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -109,6 +109,8 @@ private object PrefKeys { const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache" const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly" + const val MIRROR_UPLOADS_TO_ALL_SERVERS = "mirrorUploadsToAllServers" + const val OPTIMIZE_MEDIA_ON_UPLOAD = "optimizeMediaOnUpload" const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations" const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled" const val NIP46_BUNKER_SECRET = "nip46BunkerSecret" @@ -473,6 +475,8 @@ object LocalPreferences { putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value) putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value) + putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value) + putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value) putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value) putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value) putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value) @@ -688,6 +692,8 @@ object LocalPreferences { val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true) val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) + val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true) + val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false) val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false) val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "" @@ -872,6 +878,8 @@ object LocalPreferences { stripLocationOnUpload = stripLocationOnUpload, useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache), localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly), + mirrorUploadsToAllServers = MutableStateFlow(mirrorUploadsToAllServers), + optimizeMediaOnUpload = MutableStateFlow(optimizeMediaOnUpload), hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations), nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled), nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 02833dc6d0..d73223ca4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -186,6 +186,16 @@ class AccountSettings( var stripLocationOnUpload: Boolean = true, val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true), val localBlossomCacheProfilePicturesOnly: MutableStateFlow = MutableStateFlow(false), + /** + * BUD-04: after uploading a blob to the primary Blossom server, replicate it to + * the user's other configured servers (kind 10063) for redundancy. + */ + val mirrorUploadsToAllServers: MutableStateFlow = MutableStateFlow(true), + /** + * BUD-05: upload media through the server's `/media` endpoint so the server may + * strip metadata and optimize it, instead of the bit-exact `/upload`. + */ + val optimizeMediaOnUpload: MutableStateFlow = MutableStateFlow(false), /** * NIP-46: when true, this account acts as a remote signer (a "bunker") for * other apps, listening on the user's inbox relays for kind:24133 requests. @@ -665,6 +675,20 @@ class AccountSettings( } } + fun changeMirrorUploadsToAllServers(enabled: Boolean) { + if (mirrorUploadsToAllServers.value != enabled) { + mirrorUploadsToAllServers.tryEmit(enabled) + saveAccountSettings() + } + } + + fun changeOptimizeMediaOnUpload(enabled: Boolean) { + if (optimizeMediaOnUpload.value != enabled) { + optimizeMediaOnUpload.tryEmit(enabled) + saveAccountSettings() + } + } + fun updateAddClientTag(add: Boolean): Boolean = if (syncedSettings.security.updateAddClientTag(add)) { saveAccountSettings() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index a10a63d66a..5b6d28a26e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -202,6 +202,9 @@ class UploadOrchestrator { context: Context, ): UploadingFinalState { updateState(0.2, UploadingState.Uploading) + // BUD-05: route through /media (optimize) when the user opted in. The forced-signer + // path (e.g. NIP-46 draft signing) always uses the bit-exact /upload. + val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value return try { val result = BlossomUploader() @@ -213,14 +216,16 @@ class UploadOrchestrator { sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - // Scope the upload token to the target server (BUD-11) so it can't be replayed elsewhere. + // Scope the token to the target server (BUD-11) so it can't be replayed elsewhere, + // and use a t=media token when optimizing via /media. httpAuth = - if (forcedSigner != null) { - { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) } - } else { - { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) } + when { + forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) } + useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt, listOf(serverBaseUrl)) } + else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) } }, context = context, + useMediaEndpoint = useMedia, ) val finalState = @@ -234,7 +239,7 @@ class UploadOrchestrator { // BUD-04: replicate the blob to the user's other Blossom servers for redundancy. // Best-effort — a mirror failure never fails the upload the user already completed. - if (finalState is UploadingState.Finished && forcedSigner == null) { + if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) { mirrorToOtherServers(result, serverBaseUrl, account) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 3d5248207c..5cf8cb029c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -79,6 +79,7 @@ class BlossomUploader { okHttpClient: (String) -> OkHttpClient, httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, context: Context, + useMediaEndpoint: Boolean = false, onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null, ): MediaUploadResult { checkNotInMainThread() @@ -115,6 +116,7 @@ class BlossomUploader { okHttpClient, httpAuth, context, + useMediaEndpoint, onProgress, ) }.mergeLocalMetadata(localMetadata) @@ -132,6 +134,7 @@ class BlossomUploader { okHttpClient: (String) -> OkHttpClient, httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?, context: Context, + useMediaEndpoint: Boolean = false, onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null, ): MediaUploadResult { checkNotInMainThread() @@ -142,7 +145,8 @@ class BlossomUploader { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it) } ?: "" - val apiUrl = BlossomServerUrl.upload(serverBaseUrl) + // BUD-05: /media asks the server to optimize; /upload stores the exact bytes. + val apiUrl = if (useMediaEndpoint) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl) val client = okHttpClient(apiUrl) val requestBuilder = Request.Builder() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 572f75fce1..0e01b2c88d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -40,8 +40,10 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -60,6 +62,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem @@ -91,6 +95,7 @@ private val MonogramColors = fun AllMediaBody( blossomServersViewModel: BlossomServersViewModel, accountViewModel: AccountViewModel, + nav: INav, modifier: Modifier = Modifier, ) { val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle() @@ -156,6 +161,11 @@ fun AllMediaBody( ) } + item { + SectionLabel(title = stringRes(id = R.string.media_servers_upload_section)) + UploadBehaviorSection(accountViewModel, nav) + } + item { SectionLabel(title = stringRes(id = R.string.media_servers_cache_section)) MediaCacheSection(accountViewModel) @@ -167,6 +177,97 @@ fun AllMediaBody( } } +/** + * Upload-side Blossom controls: mirror uploads across the user's servers (BUD-04), + * optimize via `/media` (BUD-05), and a shortcut into the blob manager. + */ +@Composable +private fun UploadBehaviorSection( + accountViewModel: AccountViewModel, + nav: INav, +) { + val mirror by accountViewModel.account.settings.mirrorUploadsToAllServers + .collectAsStateWithLifecycle() + val optimize by accountViewModel.account.settings.optimizeMediaOnUpload + .collectAsStateWithLifecycle() + + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) { + UploadToggleRow( + title = stringRes(id = R.string.blossom_mirror_uploads), + caption = stringRes(id = R.string.blossom_mirror_uploads_caption), + checked = mirror, + onCheckedChange = { accountViewModel.account.settings.changeMirrorUploadsToAllServers(it) }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 14.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + UploadToggleRow( + title = stringRes(id = R.string.blossom_optimize_media), + caption = stringRes(id = R.string.blossom_optimize_media_caption), + checked = optimize, + onCheckedChange = { accountViewModel.account.settings.changeOptimizeMediaOnUpload(it) }, + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 14.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { nav.nav(Route.ManageBlossomBlobs) } + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringRes(id = R.string.manage_stored_files), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Icon( + symbol = MaterialSymbols.Storage, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun UploadToggleRow( + title: String, + caption: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + Text( + text = caption, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + /** Compact section header: an accent label with an optional gray caption below. */ @Composable private fun SectionLabel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index cefa1fe242..32233198b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -92,6 +92,7 @@ fun MediaServersScaffold( AllMediaBody( blossomServersViewModel = blossomServersViewModel, accountViewModel = accountViewModel, + nav = nav, modifier = Modifier .fillMaxSize() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 7b290f1ce4..cacfb01b2a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.material3.AlertDialog @@ -38,6 +39,7 @@ import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -57,6 +59,8 @@ 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.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -80,6 +84,15 @@ fun BlossomBlobManagerScreen( Scaffold( topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) }, + floatingActionButton = { + FloatingActionButton(onClick = { vm.refresh() }) { + Icon( + symbol = MaterialSymbols.Sync, + contentDescription = stringRes(R.string.retry), + modifier = Modifier.size(22.dp), + ) + } + }, ) { padding -> Column( modifier = diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 68f323a66b..760ad5a314 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1545,6 +1545,11 @@ You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ + Upload behaviour + Mirror uploads + After uploading, copy the file to your other Blossom servers so it stays available if one goes offline. + Optimize media on the server + Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original. Manage stored files No stored files found on your Blossom servers. Mirror to missing From 4f661a501fb89403b8f6223f38bfacbf9f5a0a45 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:04:35 +0000 Subject: [PATCH 04/14] feat(blossom): blob-manager per-file controls + clear 402 on upload - Blob manager rows now show an image thumbnail (image/* blobs) and Copy-link / Open controls, alongside the existing mirror-to-missing / delete / report. - BUD-07: BlossomUploader now raises a typed BlossomPaymentException on a 402, and UploadOrchestrator surfaces a specific "server requires payment" message instead of a generic upload failure (auto-settlement via the NIP-60 wallet is still TBD). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../service/uploads/UploadOrchestrator.kt | 4 ++ .../uploads/blossom/BlossomUploader.kt | 6 ++ .../mediaServers/BlossomBlobManagerScreen.kt | 61 +++++++++++++++---- amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 62 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 5b6d28a26e..4aad2555f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -25,6 +25,7 @@ import android.net.Uri import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R 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.UploadingState.UploadingFinalState import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -246,6 +247,9 @@ class UploadOrchestrator { finalState } catch (_: SignerExceptions.ReadOnlyException) { error(R.string.login_with_a_private_key_to_be_able_to_upload) + } catch (e: BlossomPaymentException) { + // BUD-07: the server wants payment before it will store the blob. + error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl) } catch (e: Exception) { if (e is CancellationException) throw e error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 5cf8cb029c..95f14150c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -26,6 +26,7 @@ import android.net.Uri import android.provider.OpenableColumns import android.webkit.MimeTypeMap import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult @@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.RandomInstance @@ -204,6 +206,10 @@ class BlossomUploader { response.body.use { body -> convertToMediaResult(parseResults(body.string())) } + } else if (response.code == 402) { + // BUD-07: paid server. Surface the payment challenge so the caller can + // tell the user (auto-settlement via the NIP-60 wallet is a follow-up). + throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] }) } else { val errorMessage = response.headers.get(BlossomServerUrl.REASON_HEADER) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index cacfb01b2a..7bbec1f65d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -31,6 +32,7 @@ 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.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults @@ -54,10 +56,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -152,23 +161,40 @@ private fun BlobCard( ) { var deleteMenuOpen by remember { mutableStateOf(false) } var reportOpen by remember { mutableStateOf(false) } + val clipboard = LocalClipboardManager.current + val context = LocalContext.current Card(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = row.hash.take(16) + "…", - style = MaterialTheme.typography.titleSmall, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - Text( - text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.grayText, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (row.url != null && row.type?.startsWith("image/") == true) { + AsyncImage( + model = row.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = row.hash.take(16) + "…", + style = MaterialTheme.typography.titleSmall, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) + } + } FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { row.serversPresent.forEach { server -> @@ -228,6 +254,19 @@ private fun BlobCard( } } } + + if (row.url != null) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TextButton(onClick = { clipboard.setText(AnnotatedString(row.url)) }) { + Text(stringRes(R.string.copy), style = MaterialTheme.typography.labelMedium) + } + TextButton(onClick = { + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) } + }) { + Text(stringRes(R.string.blossom_open), style = MaterialTheme.typography.labelMedium) + } + } + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 760ad5a314..65338562ce 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1555,6 +1555,8 @@ Mirror to missing Delete from… Report + Open + This server requires payment to upload: %1$s Report blob Reason (optional) Send From 9bc436b0c28f674739655e29f7f1bbc2dc3a09c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 01:18:46 +0000 Subject: [PATCH 05/14] feat(blossom): BUD-07 confirm-then-pay for paid-server mirroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled from the app instead of only being reported. - quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof. - BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the account's existing NIP-47 (NWC) wallet and returns the preimage — it never handles keys or funds itself, only drives the connected wallet. Decodes the invoice amount for display. - Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows the amount and, on confirm, pays and retries the mirror, then continues with the remaining servers. Cancel leaves the blob unmirrored. Cashu-only servers and the composer upload path still surface a clear message; auto-settlement there can reuse this handler next. Not yet validated against a live paid server. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../uploads/blossom/BlossomPaymentHandler.kt | 80 +++++++++++++++++++ .../mediaServers/BlossomBlobManagerScreen.kt | 47 +++++++++++ .../BlossomBlobManagerViewModel.kt | 73 ++++++++++++++--- amethyst/src/main/res/values/strings.xml | 7 ++ .../commons/service/upload/BlossomClient.kt | 8 +- .../nipB7Blossom/BlossomPaymentRequired.kt | 17 ++++ .../nipB7Blossom/BlossomUploadResultTest.kt | 13 +++ 7 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt new file mode 100644 index 0000000000..1de97b9753 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomPaymentHandler.kt @@ -0,0 +1,80 @@ +/* + * 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 com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Settles a BUD-07 [BlossomPaymentRequired] challenge so a blocked upload/mirror can + * be retried. Reuses the account's existing NIP-47 (Nostr Wallet Connect) lightning + * path — this handler never touches keys or moves funds itself, it only asks the + * user's connected wallet to pay the invoice and returns the resulting preimage as + * the [BlossomPaymentProof]. + * + * Cashu-only servers (`X-Cashu`, no `X-Lightning`) are not yet supported here; the + * caller should surface that a lightning wallet is required. + */ +object BlossomPaymentHandler { + /** True when this account has a wallet we can pay the lightning invoice with. */ + fun canPay( + account: Account, + payment: BlossomPaymentRequired, + ): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup() + + /** The invoice amount in sats, for display in a confirmation prompt. */ + fun amountSats(payment: BlossomPaymentRequired): Long? = + payment.lightning?.let { + runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull() + } + + /** + * Pays the challenge's BOLT-11 invoice via NWC and returns the proof, or null if + * there is no payable invoice, no wallet, or the wallet didn't confirm in time. + */ + suspend fun pay( + account: Account, + payment: BlossomPaymentRequired, + ): BlossomPaymentProof? { + val invoice = payment.lightning ?: return null + if (!account.nip47SignerState.hasWalletConnectSetup()) return null + + val preimageResult = CompletableDeferred() + try { + account.sendZapPaymentRequestFor(invoice, null) { response -> + // CompletableDeferred.complete is idempotent, so extra callbacks are harmless. + preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage) + } + } catch (e: Exception) { + Log.w("BlossomPayment", "Failed to send NWC payment request", e) + return null + } + + val preimage = withTimeoutOrNull(90_000) { preimageResult.await() } ?: return null + return BlossomPaymentProof(lightningPreimage = preimage) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 7bbec1f65d..344f4bf900 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -90,6 +91,16 @@ fun BlossomBlobManagerScreen( val blobs by vm.blobs.collectAsStateWithLifecycle() val loading by vm.isLoading.collectAsStateWithLifecycle() val error by vm.error.collectAsStateWithLifecycle() + val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle() + + pendingPayment?.let { pending -> + BlossomPaymentDialog( + amountSats = pending.amountSats, + reason = pending.payment.reason, + onConfirm = { vm.confirmPendingPayment() }, + onDismiss = { vm.cancelPendingPayment() }, + ) + } Scaffold( topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) }, @@ -279,6 +290,42 @@ private fun BlobCard( } } +@Composable +private fun BlossomPaymentDialog( + amountSats: Long?, + reason: String?, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringRes(R.string.blossom_payment_title)) }, + text = { + Text( + text = + listOfNotNull( + stringRes(R.string.blossom_payment_message), + reason, + ).joinToString("\n\n"), + ) + }, + confirmButton = { + Button(onClick = onConfirm) { + Text( + if (amountSats != null) { + pluralStringResource(R.plurals.blossom_pay_sats, amountSats.toInt(), amountSats.toInt()) + } else { + stringRes(R.string.blossom_pay) + }, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } + }, + ) +} + private fun humanBytes(bytes: Long): String = when { bytes >= 1_000_000 -> "${bytes / 1_000_000} MB" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt index 39acd1ee82..4ac5b63efc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -26,10 +26,14 @@ 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.commons.service.upload.BlossomPaymentException import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip56Reports.ReportType +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.utils.Log @@ -54,6 +58,15 @@ data class BlobRow( val serversMissing: List, ) +/** A BUD-07 payment prompt raised while mirroring [row] to [target]. */ +@Immutable +data class PendingMirrorPayment( + val row: BlobRow, + val target: String, + val payment: BlossomPaymentRequired, + val amountSats: Long?, +) + /** * Backs the Blossom blob-manager screen. For the active account it fans a * `GET /list/` (BUD-02) across every server in the user's kind-10063 list, @@ -74,6 +87,9 @@ class BlossomBlobManagerViewModel : ViewModel() { private val _error = MutableStateFlow(null) val error = _error.asStateFlow() + private val _pendingPayment = MutableStateFlow(null) + val pendingPayment = _pendingPayment.asStateFlow() + fun init(accountViewModel: AccountViewModel) { this.account = accountViewModel.account } @@ -176,27 +192,66 @@ class BlossomBlobManagerViewModel : ViewModel() { } /** BUD-04: mirror a blob to every server in the user's list that doesn't have it yet. */ - fun mirrorToMissing( - row: BlobRow, - onDone: (Int) -> Unit = {}, - ) { - val source = row.url ?: return onDone(0) + fun mirrorToMissing(row: BlobRow) { + val source = row.url ?: return viewModelScope.launch(Dispatchers.IO) { var mirrored = 0 - row.serversMissing.forEach { target -> + for (target in row.serversMissing) { try { - val auth = account.createBlossomUploadAuth(row.hash, row.size ?: 0L, "Mirror ${row.hash}", listOf(target)).toAuthorizationHeader() - clientFor(target).mirror(source, target, auth) + mirrorOne(source, row, target, null) mirrored++ + } catch (e: BlossomPaymentException) { + // BUD-07: this server wants payment. Pause and ask the user to confirm; + // the rest of the servers are retried after they decide. + if (BlossomPaymentHandler.canPay(account, e.payment)) { + _pendingPayment.value = PendingMirrorPayment(row, target, e.payment, BlossomPaymentHandler.amountSats(e.payment)) + return@launch + } + Log.w("BlossomBlobManager", "mirror to $target needs unsupported payment", e) } catch (e: Exception) { Log.w("BlossomBlobManager", "mirror to $target failed", e) } } if (mirrored > 0) refresh() - withContext(Dispatchers.Main) { onDone(mirrored) } } } + private suspend fun mirrorOne( + source: String, + row: BlobRow, + target: String, + proof: BlossomPaymentProof?, + ) { + val auth = account.createBlossomUploadAuth(row.hash, row.size ?: 0L, "Mirror ${row.hash}", listOf(target)).toAuthorizationHeader() + clientFor(target).mirror(source, target, auth, proof) + } + + /** User confirmed the BUD-07 prompt: pay via the wallet, retry, then continue with the rest. */ + fun confirmPendingPayment() { + val pending = _pendingPayment.value ?: return + _pendingPayment.value = null + val source = pending.row.url ?: return + viewModelScope.launch(Dispatchers.IO) { + val proof = BlossomPaymentHandler.pay(account, pending.payment) + if (proof == null) { + _error.value = "Payment failed or was not confirmed by the wallet." + return@launch + } + try { + mirrorOne(source, pending.row, pending.target, proof) + } catch (e: Exception) { + Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e) + } + // Continue mirroring to any remaining servers (which may prompt again). + refresh() + mirrorToMissing(pending.row.copy(serversMissing = pending.row.serversMissing.filter { it != pending.target })) + } + } + + fun cancelPendingPayment() { + _pendingPayment.value = null + } + /** BUD-09: report a blob to a server as problematic content. */ fun report( hash: HexKey, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 65338562ce..0dc07c6ec3 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1557,6 +1557,13 @@ Report Open This server requires payment to upload: %1$s + Payment required + This server charges a lightning payment to store the file. Pay from your connected wallet to continue. + Pay + + Pay %1$d sat + Pay %1$d sats + Report blob Reason (optional) Send 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 510631c6d2..4bafe94247 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 @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.service.upload import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper +import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult @@ -114,6 +115,7 @@ open class BlossomClient( sourceUrl: String, serverBaseUrl: String, authHeader: String?, + paymentProof: BlossomPaymentProof? = null, ): BlossomUploadResult = withContext(Dispatchers.IO) { val body = JsonMapper.toJson(MirrorRequest(sourceUrl)).toRequestBody("application/json".toMediaType()) @@ -121,8 +123,10 @@ open class BlossomClient( Request .Builder() .url(BlossomServerUrl.mirror(serverBaseUrl)) - .apply { authHeader?.let { addHeader("Authorization", it) } } - .put(body) + .apply { + authHeader?.let { addHeader("Authorization", it) } + paymentProof?.headers()?.forEach { (name, value) -> addHeader(name, value) } + }.put(body) .build() okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt index 7b13a75332..63a80b0611 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt @@ -56,3 +56,20 @@ data class BlossomPaymentRequired( ) } } + +/** + * The proof a client sends when retrying a request after settling a BUD-07 [BlossomPaymentRequired]: + * a settled Cashu token (echoed in `X-Cashu`) or the preimage of the paid BOLT-11 invoice + * (echoed in `X-Lightning`). + */ +data class BlossomPaymentProof( + val cashu: String? = null, + val lightningPreimage: String? = null, +) { + /** The header name/value pairs to attach to the retried request. */ + fun headers(): List> = + buildList { + cashu?.let { add(BlossomServerUrl.X_CASHU_HEADER to it) } + lightningPreimage?.let { add(BlossomServerUrl.X_LIGHTNING_HEADER to it) } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt index be732c00fd..6682cc79d7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt @@ -105,4 +105,17 @@ class BlossomUploadResultTest { val payment = BlossomPaymentRequired.fromHeaders { null } assertEquals(false, payment.hasPaymentOption()) } + + @Test + fun paymentProofBuildsRetryHeaders() { + assertEquals( + listOf(BlossomServerUrl.X_LIGHTNING_HEADER to "preimageabc"), + BlossomPaymentProof(lightningPreimage = "preimageabc").headers(), + ) + assertEquals( + listOf(BlossomServerUrl.X_CASHU_HEADER to "cashuToken"), + BlossomPaymentProof(cashu = "cashuToken").headers(), + ) + assertEquals(emptyList(), BlossomPaymentProof().headers()) + } } From e87309e038e3e510376356f42d4313069963f144 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:16:17 +0000 Subject: [PATCH 06/14] test(blossom): live interop smoke test for amy blossom (BUD-01/02/04/09) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds cli/tests/blossom/blossom-live.sh, an end-to-end harness that drives the full amy blossom lifecycle against a REAL public Blossom server: upload → HEAD check → download-and-verify-hash → list → cross-server mirror → delete. Verified green (6/6) against files.sovbit.host with a mirror to blossom.primal.net, which exercises the whole quartz/commons/CLI Blossom stack over the wire. Server-side write rejections (whitelists, rate limits, payment) record as SKIP, not FAIL — only a broken client contract (bad descriptor, hash mismatch, unparseable list) fails — since public servers gate anonymous writes very differently. Documented in cli/tests/README.md; state dir gitignored. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- cli/tests/.gitignore | 1 + cli/tests/README.md | 12 ++ cli/tests/blossom/blossom-live.sh | 196 ++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100755 cli/tests/blossom/blossom-live.sh diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index b2a0f61f7a..181dffb131 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -5,3 +5,4 @@ nests/state/ clink/state-clink-headless/ relaygroup/state-relaygroup-headless/ sync/state-sync-deletions/ +blossom/state-blossom-live/ diff --git a/cli/tests/README.md b/cli/tests/README.md index 0fc0100f90..3b4a882210 100644 --- a/cli/tests/README.md +++ b/cli/tests/README.md @@ -49,6 +49,18 @@ A third, slimmer harness covers the NIP-17 DM surface: is shared with the Marmot harness's checkout at `marmot/state-headless/nostr-rs-relay/`). +A harness covers Blossom blob storage (BUD-01/02/04/09) against **live** +public servers rather than a loopback relay: + +- **`blossom/blossom-live.sh`** — drives the full `amy blossom` lifecycle + (upload → HEAD check → download-and-verify-hash → list → cross-server + mirror → delete) against a real Blossom server. Server-side write + rejections (whitelists, rate limits, payment) record as SKIP, not FAIL — + only a broken client contract (bad descriptor, hash mismatch) fails. + Defaults to `https://files.sovbit.host`; pass `--mirror-server URL` to + exercise BUD-04. Example: + `blossom/blossom-live.sh --server https://files.sovbit.host --mirror-server https://blossom.primal.net`. + A fourth harness covers audio rooms (NIP-53 + moq-lite): - **`nests/nests-interop.sh`** — fully manual interop between Amethyst diff --git a/cli/tests/blossom/blossom-live.sh b/cli/tests/blossom/blossom-live.sh new file mode 100755 index 0000000000..d78ace467a --- /dev/null +++ b/cli/tests/blossom/blossom-live.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# blossom-live.sh — end-to-end interop smoke test for `amy blossom` +# against a LIVE public Blossom server (BUD-01/02/04/09). +# +# Exercises the full blob lifecycle with a throwaway identity: +# +# 1. upload — PUT /upload returns a Blob Descriptor; sha256 matches +# the bytes we sent (content addressing). +# 2. check — HEAD / reports the blob present (BUD-01). +# 3. download — GET / returns bytes whose sha256 matches. +# 4. list — GET /list/ includes the uploaded hash (BUD-02). +# 5. mirror — PUT /mirror on a 2nd server pulls the blob by URL and +# reports it present (BUD-04). [needs --mirror-server] +# 6. delete — DELETE / then HEAD shows it gone (BUD-02). +# +# Third-party servers gate writes in wildly different ways (whitelists, +# rate limits, payment, content sniffing). So a server-side *rejection* +# of a write is recorded as SKIP, not FAIL — only a broken client +# contract (bad descriptor, hash mismatch, unparuseable list) FAILS. +# +# Usage: +# ./blossom-live.sh [--server URL] [--mirror-server URL] [--no-build] +# +# Defaults: --server https://files.sovbit.host (accepts anonymous +# uploads + clean deletes at time of writing). Override freely. +# +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-blossom-live" +LOG_DIR="$STATE_DIR/logs" + +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" + +SERVER="${BLOSSOM_SERVER:-https://files.sovbit.host}" +MIRROR_SERVER="${BLOSSOM_MIRROR_SERVER:-}" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --server) SERVER="$2"; shift ;; + --mirror-server) MIRROR_SERVER="$2"; shift ;; + --no-build) NO_BUILD=1 ;; + -h|--help) + sed -n '3,27p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + exit 0 ;; + *) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;; + esac + shift +done + +mkdir -p "$STATE_DIR" "$LOG_DIR" +: >"$LOG_FILE" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" + +# Throwaway identity under a private fake $HOME with the plaintext secret +# backend (no passphrase prompt). Never publishes to relays. +export HOME="$STATE_DIR/home" +mkdir -p "$HOME" +amy() { "$AMY_BIN" --secret-backend plaintext --account blossomtest --json "$@" 2>>"$LOG_FILE"; } + +cleanup() { + local rc=$? + trap - EXIT INT TERM HUP + print_summary + exit "$rc" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +banner "amy blossom live interop ($RUN_TS) — server=$SERVER" + +if [[ ! -x "$AMY_BIN" ]]; then + if [[ "$NO_BUILD" -eq 1 ]]; then + fail_msg "amy binary missing at $AMY_BIN and --no-build set" + record_result preflight.amy fail "no amy binary" + exit 1 + fi + step "building amy (./gradlew :cli:installDist)" + (cd "$REPO_ROOT" && ./gradlew :cli:installDist -q) >>"$LOG_FILE" 2>&1 \ + || { fail_msg "amy build failed"; record_result preflight.build fail ""; exit 1; } +fi + +# A tiny 1x1 PNG — Blossom servers are media-oriented and often reject +# arbitrary octet-streams, so use a real recognizable image. +BLOB="$STATE_DIR/px.png" +printf '\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\xf5\xd7\xd4\xc2\x00\x00\x00\x00IEND\xaeB\x60\x82' >"$BLOB" + +step "creating throwaway identity" +amy create --name "Blossom Live Test" >>"$LOG_FILE" 2>&1 || { skip_msg "could not create identity"; record_result preflight.identity skip ""; exit 0; } +PUBKEY=$(amy whoami | jq -r '.hex // empty') + +# --------------------------------------------------------------------------- +# 1. upload +# --------------------------------------------------------------------------- +banner "T1 — upload" +UP=$(amy blossom upload --server "$SERVER" --mime-type image/png "$BLOB") +UP_RC=$? +HASH=$(printf '%s' "$UP" | jq -r '.sha256 // empty') +URL=$(printf '%s' "$UP" | jq -r '.url // empty') +if [[ "$UP_RC" -ne 0 || -z "$HASH" ]]; then + skip_msg "server rejected the upload (policy/whitelist/rate limit) — $(printf '%s' "$UP" | jq -r '.detail // .' 2>/dev/null)" + record_result T1.upload skip "server-side write rejection" + exit 0 +fi +if [[ ${#HASH} -eq 64 && -n "$URL" ]]; then + record_result T1.upload pass "sha256=$HASH" +else + record_result T1.upload fail "descriptor missing sha256/url: $UP" +fi + +# --------------------------------------------------------------------------- +# 2. check (HEAD) +# --------------------------------------------------------------------------- +banner "T2 — HEAD check reports present" +CK=$(amy blossom check --server "$SERVER" "$HASH") +if [[ "$(printf '%s' "$CK" | jq -r '.all_found')" == "true" ]]; then + record_result T2.check pass "HEAD found the blob" +else + record_result T2.check fail "HEAD did not find the just-uploaded blob: $CK" +fi + +# --------------------------------------------------------------------------- +# 3. download — bytes hash back to the same sha256 +# --------------------------------------------------------------------------- +banner "T3 — download round-trips the content hash" +DL=$(amy blossom download "$HASH" --server "$SERVER") +DL_HASH=$(printf '%s' "$DL" | jq -r '.sha256 // empty') +if [[ "$DL_HASH" == "$HASH" ]]; then + record_result T3.download pass "downloaded bytes hash to $DL_HASH" +else + record_result T3.download fail "download hash $DL_HASH != upload hash $HASH" +fi + +# --------------------------------------------------------------------------- +# 4. list — our hash appears in the pubkey's blob list +# --------------------------------------------------------------------------- +banner "T4 — list includes the uploaded blob" +LS=$(amy blossom list --server "$SERVER") +if printf '%s' "$LS" | jq -e --arg h "$HASH" '.blobs[]? | select(.sha256 == $h)' >/dev/null 2>&1; then + record_result T4.list pass "blob present in /list/$PUBKEY" +elif [[ "$(printf '%s' "$LS" | jq -r '.error // empty')" != "" ]]; then + skip_msg "server does not implement /list" + record_result T4.list skip "no /list endpoint" +else + record_result T4.list fail "uploaded hash not in list: $(printf '%s' "$LS" | head -c 200)" +fi + +# --------------------------------------------------------------------------- +# 5. mirror (optional 2nd server) +# --------------------------------------------------------------------------- +if [[ -n "$MIRROR_SERVER" ]]; then + banner "T5 — mirror $SERVER -> $MIRROR_SERVER" + MR=$(amy blossom mirror --server "$MIRROR_SERVER" "$URL") + if [[ $? -eq 0 && "$(printf '%s' "$MR" | jq -r '.sha256 // empty')" == "$HASH" ]]; then + CK2=$(amy blossom check --server "$MIRROR_SERVER" "$HASH") + if [[ "$(printf '%s' "$CK2" | jq -r '.all_found')" == "true" ]]; then + record_result T5.mirror pass "mirrored blob present on $MIRROR_SERVER" + else + record_result T5.mirror fail "mirror reported ok but HEAD misses it" + fi + else + skip_msg "mirror target rejected the write (policy) — $(printf '%s' "$MR" | jq -r '.detail // .' 2>/dev/null | head -c 160)" + record_result T5.mirror skip "server-side mirror rejection" + fi +else + skip_msg "no --mirror-server given; skipping BUD-04 mirror" + record_result T5.mirror skip "not configured" +fi + +# --------------------------------------------------------------------------- +# 6. delete then confirm gone +# --------------------------------------------------------------------------- +banner "T6 — delete removes the blob" +DEL=$(amy blossom delete --server "$SERVER" "$HASH") +DEL_OK=$(printf '%s' "$DEL" | jq -r '.deleted // false') +CK3=$(amy blossom check --server "$SERVER" "$HASH") +GONE=$([[ "$(printf '%s' "$CK3" | jq -r '.all_found')" == "false" ]] && echo true || echo false) +if [[ "$GONE" == "true" ]]; then + record_result T6.delete pass "blob no longer found after delete (deleted flag=$DEL_OK)" +else + skip_msg "server did not honor delete (may forbid deletes) — deleted=$DEL_OK" + record_result T6.delete skip "delete not honored" +fi From 22396cf583bdb69d0a5c5bfeef1adcedfa8cc286 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 02:32:51 +0000 Subject: [PATCH 07/14] feat(blossom): redesign blob manager to match the app's modern UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the "Manage stored files" screen up to the design language main just applied to the Media Servers screen: - Rounded surfaceContainer cards instead of flat Cards. - Image thumbnail, or a file-type glyph in a rounded secondaryContainer box for non-images. - Monospace, truncated-both-ends hash; type · size subtitle. - Per-server presence as pills with a status dot — allGoodColor tint for present, muted for missing — replacing the ambiguous same-shaped chips. - A single MoreVert overflow (copy link / open / report / per-server delete) with leading icons, plus a full-width tonal "Mirror to missing" CTA. - Icon-in-rounded-box empty/error states and an ExtendedFloatingActionButton refresh. - BUD-07 pay dialog restored with a Bolt icon and tonal confirm; payment message now names the server. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../mediaServers/BlossomBlobManagerScreen.kt | 371 +++++++++++------- amethyst/src/main/res/values/strings.xml | 3 +- 2 files changed, 230 insertions(+), 144 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 344f4bf900..56b9a36ee4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -21,27 +21,32 @@ package com.vitorpamplona.amethyst.ui.actions.mediaServers import android.content.Intent +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +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.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -57,11 +62,13 @@ import androidx.compose.runtime.setValue 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.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.net.toUri @@ -70,11 +77,13 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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 import com.vitorpamplona.quartz.nip56Reports.ReportType @@ -95,6 +104,7 @@ fun BlossomBlobManagerScreen( pendingPayment?.let { pending -> BlossomPaymentDialog( + host = vm.hostOf(pending.target), amountSats = pending.amountSats, reason = pending.payment.reason, onConfirm = { vm.confirmPendingPayment() }, @@ -105,11 +115,12 @@ fun BlossomBlobManagerScreen( Scaffold( topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) }, floatingActionButton = { - FloatingActionButton(onClick = { vm.refresh() }) { - Icon( - symbol = MaterialSymbols.Sync, - contentDescription = stringRes(R.string.retry), - modifier = Modifier.size(22.dp), + if (blobs.isNotEmpty()) { + ExtendedFloatingActionButton( + onClick = { vm.refresh() }, + icon = { Icon(symbol = MaterialSymbols.Sync, contentDescription = null, modifier = Modifier.size(20.dp)) }, + text = { Text(stringRes(R.string.retry)) }, + expanded = !loading, ) } }, @@ -119,179 +130,254 @@ fun BlossomBlobManagerScreen( Modifier .fillMaxSize() .padding( - start = 12.dp, - end = 12.dp, top = padding.calculateTopPadding(), bottom = padding.calculateBottomPadding(), ), ) { when { - loading && blobs.isEmpty() -> - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { CircularProgressIndicator() } + loading && blobs.isEmpty() -> CenteredState { CircularProgressIndicator() } error != null && blobs.isEmpty() -> - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(error ?: "", color = MaterialTheme.colorScheme.error) - TextButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) } + CenteredState { + StatusGlyph(MaterialSymbols.Warning, MaterialTheme.colorScheme.error) + Spacer(Modifier.height(12.dp)) + Text(error ?: "", color = MaterialTheme.colorScheme.grayText) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) } } blobs.isEmpty() -> - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { Text(stringRes(R.string.manage_stored_files_empty), color = MaterialTheme.colorScheme.grayText) } + CenteredState { + StatusGlyph(MaterialSymbols.Storage, MaterialTheme.colorScheme.grayText) + Spacer(Modifier.height(12.dp)) + Text( + stringRes(R.string.manage_stored_files_empty), + color = MaterialTheme.colorScheme.grayText, + ) + } else -> LazyColumn( modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(blobs, key = { it.hash }) { row -> BlobCard(row, vm) } + item { Spacer(Modifier.height(72.dp)) } } } } } } +@Composable +private fun CenteredState(content: @Composable () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { content() } +} + +@Composable +private fun StatusGlyph( + symbol: MaterialSymbol, + tint: Color, +) { + Box( + modifier = + Modifier + .size(72.dp) + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest), + contentAlignment = Alignment.Center, + ) { + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(34.dp), tint = tint) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun BlobCard( row: BlobRow, vm: BlossomBlobManagerViewModel, ) { - var deleteMenuOpen by remember { mutableStateOf(false) } + var menuOpen by remember { mutableStateOf(false) } var reportOpen by remember { mutableStateOf(false) } val clipboard = LocalClipboardManager.current val context = LocalContext.current - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (row.url != null && row.type?.startsWith("image/") == true) { - AsyncImage( - model = row.url, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.size(44.dp).clip(RoundedCornerShape(8.dp)), - ) - } - Column(modifier = Modifier.weight(1f)) { - Text( - text = row.hash.take(16) + "…", - style = MaterialTheme.typography.titleSmall, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - Text( - text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.grayText, - ) - } + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Header: thumbnail / file glyph + hash + overflow menu. + Row(verticalAlignment = Alignment.CenterVertically) { + BlobThumbnail(row) + Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) { + Text( + text = row.hash.take(12) + "…" + row.hash.takeLast(6), + style = MaterialTheme.typography.titleSmall, + fontFamily = FontFamily.Monospace, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.grayText, + ) } - FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - row.serversPresent.forEach { server -> - AssistChip( - onClick = {}, - label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) }, - colors = - AssistChipDefaults.assistChipColors( - labelColor = MaterialTheme.colorScheme.primary, - ), - ) + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null) } - row.serversMissing.forEach { server -> - AssistChip( - onClick = {}, - label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) }, - colors = - AssistChipDefaults.assistChipColors( - labelColor = MaterialTheme.colorScheme.grayText, - ), - ) + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (row.url != null) { + DropdownMenuItem( + text = { Text(stringRes(R.string.copy)) }, + leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) }, + onClick = { + menuOpen = false + clipboard.setText(AnnotatedString(row.url)) + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.blossom_open)) }, + leadingIcon = { MenuIcon(MaterialSymbols.AutoMirrored.OpenInNew) }, + onClick = { + menuOpen = false + runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) } + }, + ) + } + if (row.serversPresent.isNotEmpty()) { + DropdownMenuItem( + text = { Text(stringRes(R.string.blossom_report)) }, + leadingIcon = { MenuIcon(MaterialSymbols.Report) }, + onClick = { + menuOpen = false + reportOpen = true + }, + ) + HorizontalDivider() + row.serversPresent.forEach { server -> + DropdownMenuItem( + text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) }, + leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + vm.delete(row.hash, server) + }, + ) + } + } } } + } - Row( + // Per-server presence pills. + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + row.serversPresent.forEach { ServerPill(vm.hostOf(it), present = true) } + row.serversMissing.forEach { ServerPill(vm.hostOf(it), present = false) } + } + + // Primary CTA: fill the gaps. + if (row.serversMissing.isNotEmpty() && row.url != null) { + FilledTonalButton( + onClick = { vm.mirrorToMissing(row) }, modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - if (row.serversMissing.isNotEmpty() && row.url != null) { - OutlinedButton(onClick = { vm.mirrorToMissing(row) }) { - Text(stringRes(R.string.blossom_mirror_to_missing), style = MaterialTheme.typography.labelMedium) - } - } - - if (row.serversPresent.isNotEmpty()) { - Column { - OutlinedButton(onClick = { deleteMenuOpen = true }) { - Text(stringRes(R.string.blossom_delete_from), style = MaterialTheme.typography.labelMedium) - } - DropdownMenu(expanded = deleteMenuOpen, onDismissRequest = { deleteMenuOpen = false }) { - row.serversPresent.forEach { server -> - DropdownMenuItem( - text = { Text(vm.hostOf(server)) }, - onClick = { - deleteMenuOpen = false - vm.delete(row.hash, server) - }, - ) - } - } - } - } - - if (row.serversPresent.isNotEmpty()) { - TextButton(onClick = { reportOpen = true }) { - Text(stringRes(R.string.blossom_report), style = MaterialTheme.typography.labelMedium) - } - } - } - - if (row.url != null) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - TextButton(onClick = { clipboard.setText(AnnotatedString(row.url)) }) { - Text(stringRes(R.string.copy), style = MaterialTheme.typography.labelMedium) - } - TextButton(onClick = { - runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) } - }) { - Text(stringRes(R.string.blossom_open), style = MaterialTheme.typography.labelMedium) - } - } + Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text(stringRes(R.string.blossom_mirror_to_missing)) } } } if (reportOpen) { - BlossomReportDialog( - row = row, - vm = vm, - onDismiss = { reportOpen = false }, + BlossomReportDialog(row = row, vm = vm, onDismiss = { reportOpen = false }) + } +} + +@Composable +private fun BlobThumbnail(row: BlobRow) { + val shape = RoundedCornerShape(12.dp) + if (row.url != null && row.type?.startsWith("image/") == true) { + AsyncImage( + model = row.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp).clip(shape), + ) + } else { + Box( + modifier = Modifier.size(48.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center, + ) { + val glyph = if (row.type?.startsWith("video/") == true) MaterialSymbols.Download else MaterialSymbols.Storage + Icon( + symbol = glyph, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + +@Composable +private fun ServerPill( + host: String, + present: Boolean, +) { + val dot = if (present) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText + val bg = + if (present) { + MaterialTheme.colorScheme.allGoodColor.copy(alpha = 0.12f) + } else { + MaterialTheme.colorScheme.surfaceContainerHighest + } + Row( + modifier = Modifier.clip(CircleShape).background(bg).padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(dot)) + Text( + text = host, + style = MaterialTheme.typography.labelMedium, + color = if (present) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText, ) } } +@Composable +private fun MenuIcon( + symbol: MaterialSymbol, + tint: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint) +} + +private fun humanBytes(bytes: Long): String = + when { + bytes >= 1_000_000 -> "${bytes / 1_000_000} MB" + bytes >= 1_000 -> "${bytes / 1_000} KB" + else -> "$bytes B" + } + @Composable private fun BlossomPaymentDialog( + host: String, amountSats: Long?, reason: String?, onConfirm: () -> Unit, @@ -299,18 +385,21 @@ private fun BlossomPaymentDialog( ) { AlertDialog( onDismissRequest = onDismiss, + icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) }, title = { Text(stringRes(R.string.blossom_payment_title)) }, text = { Text( text = listOfNotNull( - stringRes(R.string.blossom_payment_message), + stringRes(R.string.blossom_payment_message, host), reason, ).joinToString("\n\n"), ) }, confirmButton = { - Button(onClick = onConfirm) { + FilledTonalButton(onClick = onConfirm) { + Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) Text( if (amountSats != null) { pluralStringResource(R.plurals.blossom_pay_sats, amountSats.toInt(), amountSats.toInt()) @@ -326,13 +415,6 @@ private fun BlossomPaymentDialog( ) } -private fun humanBytes(bytes: Long): String = - when { - bytes >= 1_000_000 -> "${bytes / 1_000_000} MB" - bytes >= 1_000 -> "${bytes / 1_000} KB" - else -> "$bytes B" - } - @Composable private fun BlossomReportDialog( row: BlobRow, @@ -347,11 +429,14 @@ private fun BlossomReportDialog( AlertDialog( onDismissRequest = onDismiss, + icon = { Icon(symbol = MaterialSymbols.Report, contentDescription = null) }, title = { Text(stringRes(R.string.blossom_report_title)) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Column { - OutlinedButton(onClick = { typeMenuOpen = true }) { Text(type.code) } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Box { + OutlinedButton(onClick = { typeMenuOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text(type.code) + } DropdownMenu(expanded = typeMenuOpen, onDismissRequest = { typeMenuOpen = false }) { ReportType.entries.forEach { rt -> DropdownMenuItem( @@ -373,7 +458,7 @@ private fun BlossomReportDialog( } }, confirmButton = { - Button(onClick = { + FilledTonalButton(onClick = { vm.report(row.hash, server, type, comment) onDismiss() }) { Text(stringRes(R.string.blossom_send)) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0dc07c6ec3..5507f72a05 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1554,11 +1554,12 @@ No stored files found on your Blossom servers. Mirror to missing Delete from… + Delete from %1$s Report Open This server requires payment to upload: %1$s Payment required - This server charges a lightning payment to store the file. Pay from your connected wallet to continue. + %1$s charges a lightning payment to store this file. Pay from your connected wallet to continue. Pay Pay %1$d sat From 6920dc6e82bd49484cee95b269e79c2f3f4021ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:05:37 +0000 Subject: [PATCH 08/14] fix(blossom): don't let mirroring corrupt upload progress or fail the upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from the mirror-on-upload change: 1. Progress state stuck. verifyHeader() ends by calling finish() (state=Finished, progress=1.0), but mirrorToOtherServers then ran updateState(0.95, ServerProcessing) and never restored Finished — so the on-screen progress ended pinned at "processing / 95%" and isUploading never cleared. Mirroring is now fire-and-forget on account.scope AFTER the upload finishes: it never touches the progress state, never delays the post, and can never turn a completed upload into a failure. 2. Upload could fail outright. The upload token was BUD-11 server-scoped; some servers reject an upload whose auth carries a `server` tag, surfacing as a bare "Uploading error:" with no detail. Upload-token replay isn't the threat scoping guards against (delete tokens are — those stay scoped), so upload/media tokens are no longer scoped. Also: blank exception messages now fall back to the class name so the dialog is never empty. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../service/uploads/UploadOrchestrator.kt | 39 ++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 4aad2555f2..7e24b556d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -39,8 +39,10 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.ciphers.NostrCipher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import java.io.File import kotlin.coroutines.cancellation.CancellationException @@ -217,13 +219,16 @@ class UploadOrchestrator { sensitiveContent = contentWarningReason, serverBaseUrl = serverBaseUrl, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, - // Scope the token to the target server (BUD-11) so it can't be replayed elsewhere, - // and use a t=media token when optimizing via /media. + // Use a t=media token when optimizing via /media, otherwise a plain + // t=upload token. Tokens are intentionally NOT server-scoped on the + // upload path: some servers reject an upload whose auth carries a + // `server` tag, and upload-token replay is not the threat scoping + // guards against (delete tokens are — those stay scoped). httpAuth = when { - forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) } - useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt, listOf(serverBaseUrl)) } - else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) } + forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) } + useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) } + else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) } }, context = context, useMediaEndpoint = useMedia, @@ -239,9 +244,18 @@ class UploadOrchestrator { ) // BUD-04: replicate the blob to the user's other Blossom servers for redundancy. - // Best-effort — a mirror failure never fails the upload the user already completed. + // Fire-and-forget on the account scope AFTER the upload is finished: mirroring is + // pure background redundancy, so it must never delay, alter, or fail the upload the + // user already completed, and must not touch the on-screen progress state. if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) { - mirrorToOtherServers(result, serverBaseUrl, account) + account.scope.launch(Dispatchers.IO) { + try { + mirrorToOtherServers(result, serverBaseUrl, account) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("UploadOrchestrator", "Background mirror failed", e) + } + } } finalState @@ -252,16 +266,16 @@ class UploadOrchestrator { error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl) } catch (e: Exception) { if (e is CancellationException) throw e - error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName) + error(R.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName) } } /** * BUD-04 mirror fan-out: asks every *other* Blossom server in the account's * kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs - * after the primary upload is confirmed, so the user's post is never delayed by - * a slow/offline mirror; failures are swallowed per-server. Requires the blob's - * sha256 (to scope the mirror auth and let server B verify the download). + * in the background after the upload is finished (see caller), so it never + * delays the user's post or touches the upload progress UI; per-server failures + * are swallowed. Requires the blob's sha256 so server B can verify the download. */ private suspend fun mirrorToOtherServers( result: MediaUploadResult, @@ -283,10 +297,9 @@ class UploadOrchestrator { if (targets.isEmpty()) return - updateState(0.95, UploadingState.ServerProcessing) targets.forEach { target -> try { - val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash", listOf(target)).toAuthorizationHeader() + val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader() BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target)) .mirror(sourceUrl, target, auth) } catch (e: Exception) { From da633342bcf952724a46c57c0d7c53a9046144fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:34:42 +0000 Subject: [PATCH 09/14] feat(blossom): faster, more legible blob manager (load, progress, refresh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three UX gaps on the "Manage stored files" screen: - Loading was a full-screen spinner until every server × every blob HEAD probe finished, sequentially — and it even HEAD-probed servers that had already returned a /list. Now: /list runs across all servers in parallel and renders immediately; only servers that don't implement /list are HEAD-probed, and those run in parallel too. A server that listed its blobs needs no probe. Result is shown as soon as the lists land. - Mirror-to-missing gave no feedback. Presence is now a per-server state (present/missing/pending); the target pill shows a spinner while mirroring and turns green on success or back to grey on failure — updated in place, no full reload. Delete spins the pill then greys it (and drops the row when it's gone everywhere). - The refresh control was a FAB mislabeled "Retry". It's now a proper refresh action in the top bar (a spinner while loading); the contextual "Retry" stays only on the error state. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../mediaServers/BlossomBlobManagerScreen.kt | 72 +++--- .../BlossomBlobManagerViewModel.kt | 238 ++++++++++++------ amethyst/src/main/res/values/strings.xml | 1 + 3 files changed, 200 insertions(+), 111 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index 56b9a36ee4..fd9f74fd9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton @@ -80,7 +79,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.allGoodColor @@ -104,7 +103,7 @@ fun BlossomBlobManagerScreen( pendingPayment?.let { pending -> BlossomPaymentDialog( - host = vm.hostOf(pending.target), + host = pending.targetHost, amountSats = pending.amountSats, reason = pending.payment.reason, onConfirm = { vm.confirmPendingPayment() }, @@ -113,16 +112,21 @@ fun BlossomBlobManagerScreen( } Scaffold( - topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) }, - floatingActionButton = { - if (blobs.isNotEmpty()) { - ExtendedFloatingActionButton( - onClick = { vm.refresh() }, - icon = { Icon(symbol = MaterialSymbols.Sync, contentDescription = null, modifier = Modifier.size(20.dp)) }, - text = { Text(stringRes(R.string.retry)) }, - expanded = !loading, - ) - } + topBar = { + TopBarExtensibleWithBackButton( + title = { Text(stringRes(R.string.manage_stored_files)) }, + 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)) + } + } + }, + ) }, ) { padding -> Column( @@ -165,7 +169,6 @@ fun BlossomBlobManagerScreen( items(blobs, key = { it.hash }) { row -> BlobCard(row, vm) } - item { Spacer(Modifier.height(72.dp)) } } } } @@ -259,7 +262,7 @@ private fun BlobCard( }, ) } - if (row.serversPresent.isNotEmpty()) { + if (row.hasPresent) { DropdownMenuItem( text = { Text(stringRes(R.string.blossom_report)) }, leadingIcon = { MenuIcon(MaterialSymbols.Report) }, @@ -269,7 +272,7 @@ private fun BlobCard( }, ) HorizontalDivider() - row.serversPresent.forEach { server -> + row.presentServers.forEach { server -> DropdownMenuItem( text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) }, leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) }, @@ -284,14 +287,13 @@ private fun BlobCard( } } - // Per-server presence pills. + // Per-server presence pills (green = has it, grey = missing, spinner = working). FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - row.serversPresent.forEach { ServerPill(vm.hostOf(it), present = true) } - row.serversMissing.forEach { ServerPill(vm.hostOf(it), present = false) } + row.servers.forEach { ServerPill(it) } } // Primary CTA: fill the gaps. - if (row.serversMissing.isNotEmpty() && row.url != null) { + if (row.hasMissing && row.url != null) { FilledTonalButton( onClick = { vm.mirrorToMissing(row) }, modifier = Modifier.fillMaxWidth(), @@ -335,25 +337,29 @@ private fun BlobThumbnail(row: BlobRow) { } @Composable -private fun ServerPill( - host: String, - present: Boolean, -) { - val dot = if (present) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText +private fun ServerPill(presence: ServerPresence) { + val present = presence.state == PresenceState.PRESENT + val pending = presence.state == PresenceState.PENDING + val accent = MaterialTheme.colorScheme.allGoodColor val bg = - if (present) { - MaterialTheme.colorScheme.allGoodColor.copy(alpha = 0.12f) - } else { - MaterialTheme.colorScheme.surfaceContainerHighest - } + if (present) accent.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceContainerHighest Row( modifier = Modifier.clip(CircleShape).background(bg).padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(dot)) + if (pending) { + CircularProgressIndicator( + modifier = Modifier.size(9.dp), + strokeWidth = 1.5.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + val dot = if (present) accent else MaterialTheme.colorScheme.grayText + Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(dot)) + } Text( - text = host, + text = presence.host, style = MaterialTheme.typography.labelMedium, color = if (present) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText, ) @@ -425,7 +431,7 @@ private fun BlossomReportDialog( var typeMenuOpen by remember { mutableStateOf(false) } var type by remember { mutableStateOf(ReportType.OTHER) } // Report to the first server that actually holds the blob. - val server = row.serversPresent.firstOrNull() ?: return + val server = row.presentServers.firstOrNull() ?: return AlertDialog( onDismissRequest = onDismiss, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt index 4ac5b63efc..b276c082c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -36,17 +36,39 @@ import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers +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.launch import kotlinx.coroutines.withContext +/** Whether one of the user's servers holds a blob, or an operation on it is in flight. */ +enum class PresenceState { + /** Confirmed present (green). */ + PRESENT, + + /** Confirmed absent (grey). */ + MISSING, + + /** An operation is in flight — first-load HEAD probe, a mirror, or a delete (spinner). */ + PENDING, +} + +@Immutable +data class ServerPresence( + val server: String, + val host: String, + val state: PresenceState, +) + /** - * One stored blob, plus which of the user's Blossom servers currently hold it — - * the "seen files per server" view (BUD-01 HEAD / BUD-02 list). [serversPresent] - * and [serversMissing] are server base URLs from the user's kind-10063 list. + * One stored blob, plus the state of each of the user's Blossom servers for it — + * the "seen files per server" view (BUD-01 HEAD / BUD-02 list). */ @Immutable data class BlobRow( @@ -54,25 +76,34 @@ data class BlobRow( val url: String?, val size: Long?, val type: String?, - val serversPresent: List, - val serversMissing: List, -) + val servers: List, +) { + val presentServers get() = servers.filter { it.state == PresenceState.PRESENT }.map { it.server } + val missingServers get() = servers.filter { it.state == PresenceState.MISSING }.map { it.server } + val hasPresent get() = servers.any { it.state == PresenceState.PRESENT } + val hasMissing get() = servers.any { it.state == PresenceState.MISSING } + val presentCount get() = servers.count { it.state == PresenceState.PRESENT } +} -/** A BUD-07 payment prompt raised while mirroring [row] to [target]. */ +/** A BUD-07 payment prompt raised while mirroring [hash] to [target]. */ @Immutable data class PendingMirrorPayment( - val row: BlobRow, + val hash: HexKey, + val sourceUrl: String, val target: String, + val targetHost: String, val payment: BlossomPaymentRequired, val amountSats: Long?, ) /** * Backs the Blossom blob-manager screen. For the active account it fans a - * `GET /list/` (BUD-02) across every server in the user's kind-10063 list, - * inverts the results into a per-blob presence matrix, and backfills servers that - * don't implement `/list` with cheap `HEAD /` probes (BUD-01). Exposes - * delete (BUD-02), mirror-to-missing (BUD-04), and report (BUD-09) actions. + * `GET /list/` (BUD-02) across every server in the user's kind-10063 list + * in parallel, shows the result immediately, then backfills only the servers that + * do NOT implement `/list` with parallel `HEAD /` probes (BUD-01) — a server + * that returned a list already reports its full holdings, so no probe is needed for + * it. Delete (BUD-02), mirror-to-missing (BUD-04) and report (BUD-09) update the + * affected pill in place (spinner → green/grey) instead of reloading the screen. */ @Stable class BlossomBlobManagerViewModel : ViewModel() { @@ -108,143 +139,194 @@ class BlossomBlobManagerViewModel : ViewModel() { _isLoading.value = true _error.value = null try { - _blobs.value = loadMatrix() + loadMatrix() } catch (e: Exception) { Log.w("BlossomBlobManager", "Failed to load blob list", e) - _error.value = e.message ?: e.javaClass.simpleName + _error.value = e.message?.ifBlank { null } ?: e.javaClass.simpleName } finally { _isLoading.value = false } } } - private suspend fun loadMatrix(): List { + private suspend fun loadMatrix() { val pubkey = account.signer.pubKey val servers = servers() - if (servers.isEmpty()) return emptyList() + if (servers.isEmpty()) { + _blobs.value = emptyList() + return + } - // Per-server /list results, keyed by hash. Servers that don't implement - // /list (or error) contribute an empty holding and get HEAD-backfilled below. - val presence = mutableMapOf>() - val meta = mutableMapOf() - val listCapable = mutableSetOf() + // Phase 1 — /list every server in parallel. A null result means the server + // doesn't implement /list (or errored) and needs HEAD backfill in phase 2. + val listed: List?>> = + coroutineScope { + servers + .map { server -> + async { + server to + try { + val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader() + clientFor(server).list(server, pubkey, auth) + } catch (e: Exception) { + Log.w("BlossomBlobManager", "list failed on $server", e) + null + } + } + }.awaitAll() + } - servers.forEach { server -> - try { - val auth = account.createBlossomListAuth("List blobs", listOf(server)).toAuthorizationHeader() - val blobs = clientFor(server).list(server, pubkey, auth) + val meta = HashMap() + val hashesByServer = HashMap>() + val listCapable = HashSet() + listed.forEach { (server, blobs) -> + if (blobs != null) { listCapable.add(server) - blobs.forEach { d -> - val hash = d.sha256 ?: return@forEach - presence.getOrPut(hash) { mutableSetOf() }.add(server) - meta.putIfAbsent(hash, BlobMeta(d.url, d.size, d.type)) - } - } catch (e: Exception) { - Log.w("BlossomBlobManager", "list failed on $server", e) + hashesByServer[server] = blobs.mapNotNull { it.sha256 }.toSet() + blobs.forEach { d -> d.sha256?.let { meta.putIfAbsent(it, BlobMeta(d.url, d.size, d.type)) } } } } + val allHashes = meta.keys.toList() - // BUD-01 backfill: for every known hash, HEAD-probe the servers that - // didn't (or couldn't) list it, so the presence matrix is complete. - val allHashes = presence.keys.toList() - servers.forEach { server -> - allHashes.forEach { hash -> - if (server !in presence[hash].orEmpty()) { - if (clientFor(server).has(hash, server)) { - presence.getOrPut(hash) { mutableSetOf() }.add(server) - } - } - } + fun rows(head: Map, Boolean> = emptyMap()): List = + allHashes + .map { hash -> + val presences = + servers.map { server -> + val state = + when { + server in listCapable -> + if (hash in hashesByServer[server].orEmpty()) PresenceState.PRESENT else PresenceState.MISSING + else -> + head[server to hash]?.let { if (it) PresenceState.PRESENT else PresenceState.MISSING } + ?: PresenceState.PENDING + } + ServerPresence(server, hostOf(server), state) + } + val m = meta[hash] + BlobRow(hash, m?.url, m?.size, m?.type, presences) + }.sortedByDescending { it.presentCount } + + // Show the /list result right away; the non-list servers appear as spinning + // pills until their HEAD probe lands. + _blobs.value = rows() + _isLoading.value = false + + // Phase 2 — HEAD-probe ONLY the non-list servers, for the known hashes, in parallel. + val nonListServers = servers.filter { it !in listCapable } + if (nonListServers.isNotEmpty() && allHashes.isNotEmpty()) { + val head = + coroutineScope { + nonListServers + .flatMap { server -> + allHashes.map { hash -> + async { (server to hash) to clientFor(server).has(hash, server) } + } + }.awaitAll() + }.toMap() + _blobs.value = rows(head) } - - return presence - .map { (hash, present) -> - val m = meta[hash] - BlobRow( - hash = hash, - url = m?.url, - size = m?.size, - type = m?.type, - serversPresent = servers.filter { it in present }, - serversMissing = servers.filter { it !in present }, - ) - }.sortedByDescending { it.serversPresent.size } } - /** BUD-02 delete: remove [hash] from a single [server]. */ + private fun currentRow(hash: HexKey) = _blobs.value.firstOrNull { it.hash == hash } + + private fun setServerState( + hash: HexKey, + server: String, + state: PresenceState, + ) { + _blobs.value = + _blobs.value.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. */ fun delete( hash: HexKey, server: String, - onDone: (Boolean) -> Unit = {}, ) { viewModelScope.launch(Dispatchers.IO) { + setServerState(hash, server, PresenceState.PENDING) val ok = try { - val auth = account.createBlossomDeleteAuth(hash, "Delete blob", listOf(server)).toAuthorizationHeader() + val auth = account.createBlossomDeleteAuth(hash, "Delete blob").toAuthorizationHeader() clientFor(server).delete(hash, server, auth) } catch (e: Exception) { Log.w("BlossomBlobManager", "delete failed on $server", e) false } - if (ok) refresh() - withContext(Dispatchers.Main) { onDone(ok) } + 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 } + } } } - /** BUD-04: mirror a blob to every server in the user's list that doesn't have it yet. */ + /** BUD-04: mirror a blob to every server that doesn't have it; each pill spins then turns green. */ fun mirrorToMissing(row: BlobRow) { val source = row.url ?: return + val targets = currentRow(row.hash)?.missingServers ?: row.missingServers + if (targets.isEmpty()) return viewModelScope.launch(Dispatchers.IO) { - var mirrored = 0 - for (target in row.serversMissing) { + for (target in targets) { + setServerState(row.hash, target, PresenceState.PENDING) try { - mirrorOne(source, row, target, null) - mirrored++ + mirrorOne(source, row.hash, row.size, target, null) + setServerState(row.hash, target, PresenceState.PRESENT) } catch (e: BlossomPaymentException) { - // BUD-07: this server wants payment. Pause and ask the user to confirm; - // the rest of the servers are retried after they decide. + setServerState(row.hash, target, PresenceState.MISSING) if (BlossomPaymentHandler.canPay(account, e.payment)) { - _pendingPayment.value = PendingMirrorPayment(row, target, e.payment, BlossomPaymentHandler.amountSats(e.payment)) + _pendingPayment.value = + PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment)) return@launch } Log.w("BlossomBlobManager", "mirror to $target needs unsupported payment", e) } catch (e: Exception) { + setServerState(row.hash, target, PresenceState.MISSING) Log.w("BlossomBlobManager", "mirror to $target failed", e) } } - if (mirrored > 0) refresh() } } private suspend fun mirrorOne( source: String, - row: BlobRow, + hash: HexKey, + size: Long?, target: String, proof: BlossomPaymentProof?, ) { - val auth = account.createBlossomUploadAuth(row.hash, row.size ?: 0L, "Mirror ${row.hash}", listOf(target)).toAuthorizationHeader() + val auth = account.createBlossomUploadAuth(hash, size ?: 0L, "Mirror $hash").toAuthorizationHeader() clientFor(target).mirror(source, target, auth, proof) } - /** User confirmed the BUD-07 prompt: pay via the wallet, retry, then continue with the rest. */ + /** User confirmed the BUD-07 prompt: pay via the wallet, retry that server, then continue. */ fun confirmPendingPayment() { val pending = _pendingPayment.value ?: return _pendingPayment.value = null - val source = pending.row.url ?: return viewModelScope.launch(Dispatchers.IO) { + setServerState(pending.hash, pending.target, PresenceState.PENDING) val proof = BlossomPaymentHandler.pay(account, pending.payment) if (proof == null) { + setServerState(pending.hash, pending.target, PresenceState.MISSING) _error.value = "Payment failed or was not confirmed by the wallet." return@launch } try { - mirrorOne(source, pending.row, pending.target, proof) + mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof) + setServerState(pending.hash, pending.target, PresenceState.PRESENT) } catch (e: Exception) { + setServerState(pending.hash, pending.target, PresenceState.MISSING) Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e) } - // Continue mirroring to any remaining servers (which may prompt again). - refresh() - mirrorToMissing(pending.row.copy(serversMissing = pending.row.serversMissing.filter { it != pending.target })) + // Continue with any remaining missing servers (which may prompt again). + currentRow(pending.hash)?.let { mirrorToMissing(it) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5507f72a05..3b014b22fc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1551,6 +1551,7 @@ Optimize media on the server Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original. Manage stored files + Refresh No stored files found on your Blossom servers. Mirror to missing Delete from… From ecbe2c67047a8a1ec4707d6d6d08597efb4a43c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:57:58 +0000 Subject: [PATCH 10/14] feat(blossom): app-level "Sync all" with floating progress; rename to "My Blossom Data" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 + .../uploads/blossom/BlossomMirrorQueue.kt | 137 ++++++++++++++++++ .../mediaServers/AllMediaServersLIstView.kt | 2 +- .../mediaServers/BlossomBlobManagerScreen.kt | 27 +++- .../BlossomBlobManagerViewModel.kt | 38 +++++ .../DisplayBlossomSyncProgress.kt | 130 +++++++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 10 ++ .../loggedIn/BottomBarFeedPreloaders.kt | 1 + .../settings/SettingsCatalogBuilder.kt | 1 - amethyst/src/main/res/values/strings.xml | 5 + 11 files changed, 354 insertions(+), 3 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 72d8c52e0f..8a4f709931 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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) } 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 new file mode 100644 index 0000000000..fb637725f0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomMirrorQueue.kt @@ -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, + ) + + private val _state = MutableStateFlow(null) + val state: StateFlow = _state.asStateFlow() + + private val _results = MutableSharedFlow(extraBufferCapacity = 128) + val results: SharedFlow = _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, + ) { + 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 + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 0e01b2c88d..22e036e31e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -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), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt index fd9f74fd9d..d474c5e45f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt index b276c082c8..1b3c9a9915 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -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(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, 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 new file mode 100644 index 0000000000..f2e1e65682 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/DisplayBlossomSyncProgress.kt @@ -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), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 6165aaaef2..53e8a2c3bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index c3a53e7919..165b506fdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -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 = 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.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 = NavBarItem.SCHEDULED_POSTS, NavBarItem.INTEREST_SETS, NavBarItem.FAVORITE_ALGO_FEEDS, + NavBarItem.BLOSSOM_DATA, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, NavBarItem.NOSTR_SIGNER, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 41b489db8e..ddce6cde6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index a2fdee8592..5e85e88c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -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()), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 3b014b22fc..8f194d9370 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1551,7 +1551,12 @@ Optimize media on the server Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original. Manage stored files + My Blossom Data Refresh + Sync all + Some of your files aren\'t on all your servers yet. + Copying your files across servers… + Sync complete No stored files found on your Blossom servers. Mirror to missing Delete from… From 4845e95c9fbcdabe9aaa2cfd8ce52d669df27fd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 03:59:37 +0000 Subject: [PATCH 11/14] chore(blossom): rename "My Blossom Data" to "My Blossom Files" Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- amethyst/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8f194d9370..0b39fe1c5c 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1551,7 +1551,7 @@ Optimize media on the server Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original. Manage stored files - My Blossom Data + My Blossom Files Refresh Sync all Some of your files aren\'t on all your servers yet. From 5d54a0ff90b85ccf42a53d593f6678f91c68ef58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 04:33:10 +0000 Subject: [PATCH 12/14] refactor(fgs): extract FlowProgressForegroundService; add dataSync Blossom sync service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalizes the shared foreground-service scaffolding (channel setup, start/stop- when-idle over a StateFlow, the ProgressStyle notification skeleton, tap + cancel intents, onTimeout) into an abstract FlowProgressForegroundService. Kept as a base — NOT one mega-service — because Android 14+ binds foregroundServiceType to behavior, so each workload keeps its own subclass with the correct type. - FlowProgressForegroundService: the shared base. - PowMiningForegroundService: refactored onto the base (still shortService); mining state maps to the card via render(); behavior preserved. - BlossomSyncForegroundService: new, typed dataSync (the right type for a multi-file sync that exceeds shortService's ~3-min budget), bound to blossomMirrorQueue.state. Started (from the foreground, as dataSync requires) when a "Sync all" sweep begins, so it survives backgrounding; stops itself when done. - BlossomMirrorQueue: publishes the active state and fires onActive() synchronously so the FGS starts with live state; wired in AppModules. - Manifest: FOREGROUND_SERVICE_DATA_SYNC permission + the service declaration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- amethyst/src/main/AndroidManifest.xml | 10 + .../com/vitorpamplona/amethyst/AppModules.kt | 8 +- .../FlowProgressForegroundService.kt | 298 ++++++++++++++++++ .../service/pow/PowMiningForegroundService.kt | 261 ++++----------- .../uploads/blossom/BlossomMirrorQueue.kt | 8 +- .../blossom/BlossomSyncForegroundService.kt | 94 ++++++ amethyst/src/main/res/values/strings.xml | 3 + 7 files changed, 473 insertions(+), 209 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/foreground/FlowProgressForegroundService.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index fa5b7740ef..b2e245363b 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -54,6 +54,7 @@ + @@ -373,6 +374,15 @@ android:stopWithTask="false" android:exported="false" /> + + + : Service() { + protected val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + private var watchJob: Job? = null + + /** The Android 14+ `ServiceInfo.FOREGROUND_SERVICE_TYPE_*` this service runs as. */ + protected abstract val fgsType: Int + protected abstract val channelId: String + protected abstract val channelNameRes: Int + protected abstract val channelDescRes: Int + protected abstract val notificationId: Int + + /** The intent action that routes back here to cancel everything. */ + protected abstract val cancelAction: String + protected abstract val cancelLabelRes: Int + protected open val smallIcon: Int = R.drawable.amethyst + + /** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */ + protected open val refreshMs: Long? = null + + protected abstract fun state(): StateFlow + + /** Keep the service (and notification) alive while this is true; stop once it goes false. */ + protected abstract fun isActive(value: T): Boolean + + protected abstract fun render(value: T): Content + + /** Invoked by the cancel action. */ + protected abstract fun cancelAll() + + /** Called for every emission before [render]; use to update derived subclass state. */ + protected open fun onEmission(value: T) {} + + /** Only consulted for the [refreshMs] clock loop; skip re-renders when nothing is moving. */ + protected open fun needsClockRefresh(value: T): Boolean = true + + /** One-time setup once the watch loop starts (e.g. a benchmark). */ + protected open fun onStarted() {} + + /** How to draw the progress bar of the card. */ + sealed interface Bar { + data object Indeterminate : Bar + + /** A single bar filled to [fraction] in `0f..1f`. */ + data class Determinate( + val fraction: Double, + ) : Bar + + /** [total] equal segments, [done] of them filled — good for "N of M". */ + data class Segmented( + val total: Int, + val done: Int, + ) : Bar + } + + data class Content( + val title: String, + val text: String?, + val bar: Bar, + ) + + private val tapIntent: PendingIntent by lazy { + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private val cancelIntent: PendingIntent by lazy { + PendingIntent.getService( + this, + 1, + Intent(this, this.javaClass).setAction(cancelAction), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + // Android's contract: every onStartCommand after startForegroundService must call + // startForeground promptly, even on the stop path. + runCatching { startForegroundCompat(state().value) } + .onFailure { + Log.w(logTag(), "startForeground failed; work continues without the service", it) + stopSelf() + return START_NOT_STICKY + } + + if (intent?.action == cancelAction) { + cancelAll() + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + return START_NOT_STICKY + } + + watch() + return START_NOT_STICKY + } + + /** Foreground-service budget exhausted (shortService ~3 min; dataSync on newer OS). Exit cleanly. */ + override fun onTimeout(startId: Int) { + Log.d(logTag()) { "foreground-service budget exhausted; stopping" } + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + override fun onDestroy() { + scope.cancel() + super.onDestroy() + } + + private fun watch() { + if (watchJob != null) return + onStarted() + watchJob = + scope.launch { + state().collect { value -> + onEmission(value) + if (!isActive(value)) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } else { + updateNotification(value) + } + } + } + + refreshMs?.let { ms -> + scope.launch { + while (true) { + val v = state().value + if (isActive(v) && needsClockRefresh(v)) updateNotification(v) + delay(ms) + } + } + } + } + + private fun startForegroundCompat(value: T) { + ensureChannel() + val notification = buildNotification(value) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(notificationId, notification, fgsType) + } else { + startForeground(notificationId, notification) + } + } + + private fun updateNotification(value: T) { + val manager = NotificationManagerCompat.from(this) + if (!manager.areNotificationsEnabled()) return + try { + manager.notify(notificationId, buildNotification(value)) + } catch (_: SecurityException) { + // POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running. + } + } + + private fun buildNotification(value: T): Notification { + val content = render(value) + val style = + when (val bar = content.bar) { + is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true) + is Bar.Determinate -> + if (bar.fraction.isFinite() && bar.fraction in 0.0..1.0) { + NotificationCompat + .ProgressStyle() + .setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100))) + .setProgress((bar.fraction * 100).toInt()) + } else { + NotificationCompat.ProgressStyle().setProgressIndeterminate(true) + } + is Bar.Segmented -> + NotificationCompat + .ProgressStyle() + .setProgressSegments(List(bar.total.coerceAtLeast(1)) { NotificationCompat.ProgressStyle.Segment(1) }) + .setProgress(bar.done) + } + + return NotificationCompat + .Builder(this, channelId) + .setSmallIcon(smallIcon) + .setContentTitle(content.title) + .setContentText(content.text) + .setStyle(style) + .setContentIntent(tapIntent) + .addAction(0, stringRes(this, cancelLabelRes), cancelIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .build() + } + + private fun ensureChannel() { + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(channelId) != null) return + manager.createNotificationChannel( + NotificationChannel(channelId, stringRes(this, channelNameRes), NotificationManager.IMPORTANCE_LOW).apply { + description = stringRes(this@FlowProgressForegroundService, channelDescRes) + setShowBadge(false) + }, + ) + } + + protected open fun logTag(): String = this.javaClass.simpleName + + companion object { + /** + * Best-effort start of a [FlowProgressForegroundService] subclass. A start from the + * background (e.g. a restore) may be denied — the work then proceeds unprotected and + * the service starts on the next foreground trigger. + */ + fun start( + context: Context, + clazz: Class>, + tag: String, + ) { + try { + context.startForegroundService(Intent(context, clazz)) + } catch (e: Exception) { + Log.w(tag, "Could not start foreground service (backgrounded?); work continues unprotected", e) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt index 36fe373b50..d61ea2c8ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/pow/PowMiningForegroundService.kt @@ -20,34 +20,17 @@ */ package com.vitorpamplona.amethyst.service.pow -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service import android.content.Context -import android.content.Intent import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState -import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService import com.vitorpamplona.amethyst.ui.pluralStringRes import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** @@ -56,161 +39,72 @@ import kotlinx.coroutines.launch * finish mining even after the user backgrounds the app. * * Uses the Android 14+ `shortService` type — no special permission, but a - * hard ~3 minute budget. On [onTimeout] the service exits cleanly; every + * hard ~3 minute budget. On `onTimeout` the service exits cleanly; every * persistable job is already checkpointed by [PowJobStore], so anything still * unmined resumes on the next app launch. Started on every enqueue (the app * is necessarily in the foreground then), stops itself when the queue drains. * - * The notification is a live progress card ([NotificationCompat.ProgressStyle]): - * one track segment per post, filling as jobs complete, indeterminate while a - * single post mines, with a cancel-all action. On Android 16+ it renders as a - * Live Updates chip; older versions fall back to a standard progress bar. + * The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the + * service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state + * to that card. */ -class PowMiningForegroundService : Service() { - private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) - private var watchJob: Job? = null +class PowMiningForegroundService : FlowProgressForegroundService>() { + override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE + override val channelId = CHANNEL_ID + override val channelNameRes = R.string.pow_notification_channel_name + override val channelDescRes = R.string.pow_notification_channel_description + override val notificationId = NOTIFICATION_ID + override val cancelAction = ACTION_CANCEL_ALL + override val cancelLabelRes = R.string.pow_notification_cancel_all - // Session totals so the progress track can show "done / enqueued since the - // service started" — the queue itself only knows what is still pending. + // clock-driven refresh for the time-left text and bar; the shortService budget (~3 min) + // caps this at a handful of updates. + override val refreshMs: Long = PROGRESS_REFRESH_MS + + // Session totals so the progress track can show "done / enqueued since the service + // started" — the queue itself only knows what is still pending. private var sessionTotal = 0 private var lastQueueSize = 0 - // Benchmarked once per service run (~250 ms, cached by the estimator); - // read from the notification builder to compute expected durations. + // Benchmarked once per service run (~250 ms, cached by the estimator). @Volatile private var hashRate: Double? = null - // Built once per service instance: the intents never change, and - // buildNotification runs on every queue update. - private val tapIntent: PendingIntent by lazy { - PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) - }, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - } - - private val cancelIntent: PendingIntent by lazy { - PendingIntent.getService( - this, - 1, - Intent(this, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - } - override fun onCreate() { super.onCreate() running = true } - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand( - intent: Intent?, - flags: Int, - startId: Int, - ): Int { - // Android's contract: every onStartCommand after startForegroundService - // must call startForeground promptly, even on the stop path. - runCatching { startForegroundCompat(currentJobs()) } - .onFailure { - Log.w(TAG, "startForeground failed; mining continues without the service", it) - stopSelf() - return START_NOT_STICKY - } - - if (intent?.action == ACTION_CANCEL_ALL) { - Amethyst.instance.powPublishQueue.cancelAll() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - return START_NOT_STICKY - } - - watchQueue() - return START_NOT_STICKY - } - - /** - * The shortService budget (~3 min) is exhausted. Exit before the system - * ANRs us: persisted jobs are checkpointed and resume on next launch; - * in-memory jobs keep mining opportunistically until the process freezes. - */ - override fun onTimeout(startId: Int) { - Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" } - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - override fun onDestroy() { running = false - scope.cancel() super.onDestroy() } - private fun currentJobs(): ImmutableList = Amethyst.instance.powPublishQueue.jobs.value + override fun state() = Amethyst.instance.powPublishQueue.jobs - private fun watchQueue() { - if (watchJob != null) return - watchJob = - scope.launch { - Amethyst.instance.powPublishQueue.jobs.collect { jobs -> - if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize - lastQueueSize = jobs.size + override fun isActive(value: ImmutableList) = value.isNotEmpty() - if (jobs.isEmpty()) { - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } else { - updateNotification(jobs) - } - } - } + override fun cancelAll() = Amethyst.instance.powPublishQueue.cancelAll() - // the estimated-time-left figure and progress fraction only move with - // the clock, not with queue events: benchmark the hash rate once, - // then refresh the card periodically while something is mining. - scope.launch { - hashRate = PoWEstimator.hashesPerSecond() - while (true) { - val jobs = currentJobs() - if (jobs.any { it.isMining }) updateNotification(jobs) - delay(PROGRESS_REFRESH_MS) - } - } + override fun needsClockRefresh(value: ImmutableList) = value.any { it.isMining } + + override fun onStarted() { + scope.launch { hashRate = PoWEstimator.hashesPerSecond() } } - private fun startForegroundCompat(jobs: ImmutableList) { - ensureChannel(this) - val notification = buildNotification(jobs) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE) - } else { - startForeground(NOTIFICATION_ID, notification) - } + override fun onEmission(value: ImmutableList) { + if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize + lastQueueSize = value.size } - private fun updateNotification(jobs: ImmutableList) { - val manager = NotificationManagerCompat.from(this) - if (!manager.areNotificationsEnabled()) return - try { - manager.notify(NOTIFICATION_ID, buildNotification(jobs)) - } catch (_: SecurityException) { - // POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running. - } - } + override fun render(value: ImmutableList): Content { + val done = (sessionTotal - value.size).coerceAtLeast(0) + val total = (done + value.size).coerceAtLeast(1) - private fun buildNotification(jobs: ImmutableList): Notification { - val done = (sessionTotal - jobs.size).coerceAtLeast(0) - val total = (done + jobs.size).coerceAtLeast(1) + val current = value.firstOrNull { it.isMining } ?: value.firstOrNull() - val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull() - - // expected duration for the job being mined right now, so the card can - // say "≈ 10 minutes left" and fill its bar toward a predictable end. + // expected duration for the job being mined right now, so the card can say + // "≈ 10 minutes left" and fill its bar toward a predictable end. val rate = hashRate val startedAt = current?.miningStartedAt val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null @@ -229,44 +123,23 @@ class PowMiningForegroundService : Service() { val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null - val progressStyle: NotificationCompat.ProgressStyle = - if (total <= 1) { - // single post: fill toward the estimated duration; past the - // mean the search is memoryless, so sweep instead of lying. - if (fraction != null && fraction < 1.0) { - NotificationCompat - .ProgressStyle() - .setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100))) - .setProgress((fraction * 100).toInt()) - } else { - NotificationCompat.ProgressStyle().setProgressIndeterminate(true) - } + val title = + if (value.size > 1) { + pluralStringRes(this, R.plurals.pow_mining_progress, value.size, value.size) } else { - NotificationCompat - .ProgressStyle() - .setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) }) - .setProgress(done) + stringRes(this, R.string.pow_mining_title) } - return NotificationCompat - .Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle( - if (jobs.size > 1) { - pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size) - } else { - stringRes(this, R.string.pow_mining_title) - }, - ).setContentText(text) - .setStyle(progressStyle) - .setContentIntent(tapIntent) - .addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setCategory(NotificationCompat.CATEGORY_PROGRESS) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .build() + val bar = + if (total <= 1) { + // single post: fill toward the estimated duration; past the mean the search is + // memoryless, so sweep instead of lying. + if (fraction != null && fraction < 1.0) Bar.Determinate(fraction) else Bar.Indeterminate + } else { + Bar.Segmented(total, done) + } + + return Content(title, text, bar) } companion object { @@ -275,45 +148,19 @@ class PowMiningForegroundService : Service() { private const val NOTIFICATION_ID = 0x504F57 // "POW" private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL" - // clock-driven refresh cadence for the time-left text and bar; the - // shortService budget (~3 min) caps this at a handful of updates. private const val PROGRESS_REFRESH_MS = 30_000L - // Best-effort de-dup for start(): the queue calls it on EVERY enqueue, - // and each call otherwise round-trips through system_server. A stale - // false only costs one redundant startForegroundService (which Android - // routes to the existing instance's onStartCommand anyway). + // Best-effort de-dup for start(): the queue calls it on EVERY enqueue. @Volatile private var running = false /** - * Best-effort start: enqueue happens while the user is interacting - * with the app, so the foreground-start allowance normally holds. A - * restore during a cold background launch may be denied — mining then - * proceeds unprotected and the service starts on the next enqueue. + * Best-effort start: enqueue happens while the user is interacting with the app, + * so the foreground-start allowance normally holds. */ fun start(context: Context) { if (running) return - try { - context.startForegroundService(Intent(context, PowMiningForegroundService::class.java)) - } catch (e: Exception) { - Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e) - } - } - - private fun ensureChannel(context: Context) { - val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager - if (manager.getNotificationChannel(CHANNEL_ID) != null) return - manager.createNotificationChannel( - NotificationChannel( - CHANNEL_ID, - stringRes(context, R.string.pow_notification_channel_name), - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = stringRes(context, R.string.pow_notification_channel_description) - setShowBadge(false) - }, - ) + FlowProgressForegroundService.start(context, PowMiningForegroundService::class.java, TAG) } } } 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 fb637725f0..5437fb0054 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 @@ -68,6 +68,8 @@ data class BlossomMirrorResult( */ class BlossomMirrorQueue( private val scope: CoroutineScope, + /** Invoked (on the foreground thread that called [start]) when a sweep begins, to start the FGS. */ + private val onActive: () -> Unit = {}, ) { data class Task( val hash: HexKey, @@ -95,11 +97,15 @@ class BlossomMirrorQueue( val work = tasks.flatMap { t -> t.targets.map { t to it } } if (work.isEmpty()) return + // 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. + _state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true) + onActive() + 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 = 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 new file mode 100644 index 0000000000..e5a3739883 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomSyncForegroundService.kt @@ -0,0 +1,94 @@ +/* + * 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 android.content.Context +import android.content.pm.ServiceInfo +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Foreground service that keeps the BUD-04 "sync all" sweep ([BlossomMirrorQueue]) running + * while the app is backgrounded. Uses the Android 14+ `dataSync` type — the correct type for + * an upload/download/sync operation (a multi-file mirror routinely exceeds the `shortService` + * ~3-minute budget that PoW mining uses). + * + * `dataSync` must be started while the app is foreground; "Sync all" is user-initiated from the + * manager, so that holds. All of the notification + lifecycle lives in the shared + * [FlowProgressForegroundService]; this maps the sweep's [BlossomSyncState] onto the card. + */ +class BlossomSyncForegroundService : FlowProgressForegroundService() { + override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + override val channelId = CHANNEL_ID + override val channelNameRes = R.string.blossom_sync_channel_name + override val channelDescRes = R.string.blossom_sync_channel_description + override val notificationId = NOTIFICATION_ID + override val cancelAction = ACTION_CANCEL + override val cancelLabelRes = R.string.blossom_sync_cancel + + override fun onCreate() { + super.onCreate() + running = true + } + + override fun onDestroy() { + running = false + super.onDestroy() + } + + override fun state() = Amethyst.instance.blossomMirrorQueue.state + + override fun isActive(value: BlossomSyncState?) = value?.running == true + + override fun cancelAll() = Amethyst.instance.blossomMirrorQueue.cancel() + + // State emits on every mirror step (including currentHost changes), so no clock refresh. + override val refreshMs: Long? = null + + override fun render(value: BlossomSyncState?): Content { + if (value == null) return Content(stringRes(this, R.string.blossom_syncing), null, Bar.Indeterminate) + val text = + buildString { + append("${value.done} / ${value.total}") + if (value.currentHost != null) append(" · ${value.currentHost}") + if (value.failed > 0) append(" · ${value.failed} failed") + } + return Content(stringRes(this, R.string.blossom_syncing), text, Bar.Determinate(value.fraction.toDouble())) + } + + companion object { + private const val TAG = "BlossomSyncFgs" + private const val CHANNEL_ID = "blossom_sync" + private const val NOTIFICATION_ID = 0x424C4F // "BLO" + private const val ACTION_CANCEL = "com.vitorpamplona.amethyst.blossom.SYNC_CANCEL" + + @Volatile + private var running = false + + /** Started when a sweep begins (from the foreground); stops itself when it finishes. */ + fun start(context: Context) { + if (running) return + FlowProgressForegroundService.start(context, BlossomSyncForegroundService::class.java, TAG) + } + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0b39fe1c5c..2dfb9d6327 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1557,6 +1557,9 @@ Some of your files aren\'t on all your servers yet. Copying your files across servers… Sync complete + Cancel + Blossom sync + Shows progress while copying your files across your Blossom servers. No stored files found on your Blossom servers. Mirror to missing Delete from… From 84640a5890cd9a458ad3860d1a19b9f0e83b4940 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 13:31:36 +0000 Subject: [PATCH 13/14] perf(blossom): parallelize "sync all" across servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sweep was strictly sequential (one mirror at a time). Now it parallelizes ACROSS servers but stays serial WITHIN each server, so every server gets a steady one-at-a-time stream (no rate-limit storms) while all of the user's servers work at once — wall-clock drops to roughly the slowest single server's queue instead of the sum of everything. - BlossomMirrorQueue: group work by target server, run one coroutine per server, and update the shared progress atomically via MutableStateFlow.update from the parallel workers. Extracted mirrorOne() for the per-task call. - Dropped the now-ambiguous currentHost from BlossomSyncState (and the banner / notification text) since multiple servers are in flight at once. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../uploads/blossom/BlossomMirrorQueue.kt | 58 +++++++++++-------- .../blossom/BlossomSyncForegroundService.kt | 1 - .../DisplayBlossomSyncProgress.kt | 1 - 3 files changed, 35 insertions(+), 25 deletions(-) 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 5437fb0054..ce7582fc75 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 @@ -25,16 +25,18 @@ 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.async +import kotlinx.coroutines.awaitAll 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.flow.update import kotlinx.coroutines.launch import kotlin.coroutines.cancellation.CancellationException @@ -45,7 +47,6 @@ data class BlossomSyncState( 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 @@ -102,33 +103,44 @@ class BlossomMirrorQueue( _state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true) onActive() + // Parallelize ACROSS servers but stay serial WITHIN a server, so every server gets a + // steady one-at-a-time stream (no hammering / rate-limit storms) while all of the user's + // servers work at once. Wall-clock ≈ the slowest single server's queue, not the sum. + val byServer: Map> = work.groupBy({ it.second }, { it.first }) + job = scope.launch { - var done = 0 - var failed = 0 - 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 + byServer + .map { (target, serverTasks) -> + async { + for (task in serverTasks) { + val ok = mirrorOne(account, task, target) + _results.tryEmit(BlossomMirrorResult(task.hash, target, ok)) + _state.update { it?.copy(done = it.done + 1, failed = it.failed + if (ok) 0 else 1) } + } } - _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) + }.awaitAll() + _state.update { it?.copy(running = false) } } } + private suspend fun mirrorOne( + account: Account, + task: Task, + target: String, + ): Boolean = + 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 + } + /** Cancel a running sweep and clear the banner. */ fun cancel() { job?.cancel() 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 e5a3739883..37bfb93b5c 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 @@ -70,7 +70,6 @@ class BlossomSyncForegroundService : FlowProgressForegroundService 0) append(" · ${value.failed} failed") } return Content(stringRes(this, R.string.blossom_syncing), text, Bar.Determinate(value.fraction.toDouble())) 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 f2e1e65682..0ec725109d 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 @@ -116,7 +116,6 @@ fun DisplayBlossomSyncProgress() { 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, From a25c5f9518d7be17cfee43586b673e97853bc07c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 13:46:06 +0000 Subject: [PATCH 14/14] fix: harden Blossom sync concurrency and cancellation Audit fixes for the Blossom client: - BlossomBlobManagerViewModel: use StateFlow.update{} for the presence matrix so the Main-thread sync collector and IO-thread delete/mirror actions can't lose each other's writes; add refreshJob de-dup so two quick refreshes can't interleave; rethrow CancellationException; bound the /list HEAD-probe backfill with a Semaphore(8). - BlossomClient.has(): rethrow CancellationException instead of swallowing it as 'not found'. - BlossomSyncForegroundService: drop the stale 'running' de-dup guard so a fresh sweep always gets foreground protection. - CLI mirror: strip query/fragment before extracting the sha256. - DisplayBlossomSyncProgress: retain the last state so the slide-out exit animation still has content to draw after the state clears. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP --- .../blossom/BlossomSyncForegroundService.kt | 21 ++---- .../BlossomBlobManagerViewModel.kt | 64 +++++++++++++------ .../DisplayBlossomSyncProgress.kt | 12 +++- .../amethyst/cli/commands/BlossomCommands.kt | 7 +- .../commons/service/upload/BlossomClient.kt | 3 + 5 files changed, 70 insertions(+), 37 deletions(-) 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 }