mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
28b93132a3
commit
413bf726fd
+3
-7
@@ -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))
|
||||
|
||||
+4
-8
@@ -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
|
||||
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
+49
-27
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user