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:
+87
-2
@@ -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,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?) {
|
private suspend fun innerSendPost(draftTag: String?) {
|
||||||
val room = room ?: return
|
val room = room ?: return
|
||||||
|
|
||||||
@@ -563,6 +646,8 @@ class ChatNewMessageViewModel :
|
|||||||
uploadsWaitingToBeSent = emptyList()
|
uploadsWaitingToBeSent = emptyList()
|
||||||
uploadState?.reset()
|
uploadState?.reset()
|
||||||
|
|
||||||
|
dismissEncryptedUploadError()
|
||||||
|
|
||||||
iMetaAttachments.reset()
|
iMetaAttachments.reset()
|
||||||
|
|
||||||
emojiSuggestions?.reset()
|
emojiSuggestions?.reset()
|
||||||
|
|||||||
+9
@@ -302,6 +302,15 @@ fun GroupDMScreenContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postViewModel.encryptedUploadErrorTitle?.let { title ->
|
||||||
|
EncryptedUploadErrorDialog(
|
||||||
|
title = title,
|
||||||
|
message = postViewModel.encryptedUploadErrorMessage ?: "",
|
||||||
|
onDismiss = postViewModel::dismissEncryptedUploadError,
|
||||||
|
onRetryWithoutEncryption = postViewModel::retryWithoutEncryption,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
@@ -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
|
||||||
@@ -114,6 +116,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,
|
||||||
) {
|
) {
|
||||||
@@ -215,3 +226,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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
+1
@@ -74,5 +74,6 @@ fun RoomChatFileUploadDialog(
|
|||||||
onCancel,
|
onCancel,
|
||||||
accountViewModel,
|
accountViewModel,
|
||||||
nav,
|
nav,
|
||||||
|
isNip17 = channelScreenModel.nip17,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -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 = "",
|
||||||
|
|||||||
+3
@@ -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) {
|
||||||
|
|||||||
@@ -1215,6 +1215,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>
|
||||||
|
|||||||
Reference in New Issue
Block a user