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