feat: add file encryption toggle for encrypted DMs with retry on failure

Add an "Encrypt files" toggle (default: on) to the file upload dialog in
NIP-17 encrypted DMs. When encrypted upload fails, show an informative
dialog explaining that many servers don't accept encrypted files on free
accounts, with an option to retry without encryption. The retry dialog
warns that without encryption anyone with the link can see the content.

https://claude.ai/code/session_012xvKzHrZPq3ZTAzBN2LvrR
This commit is contained in:
Claude
2026-03-15 14:30:26 +00:00
parent a21e38f428
commit 997a59dc23
8 changed files with 216 additions and 7 deletions
@@ -403,7 +403,16 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner {
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
draftTag.newVersion()
onceUploaded()
@@ -428,7 +437,19 @@ class ChatNewMessageViewModel :
accountViewModel.launchSigner {
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)
draftTag.newVersion()
onceUploaded()
@@ -443,6 +464,68 @@ 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?) {
val room = room ?: return
@@ -563,6 +646,8 @@ class ChatNewMessageViewModel :
uploadsWaitingToBeSent = emptyList()
uploadState?.reset()
dismissEncryptedUploadError()
iMetaAttachments.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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -114,6 +116,15 @@ fun PrivateMessageEditFieldRow(
}
}
channelScreenModel.encryptedUploadErrorTitle?.let { title ->
EncryptedUploadErrorDialog(
title = title,
message = channelScreenModel.encryptedUploadErrorMessage ?: "",
onDismiss = channelScreenModel::dismissEncryptedUploadError,
onRetryWithoutEncryption = channelScreenModel::retryWithoutEncryption,
)
}
Column(
modifier = EditFieldModifier,
) {
@@ -215,3 +226,36 @@ fun KeyboardLeadingIcon(
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))
}
},
)
}
@@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog(
onCancel,
accountViewModel,
nav,
isNip17 = channelScreenModel.nip17,
)
}
@@ -39,20 +39,68 @@ class ChatFileUploader(
suspend fun justUploadNIP17(
viewState: ChatFileUploadState,
onError: (title: String, message: String) -> Unit,
onEncryptedUploadError: (title: String, message: String) -> Unit,
context: Context,
onceUploaded: suspend (List<SuccessfulUploads>) -> Unit,
) {
val orchestrator = viewState.multiOrchestrator ?: return
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 =
orchestrator.uploadEncrypted(
orchestrator.upload(
viewState.caption,
viewState.contentWarningReason,
MediaCompressor.intToCompressorQuality(viewState.mediaQualitySlider),
cipher,
viewState.selectedServer,
account,
context,
@@ -62,7 +110,7 @@ class ChatFileUploader(
val list =
results.successful.mapNotNull { state ->
if (state.result is UploadOrchestrator.OrchestratorResult.ServerResult) {
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, cipher)
SuccessfulUploads(state.result, viewState.caption, viewState.contentWarningReason, null)
} else {
null
}
@@ -81,6 +81,7 @@ fun ChatFileUploadDialog(
onCancel: () -> Unit,
accountViewModel: AccountViewModel,
nav: INav,
isNip17: Boolean = false,
) {
val scrollState = rememberScrollState()
@@ -132,7 +133,7 @@ fun ChatFileUploadDialog(
) {
Column(Modifier.fillMaxSize().padding(start = 10.dp, end = 10.dp, bottom = 10.dp)) {
Column(Modifier.fillMaxWidth().verticalScroll(scrollState)) {
ImageVideoPostChat(state, accountViewModel)
ImageVideoPostChat(state, accountViewModel, isNip17)
}
}
}
@@ -144,6 +145,7 @@ fun ChatFileUploadDialog(
private fun ImageVideoPostChat(
fileUploadState: ChatFileUploadState,
accountViewModel: AccountViewModel,
isNip17: Boolean = false,
) {
val fileServers by accountViewModel.account.blossomServers.hostNameFlow
.collectAsState()
@@ -190,6 +192,16 @@ private fun ImageVideoPostChat(
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) {
TextSpinner(
label = "",
@@ -55,6 +55,8 @@ class ChatFileUploadState(
// 0 = Low, 1 = Medium, 2 = High, 3=UNCOMPRESSED
var mediaQualitySlider by mutableIntStateOf(1)
var encryptFiles by mutableStateOf(true)
fun load(uris: ImmutableList<SelectedMedia>) {
reset()
this.multiOrchestrator = MultiOrchestrator(uris)
@@ -70,6 +72,7 @@ class ChatFileUploadState(
mediaUploadTracker.finishUpload()
caption = ""
selectedServer = defaultServer
encryptFiles = true
}
fun deleteMediaToUpload(selected: SelectedMediaProcessing) {
+7
View File
@@ -1215,6 +1215,13 @@
<string name="compression_cancelled">Compression Cancelled</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_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>