Merge branch 'master' into ep/improve-names-2

This commit is contained in:
Evgeny @ SimpleX Chat
2026-07-12 05:54:41 +00:00
72 changed files with 1837 additions and 391 deletions
@@ -2261,6 +2261,7 @@ data class GroupInfo (
GroupFeature.Reports -> p.reports.on
GroupFeature.History -> p.history.on
GroupFeature.Support -> p.support.on
GroupFeature.SignMessages -> p.signMessages.on
}
}
@@ -2422,6 +2423,26 @@ data class GroupShortLinkData (
val publicGroupData: PublicGroupData? = null
)
@Serializable
enum class MsgSigStatus {
@SerialName("verified") Verified,
@SerialName("signedNoKey") SignedNoKey;
}
@Serializable
sealed class MsgVerified {
@Serializable @SerialName("signed") data class Signed(val sigStatus: MsgSigStatus): MsgVerified()
@Serializable @SerialName("sigMissing") object SigMissing: MsgVerified()
@Serializable @SerialName("unsigned") object Unsigned: MsgVerified()
val verified: Boolean get() = this is Signed && sigStatus == MsgSigStatus.Verified
val sigMissingInfo: Pair<String, String>? get() = when (this) {
is SigMissing -> generalGetString(MR.strings.signature_missing_alert_title) to generalGetString(MR.strings.signature_missing_alert_desc)
else -> null
}
}
@Serializable
enum class RelayStatus {
@SerialName("new") New,
@@ -3555,7 +3576,8 @@ data class CIMeta (
val userMention: Boolean,
val deletable: Boolean,
val editable: Boolean,
val showGroupAsSender: Boolean
val showGroupAsSender: Boolean,
val msgVerified: MsgVerified = MsgVerified.Unsigned
) {
val timestampText: String get() = getTimestampText(itemTs, true)
@@ -129,6 +129,8 @@ class AppPreferences {
val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } }
val simplexLinkMode: SharedPreference<SimplexLinkMode> = mkSafeEnumPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default)
val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true)
val privacyShowSignature = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_SIGNATURE, true)
val privacyShowEncryption = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_FILE_ENCRYPTION, true)
val privacySaveLastDraft = mkBoolPreference(SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT, true)
val privacyDeliveryReceiptsSet = mkBoolPreference(SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET, false)
val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
@@ -186,6 +188,7 @@ class AppPreferences {
val networkTCPKeepCnt = mkIntPreference(SHARED_PREFS_NETWORK_TCP_KEEP_CNT, KeepAliveOpts.defaults.keepCnt)
val incognito = mkBoolPreference(SHARED_PREFS_INCOGNITO, false)
val liveMessageAlertShown = mkBoolPreference(SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN, false)
val signMessageAlertShown = mkBoolPreference(SHARED_PREFS_SIGN_MESSAGE_ALERT_SHOWN, false)
val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true)
val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false)
val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false)
@@ -271,6 +274,7 @@ class AppPreferences {
hintPref(oneHandUICardShown, false),
hintPref(addressCreationCardShown, false),
hintPref(liveMessageAlertShown, false),
hintPref(signMessageAlertShown, false),
hintPref(showHiddenProfilesNotice, true),
hintPref(showMuteProfileAlert, true),
hintPref(showReportsInSupportChatAlert, true),
@@ -404,6 +408,8 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews"
private const val SHARED_PREFS_PRIVACY_SHOW_SIGNATURE = "PrivacyShowSignature"
private const val SHARED_PREFS_PRIVACY_SHOW_FILE_ENCRYPTION = "PrivacyShowEncryption"
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
private const val SHARED_PREFS_PRIVACY_DELIVERY_RECEIPTS_SET = "PrivacyDeliveryReceiptsSet"
private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
@@ -454,6 +460,7 @@ class AppPreferences {
private const val SHARED_PREFS_NETWORK_TCP_KEEP_CNT = "NetworkTCPKeepCnt"
private const val SHARED_PREFS_INCOGNITO = "Incognito"
private const val SHARED_PREFS_LIVE_MESSAGE_ALERT_SHOWN = "LiveMessageAlertShown"
private const val SHARED_PREFS_SIGN_MESSAGE_ALERT_SHOWN = "SignMessageAlertShown"
private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice"
private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown"
private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown"
@@ -1096,8 +1103,8 @@ object ChatController {
suspend fun apiReorderChatTags(rh: Long?, tagIds: List<Long>) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds))
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages)
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, sign: Boolean = false, composedMessages: List<ComposedMessage>): List<AChatItem>? {
val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, sign, composedMessages)
return processSendMessageCmd(rh, cmd)
}
@@ -3784,7 +3791,7 @@ sealed class CC {
class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC()
class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List<ComposedMessage>): CC()
class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val sign: Boolean, val composedMessages: List<ComposedMessage>): CC()
class ApiCreateChatTag(val tag: ChatTagData): CC()
class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List<Long>): CC()
class ApiDeleteChatTag(val tagId: Long): CC()
@@ -3982,7 +3989,7 @@ sealed class CC {
is ApiSendMessages -> {
val msgs = json.encodeToString(composedMessages)
val ttlStr = if (ttl != null) "$ttl" else "default"
"/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} json $msgs"
"/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} sign=${onOff(sign)} json $msgs"
}
is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}"
is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}"
@@ -5857,7 +5864,8 @@ enum class GroupFeature: Feature {
@SerialName("simplexLinks") SimplexLinks,
@SerialName("reports") Reports,
@SerialName("history") History,
@SerialName("support") Support;
@SerialName("support") Support,
@SerialName("signMessages") SignMessages;
override val hasParam: Boolean get() = when(this) {
TimedMessages -> true
@@ -5876,6 +5884,7 @@ enum class GroupFeature: Feature {
Reports -> false
History -> false
Support -> false
SignMessages -> false
}
override val text: String get() = text(isChannel = false)
@@ -5891,6 +5900,7 @@ enum class GroupFeature: Feature {
Reports -> generalGetString(if (isChannel) MR.strings.group_reports_subscriber_reports else MR.strings.group_reports_member_reports)
History -> generalGetString(MR.strings.recent_history)
Support -> generalGetString(MR.strings.chat_with_admins)
SignMessages -> generalGetString(MR.strings.sign_messages)
}
val icon: Painter
@@ -5905,6 +5915,7 @@ enum class GroupFeature: Feature {
Reports -> painterResource(MR.images.ic_flag)
History -> painterResource(MR.images.ic_schedule)
Support -> painterResource(MR.images.ic_help)
SignMessages -> painterResource(MR.images.ic_verified)
}
@Composable
@@ -5919,6 +5930,7 @@ enum class GroupFeature: Feature {
Reports -> painterResource(MR.images.ic_flag_filled)
History -> painterResource(MR.images.ic_schedule_filled)
Support -> painterResource(MR.images.ic_help_filled)
SignMessages -> painterResource(MR.images.ic_verified_filled)
}
fun enableDescription(enabled: GroupFeatureEnabled, canEdit: Boolean, isChannel: Boolean = false): String =
@@ -5964,6 +5976,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.allow_chat_with_admins_channel else MR.strings.allow_chat_with_admins)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_chat_with_admins)
}
SignMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.require_message_signatures)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.do_not_require_message_signatures)
}
}
} else {
when(this) {
@@ -6007,6 +6023,10 @@ enum class GroupFeature: Feature {
GroupFeatureEnabled.ON -> generalGetString(if (isChannel) MR.strings.members_can_chat_with_admins_channel else MR.strings.members_can_chat_with_admins)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.chat_with_admins_is_prohibited)
}
SignMessages -> when(enabled) {
GroupFeatureEnabled.ON -> generalGetString(MR.strings.message_signatures_are_required)
GroupFeatureEnabled.OFF -> generalGetString(MR.strings.message_signatures_are_not_required)
}
}
}
}
@@ -6133,6 +6153,7 @@ data class FullGroupPreferences(
val reports: GroupPreference,
val history: GroupPreference,
val support: GroupPreference,
val signMessages: GroupPreference,
val commands: List<ChatBotCommand>,
) {
fun toGroupPreferences(): GroupPreferences =
@@ -6147,6 +6168,7 @@ data class FullGroupPreferences(
reports = reports,
history = history,
support = support,
signMessages = signMessages,
commands = commands,
)
@@ -6162,6 +6184,7 @@ data class FullGroupPreferences(
reports = GroupPreference(GroupFeatureEnabled.ON),
history = GroupPreference(GroupFeatureEnabled.ON),
support = GroupPreference(GroupFeatureEnabled.ON),
signMessages = GroupPreference(GroupFeatureEnabled.OFF),
commands = listOf()
)
}
@@ -6179,6 +6202,7 @@ data class GroupPreferences(
val reports: GroupPreference? = null,
val history: GroupPreference? = null,
val support: GroupPreference? = null,
val signMessages: GroupPreference? = null,
val commands: List<ChatBotCommand>? = null
) {
companion object {
@@ -392,6 +392,8 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: ()
null, Profile(displayName.trim(), "", null, null)
) ?: return@withBGApi
chatModel.localUserCreated.value = true
// new users don't need the local file encryption indicator (all files are encrypted); existing users keep it on
chatModel.controller.appPrefs.privacyShowEncryption.set(false)
val onboardingStage = chatModel.controller.appPrefs.onboardingStage
// No users or no visible users
if (chatModel.users.none { u -> !u.user.hidden }) {
@@ -272,7 +272,16 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
if (deleteAt != null) {
InfoRow(stringResource(MR.strings.info_row_disappears_at), localTimestamp(deleteAt))
}
if (devTools) {
if (ci.meta.msgVerified.verified) {
val signedRes = if (sent) MR.strings.info_row_signed else MR.strings.info_row_signed_verified
InfoRow(stringResource(signedRes), "", icon = painterResource(MR.images.ic_verified))
} else if (ci.meta.msgVerified is MsgVerified.SigMissing) {
InfoRow(stringResource(MR.strings.signature_missing_alert_title), "", icon = painterResource(MR.images.ic_verified_missing), iconTint = Color.Red)
}
}
if (devTools) {
SectionDividerSpaced()
SectionView {
InfoRow(stringResource(MR.strings.info_row_database_id), ci.meta.itemId.toString())
InfoRow(stringResource(MR.strings.info_row_updated_at), localTimestamp(ci.meta.updatedAt))
ExpandableInfoRow(stringResource(MR.strings.info_row_message_status), jsonShort.encodeToString(ci.meta.itemStatus))
@@ -559,6 +568,11 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem
if (deleteAt != null) {
shareText.add(String.format(generalGetString(MR.strings.share_text_disappears_at), localTimestamp(deleteAt)))
}
if (ci.meta.msgVerified.verified) {
shareText.add(generalGetString(if (sent) MR.strings.info_row_signed else MR.strings.info_row_signed_verified))
} else if (ci.meta.msgVerified is MsgVerified.SigMissing) {
shareText.add(generalGetString(MR.strings.signature_missing_alert_title))
}
if (devTools) {
shareText.add(String.format(generalGetString(MR.strings.share_text_database_id), meta.itemId))
shareText.add(String.format(generalGetString(MR.strings.share_text_updated_at), meta.updatedAt))
@@ -539,7 +539,7 @@ fun ComposeView(
}
}
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map<String, Long>): ChatItem? {
suspend fun send(chat: Chat, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?, mentions: Map<String, Long>, sign: Boolean = false): ChatItem? {
val cInfo = chat.chatInfo
val chatItems = if (chat.chatInfo.chatType == ChatType.Local)
chatModel.controller.apiCreateChatItems(
@@ -556,6 +556,7 @@ fun ComposeView(
sendAsGroup = cInfo.sendAsGroup,
live = live,
ttl = ttl,
sign = sign,
composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions))
)
if (!chatItems.isNullOrEmpty()) {
@@ -672,7 +673,7 @@ fun ComposeView(
}
}
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?): List<ChatItem>? {
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?, sign: Boolean = false): List<ChatItem>? {
val cs = composeState.value
var sent: List<ChatItem>?
var lastMessageFailedToSend: ComposeState? = null
@@ -797,7 +798,7 @@ fun ComposeView(
if (cs.message.text.isNotEmpty()) {
sent?.mapIndexed { index, message ->
if (index == sent!!.lastIndex) {
send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions)
send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions, sign = sign)
} else {
message
}
@@ -837,7 +838,7 @@ fun ComposeView(
if (remoteHost == null) saveAnimImage(it.uri)
else CryptoFile.desktopPlain(it.uri)
is UploadContent.Video ->
if (remoteHost == null) saveFileFromUri(it.uri, hiddenFileNamePrefix = "video")
if (remoteHost == null) saveFileFromUri(it.uri, cs.maxFileSize, hiddenFileNamePrefix = "video")
else CryptoFile.desktopPlain(it.uri)
}
if (file != null) {
@@ -886,7 +887,7 @@ fun ComposeView(
}
is ComposePreview.FilePreview -> {
val file = if (remoteHost == null) {
saveFileFromUri(preview.uri)
saveFileFromUri(preview.uri, cs.maxFileSize)
} else {
CryptoFile.desktopPlain(preview.uri)
}
@@ -914,7 +915,8 @@ fun ComposeView(
val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file,
live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false,
ttl = ttl,
mentions = cs.memberMentions
mentions = cs.memberMentions,
sign = sign
)
sent = if (sendResult != null) listOf(sendResult) else null
if (sent == null && index == msgs.lastIndex && cs.liveMessage == null) {
@@ -941,9 +943,9 @@ fun ComposeView(
return sent
}
fun sendMessage(ttl: Int?) {
fun sendMessage(ttl: Int?, sign: Boolean = false) {
withLongRunningApi(slow = 120_000) {
sendMessageAsync(null, false, ttl)
sendMessageAsync(null, false, ttl, sign)
}
}
@@ -1387,12 +1389,18 @@ fun ComposeView(
allowVoiceToContact = ::allowVoiceToContact,
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
showSign = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.useRelays == true,
signMessageAlertShown = chatModel.controller.appPrefs.signMessageAlertShown,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
placeholder = if (userCantSendReason.value != null) "" else placeholder ?: composeState.value.placeholder,
sendMessage = { ttl ->
sendMessage(ttl)
resetLinkPreview()
},
sendSignedMessage = {
sendMessage(null, sign = true)
resetLinkPreview()
},
sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null,
updateLiveMessage = ::updateLiveMessage,
cancelLiveMessage = {
@@ -49,9 +49,12 @@ fun SendMsgView(
sendButtonColor: Color = MaterialTheme.colors.primary,
allowVoiceToContact: () -> Unit,
timedMessageAllowed: Boolean = false,
showSign: Boolean = false,
signMessageAlertShown: SharedPreference<Boolean> = SharedPreference(get = { false }, set = {}),
customDisappearingMessageTimePref: SharedPreference<Int>? = null,
placeholder: String,
sendMessage: (Int?) -> Unit,
sendSignedMessage: () -> Unit = {},
sendLiveMessage: (suspend () -> Unit)? = null,
updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null,
@@ -207,6 +210,30 @@ fun SendMsgView(
)
}
}
if (showSign && !cs.editing) {
menuItems.add {
ItemAction(
generalGetString(MR.strings.sign_message),
painterResource(MR.images.ic_verified),
onClick = {
if (signMessageAlertShown.state.value) {
sendSignedMessage()
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.sign_message),
text = generalGetString(MR.strings.sign_message_desc),
confirmText = generalGetString(MR.strings.send_verb),
onConfirm = {
signMessageAlertShown.set(true)
sendSignedMessage()
}
)
}
showDropdown.value = false
}
)
}
}
}
return menuItems
@@ -178,6 +178,12 @@ private fun GroupPreferencesLayout(
}
}
}
@Composable fun SignMessagesPreference() {
val enableSignMessages = remember(preferences) { mutableStateOf(preferences.signMessages.enable) }
FeatureSection(GroupFeature.SignMessages, enableSignMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ ->
applyPrefs(preferences.copy(signMessages = GroupPreference(enable = enable)))
}
}
ColumnWithScrollBar {
val titleId = if (groupInfo.useRelays) MR.strings.channel_preferences
else if (groupInfo.businessChat == null) MR.strings.group_preferences
@@ -210,6 +216,8 @@ private fun GroupPreferencesLayout(
SectionDividerSpaced()
SupportPreference(disabled = true)
} else {
SignMessagesPreference()
SectionDividerSpaced()
TimedMessagesPreference()
SectionDividerSpaced()
FullDeletePreference()
@@ -211,7 +211,7 @@ fun CIFileView(
val encrypted = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null
val metaReserve = buildAnnotatedString {
withStyle(reserveTimestampStyle) {
append(reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor = secondaryColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp))
append(reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor = secondaryColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp, signedFileVerified = file?.loaded))
}
}
if (file != null) {
@@ -13,6 +13,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPreferences
import chat.simplex.common.ui.theme.isInDarkTheme
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -56,7 +57,8 @@ fun CIMetaView(
showStatus = showStatus,
showEdited = showEdited,
showViaProxy = showViaProxy,
showTimestamp = showTimestamp
showTimestamp = showTimestamp,
signedFileVerified = chatItem.file?.loaded
)
}
}
@@ -74,7 +76,10 @@ private fun CIMetaText(
showEdited: Boolean = true,
showTimestamp: Boolean,
showViaProxy: Boolean,
signedFileVerified: Boolean?,
) {
val showSignature = appPreferences.privacyShowSignature.state.value
val showEncryption = appPreferences.privacyShowEncryption.state.value
if (showEdited && meta.itemEdited) {
StatusIconText(painterResource(MR.images.ic_edit), color)
}
@@ -103,10 +108,17 @@ private fun CIMetaText(
StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent)
}
}
if (encrypted != null) {
if (encrypted != null && showEncryption) {
Spacer(Modifier.width(4.dp))
StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color)
}
if (showSignature && meta.msgVerified.verified && signedFileVerified != false) {
Spacer(Modifier.width(4.dp))
StatusIconText(painterResource(MR.images.ic_verified), color)
} else if (meta.msgVerified is MsgVerified.SigMissing) {
Spacer(Modifier.width(4.dp))
StatusIconText(painterResource(MR.images.ic_verified_missing), Color.Red)
}
if (showTimestamp) {
Spacer(Modifier.width(4.dp))
@@ -123,8 +135,11 @@ fun reserveSpaceForMeta(
showStatus: Boolean = true,
showEdited: Boolean = true,
showViaProxy: Boolean = false,
showTimestamp: Boolean
showTimestamp: Boolean,
signedFileVerified: Boolean? = null
): String {
val showSignature = appPreferences.privacyShowSignature.state.value
val showEncryption = appPreferences.privacyShowEncryption.state.value
val iconSpace = " \u00A0\u00A0\u00A0"
val whiteSpace = "\u00A0"
var res = if (showTimestamp) "" else iconSpace
@@ -162,7 +177,12 @@ fun reserveSpaceForMeta(
space = whiteSpace
}
if (encrypted != null) {
if (encrypted != null && showEncryption) {
appendSpace()
res += iconSpace
space = whiteSpace
}
if ((showSignature && meta.msgVerified.verified && signedFileVerified != false) || meta.msgVerified is MsgVerified.SigMissing) {
appendSpace()
res += iconSpace
space = whiteSpace
@@ -127,7 +127,7 @@ fun ChatItemView(
modifier = (if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier),
contentAlignment = alignment,
) {
val info = cItem.meta.itemStatus.statusInto
val info = cItem.meta.itemStatus.statusInto ?: cItem.meta.msgVerified.sigMissingInfo
val onClick = if (info != null) {
{
AlertManager.shared.showAlertMsg(
@@ -256,6 +256,7 @@ expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean)
fun saveFileFromUri(
uri: URI,
maxBytes: Long,
withAlertOnException: Boolean = true,
hiddenFileNamePrefix: String? = null
): CryptoFile? {
@@ -277,7 +278,7 @@ fun saveFileFromUri(
val destFile = File(getAppFilePath(destFileName))
if (encrypted) {
createTmpFileAndDelete { tmpFile ->
Files.copy(inputStream, tmpFile.toPath())
copyInputStreamToFile(inputStream, tmpFile, maxBytes)
try {
val args = encryptCryptoFile(tmpFile.absolutePath, destFile.absolutePath)
CryptoFile(destFileName, args)
@@ -288,7 +289,7 @@ fun saveFileFromUri(
}
}
} else {
Files.copy(inputStream, destFile.toPath())
copyInputStreamToFile(inputStream, destFile, maxBytes)
CryptoFile.plain(destFileName)
}
} else {
@@ -297,6 +298,15 @@ fun saveFileFromUri(
null
}
} catch (e: FileTooLargeException) {
Log.e(TAG, "Util.kt saveFileFromUri file too large: ${e.message}")
if (withAlertOnException) {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxBytes))
)
}
null
} catch (e: Exception) {
Log.e(TAG, "Util.kt saveFileFromUri error: ${e.stackTraceToString()}")
if (withAlertOnException) showWrongUriAlert()
@@ -305,6 +315,27 @@ fun saveFileFromUri(
}
}
class FileTooLargeException(maxBytes: Long) : IOException("file exceeds $maxBytes bytes")
fun copyInputStreamToFile(inputStream: InputStream, destFile: File, maxBytes: Long) {
try {
destFile.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var copied = 0L
while (true) {
val read = inputStream.read(buffer)
if (read < 0) break
if (copied > maxBytes - read) throw FileTooLargeException(maxBytes)
output.write(buffer, 0, read)
copied += read
}
}
} catch (e: Throwable) {
destFile.delete()
throw e
}
}
fun saveWallpaperFile(uri: URI): String? {
val destFileName = generateNewFileName("wallpaper", "jpg", File(getWallpaperFilePath("")))
val destFile = File(getWallpaperFilePath(destFileName))
@@ -139,6 +139,11 @@ fun MorePrivacyView(chatModel: ChatModel) {
stringResource(MR.strings.verify_simplex_names),
chatModel.controller.appPrefs.privacyVerifySimplexNames
)
SettingsPreferenceItem(
painterResource(MR.images.ic_verified),
stringResource(MR.strings.show_signature),
chatModel.controller.appPrefs.privacyShowSignature
)
}
SectionDividerSpaced()
@@ -147,6 +152,7 @@ fun MorePrivacyView(chatModel: ChatModel) {
withBGApi { chatModel.controller.apiSetEncryptLocalFiles(enable) }
})
SettingsPreferenceItem(painterResource(MR.images.ic_security), stringResource(MR.strings.protect_ip_address), chatModel.controller.appPrefs.privacyAskToApproveRelays)
SettingsPreferenceItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.show_encryption), chatModel.controller.appPrefs.privacyShowEncryption)
}
SectionTextFooter(
if (chatModel.controller.appPrefs.privacyAskToApproveRelays.state.value) {
@@ -759,6 +759,19 @@
<string name="send_disappearing_message_5_minutes">5 minutes</string>
<string name="send_disappearing_message_custom_time">Custom time</string>
<string name="send_disappearing_message_send">Send</string>
<string name="sign_message">Sign message</string>
<string name="sign_message_desc">Signing proves you authored this message and can\'t be denied later.</string>
<string name="info_row_signed">Signed</string>
<string name="info_row_signed_verified">Signed &amp; verified</string>
<string name="sign_messages">Sign messages</string>
<string name="require_message_signatures">Require signing messages.</string>
<string name="do_not_require_message_signatures">Do not require signing messages.</string>
<string name="message_signatures_are_required">Message signing is required.</string>
<string name="message_signatures_are_not_required">Message signing is not required.</string>
<string name="show_signature">Show signature</string>
<string name="show_encryption">Show encryption</string>
<string name="signature_missing_alert_title">Signature missing</string>
<string name="signature_missing_alert_desc">The channel required this message to be signed, but the signature is missing.</string>
<string name="live_message">Live message!</string>
<string name="send_live_message_desc">Send a live message - it will update for the recipient(s) as you type it</string>
<string name="send_verb">Send</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="m437.5-432-73-76.5q-9-9.5-21.5-9.75T321-510q-10 10-10 22.5t10 22l96.5 95.5q8.5 9 20.05 9 11.54 0 20.45-9l181.4-180.4q8.6-8.6 8.6-21.1 0-12.5-9.5-21.5-9-7.5-20.75-7T598-591.5L437.5-432Zm-103 343L273-194l-123.5-25q-10.48-2.07-17.74-11.36-7.26-9.29-5.26-20.14l14-119.14-78-91.36q-7.5-7.5-7.5-19.05t7.5-19.45l78-90.15-14-118.85q-2-10.88 5.25-20.19t17.75-11.81L273-765l61.5-106.5q6-9.5 16.5-13.5t21 1l108 51 108-51q10.33-4.5 20.92-1.25Q619.5-882 625.5-872.5L688-765l122.5 24.5q10.5 2.5 17.75 11.81t5.25 20.19l-14 118.85L898-499.5q7 7.9 7 19.45 0 11.55-7.14 19.15l-78.36 91.32 14 119.08q2 10.85-5.26 20.14-7.26 9.29-17.74 11.36L688-194 625.5-88q-6 10-16.58 13.25Q598.33-71.5 588-76l-108-51-108 51q-10.33 4.5-20.92.75Q340.5-79 334.5-89Zm39.5-52.5L480-186l109 44.5 67-99 115.95-30.18-12.09-117.75L840.5-480l-80.64-93.58 12.09-117.76L656-719.5l-69-99L480-774l-109-44.5-67 99-115.95 28.16 12.09 117.76L119.5-480l80.64 91.57-12.09 119.77L304-240.5l70 99ZM480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="m437.5-432-73-76.5q-9-9.5-21.5-9.75T321-510q-10 10-10 22.5t10 22l96.5 95.5q8.5 9 20 9t20.5-9l181.5-180.5q8.5-8.5 8.5-21t-9.5-21.5q-9-7.5-20.75-7T598-591.5L437.5-432Zm-103 343L273-194l-123.5-25q-10.5-2-17.75-11.25t-5.25-20.25l14-119-78-91.5q-7.5-7.5-7.5-19t7.5-19.5l78-90-14-119q-2-11 5.25-20.25t17.75-11.75L273-765l61.5-106.5q6-9.5 16.5-13.5t21 1l108 51 108-51q10.5-4.5 21-1.25t16.5 12.75L688-765l122.5 24.5q10.5 2.5 17.75 11.75t5.25 20.25l-14 119 78.5 90q7 8 7 19.5t-7 19l-78.5 91.5 14 119q2 11-5.25 20.25T810.5-219L688-194 625.5-88q-6 10-16.5 13.25T588-76l-108-51-108 51q-10.5 4.5-21 .75T334.5-89Z"/></svg>

After

Width:  |  Height:  |  Size: 724 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M334.5-89L273-194l-123.5-25q-10.48-2.07-17.74-11.36-7.26-9.29-5.26-20.14l14-119.14-78-91.36q-7.5-7.5-7.5-19.05t7.5-19.45l78-90.15-14-118.85q-2-10.88 5.25-20.19t17.75-11.81L273-765l61.5-106.5q6-9.5 16.5-13.5t21 1l108 51 108-51q10.33-4.5 20.92-1.25Q619.5-882 625.5-872.5L688-765l122.5 24.5q10.5 2.5 17.75 11.81t5.25 20.19l-14 118.85L898-499.5q7 7.9 7 19.45 0 11.55-7.14 19.15l-78.36 91.32 14 119.08q2 10.85-5.26 20.14-7.26 9.29-17.74 11.36L688-194 625.5-88q-6 10-16.58 13.25Q598.33-71.5 588-76l-108-51-108 51q-10.33 4.5-20.92.75Q340.5-79 334.5-89Zm39.5-52.5L480-186l109 44.5 67-99 115.95-30.18-12.09-117.75L840.5-480l-80.64-93.58 12.09-117.76L656-719.5l-69-99L480-774l-109-44.5-67 99-115.95 28.16 12.09 117.76L119.5-480l80.64 91.57-12.09 119.77L304-240.5l70 99Z"/><path d="M557.782 -600.208L359.792 -402.218C348.076 -390.503 348.076 -371.508 359.792 -359.792C371.508 -348.076 390.503 -348.076 402.218 -359.792L600.208 -557.782C611.924 -569.497 611.924 -588.492 600.208 -600.208C588.492 -611.924 569.497 -611.924 557.782 -600.208ZM359.792 -557.782L557.782 -359.792C569.497 -348.076 588.492 -348.076 600.208 -359.792C611.924 -371.508 611.924 -390.503 600.208 -402.218L402.218 -600.208C390.503 -611.924 371.508 -611.924 359.792 -600.208C348.076 -588.492 348.076 -569.497 359.792 -557.782Z"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,70 @@
package chat.simplex.app
import chat.simplex.common.views.helpers.FileTooLargeException
import chat.simplex.common.views.helpers.copyInputStreamToFile
import java.io.ByteArrayInputStream
import java.io.InputStream
import kotlin.io.path.createTempFile
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class UtilsFileCopyTest {
@Test
fun testCopyInputStreamAllowsLimitBoundary() {
val dest = createTempFile().toFile()
val data = ByteArray(4) { it.toByte() }
copyInputStreamToFile(ByteArrayInputStream(data), dest, maxBytes = 4)
assertTrue(dest.exists())
assertContentEquals(data, dest.readBytes())
dest.delete()
}
@Test
fun testCopyInputStreamRejectsAndDeletesOversizedOutput() {
val dest = createTempFile().toFile()
val data = ByteArray(5) { it.toByte() }
assertFailsWith<FileTooLargeException> {
copyInputStreamToFile(ByteArrayInputStream(data), dest, maxBytes = 4)
}
assertFalse(dest.exists())
}
@Test
fun testCopyInputStreamCopiesAcrossMultipleReads() {
val dest = createTempFile().toFile()
val data = ByteArray(20) { it.toByte() }
// Delivered 3 bytes per read, so the copy loop runs many iterations and accumulates copied > 0
copyInputStreamToFile(chunkedStream(data, 3), dest, maxBytes = 20)
assertTrue(dest.exists())
assertContentEquals(data, dest.readBytes())
dest.delete()
}
@Test
fun testCopyInputStreamRejectsAndDeletesWhenLimitCrossedMidStream() {
val dest = createTempFile().toFile()
val data = ByteArray(10) { it.toByte() }
// With 3-byte reads and a 7-byte limit, 6 bytes are written before the next read would exceed it
assertFailsWith<FileTooLargeException> {
copyInputStreamToFile(chunkedStream(data, 3), dest, maxBytes = 7)
}
assertFalse(dest.exists())
}
// Returns at most chunkSize bytes per read to force the copy loop to iterate, regardless of buffer size
private fun chunkedStream(data: ByteArray, chunkSize: Int): InputStream =
object : ByteArrayInputStream(data) {
override fun read(b: ByteArray, off: Int, len: Int): Int = super.read(b, off, minOf(len, chunkSize))
}
}