From 9d856315ade41af7ea965d9048da9c4f8f24c9fe Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Sun, 27 Sep 2026 21:13:23 -0400 Subject: [PATCH] fix(marmot): new groups carry the encrypted-media policy so White Noise can send media Groups created here left the encrypted-media-v2 component (0x800b) off, and White Noise (MDK) refuses to send any attachment in a group without it: InvalidMediaReference, 'group does not require encrypted media'. MDK treats the component's presence as the requirement. After the initial metadata commit, while the creator is still the only member, commit the policy with the account's Blossom servers. Joiners get it in their Welcome, so no member ever has to apply a later commit before sharing media. The policy names the account's own Blossom servers, or, for an account without a list, the default list the upload picker offers and falls back through (the Group Info "Use encrypted attachments" button now works for those accounts too, instead of asking them to add a server first). Best-effort: if the commit fails the group is still created, and text and legacy MIP-04 media keep working. Co-Authored-By: Claude Opus 5.5 --- .../amethyst/model/AccountMarmotActions.kt | 32 +++++++++++++++++-- .../ui/screen/loggedIn/AccountViewModel.kt | 4 +-- .../chats/marmotGroup/CreateGroupScreen.kt | 13 ++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt index 116e36b8b1..d31be56224 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountMarmotActions.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.model import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType import com.vitorpamplona.quartz.marmot.appComponents.BlobStoreEndpointV2 import com.vitorpamplona.quartz.marmot.appComponents.EncryptedMediaPolicyV2 import com.vitorpamplona.quartz.marmot.appComponents.GroupAvatarUrlV1 @@ -962,13 +963,40 @@ class AccountMarmotActions( * * The endpoints come from the account's own Blossom server list, because a * policy naming servers the uploader does not use would describe a group - * nobody can actually post media to. + * nobody can actually post media to. An account without one uploads to the + * default list (the same one the upload picker offers and falls back + * through), so that is what its policy names. */ + fun marmotMediaPolicyServers(): List = + account.blossomServers.flow.value + .ifEmpty { + account.blossomServers.hostNameFlow.value + .filter { it.type == ServerType.Blossom } + .map { it.baseUrl } + }.mapNotNull { normalizedPolicyBaseUrl(it) } + .distinct() + .take(EncryptedMediaPolicyV2.MAX_ENTRIES) + + /** + * [url] in the byte-exact form the media policy requires, or null when it has none. + * The component rejects a base URL that isn't its own WHATWG serialization, so the + * everyday spelling without a trailing slash (`https://cdn.nostrcheck.me`) failed the + * whole commit. + */ + private fun normalizedPolicyBaseUrl(url: String): String? = + try { + MarmotWebUrl.normalize(url, allowHttp = true, label = "base_url").also { + EncryptedMediaPolicyV2.requireNormalizedBaseUrl(it) + } + } catch (e: IllegalArgumentException) { + null + } + suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: HexKey) { val manager = account.marmotManager ?: return if (!account.isWriteable()) return - val servers = account.blossomServers.flow.value + val servers = marmotMediaPolicyServers() require(servers.isNotEmpty()) { "Cannot enable encrypted media without at least one Blossom server configured" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0439256b55..6439cc7e86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -2537,9 +2537,7 @@ class AccountViewModel( fun marmotUsesEncryptedMediaV2(nostrGroupId: String): Boolean = account.marmotManager?.encryptedMediaPolicy(nostrGroupId) != null /** True when this account has somewhere to upload a group's encrypted media. */ - fun hasBlossomServers(): Boolean = - account.blossomServers.flow.value - .isNotEmpty() + fun hasBlossomServers(): Boolean = account.marmot.marmotMediaPolicyServers().isNotEmpty() suspend fun enableMarmotEncryptedMediaV2(nostrGroupId: String) { account.marmot.enableMarmotEncryptedMediaV2(nostrGroupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt index 67eb18089d..cae9a3f162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/marmotGroup/CreateGroupScreen.kt @@ -67,9 +67,11 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.marmotGroup.send.MarmotGroupIconChange import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException @Composable fun CreateGroupScreen( @@ -136,6 +138,17 @@ fun CreateGroupScreen( description = groupDescription.trim(), icon = iconChange, ) + // Commit the encrypted-media policy while we are the only member. White Noise + // (MDK) refuses to send any attachment in a group without one + // ("group does not require encrypted media"), and joiners learn it from the + // Welcome, so nobody has to apply a later commit to be able to share media. + // Best-effort: a group without it still works for text and legacy MIP-04. + try { + accountViewModel.enableMarmotEncryptedMediaV2(nostrGroupId) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.w("CreateGroupScreen") { "Could not enable encrypted media for $nostrGroupId: ${e.message}" } + } nav.popUpTo(Route.MarmotGroupChat(nostrGroupId), Route.CreateMarmotGroup::class) } catch (e: Exception) { isCreating = false