Merge pull request #1849 from vitorpamplona/claude/file-encryption-toggle-PwA8W

Add encrypted file upload with fallback option for NIP17 chats
This commit is contained in:
Vitor Pamplona
2026-03-17 11:45:25 -04:00
committed by GitHub
9 changed files with 247 additions and 7 deletions
@@ -403,7 +403,16 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.HOLD
},
context,
) {
uploadsWaitingToBeSent += it uploadsWaitingToBeSent += it
draftTag.newVersion() draftTag.newVersion()
onceUploaded() onceUploaded()
@@ -428,7 +437,19 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner { accountViewModel.launchSigner {
if (nip17) { if (nip17) {
ChatFileUploader(account).justUploadNIP17(uploadState, onError, context) { ChatFileUploader(account).justUploadNIP17(
uploadState,
onError,
onEncryptedUploadError = { title, message ->
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.SEND
pendingRetryOnError = onError
pendingRetryContext = context
pendingRetryOnceUploaded = onceUploaded
},
context,
) {
ChatFileSender(room, account).sendNIP17(it) ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion() draftTag.newVersion()
onceUploaded() onceUploaded()
@@ -443,6 +464,69 @@ class ChatNewMessageViewModel :
} }
} }
// Encrypted upload error state for retry dialog
var encryptedUploadErrorTitle by mutableStateOf<String?>(null)
var encryptedUploadErrorMessage by mutableStateOf<String?>(null)
var pendingRetryMode by mutableStateOf<RetryMode?>(null)
var pendingRetryOnError by mutableStateOf<((String, String) -> Unit)?>(null)
var pendingRetryContext by mutableStateOf<Context?>(null)
var pendingRetryOnceUploaded by mutableStateOf<(() -> Unit)?>(null)
enum class RetryMode { HOLD, SEND }
fun dismissEncryptedUploadError() {
encryptedUploadErrorTitle = null
encryptedUploadErrorMessage = null
pendingRetryMode = null
pendingRetryOnError = null
pendingRetryContext = null
pendingRetryOnceUploaded = null
}
fun retryWithoutEncryption() {
val mode = pendingRetryMode ?: return
val onError = pendingRetryOnError
val context = pendingRetryContext
val onceUploaded = pendingRetryOnceUploaded
val room = room
val uploadState = uploadState
dismissEncryptedUploadError()
if (uploadState == null || context == null) return
uploadState.encryptFiles = false
accountViewModel.launchSigner {
when (mode) {
RetryMode.HOLD -> {
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
) {
uploadsWaitingToBeSent += it
draftTag.newVersion()
onceUploaded?.invoke()
}
}
RetryMode.SEND -> {
if (room == null) return@launchSigner
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
context,
) {
ChatFileSender(room, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded?.invoke()
}
}
}
}
}
private suspend fun innerSendPost(draftTag: String?) { private suspend fun innerSendPost(draftTag: String?) {
val room = room ?: return val room = room ?: return
@@ -563,6 +647,8 @@ class ChatNewMessageViewModel :
uploadsWaitingToBeSent = emptyList() uploadsWaitingToBeSent = emptyList()
uploadState?.reset() uploadState?.reset()
dismissEncryptedUploadError()
iMetaAttachments.reset() iMetaAttachments.reset()
emojiSuggestions?.reset() emojiSuggestions?.reset()
@@ -302,6 +302,15 @@ fun GroupDMScreenContent(
) )
} }
} }
postViewModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = postViewModel.encryptedUploadErrorMessage ?: "",
onDismiss = postViewModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
)
}
} }
} }
@@ -27,8 +27,10 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -116,6 +118,15 @@ fun PrivateMessageEditFieldRow(
} }
} }
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = channelScreenModel.encryptedUploadErrorMessage ?: "",
onDismiss = channelScreenModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption,
)
}
Column( Column(
modifier = EditFieldModifier, modifier = EditFieldModifier,
) { ) {
@@ -217,3 +228,36 @@ fun KeyboardLeadingIcon(
ToggleNip17Button(channelScreenModel, accountViewModel) ToggleNip17Button(channelScreenModel, accountViewModel)
} }
} }
@Composable
fun EncryptedUploadErrorDialog(
title: String,
message: String,
onDismiss: () -> Unit,
onRetryWithoutEncryption: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message)
Text(
stringRes(R.string.upload_without_encryption_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
},
confirmButton = {
TextButton(onClick = onRetryWithoutEncryption) {
Text(stringRes(R.string.retry_without_encryption))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
@@ -24,11 +24,14 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaAttachments
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning import com.vitorpamplona.quartz.nip36SensitiveContent.contentWarning
import com.vitorpamplona.quartz.nip92IMeta.imetas
import com.vitorpamplona.quartz.utils.ciphers.AESGCM import com.vitorpamplona.quartz.utils.ciphers.AESGCM
class ChatFileSender( class ChatFileSender(
@@ -39,6 +42,8 @@ class ChatFileSender(
uploads.forEach { uploads.forEach {
if (it.cipher != null) { if (it.cipher != null) {
sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher) sendNIP17(it.result, it.caption, it.contentWarningReason, it.cipher)
} else {
sendNIP17AsHiddenLink(it.result, it.caption, it.contentWarningReason)
} }
} }
} }
@@ -70,6 +75,31 @@ class ChatFileSender(
) )
} }
suspend fun sendNIP17AsHiddenLink(
result: UploadOrchestrator.OrchestratorResult.ServerResult,
caption: String?,
contentWarningReason: String?,
) {
val iMetaAttachments = IMetaAttachments()
iMetaAttachments.add(result, caption, contentWarningReason)
val toUsers = chatroom.users.map { LocalCache.getOrCreateUser(it).toPTag() }
val template =
ChatMessageEvent.build(result.url, toUsers) {
references(listOf(result.url))
if (!caption.isNullOrEmpty()) {
alt(caption)
}
contentWarningReason?.let { contentWarning(it) }
imetas(iMetaAttachments.filterIsIn(setOf(result.url)))
}
account.sendNip17PrivateMessage(template)
}
// ------ // ------
// NIP 04 // NIP 04
// ------ // ------
@@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog(
onCancel, onCancel,
accountViewModel, accountViewModel,
nav, nav,
isNip17 = channelScreenModel.nip17,
) )
} }
@@ -39,20 +39,68 @@ class ChatFileUploader(
suspend fun justUploadNIP17( suspend fun justUploadNIP17(
viewState: ChatFileUploadState, viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit, onError: (title: String, message: String) -> Unit,
onEncryptedUploadError: (title: String, message: String) -> Unit,
context: Context, context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit, onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) { ) {
val orchestrator = viewState.multiOrchestrator ?: return val orchestrator = viewState.multiOrchestrator ?: return
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia()) viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
val cipher = AESGCM() if (viewState.encryptFiles) {
val cipher = AESGCM()
val results =
orchestrator.uploadEncrypted(
viewState.caption,
viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
cipher,
viewState.selectedServer,
account,
context,
)
if (results.allGood) {
val list =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher)
} else {
null
}
}
onceUploaded(list)
viewState.reset()
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
onEncryptedUploadError(
stringRes(context, R.string.failed_to_upload_encrypted_media_title),
stringRes(context, R.string.failed_to_upload_encrypted_media_message) + "\n\n" + errorMessages.joinToString(".\n"),
)
}
} else {
justUploadNIP17Unencrypted(viewState, onError, context, onceUploaded)
}
viewState.mediaUploadTracker.finishUpload()
}
suspend fun justUploadNIP17Unencrypted(
viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) {
val orchestrator = viewState.multiOrchestrator ?: return
viewState.mediaUploadTracker.startUpload(orchestrator.hasNonMedia())
val results = val results =
orchestrator.uploadEncrypted( orchestrator.upload(
viewState.caption, viewState.caption,
viewState.contentWarningReason, viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider), MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
cipher,
viewState.selectedServer, viewState.selectedServer,
account, account,
context, context,
@@ -62,7 +110,7 @@ class ChatFileUploader(
val list = val list =
results.successful.mapNotNull { state -> results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) { if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher) SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null)
} else { } else {
null null
} }
@@ -81,6 +81,7 @@ fun ChatFileUploadDialog(
onCancel: () -> Unit, onCancel: () -> Unit,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
isNip17: Boolean = false,
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
@@ -132,7 +133,7 @@ fun ChatFileUploadDialog(
) { ) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) { Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) { Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel) ImageVideoPostChat(state, accountViewModel, isNip17)
} }
} }
} }
@@ -144,6 +145,7 @@ fun ChatFileUploadDialog(
private fun ImageVideoPostChat( private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState, fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
isNip17: Boolean = false,
) { ) {
val fileServers by accountViewModel.account.blossomServers.hostNameFlow val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState() .collectAsState()
@@ -190,6 +192,16 @@ private fun ImageVideoPostChat(
onCheckedChange = fileUploadState::updateContentWarning, onCheckedChange = fileUploadState::updateContentWarning,
) )
if (isNip17) {
SettingSwitchItem(
title = R.string.encrypt_files_label,
description = R.string.encrypt_files_description,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
checked = fileUploadState.encryptFiles,
onCheckedChange = { fileUploadState.encryptFiles = it },
)
}
SettingsRow(R.string.file_server, R.string.file_server_description) { SettingsRow(R.string.file_server, R.string.file_server_description) {
TextSpinner( TextSpinner(
label = "", label = "",
@@ -55,6 +55,8 @@ class ChatFileUploadState(
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED // 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1) var mediaQualitySlider by mutableIntStateOf(1)
var encryptFiles by mutableStateOf(true)
fun load(uris: ImmutableList<SelectedMedia>) { fun load(uris: ImmutableList<SelectedMedia>) {
reset() reset()
this.multiOrchestrator = MultiOrchestrator(uris) this.multiOrchestrator = MultiOrchestrator(uris)
@@ -70,6 +72,7 @@ class ChatFileUploadState(
mediaUploadTracker.finishUpload() mediaUploadTracker.finishUpload()
caption = "" caption = ""
selectedServer = defaultServer selectedServer = defaultServer
encryptFiles = true
} }
fun deleteMediaToUpload(selected: SelectedMediaProcessing) { fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
+7
View File
@@ -1216,6 +1216,13 @@
<string name="compression_cancelled">Compression Cancelled</string> <string name="compression_cancelled">Compression Cancelled</string>
<string name="compression_returned_null">Compression failed to return a file</string> <string name="compression_returned_null">Compression failed to return a file</string>
<string name="encrypt_files_label">Encrypt files</string>
<string name="encrypt_files_description">Encrypt files before uploading for privacy. Some servers may not accept encrypted files on free accounts.</string>
<string name="failed_to_upload_encrypted_media_title">Encrypted upload failed</string>
<string name="failed_to_upload_encrypted_media_message">Many servers do not accept encrypted files on free accounts. You can retry without encryption.</string>
<string name="retry_without_encryption">Retry without encryption</string>
<string name="upload_without_encryption_warning">Warning: Without encryption, anyone with the file link can see the content.</string>
<string name="media_compression_quality_label">Media Quality</string> <string name="media_compression_quality_label">Media Quality</string>
<string name="media_compression_quality_explainer">Select Low quality to compress your media to a smaller file with less quality, High quality to compress to a larger file with higher quality or Uncompressed to upload the media without compression.</string> <string name="media_compression_quality_explainer">Select Low quality to compress your media to a smaller file with less quality, High quality to compress to a larger file with higher quality or Uncompressed to upload the media without compression.</string>
<string name="media_compression_quality_low">Low</string> <string name="media_compression_quality_low">Low</string>