feat(concord): author encrypted community icons (encrypt + Blossom upload)

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-13 17:32:56 -04:00
co-authored by Claude Opus 4.8
parent 28b93132a3
commit 413bf726fd
4 changed files with 157 additions and 42 deletions
@@ -71,7 +71,7 @@ fun ConcordCreateScreen(
) {
val name = remember { mutableStateOf("") }
val about = remember { mutableStateOf("") }
val iconUrl = remember { mutableStateOf("") }
val icon = remember { mutableStateOf<ImagePointer?>(null) }
val relays = remember { mutableListOf<NormalizedRelayUrl>().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))
@@ -83,7 +83,7 @@ fun ConcordEditScreen(
val name = remember { mutableStateOf("") }
val about = remember { mutableStateOf("") }
val iconUrl = remember { mutableStateOf("") }
val icon = remember { mutableStateOf<ImagePointer?>(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
@@ -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)
}
}
@@ -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<String>,
about: MutableState<String>,
iconUrl: MutableState<String>,
icon: MutableState<ImagePointer?>,
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<ImagePointer?>,
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),
)
}