From 413bf726fd68a0b62fa16494cd71ce035a58433a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 13 Jul 2026 17:32:56 -0400 Subject: [PATCH] feat(concord): author encrypted community icons (encrypt + Blossom upload) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CORD-02 §6 image write path: the community-metadata form's icon hero is now a photo picker that AES-256-GCM-encrypts the chosen image under a fresh key/nonce, uploads the *ciphertext* as an opaque blob to the account's Blossom server, and seals the resulting ImagePointer {url,key,nonce,hash} into the metadata — the inverse of the read path, and what Armada does in concord-v2 (`encryptImageBlob` + Blossom upload). - ConcordImageUploader reuses the existing NIP-17 DM encrypted-media primitives (AESGCM + BlossomUploader.upload(inputStream, …) + the account's Blossom server list + createBlossomUploadAuth). The blob is content- addressed by the ciphertext SHA-256; the pointer's hash is the plaintext SHA-256 for read-side integrity. - ConcordMetadataFields now holds an ImagePointer? and its hero opens the photo picker (spinner while uploading), replacing the plain-URL field — a URL-string icon was never CORD-02-valid (Armada renders robohash for it). - Create/edit pass the encrypted pointer straight through to createConcordCommunity / editConcordMetadata. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../concord/ConcordCreateScreen.kt | 10 +- .../concord/ConcordEditScreen.kt | 12 +-- .../concord/ConcordImageUploader.kt | 101 ++++++++++++++++++ .../concord/ConcordMetadataForm.kt | 76 ++++++++----- 4 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt index 62d5c62f4d..a2077a1097 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordCreateScreen.kt @@ -71,7 +71,7 @@ fun ConcordCreateScreen( ) { val name = remember { mutableStateOf("") } val about = remember { mutableStateOf("") } - val iconUrl = remember { mutableStateOf("") } + val icon = remember { mutableStateOf(null) } val relays = remember { mutableListOf().toMutableStateList() } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -100,7 +100,7 @@ fun ConcordCreateScreen( ConcordMetadataFields( name = name, about = about, - iconUrl = iconUrl, + icon = icon, robotSeed = "concord-new", accountViewModel = accountViewModel, ) @@ -134,11 +134,7 @@ fun ConcordCreateScreen( name = name.value.trim(), description = about.value.trim().ifBlank { null }, relays = relays.map { it.url }, - icon = - iconUrl.value - .trim() - .ifBlank { null } - ?.let { ImagePointer(url = it) }, + icon = icon.value, ) working = false if (communityId != null) nav.newStack(Route.ConcordServer(communityId)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt index 9a89e1d552..285954831a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordEditScreen.kt @@ -83,7 +83,7 @@ fun ConcordEditScreen( val name = remember { mutableStateOf("") } val about = remember { mutableStateOf("") } - val iconUrl = remember { mutableStateOf("") } + val icon = remember { mutableStateOf(null) } var prefilled by remember { mutableStateOf(false) } var working by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -94,7 +94,7 @@ fun ConcordEditScreen( if (!prefilled && md != null) { name.value = md.name about.value = md.description.orEmpty() - iconUrl.value = md.icon?.url.orEmpty() + icon.value = md.icon prefilled = true } } @@ -129,7 +129,7 @@ fun ConcordEditScreen( ConcordMetadataFields( name = name, about = about, - iconUrl = iconUrl, + icon = icon, robotSeed = communityId, accountViewModel = accountViewModel, ) @@ -144,11 +144,7 @@ fun ConcordEditScreen( communityId = communityId, name = name.value.trim(), description = about.value.trim().ifBlank { null }, - icon = - iconUrl.value - .trim() - .ifBlank { null } - ?.let { ImagePointer(url = it) }, + icon = icon.value, relays = state?.metadata?.relays ?: session.entry.relays, ) working = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt new file mode 100644 index 0000000000..5b3c7af3be --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordImageUploader.kt @@ -0,0 +1,101 @@ +/* + * 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.screen.loggedIn.chats.publicChannels.concord + +import android.content.Context +import android.net.Uri +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.ciphers.AESGCM +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.ByteArrayInputStream + +/** + * Authors a CORD-02 §6 encrypted community image: AES-256-GCM-encrypts the plaintext under a fresh + * random key/nonce (same scheme as NIP-17 DM encrypted media), uploads the *ciphertext* as an opaque + * blob to the account's Blossom server, and returns the [ImagePointer] to seal in the community + * metadata — the exact inverse of [rememberConcordImageModel]'s read path. Mirrors Armada's + * `encryptImageBlob` + Blossom upload in `concord-v2/lib/image.ts`. + */ +class ConcordImageUploader( + private val account: Account, +) { + suspend fun uploadEncrypted( + plaintext: ByteArray, + context: Context, + ): ImagePointer { + val serverBaseUrl = + account.blossomServers + .getBlossomServersList() + ?.servers() + ?.firstOrNull() + ?: DEFAULT_MEDIA_SERVERS.first { it.type == ServerType.Blossom }.baseUrl + + val cipher = AESGCM() + val ciphertext = cipher.encrypt(plaintext) + + val result = + withContext(Dispatchers.IO) { + BlossomUploader().upload( + // The blob is content-addressed by the SHA-256 of the *uploaded* (encrypted) bytes; + // the pointer's own hash below is over the *plaintext* for integrity on read. + inputStream = ByteArrayInputStream(ciphertext), + hash = sha256(ciphertext).toHexKey(), + length = ciphertext.size.toLong(), + baseFileName = "concord-image", + contentType = "application/octet-stream", + alt = "Encrypted Concord community image", + sensitiveContent = null, + serverBaseUrl = serverBaseUrl, + okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads, + httpAuth = account::createBlossomUploadAuth, + context = context, + ) + } + + val url = result.url ?: throw IllegalStateException("Blossom upload returned no URL") + return ImagePointer( + url = url, + key = cipher.keyBytes.toHexKey(), + nonce = cipher.nonce.toHexKey(), + hash = sha256(plaintext).toHexKey(), + ) + } + + /** Reads the picked [uri]'s bytes then [uploadEncrypted]s them. */ + suspend fun uploadEncrypted( + uri: Uri, + context: Context, + ): ImagePointer { + val bytes = + withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + } ?: throw IllegalStateException("Could not read the selected image") + return uploadEncrypted(bytes, context) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index fb712c7079..ec6254a156 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -20,6 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -28,18 +32,21 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -48,26 +55,26 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import kotlinx.coroutines.launch /** - * The shared metadata form for creating and editing a Concord community — a large - * circular icon preview at the top that reflects the icon URL live (tap it to jump - * to the URL field), then the name, description, and icon-URL fields. Mirrors the - * NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features - * feel consistent. Callers own the state and add the surrounding scaffold, relays - * section (create only), and the create/save action. + * The shared metadata form for creating and editing a Concord community — a large circular icon + * hero at the top (tap to pick an image, which is AES-256-GCM-encrypted and uploaded to Blossom as + * a CORD-02 §6 [ImagePointer], see [ConcordImageUploader]), then the name and description fields. + * Mirrors the NIP-29 `GroupImagePicker` hero + `GroupMetadataFields` layout so the two features feel + * consistent. Callers own the state and add the surrounding scaffold, relays section (create only), + * and the create/save action. */ @Composable fun ConcordMetadataFields( name: MutableState, about: MutableState, - iconUrl: MutableState, + icon: MutableState, robotSeed: String, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, ) { - val iconFocus = remember { FocusRequester() } - Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), @@ -75,10 +82,9 @@ fun ConcordMetadataFields( ) { ConcordIconHero( robotSeed = robotSeed, - iconUrl = iconUrl.value, + icon = icon, displayName = name.value, accountViewModel = accountViewModel, - onClick = { iconFocus.requestFocus() }, ) OutlinedTextField( @@ -96,45 +102,61 @@ fun ConcordMetadataFields( maxLines = 5, label = { Text(stringRes(R.string.concord_create_about)) }, ) - OutlinedTextField( - value = iconUrl.value, - onValueChange = { iconUrl.value = it }, - modifier = Modifier.fillMaxWidth().focusRequester(iconFocus), - singleLine = true, - label = { Text(stringRes(R.string.concord_create_icon)) }, - placeholder = { Text("https://…/icon.png") }, - ) } } -/** The circular community-icon hero: shows the icon URL live over a stable robohash placeholder. */ +/** + * The circular community-icon hero: shows the current (decrypted) icon over a stable robohash + * placeholder, and on tap opens the photo picker → encrypts + uploads the chosen image and updates + * [icon] to the resulting encrypted pointer. A spinner covers the hero while the upload is in flight. + */ @Composable private fun ConcordIconHero( robotSeed: String, - iconUrl: String, + icon: MutableState, displayName: String, accountViewModel: AccountViewModel, - onClick: () -> Unit, ) { val autoPlayGif by accountViewModel.settings.autoPlayVideosFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + var uploading by remember { mutableStateOf(false) } + val iconModel = rememberConcordImageModel(icon.value, accountViewModel) + + val picker = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + uploading = true + scope.launch { + try { + icon.value = ConcordImageUploader(accountViewModel.account).uploadEncrypted(uri, context) + } catch (e: Exception) { + Toast.makeText(context, stringRes(context, R.string.failed_to_upload_media_no_details), Toast.LENGTH_SHORT).show() + } finally { + uploading = false + } + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier .size(104.dp) .clip(CircleShape) - .clickable(onClick = onClick), + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, contentAlignment = Alignment.Center, ) { RobohashFallbackAsyncImage( robot = robotSeed, - model = iconUrl.ifBlank { null }, + model = iconModel, contentDescription = displayName.ifBlank { stringRes(R.string.concord_create_title) }, modifier = Modifier.size(104.dp).clip(CircleShape), loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), autoPlayGif = autoPlayGif, ) + if (uploading) CircularProgressIndicator(modifier = Modifier.size(36.dp)) } Text( text = stringRes(R.string.concord_create_icon_hint), @@ -146,7 +168,7 @@ private fun ConcordIconHero( Modifier .padding(top = 8.dp) .clip(CircleShape) - .clickable(onClick = onClick) + .clickable(enabled = !uploading) { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } .padding(horizontal = 8.dp, vertical = 4.dp), ) }