feat(blossom): mobile upload controls — mirror + /media toggles, manager refresh

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
Claude
2026-07-18 00:07:30 +00:00
parent d0f2f08e19
commit 6c01ad2b10
8 changed files with 168 additions and 7 deletions
@@ -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),
@@ -186,6 +186,16 @@ class AccountSettings(
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = 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<Boolean> = 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<Boolean> = 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()
@@ -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)
}
@@ -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()
@@ -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(
@@ -92,6 +92,7 @@ fun MediaServersScaffold(
AllMediaBody(
blossomServersViewModel = blossomServersViewModel,
accountViewModel = accountViewModel,
nav = nav,
modifier =
Modifier
.fillMaxSize()
@@ -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 =
+5
View File
@@ -1545,6 +1545,11 @@
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="media_servers_upload_section">Upload behaviour</string>
<string name="blossom_mirror_uploads">Mirror uploads</string>
<string name="blossom_mirror_uploads_caption">After uploading, copy the file to your other Blossom servers so it stays available if one goes offline.</string>
<string name="blossom_optimize_media">Optimize media on the server</string>
<string name="blossom_optimize_media_caption">Upload through the server\'s /media endpoint so it can strip metadata and compress the file. The stored file may differ from the original.</string>
<string name="manage_stored_files">Manage stored files</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>