From eacae74fed85f7f09d54057291a4ca00ec4636ec Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 12 Jan 2025 21:25:25 +0000 Subject: [PATCH] core, ui: errors for blocked files and contact addresses (#5510) * core, ui: errors for blocked files and contact addresses * android * iOS: How it works, stub for blog post * android: blocked errors WIP * android: alert with button * update * fix encoding * nix * simplexmq --- apps/ios/Shared/Model/SimpleXAPI.swift | 12 +++++ .../Views/Chat/ChatItem/CIFileView.swift | 40 ++++++++------ .../Views/Chat/ChatItem/CIImageView.swift | 20 ++----- .../Views/Chat/ChatItem/CIVideoView.swift | 20 ++----- .../Views/Chat/ChatItem/CIVoiceView.swift | 20 ++----- apps/ios/SimpleXChat/APITypes.swift | 18 +++++++ apps/ios/SimpleXChat/ChatTypes.swift | 14 ++++- .../chat/simplex/common/model/ChatModel.kt | 17 ++++-- .../chat/simplex/common/model/SimpleXAPI.kt | 34 +++++++++++- .../common/views/chat/item/CIFileView.kt | 54 +++++++++++++------ .../common/views/chat/item/CIImageView.kt | 20 ++----- .../common/views/chat/item/CIVideoView.kt | 20 ++----- .../common/views/chat/item/CIVoiceView.kt | 20 ++----- .../commonMain/resources/MR/base/strings.xml | 5 ++ ...k-privacy-preserving-content-moderation.md | 22 ++++++++ cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Subscriber.hs | 1 + src/Simplex/Chat/Messages.hs | 7 ++- src/Simplex/Chat/View.hs | 8 ++- 20 files changed, 216 insertions(+), 140 deletions(-) create mode 100644 blog/20250112-simplex-network-privacy-preserving-content-moderation.md diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index e7a691f9e1..48b78d8505 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -852,6 +852,18 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) return (nil, alert) + case let .chatCmdError(_, .errorAgent(.SMP(_, .BLOCKED(info)))): + let alert = Alert( + title: Text("Connection blocked"), + message: Text("Connection is blocked by server operator:\n\(info.reason.text)"), + primaryButton: .default(Text("Ok")), + secondaryButton: .default(Text("How it works")) { + DispatchQueue.main.async { + UIApplication.shared.open(contentModerationPostLink) + } + } + ) + return (nil, alert) case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))): let alert = mkAlert( title: "Undelivered messages", diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index f5ab7f3a4b..a785f3e6d8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -118,16 +118,10 @@ struct CIFileView: View { } case let .rcvError(rcvFileError): logger.debug("CIFileView fileAction - in .rcvError") - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) case let .rcvWarning(rcvFileError): logger.debug("CIFileView fileAction - in .rcvWarning") - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) case .sndStored: logger.debug("CIFileView fileAction - in .sndStored") if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) { @@ -140,16 +134,10 @@ struct CIFileView: View { } case let .sndError(sndFileError): logger.debug("CIFileView fileAction - in .sndError") - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) case let .sndWarning(sndFileError): logger.debug("CIFileView fileAction - in .sndWarning") - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) default: break } } @@ -268,6 +256,26 @@ func saveCryptoFile(_ fileSource: CryptoFile) { } } +func showFileErrorAlert(_ err: FileError, temporary: Bool = false) { + let title: String = if temporary { + NSLocalizedString("Temporary file error", comment: "file error alert title") + } else { + NSLocalizedString("File error", comment: "file error alert title") + } + if let btn = err.moreInfoButton { + showAlert(title, message: err.errorInfo) { + [ + okAlertAction, + UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in + UIApplication.shared.open(contentModerationPostLink) + }) + ] + } + } else { + showAlert(title, message: err.errorInfo) + } +} + struct CIFileView_Previews: PreviewProvider { static var previews: some View { let sentFile: ChatItem = ChatItem( diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index b06c6df48c..d491563913 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -69,25 +69,13 @@ struct CIImageView: View { case .rcvComplete: () // ? case .rcvCancelled: () // TODO case let .rcvError(rcvFileError): - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) case let .rcvWarning(rcvFileError): - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) case let .sndError(sndFileError): - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) case let .sndWarning(sndFileError): - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) default: () } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 851b90bc3d..f774299ad3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -355,18 +355,12 @@ struct CIVideoView: View { case let .sndError(sndFileError): fileIcon("xmark", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) } case let .sndWarning(sndFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) } case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvAccepted: fileIcon("ellipsis", 14, 11) @@ -382,18 +376,12 @@ struct CIVideoView: View { case let .rcvError(rcvFileError): fileIcon("xmark", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) } case let .rcvWarning(rcvFileError): fileIcon("exclamationmark.triangle.fill", 10, 13) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) } case .invalid: fileIcon("questionmark", 10, 13) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index acecaaae4f..ff4378c715 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -169,18 +169,12 @@ struct VoiceMessagePlayer: View { case let .sndError(sndFileError): fileStatusIcon("multiply", 14) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError) } case let .sndWarning(sndFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(sndFileError.errorInfo) - )) + showFileErrorAlert(sndFileError, temporary: true) } case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvAccepted: loadingIcon() @@ -191,18 +185,12 @@ struct VoiceMessagePlayer: View { case let .rcvError(rcvFileError): fileStatusIcon("multiply", 14) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("File error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError) } case let .rcvWarning(rcvFileError): fileStatusIcon("exclamationmark.triangle.fill", 16) .onTapGesture { - AlertManager.shared.showAlert(Alert( - title: Text("Temporary file error"), - message: Text(rcvFileError.errorInfo) - )) + showFileErrorAlert(rcvFileError, temporary: true) } case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index c8b776a57c..b1056e791f 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -2481,6 +2481,7 @@ public enum ProtocolErrorType: Decodable, Hashable { case CMD(cmdErr: ProtocolCommandError) indirect case PROXY(proxyErr: ProxyError) case AUTH + case BLOCKED(blockInfo: BlockingInfo) case CRYPTO case QUOTA case STORE(storeErr: String) @@ -2497,11 +2498,28 @@ public enum ProxyError: Decodable, Hashable { case NO_SESSION } +public struct BlockingInfo: Decodable, Equatable, Hashable { + public var reason: BlockingReason +} + +public enum BlockingReason: String, Decodable { + case spam + case content + + public var text: String { + switch self { + case .spam: NSLocalizedString("Spam", comment: "blocking reason") + case .content: NSLocalizedString("Content violates conditions of use", comment: "blocking reason") + } + } +} + public enum XFTPErrorType: Decodable, Hashable { case BLOCK case SESSION case CMD(cmdErr: ProtocolCommandError) case AUTH + case BLOCKED(blockInfo: BlockingInfo) case SIZE case QUOTA case DIGEST diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 97407817b2..0426b91704 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -15,6 +15,8 @@ public let CREATE_MEMBER_CONTACT_VERSION = 2 // version to receive reports (MCReport) public let REPORTS_VERSION = 12 +public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250112-simplex-network-privacy-preserving-content-moderation.html")! + public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var userId: Int64 public var agentUserId: String @@ -3024,7 +3026,7 @@ public enum SndError: Decodable, Hashable { case proxyRelay(proxyServer: String, srvError: SrvError) case other(sndError: String) - public var errorInfo: String { + public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown connection - most likely this connection is deleted.", comment: "snd error text") case .quota: NSLocalizedString("Capacity exceeded - recipient did not receive previously sent messages.", comment: "snd error text") @@ -3684,6 +3686,7 @@ public enum CIFileStatus: Decodable, Equatable, Hashable { public enum FileError: Decodable, Equatable, Hashable { case auth + case blocked(server: String, blockInfo: BlockingInfo) case noFile case relay(srvError: SrvError) case other(fileError: String) @@ -3691,6 +3694,7 @@ public enum FileError: Decodable, Equatable, Hashable { var id: String { switch self { case .auth: return "auth" + case let .blocked(srv, info): return "blocked \(srv) \(info)" case .noFile: return "noFile" case let .relay(srvError): return "relay \(srvError)" case let .other(fileError): return "other \(fileError)" @@ -3700,11 +3704,19 @@ public enum FileError: Decodable, Equatable, Hashable { public var errorInfo: String { switch self { case .auth: NSLocalizedString("Wrong key or unknown file chunk address - most likely file is deleted.", comment: "file error text") + case let .blocked(_, info): NSLocalizedString("File is blocked by server operator:\n\(info.reason.text).", comment: "file error text") case .noFile: NSLocalizedString("File not found - most likely file was deleted or cancelled.", comment: "file error text") case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("File server error: %@", comment: "file error text"), srvError.errorInfo) case let .other(fileError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "file error text"), fileError) } } + + public var moreInfoButton: (label: LocalizedStringKey, link: URL)? { + switch self { + case .blocked: ("How it works", contentModerationPostLink) + default: nil + } + } } public enum MsgContent: Equatable, Hashable { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 1ddf58aef8..2bb1605981 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -12,6 +12,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.chat.* +import chat.simplex.common.views.chat.item.contentModerationPostLink import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrationToDeviceState @@ -22,7 +23,6 @@ import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlin.collections.removeAll as remAll import kotlinx.datetime.* import kotlinx.datetime.TimeZone @@ -3591,15 +3591,22 @@ sealed class CIFileStatus { @Serializable sealed class FileError { @Serializable @SerialName("auth") class Auth: FileError() + @Serializable @SerialName("blocked") class Blocked(val server: String, val blockInfo: BlockingInfo): FileError() @Serializable @SerialName("noFile") class NoFile: FileError() @Serializable @SerialName("relay") class Relay(val srvError: SrvError): FileError() @Serializable @SerialName("other") class Other(val fileError: String): FileError() val errorInfo: String get() = when (this) { - is FileError.Auth -> generalGetString(MR.strings.file_error_auth) - is FileError.NoFile -> generalGetString(MR.strings.file_error_no_file) - is FileError.Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo) - is FileError.Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError) + is Auth -> generalGetString(MR.strings.file_error_auth) + is Blocked -> generalGetString(MR.strings.file_error_blocked).format(blockInfo.reason.text) + is NoFile -> generalGetString(MR.strings.file_error_no_file) + is Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo) + is Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError) + } + + val moreInfoButton: Pair? get() = when(this) { + is Blocked -> generalGetString(MR.strings.how_it_works) to contentModerationPostLink + else -> null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 7b0563e21a..6a7c3448ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -19,10 +19,12 @@ import chat.simplex.common.model.ChatController.setNetCfg import chat.simplex.common.model.ChatModel.changingActiveUserMutex import chat.simplex.common.model.ChatModel.withChats import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen +import chat.simplex.common.model.SMPErrorType.BLOCKED import dev.icerock.moko.resources.compose.painterResource import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* +import chat.simplex.common.views.chat.item.showContentBlockedAlert import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.migration.MigrationFileLinkData @@ -1411,6 +1413,15 @@ object ChatController { ) return null } + r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent + && r.chatError.agentError is AgentErrorType.SMP + && r.chatError.agentError.smpErr is SMPErrorType.BLOCKED -> { + showContentBlockedAlert( + generalGetString(MR.strings.connection_error_blocked), + generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text), + ) + return null + } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.SMP && r.chatError.agentError.smpErr is SMPErrorType.QUOTA -> { @@ -6756,6 +6767,7 @@ sealed class BrokerErrorType { @Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType() } +// ProtocolErrorType @Serializable sealed class SMPErrorType { val string: String get() = when (this) { @@ -6764,9 +6776,10 @@ sealed class SMPErrorType { is CMD -> "CMD ${cmdErr.string}" is PROXY -> "PROXY ${proxyErr.string}" is AUTH -> "AUTH" + is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}" is CRYPTO -> "CRYPTO" is QUOTA -> "QUOTA" - is STORE -> "STORE ${storeErr}" + is STORE -> "STORE $storeErr" is NO_MSG -> "NO_MSG" is LARGE_MSG -> "LARGE_MSG" is EXPIRED -> "EXPIRED" @@ -6777,6 +6790,7 @@ sealed class SMPErrorType { @Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): SMPErrorType() @Serializable @SerialName("PROXY") class PROXY(val proxyErr: ProxyError): SMPErrorType() @Serializable @SerialName("AUTH") class AUTH: SMPErrorType() + @Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): SMPErrorType() @Serializable @SerialName("CRYPTO") class CRYPTO: SMPErrorType() @Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType() @Serializable @SerialName("STORE") class STORE(val storeErr: String): SMPErrorType() @@ -6800,6 +6814,22 @@ sealed class ProxyError { @Serializable @SerialName("NO_SESSION") class NO_SESSION: ProxyError() } +@Serializable +data class BlockingInfo( + val reason: BlockingReason +) + +@Serializable +enum class BlockingReason { + @SerialName("spam") Spam, + @SerialName("content") Content; + + val text: String get() = when (this) { + Spam -> generalGetString(MR.strings.blocking_reason_spam) + Content -> generalGetString(MR.strings.blocking_reason_content) + } +} + @Serializable sealed class ProtocolCommandError { val string: String get() = when (this) { @@ -6875,6 +6905,7 @@ sealed class XFTPErrorType { is SESSION -> "SESSION" is CMD -> "CMD ${cmdErr.string}" is AUTH -> "AUTH" + is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}" is SIZE -> "SIZE" is QUOTA -> "QUOTA" is DIGEST -> "DIGEST" @@ -6890,6 +6921,7 @@ sealed class XFTPErrorType { @Serializable @SerialName("SESSION") object SESSION: XFTPErrorType() @Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): XFTPErrorType() @Serializable @SerialName("AUTH") object AUTH: XFTPErrorType() + @Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): XFTPErrorType() @Serializable @SerialName("SIZE") object SIZE: XFTPErrorType() @Serializable @SerialName("QUOTA") object QUOTA: XFTPErrorType() @Serializable @SerialName("DIGEST") object DIGEST: XFTPErrorType() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 2c16de40e9..8940161898 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import SectionItemView import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CornerSize @@ -13,6 +14,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -92,25 +95,13 @@ fun CIFileView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) file.fileStatus is CIFileStatus.RcvWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) file.forwardingAllowed() -> { withLongRunningApi(slow = 600_000) { var filePath = getLoadedFilePath(file) @@ -235,6 +226,37 @@ fun CIFileView( fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) +fun showFileErrorAlert(err: FileError, temporary: Boolean = false) { + val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error) + val btn = err.moreInfoButton + if (btn != null) { + showContentBlockedAlert(title, err.errorInfo) + } else { + AlertManager.shared.showAlertMsg(title, err.errorInfo) + } +} + +val contentModerationPostLink = "https://simplex.chat/blog/20250112-simplex-network-privacy-preserving-content-moderation.html" + +fun showContentBlockedAlert(title: String, message: String) { + AlertManager.shared.showAlertDialogButtonsColumn(title, text = message, buttons = { + val uriHandler = LocalUriHandler.current + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + uriHandler.openUriCatching(contentModerationPostLink) + }) { + Text(generalGetString(MR.strings.how_it_works), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + SectionItemView({ + AlertManager.shared.hideAlert() + }) { + Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + } + }) +} + @Composable expect fun SaveOrOpenFileMenu( showMenu: MutableState, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index b7fe9ea4cf..401d098bea 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -238,25 +238,13 @@ fun CIImageView( FileProtocol.LOCAL -> {} } file.fileStatus is CIFileStatus.RcvError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) file.fileStatus is CIFileStatus.RcvWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) file.fileStatus is CIFileStatus.SndError -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) file.fileStatus is CIFileStatus.SndWarning -> - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) file.fileStatus is CIFileStatus.RcvTransfer -> {} // ? file.fileStatus is CIFileStatus.RcvComplete -> {} // ? file.fileStatus is CIFileStatus.RcvCancelled -> {} // TODO diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt index 9f7b5dc9c6..8289149ad9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt @@ -499,10 +499,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) } ) is CIFileStatus.SndWarning -> @@ -510,10 +507,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive) @@ -532,10 +526,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_close), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) } ) is CIFileStatus.RcvWarning -> @@ -543,10 +534,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) { painterResource(MR.images.ic_warning_filled), MR.strings.icon_descr_file, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) } ) is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 4aedcc013a..136300e4ed 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -398,10 +398,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError) } ) file != null && file.fileStatus is CIFileStatus.SndWarning -> @@ -411,10 +408,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.sndFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.sndFileError, temporary = true) } ) file?.fileStatus is CIFileStatus.RcvInvitation -> @@ -430,10 +424,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError) } ) file != null && file.fileStatus is CIFileStatus.RcvWarning -> @@ -443,10 +434,7 @@ private fun VoiceMsgIndicator( sizeMultiplier, longClick, onClick = { - AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.temporary_file_error), - file.fileStatus.rcvFileError.errorInfo - ) + showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true) } ) file != null && file.loaded && progress != null && duration != null -> diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 54a49bdf31..38ae957b64 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -132,6 +132,8 @@ For chat profile %s: Errors in servers configuration. Error accepting conditions + Spam + Content violates conditions of use Connection timeout @@ -168,6 +170,8 @@ Please check that you used the correct link or ask your contact to send you another one. Connection error (AUTH) Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection. + Connection blocked + Connection is blocked by server operator:\n%1$s. Undelivered messages The connection reached the limit of undelivered messages, your contact may be offline. Error accepting contact request @@ -323,6 +327,7 @@ Wrong key or unknown file chunk address - most likely file is deleted. + File is blocked by server operator:\n%1$s. File not found - most likely file was deleted or cancelled. File server error: %1$s diff --git a/blog/20250112-simplex-network-privacy-preserving-content-moderation.md b/blog/20250112-simplex-network-privacy-preserving-content-moderation.md new file mode 100644 index 0000000000..6546db8de0 --- /dev/null +++ b/blog/20250112-simplex-network-privacy-preserving-content-moderation.md @@ -0,0 +1,22 @@ +--- +layout: layouts/article.html +title: "SimpleX network: privacy preserving content moderation" +date: 2024-12-18 +preview: How network operators prevent distribution of CSAM without compromising users privacy and security. +# image: images/20241218-pub.jpg +# imageWide: true +draft: true +permalink: "/blog/20250112-simplex-network-privacy-preserving-content-moderation.html" +--- + +# SimpleX network: privacy preserving content moderation + +**Will be published:** Jan 12, 2025 + +This blog post will cover our approach to removing CSAM that has: +- NO user identification, thus preserving privacy of the users. +- NO client- or server-side content scanning, thus preserving privacy and security of e2e encryption. + +The current and future content restriction will only be applied based on the users' complaints, and only to the content that can be accessed by server operators via public channels. + +Please read this document: https://github.com/simplex-chat/simplex-chat/blob/stable/docs/rfcs/2024-12-30-content-moderation.md diff --git a/cabal.project b/cabal.project index bcbf01d365..5a60c53b66 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 9d9ec8cd0b171b2058c59c4e7292ccafa96b6e2b + tag: 3d4e0b06c04a13555c55c2e0efde56f9f78e7ea1 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7c00706d33..54b3443b5b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."9d9ec8cd0b171b2058c59c4e7292ccafa96b6e2b" = "0mvg9yrwb835vf2kz8k0ac4i7vzjpvbpcwg895n3kcfdkdcnxh14"; + "https://github.com/simplex-chat/simplexmq.git"."3d4e0b06c04a13555c55c2e0efde56f9f78e7ea1" = "0l194fm6kxy54gkyz0lhvba3cxgjdg812qwpjki5kwfmhhliys6q"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e7ca4ed2e9..4c95375f73 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -284,6 +284,7 @@ processAgentMsgSndFile _corrId aFileId msg = do agentFileError :: AgentErrorType -> FileError agentFileError = \case XFTP _ XFTP.AUTH -> FileErrAuth + XFTP srv (XFTP.BLOCKED info) -> FileErrBlocked srv info FILE NO_FILE -> FileErrNoFile BROKER _ e -> brokerError FileErrRelay e e -> FileErrOther $ tshow e diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 6fc6b52884..d665ab806b 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -52,7 +52,7 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (MsgBody) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) #if defined(dbPostgres) import Database.PostgreSQL.Simple.FromField (FromField (..)) @@ -741,6 +741,7 @@ aciFileStatusJSON = \case data FileError = FileErrAuth + | FileErrBlocked {server :: String, blockInfo :: BlockingInfo} | FileErrNoFile | FileErrRelay {srvError :: SrvError} | FileErrOther {fileError :: Text} @@ -749,14 +750,16 @@ data FileError instance StrEncoding FileError where strEncode = \case FileErrAuth -> "auth" + FileErrBlocked srv info -> "blocked " <> strEncode (srv, info) FileErrNoFile -> "no_file" FileErrRelay srvErr -> "relay " <> strEncode srvErr FileErrOther e -> "other " <> encodeUtf8 e strP = A.takeWhile1 (/= ' ') >>= \case "auth" -> pure FileErrAuth + "blocked" -> FileErrBlocked <$> _strP <*> _strP "no_file" -> pure FileErrNoFile - "relay" -> FileErrRelay <$> (A.space *> strP) + "relay" -> FileErrRelay <$> _strP "other" -> FileErrOther . safeDecodeUtf8 <$> (A.space *> A.takeByteString) s -> FileErrOther . safeDecodeUtf8 . (s <>) <$> A.takeByteString diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 2c736d9269..b73f720930 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -66,7 +66,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON) -import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) +import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType, BlockingInfo (..), BlockingReason (..), ProtocolServer (..), ProtocolTypeI, SProtocolType (..), UserProtocol) import qualified Simplex.Messaging.Protocol as SMP import Simplex.Messaging.Transport.Client (TransportHost (..)) import Simplex.Messaging.Util (safeDecodeUtf8, tshow) @@ -2223,6 +2223,12 @@ viewChatError isCmd logLevel testView = \case [ withConnEntity <> "error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" ] + SMP _ (SMP.BLOCKED BlockingInfo {reason}) -> + [withConnEntity <> "error: connection blocked by server operator: " <> reasonStr] + where + reasonStr = case reason of + BRSpam -> "spam" + BRContent -> "content violates conditions of use" BROKER _ NETWORK | not isCmd -> [] BROKER _ TIMEOUT | not isCmd -> [] AGENT A_DUPLICATE -> [withConnEntity <> "error: AGENT A_DUPLICATE" | logLevel == CLLDebug || isCmd]