Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2025-07-15 15:39:41 +01:00
32 changed files with 448 additions and 246 deletions
+9 -4
View File
@@ -2481,10 +2481,15 @@ func processReceivedMsg(_ res: ChatEvent) async {
await MainActor.run {
m.updateGroup(groupInfo)
}
if m.chatId == groupInfo.id,
case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo {
await MainActor.run {
m.secondaryPendingInviteeChatOpened = false
if m.chatId == groupInfo.id {
if groupInfo.membership.memberPending {
await MainActor.run {
m.secondaryPendingInviteeChatOpened = true
}
} else if case .memberSupport(nil) = m.secondaryIM?.groupScopeInfo {
await MainActor.run {
m.secondaryPendingInviteeChatOpened = false
}
}
}
}
+8 -1
View File
@@ -290,6 +290,13 @@ struct ChatView: View {
}
}
}
.onChange(of: chatModel.secondaryPendingInviteeChatOpened) { secondaryChatOpened in
if secondaryChatOpened {
ItemsModel.loadSecondaryChat(chat.id, chatFilter: .groupChatScopeContext(groupScopeInfo: userSupportScopeInfo)) {
showUserSupportChatSheet = true
}
}
}
.onChange(of: chatModel.chatId) { cId in
ConnectProgressManager.shared.cancelConnectProgress()
showChatInfoSheet = false
@@ -2025,7 +2032,7 @@ struct ChatView: View {
} label: {
Label(
NSLocalizedString("Save", comment: "chat item action"),
systemImage: file.cryptoArgs == nil ? "square.and.arrow.down" : "lock.open"
systemImage: "square.and.arrow.down"
)
}
}
@@ -63,9 +63,10 @@ struct NativeTextEditor: UIViewRepresentable {
field.textAlignment = alignment(text)
field.updateFont()
field.updateHeight(updateBindingNow: false)
field.placeholder = text.isEmpty ? placeholder : ""
}
if field.placeholder != placeholder {
field.placeholder = placeholder
field.placeholder = text.isEmpty ? placeholder : ""
}
if field.selectedRange != selectedRange {
field.selectedRange = selectedRange
@@ -426,7 +426,7 @@ struct GroupChatInfoView: View {
if user {
v
} else if groupInfo.membership.memberRole >= .admin {
} else if groupInfo.membership.memberRole >= .moderator {
// TODO if there are more actions, refactor with lists of swipeActions
let canBlockForAll = member.canBlockForAll(groupInfo: groupInfo)
let canRemove = member.canBeRemoved(groupInfo: groupInfo)
@@ -469,7 +469,7 @@ struct GroupChatInfoView: View {
.foregroundColor(theme.colors.secondary)
} else {
let role = member.memberRole
if [.owner, .admin, .observer].contains(role) {
if [.owner, .admin, .moderator, .observer].contains(role) {
Text(member.memberRole.text)
.foregroundColor(theme.colors.secondary)
}
@@ -180,7 +180,7 @@ struct GroupMemberInfoView: View {
}
}
if groupInfo.membership.memberRole >= .admin {
if groupInfo.membership.memberRole >= .moderator {
adminDestructiveSection(member)
} else {
nonAdminBlockSection(member)
@@ -146,7 +146,7 @@ struct SelectedItemsBottomToolbar: View {
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo, _):
groupInfo.membership.memberRole >= .admin
groupInfo.membership.memberRole >= .moderator
default: false
}
}
@@ -114,6 +114,7 @@ struct ContactConnectionInfo: View {
.onAppear {
localAlias = contactConnection.localAlias
}
.onDisappear(perform: setConnectionAlias)
}
private func setConnectionAlias() {
@@ -412,7 +412,7 @@ struct SubscriptionStatusIndicatorView: View {
var hasSess: Bool
var body: some View {
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(
let (color, variableValue, opacity) = subscriptionStatusInfo(
online: m.networkInfo.online,
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
subs: subs,
@@ -431,25 +431,19 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
var subs: SMPServerSubs
var hasSess: Bool
var body: some View {
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(
online: m.networkInfo.online,
usesProxy: networkUseOnionHostsGroupDefault.get() != .no || groupDefaults.string(forKey: GROUP_DEFAULT_NETWORK_SOCKS_PROXY) != nil,
subs: subs,
hasSess: hasSess,
primaryColor: theme.colors.primary
)
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
let statusPercent = subscriptionStatusPercent(online: m.networkInfo.online, subs: subs, hasSess: hasSess)
let percentText: String = subs.total > 0 || hasSess ? "\(Int(floor(statusPercent * 100)))%" : "%"
Text(percentText)
.foregroundColor(.secondary)
.font(.caption)
}
}
func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double, Double) {
func subscriptionStatusInfo(online: Bool, usesProxy: Bool, subs: SMPServerSubs, hasSess: Bool, primaryColor: Color) -> (Color, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
@@ -457,26 +451,28 @@ func subscriptionStatusColorAndPercentage(online: Bool, usesProxy: Bool, subs: S
}
let activeColor: Color = usesProxy ? .indigo : primaryColor
let noConnColorAndPercent: (Color, Double, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1, 0)
let noConnColorAndPercent: (Color, Double, Double) = (Color(uiColor: .tertiaryLabel), 1, 1)
let activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return !online
? noConnColorAndPercent
: (
subs.total == 0 && !hasSess
? (activeColor, 0, 0.33, 0) // On freshly installed app (without chats) and on app start
: (
subs.ssActive == 0
? (
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
)
: ( // ssActive > 0
hasSess
? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
: (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
)
)
: subs.total == 0 && !hasSess
? (activeColor, 0, 0.33) // On freshly installed app (without chats) and on app start
: subs.ssActive == 0
? (
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive) : noConnColorAndPercent
)
: ( // ssActive > 0
hasSess
? (activeColor, activeSubsRounded, subs.shareOfActive)
: (.orange, activeSubsRounded, subs.shareOfActive) // This would mean implementation error
)
}
func subscriptionStatusPercent(online: Bool, subs: SMPServerSubs, hasSess: Bool) -> Double {
online && (hasSess || (subs.total > 0 && subs.ssActive > 0))
? subs.shareOfActive
: 0
}
struct SMPServerSummaryView: View {
@@ -97,7 +97,7 @@ struct UserPicker: View {
}
.onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase {
if case .active = scenePhase, hasChatCtrl() {
currentUser = m.currentUser?.userId
Task {
do {
+12 -14
View File
@@ -998,7 +998,7 @@ private func showOwnGroupLinkConfirmConnectSheet(
title: NSLocalizedString("Open group", comment: "new chat action"),
style: .default,
handler: { _ in
openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil)
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
}
),
UIAlertAction(
@@ -1052,8 +1052,7 @@ private func showPrepareContactAlert(
let chat = try await apiPrepareContact(connLink: connectionLink, contactShortLinkData: contactShortLinkData)
await MainActor.run {
ChatModel.shared.addChat(Chat(chat))
openKnownChat(chat.id, dismiss: dismiss, showAlreadyExistsAlert: nil)
cleanup?()
openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
}
} catch let error {
logger.error("showPrepareContactAlert apiPrepareContact error: \(error.localizedDescription)")
@@ -1088,8 +1087,7 @@ private func showPrepareGroupAlert(
let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData)
await MainActor.run {
ChatModel.shared.addChat(Chat(chat))
openKnownChat(chat.id, dismiss: dismiss, showAlreadyExistsAlert: nil)
cleanup?()
openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup)
}
} catch let error {
logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)")
@@ -1124,7 +1122,7 @@ private func showOpenKnownContactAlert(
? NSLocalizedString("Open new chat", comment: "new chat action")
: NSLocalizedString("Open chat", comment: "new chat action"),
onConfirm: {
openKnownContact(contact, dismiss: dismiss, showAlreadyExistsAlert: nil)
openKnownContact(contact, dismiss: dismiss, cleanup: nil)
}
)
}
@@ -1156,7 +1154,7 @@ private func showOpenKnownGroupAlert(
: NSLocalizedString("Open chat", comment: "new chat action")
),
onConfirm: {
openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil)
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: nil)
}
)
}
@@ -1471,28 +1469,28 @@ private func connectViaLink(
}
}
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
func openKnownContact(_ contact: Contact, dismiss: Bool, cleanup: (() -> Void)?) {
if let c = ChatModel.shared.getContactChat(contact.contactId) {
openKnownChat(c.id, dismiss: dismiss, showAlreadyExistsAlert: showAlreadyExistsAlert)
openKnownChat(c.id, dismiss: dismiss, cleanup: cleanup)
}
}
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, cleanup: (() -> Void)?) {
if let g = ChatModel.shared.getGroupChat(groupInfo.groupId) {
openKnownChat(g.id, dismiss: dismiss, showAlreadyExistsAlert: showAlreadyExistsAlert)
openKnownChat(g.id, dismiss: dismiss, cleanup: cleanup)
}
}
func openKnownChat(_ chatId: ChatId, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
func openKnownChat(_ chatId: ChatId, dismiss: Bool, cleanup: (() -> Void)?) {
if dismiss {
dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(chatId) {
showAlreadyExistsAlert?()
cleanup?()
}
}
} else {
ItemsModel.shared.loadOpenChat(chatId) {
showAlreadyExistsAlert?()
cleanup?()
}
}
}
+18 -18
View File
@@ -183,8 +183,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -553,8 +553,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -714,8 +714,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -800,8 +800,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.5.1-H9b1PUqJfdC45BY0VzYHX.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.0.6-DxjNre3mzYy3LhsUMQ3UN8.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -2005,7 +2005,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2055,7 +2055,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2097,7 +2097,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2117,7 +2117,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -2142,7 +2142,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2179,7 +2179,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2216,7 +2216,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2267,7 +2267,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2318,7 +2318,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2352,7 +2352,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 288;
CURRENT_PROJECT_VERSION = 289;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
+6 -6
View File
@@ -1768,9 +1768,9 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } }
public var active: Bool { get { contactStatus == .active } }
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
public var nextConnectPrepared: Bool { preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) }
public var nextConnectPrepared: Bool { active && preparedContact != nil && (activeConn == nil || activeConn?.connStatus == .prepared) }
public var profileChangeProhibited: Bool { activeConn != nil }
public var nextAcceptContactRequest: Bool { contactRequestId != nil && (activeConn == nil || activeConn?.connStatus == .new) }
public var nextAcceptContactRequest: Bool { active && contactRequestId != nil && (activeConn == nil || activeConn?.connStatus == .new) }
public var sendMsgToConnect: Bool { nextSendGrpInv || nextConnectPrepared }
public var displayName: String { localAlias == "" ? profile.displayName : localAlias }
public var fullName: String { get { profile.fullName } }
@@ -2419,8 +2419,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public func canBlockForAll(groupInfo: GroupInfo) -> Bool {
let userRole = groupInfo.membership.memberRole
return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin
&& userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive
return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .moderator
&& userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive
}
public var canReceiveReports: Bool {
@@ -2980,12 +2980,12 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
switch (chatInfo, chatDir) {
case let (.group(groupInfo, _), .groupRcv(groupMember)):
let m = groupInfo.membership
return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil
return m.memberRole >= .moderator && m.memberRole >= groupMember.memberRole && meta.itemDeleted == nil
? (groupInfo, groupMember)
: nil
case let (.group(groupInfo, _), .groupSnd):
let m = groupInfo.membership
return m.memberRole >= .admin ? (groupInfo, nil) : nil
return m.memberRole >= .moderator ? (groupInfo, nil) : nil
default: return nil
}
}
@@ -46,7 +46,7 @@ actual fun SaveOrOpenFileMenu(
}
ItemAction(
stringResource(MR.strings.save_verb),
painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download),
painterResource(MR.images.ic_download),
color = MaterialTheme.colors.primary,
onClick = {
saveFile()
@@ -28,7 +28,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.status == PermissionStatus.Granted) {
@@ -1743,9 +1743,9 @@ data class Contact(
val active get() = contactStatus == ContactStatus.Active
override val nextConnect get() = sendMsgToConnect
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
override val nextConnectPrepared get() = preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared)
override val nextConnectPrepared get() = active && preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared)
override val profileChangeProhibited get() = activeConn != null
val nextAcceptContactRequest get() = contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New)
val nextAcceptContactRequest get() = active && contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New)
val sendMsgToConnect get() = nextSendGrpInv || nextConnectPrepared
override val incognito get() = contactConnIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
@@ -2872,6 +2872,7 @@ object ChatController {
}
if (
chatModel.chatId.value == r.groupInfo.id
&& !r.groupInfo.membership.memberPending
&& ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)
&& chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext
) {
@@ -989,7 +989,7 @@ fun ChatLayout(
if (oneHandUI.value) {
StatusBarBackground()
}
Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
Box {
if (selectedChatItems.value == null) {
MemberSupportChatAppBar(chatsCtx, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, { ModalManager.end.closeModal() }, onSearchValueChanged)
@@ -1101,86 +1101,87 @@ fun ComposeView(
}
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when (it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> if (it.durationMs > 300) {
onAudioAdded(it.filePath, it.durationMs, true)
} else {
cancelVoice()
}
is RecordingState.NotStarted -> {}
}
}
}
LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) {
if (!chat.chatInfo.sendMsgEnabled) {
clearCurrentDraft()
clearState()
}
}
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
onDispose {
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
}
}
}
}
@Composable
fun SendMsgView_(
disableSendButton: Boolean,
placeholder: String? = null,
sendToConnect: (() -> Unit)? = null
) {
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when (it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> if (it.durationMs > 300) {
onAudioAdded(it.filePath, it.durationMs, true)
} else {
cancelVoice()
}
is RecordingState.NotStarted -> {}
}
}
}
LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) {
if (!chat.chatInfo.sendMsgEnabled) {
clearCurrentDraft()
clearState()
}
}
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
onDispose {
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
}
}
}
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
if (chat.chatInfo.incognito)
@@ -56,6 +56,7 @@ fun MemberSupportChatAppBar(
onSearchValueChanged: (String) -> Unit
) {
val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state }
val chatBottomBar = remember { ChatController.appPrefs.chatBottomBar.state }
val showSearch = rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch.value) {
@@ -71,7 +72,7 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = null,
onTop = !oneHandUI.value,
onTop = !oneHandUI.value || !chatBottomBar.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
@@ -85,7 +86,7 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
fixedTitleText = stringResource(MR.strings.support_chat),
onTitleClick = null,
onTop = !oneHandUI.value,
onTop = !oneHandUI.value || !chatBottomBar.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
@@ -212,61 +212,75 @@ fun ChatPreviewView(
fun chatPreviewText() {
val previewText = chatPreviewInfoText()
val ci = chat.chatItems.lastOrNull()
if (ci?.content?.hasMsgContent != true && previewText != null) {
if (chatModelDraftChatId == chat.id && chatModelDraft != null) {
val sp20 = with(LocalDensity.current) { 20.sp.toDp() }
val (text: CharSequence, inlineTextContent) = remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) }
val formattedText = null
MarkdownText(
text,
formattedText,
toggleSecrets = false,
linkMode = linkMode,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
fontFamily = Inter,
fontSize = 15.sp,
color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight,
lineHeight = 21.sp
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth()
)
} else if (ci?.content?.hasMsgContent != true && previewText != null) {
Text(previewText.first, color = previewText.second)
} else if (ci != null) {
if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) {
val sp20 = with(LocalDensity.current) { 20.sp.toDp() }
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) }
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci, chat.chatInfo) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
}
val prefix = when (val mc = ci.content.msgContent) {
is MsgContent.MCReport ->
buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
}
}
else -> null
}
MarkdownText(
text,
formattedText,
sender = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName
else -> null
},
mentions = ci.mentions,
userMemberId = when {
cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId
else -> null
},
toggleSecrets = false,
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
fontFamily = Inter,
fontSize = 15.sp,
color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight,
lineHeight = 21.sp
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
prefix = prefix
)
} else if (ci != null && showChatPreviews) {
val (text: CharSequence, inlineTextContent) = when {
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci, chat.chatInfo) to null
}
val formattedText = when {
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
}
val prefix = when (val mc = ci.content.msgContent) {
is MsgContent.MCReport ->
buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
}
}
else -> null
}
MarkdownText(
text,
formattedText,
sender = when {
cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName
else -> null
},
mentions = ci.mentions,
userMemberId = when {
cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId
else -> null
},
toggleSecrets = false,
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
fontFamily = Inter,
fontSize = 15.sp,
color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight,
lineHeight = 21.sp
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
prefix = prefix
)
}
}
@@ -120,7 +120,7 @@ fun SubscriptionStatusIndicatorView(subs: SMPServerSubs, hasSess: Boolean, leadi
val netCfg = rememberUpdatedState(chatModel.controller.getNetCfg())
val statusColorAndPercentage = subscriptionStatusColorAndPercentage(chatModel.networkInfo.value.online, netCfg.value.socksProxy, subs, hasSess)
val pref = remember { chatModel.controller.appPrefs.networkShowSubscriptionPercentage }
val percentageText = "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%"
val percentageText = if (subs.total > 0 || hasSess) "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%" else "%"
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -32,7 +32,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
val saveIfExists = {
when (cItem.content.msgContent) {
is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withLongRunningApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") }
+4 -4
View File
@@ -24,13 +24,13 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.4-beta.5
android.version_code=301
android.version_name=6.4-beta.6
android.version_code=302
android.bundle=false
desktop.version_name=6.4-beta.5
desktop.version_code=110
desktop.version_name=6.4-beta.6
desktop.version_code=111
kotlin.version=1.9.23
gradle.plugin.version=8.2.0
+27 -3
View File
@@ -32,6 +32,7 @@ revision: 23.04.2024
[Privacy and security](#privacy-and-security)
- [Does SimpleX support post quantum cryptography?](#does-simplex-support-post-quantum-cryptography)
- [Why can't I use the same profile on different devices?](#why-cant-i-use-the-same-profile-on-different-devices)
- [What user data can be provided on request?](#what-user-data-can-be-provided-on-request)
- [Does SimpleX protect my IP address?](#does-simplex-protect-my-ip-address)
- [Doesn't private message routing reinvent Tor?](#doesnt-private-message-routing-reinvent-tor)
@@ -53,15 +54,15 @@ Please check our [Groups Directory](./DIRECTORY.md) in the first place. You migh
Database is essential for SimpleX Chat to function properly. In comparison to centralized messaging providers, it is _the user_ who is responsible for taking care of their data. On the other hand, user is sure that _nobody but them_ has access to it. Please read more about it: [Database](./guide/managing-data.md).
### Can I send files over SimpleX?
### Can I send files over SimpleX?
Of course! While doing so, you are using a _state-of-the-art_ protocol that greatly reduces metadata leaks. Please read more about it: [XFTP Protocol](../blog/20230301-simplex-file-transfer-protocol.md).
### Whats incognito profile?
This feature is unique to SimpleX Chat it is independent from chat profiles.
This feature is unique to SimpleX Chat it is independent from chat profiles.
When "Incognito Mode” is turned on, your currently chosen profile name and image are hidden from your new contacts. It allows anonymous connections with other people without any shared data when you make new connections or join groups via a link a new random profile name will be generated for each connection.
When "Incognito Mode” is turned on, your currently chosen profile name and image are hidden from your new contacts. It allows anonymous connections with other people without any shared data when you make new connections or join groups via a link a new random profile name will be generated for each connection.
### How do invitations work?
@@ -256,6 +257,29 @@ You can resolve it by deleting the app's database: (WARNING: this results in del
Yes! Please read more about quantum resistant encryption is added to SimpleX Chat and about various properties of end-to-end encryption in [this post](../blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md).
### Why can't I use the same profile on different devices?
SimpleX Chat apps support [linking of mobile and desktop apps](https://simplex.chat/blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.html#link-mobile-and-desktop-apps-via-secure-quantum-resistant-protocol) via secure quantum-resistant protocol. It allows using the profile on your mobile device from desktop clients.
Seamlessly and securely using the same profile from two or more devices is a complex and unsolved problem. All apps that provide multi-device support do so at a cost of compromising security of end-to-end encryption. E.g., Session removed the Double Ratchet algorithm entirely to enable multi-device support, sacrificing forward secrecy. Signal provides multi-device support with Double Ratchet algorithm, but by [compromising its "break-in recovery" property](https://eprint.iacr.org/2021/626.pdf) (aka post-compromise security).
To the best of our knowledge there is no end-to-end encrypted messenger that solved this problem without compromising security, but we believe that the solution is possible. We have considered several approaches:
1. Convert each direct conversation into a group, where each device participates as a member. This is the approach that Signal and WhatsApp use, and while Signal implementation does not protect from a temporary compromise of long-term identity key (break-in recovery), such protection is possible. The downside of this approach is that the contacts and groups you participate in would know which device you use. Another possible attack is to send different messages to different devices, or to send messages to some devices but not to the others. This could lead to message history inconsistency or enable targeted attacks.
2. Store the state of the Double Ratchet algorithm for each conversation in an encrypted container on the server, allowing concurrent access and modification by each device for encrypting and decrypting messages. We did not see this approach used in any of the messaging apps, but it is technically viable. This approach has no downsides of the first, but it would increase the time it takes to send and to receive messages, as each message would require additional access to the server.
3. "Thin client" approach when user profile is stored on the server. The main challenge with this approach is to prevent the server knowing who connects to whom.
Whichever approach we choose for multi-device support, it requires careful design and implementation, and there is no existing secure solution to copy from. While we value usability very highly, we will not be improving usability in a way that compromises users' security. We will take a slower path of designing and implementing a solution for multi-device that achieves a better trade-off between usability and security than currently offered.
In the meantime, here are several secure options to enhance usability:
- link mobile profiles with desktop app. It does not compromise security in any way.
- create small groups with trusted contacts. These contacts would still know which device you use when you send the message, but it won't be shared with all contacts and groups you participate in. This approach is also secure, and it prevents devices being added to the conversation without user noticing.
- use "[business address](https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html#business-chats)" - the app would create a new small group with everybody who connects to you via your address, and you will be able to add your other devices to these groups.
While these approaches are not as convenient as seamless multi-device support offered by other apps, they also do not compromise security to achieve that convenience.
### What user data can be provided on request?
Our objective is to consistently ensure that no user data and absolute minimum of the metadata required for the network to function is available for disclosure by any infrastructure operators, under any circumstances.
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 6.4.0.6
version: 6.4.0.7
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
+6 -8
View File
@@ -1672,8 +1672,7 @@ processChatCommand vr nm = \case
gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId
m <- withFastStore $ \db -> getGroupMember db vr user gId mId
let GroupInfo {membership = GroupMember {memberRole = membershipRole}} = gInfo
-- TODO GRModerator when most users migrate
when (membershipRole >= GRAdmin) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages
when (membershipRole >= GRModerator) $ throwChatError $ CECantBlockMemberForSelf gInfo m showMessages
let settings = (memberSettings m) {showMessages}
processChatCommand vr nm $ APISetMemberSettings gId mId settings
ContactInfo cName -> withContactName cName APIContactInfo
@@ -3162,7 +3161,6 @@ processChatCommand vr nm = \case
Nothing -> do
setGroupLinkData'
let recipients = filter memberCurrentOrPending ms
liftIO $ putStrLn $ "about to sendGroupMessage to " <> show (length recipients)
sendGroupMessage user g' Nothing recipients (XGrpInfo p')
where
setGroupLinkData' :: CM ()
@@ -3200,7 +3198,7 @@ processChatCommand vr nm = \case
delGroupChatItemsForMembers :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [GroupMember] -> [CChatItem 'CTGroup] -> CM [ChatItemDeletion]
delGroupChatItemsForMembers user gInfo chatScopeInfo ms items = do
assertDeletable gInfo items
assertUserGroupRole gInfo GRAdmin -- TODO GRModerator when most users migrate
assertUserGroupRole gInfo GRModerator
let msgMemIds = itemsMsgMemIds gInfo items
events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId) $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds
mapM_ (sendGroupMessages_ user gInfo ms) events
@@ -3414,8 +3412,8 @@ processChatCommand vr nm = \case
Nothing -> do
(cReq, cData) <- getShortLinkConnReq user l'
withFastStore' (\db -> getContactWithoutConnViaShortAddress db vr user l') >>= \case
Just ct' -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct'))
Nothing -> do
Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct'))
_ -> do
contactSLinkData_ <- liftIO $ decodeShortLinkData cData
plan <- contactRequestPlan user cReq contactSLinkData_
pure (con cReq, plan)
@@ -3488,8 +3486,8 @@ processChatCommand vr nm = \case
withFastStore' (\db -> getContactConnEntityByConnReqHash db vr user cReqHashes) >>= \case
Nothing ->
withFastStore' (\db -> getContactWithoutConnViaAddress db vr user cReqSchemas) >>= \case
Nothing -> pure $ CPContactAddress (CAPOk contactSLinkData_)
Just ct -> pure $ CPContactAddress (CAPContactViaAddress ct)
Just ct | not (contactDeleted ct) -> pure $ CPContactAddress (CAPContactViaAddress ct)
_ -> pure $ CPContactAddress (CAPOk contactSLinkData_)
Just (RcvDirectMsgConnection Connection {connStatus} Nothing)
| connStatus == ConnPrepared -> pure $ CPContactAddress (CAPOk contactSLinkData_)
| otherwise -> pure $ CPContactAddress CAPConnectingConfirmReconnect
+2 -1
View File
@@ -2389,7 +2389,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
let gInfo' = gInfo {membership = membership'}
cd = CDGroupRcv gInfo' Nothing m
createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing
createGroupFeatureItems user cd CIRcvGroupFeature gInfo'
let prepared = preparedGroup gInfo'
unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo'
let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> preparedGroup gInfo'
unless (isJust welcomeMsgId_) $ maybeCreateGroupDescrLocal gInfo' m
createInternalChatItem user cd (CIRcvGroupEvent RGEUserAccepted) Nothing
+2 -2
View File
@@ -1729,7 +1729,7 @@ getIntroducedGroupMemberIds db invitee =
getForwardIntroducedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember]
getForwardIntroducedMembers db vr user invitee highlyAvailable = do
memberIds <- map fromOnly <$> query
filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
where
mId = groupMemberId' invitee
query
@@ -1769,7 +1769,7 @@ getForwardIntroducedModerators db vr user@User {userContactId} invitee = do
getForwardInvitedMembers :: DB.Connection -> VersionRangeChat -> User -> GroupMember -> Bool -> IO [GroupMember]
getForwardInvitedMembers db vr user forwardMember highlyAvailable = do
memberIds <- map fromOnly <$> query
filter memberCurrent . rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
rights <$> mapM (runExceptT . getGroupMemberById db vr user) memberIds
where
mId = groupMemberId' forwardMember
query
+12 -3
View File
@@ -809,8 +809,11 @@ findDirectChatPreviews_ db User {userId} pagination clq =
getDirectChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTDirect -> ExceptT StoreError IO AChat
getDirectChatPreview_ db vr user (DirectChatPD _ contactId lastItemId_ stats) = do
contact <- getContact db vr user contactId
ts <- liftIO getCurrentTime
lastItem <- case lastItemId_ of
Just lastItemId -> (: []) <$> getDirectChatItem db user contactId lastItemId
Just lastItemId -> do
previewItem <- liftIO $ safeGetDirectItem db user contact ts lastItemId
pure [previewItem]
Nothing -> pure []
pure $ AChat SCTDirect (Chat (DirectChat contact) lastItem stats)
@@ -917,8 +920,11 @@ findGroupChatPreviews_ db User {userId} pagination clq =
getGroupChatPreview_ :: DB.Connection -> VersionRangeChat -> User -> ChatPreviewData 'CTGroup -> ExceptT StoreError IO AChat
getGroupChatPreview_ db vr user (GroupChatPD _ groupId lastItemId_ stats) = do
groupInfo <- getGroupInfo db vr user groupId
ts <- liftIO getCurrentTime
lastItem <- case lastItemId_ of
Just lastItemId -> (: []) <$> getGroupCIWithReactions db user groupInfo lastItemId
Just lastItemId -> do
previewItem <- liftIO $ safeGetGroupItem db user groupInfo ts lastItemId
pure [previewItem]
Nothing -> pure []
pure $ AChat SCTGroup (Chat (GroupChat groupInfo Nothing) lastItem stats)
@@ -999,8 +1005,11 @@ findLocalChatPreviews_ db User {userId} pagination clq =
getLocalChatPreview_ :: DB.Connection -> User -> ChatPreviewData 'CTLocal -> ExceptT StoreError IO AChat
getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do
nf <- getNoteFolder db user noteFolderId
ts <- liftIO getCurrentTime
lastItem <- case lastItemId_ of
Just lastItemId -> (: []) <$> getLocalChatItem db user noteFolderId lastItemId
Just lastItemId -> do
previewItem <- liftIO $ safeGetLocalItem db user nf ts lastItemId
pure [previewItem]
Nothing -> pure []
pure $ AChat SCTLocal (Chat (LocalChat nf) lastItem stats)
+3 -3
View File
@@ -1782,14 +1782,14 @@ testMultipleUserAddresses =
cLinkAlisa <- getContactLink alice True
bob ##> ("/c " <> cLinkAlisa)
alice <#? bob
alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Audio/video calls: enabled"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")])
alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", "Audio/video calls: enabled"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")])
alice ##> "/ac bob"
alice <## "bob (Bob): accepting contact request, you can send messages to contact"
concurrently_
(bob <## "alisa: contact is connected")
(alice <## "bob (Bob): contact is connected")
threadDelay 100000
alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", lastChatFeature), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")])
alice #$> ("/_get chats 2 pcc=on", chats, [("@bob", lastChatFeature), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")])
alice <##> bob
bob #> "@alice hey alice"
@@ -1820,7 +1820,7 @@ testMultipleUserAddresses =
(cath <## "alisa: contact is connected")
(alice <## "cath (Catherine): contact is connected")
threadDelay 100000
alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", lastChatFeature), ("@bob", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")])
alice #$> ("/_get chats 2 pcc=on", chats, [("@cath", lastChatFeature), ("@bob", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")])
alice <##> cath
-- first user doesn't have cath as contact
+107 -3
View File
@@ -204,10 +204,9 @@ chatGroupTests = do
it "should forward messages inside support scope" testScopedSupportForward
it "should forward messages inside support scope while member is in review" testScopedSupportForwardWhileReview
it "should not forward messages from support to main scope" testScopedSupportDontForward
-- TODO test messages are not forwarded between support scopes (1 in review, 1 not? combinations?)
it "should forward group wide message (x.grp.info) to all members, including in review" testScopedSupportForwardAll
it "should not forward messages between support scopes" testScopedSupportDontForwardBetweenScopes
it "should forward file inside support scope" testScopedSupportForwardFile
-- TODO test files are forwarded inside support scope while member is in review
-- TODO test group events directed to all (e.g. XGrpInfo) are forwarded to support scope member while in review
it "should send messages to admins and members" testSupportCLISendCommand
it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead
it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete
@@ -7163,6 +7162,111 @@ testScopedSupportDontForward =
cath #> "#team (support) 4"
[alice, dan] *<# "#team (support: cath) cath> 4"
testScopedSupportForwardAll :: HasCallStack => TestParams -> IO ()
testScopedSupportForwardAll =
testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $
\alice bob cath dan eve -> do
createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GROwner)
alice ##> "/set admission review #team all"
alice <## "changed member admission rules"
concurrentlyN_
[ do
bob <## "alice updated group #team:"
bob <## "changed member admission rules",
do
cath <## "alice updated group #team:"
cath <## "changed member admission rules",
do
dan <## "alice updated group #team:"
dan <## "changed member admission rules"
]
alice ##> "/create link #team"
gLink <- getGroupLink alice "team" GRMember True
eve ##> ("/c " <> gLink)
eve <## "connection request sent!"
alice <## "eve (Eve): accepting request to join group #team..."
concurrentlyN_
[ alice <## "#team: eve connected and pending review",
eve
<### [ "#team: alice accepted you to the group, pending review",
"#team: joining the group...",
"#team: you joined the group, connecting to group moderators for admission to group",
"#team: member dan (Daniel) is connected"
],
do
dan <## "#team: alice added eve (Eve) to the group (connecting and pending review...), use /_accept member #1 5 <role> to accept member"
dan <## "#team: new member eve is connected and pending review, use /_accept member #1 5 <role> to accept member"
]
setupGroupForwarding alice bob dan
setupGroupForwarding alice dan eve
-- messages are forwarded in main scope between bob and dan
bob #> "#team 1"
[alice, cath] *<# "#team bob> 1"
dan <# "#team bob> 1 [>>]"
dan #> "#team 2"
[alice, cath] *<# "#team dan> 2"
bob <# "#team dan> 2 [>>]"
-- messages are forwarded in support scope between dan and eve
eve #> "#team (support) 3"
alice <# "#team (support: eve) eve> 3"
dan <# "#team (support: eve) eve> 3 [>>]"
dan #> "#team (support: eve) 4"
alice <# "#team (support: eve) dan> 4"
eve <# "#team (support) dan> 4 [>>]"
-- x.grp.info is forwarded from dan to both bob and eve
dan ##> "/gp team my_team"
dan <## "changed to #my_team"
concurrentlyN_
[ do
alice <## "dan updated group #team:"
alice <## "changed to #my_team",
do
bob <## "dan updated group #team:"
bob <## "changed to #my_team",
do
cath <## "dan updated group #team:"
cath <## "changed to #my_team",
do
eve <## "dan updated group #team:"
eve <## "changed to #my_team"
]
testScopedSupportDontForwardBetweenScopes :: HasCallStack => TestParams -> IO ()
testScopedSupportDontForwardBetweenScopes =
testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do
createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator)
setupGroupForwarding alice bob cath
-- messages are forwarded in main scope
bob #> "#team 1"
[alice, dan] *<# "#team bob> 1"
cath <# "#team bob> 1 [>>]"
cath #> "#team 2"
[alice, dan] *<# "#team cath> 2"
bob <# "#team cath> 2 [>>]"
-- messages not forwarded between support scopes
bob #> "#team (support) 3"
alice <# "#team (support: bob) bob> 3"
dan <# "#team (support: bob) bob> 3"
cath #> "#team (support) 4"
alice <# "#team (support: cath) cath> 4"
dan <# "#team (support: cath) cath> 4"
bob #> "#team (support) 5"
alice <# "#team (support: bob) bob> 5"
dan <# "#team (support: bob) bob> 5"
testScopedSupportForwardFile :: HasCallStack => TestParams -> IO ()
testScopedSupportForwardFile =
testChat4 aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> withXFTPServer $ do
+48 -7
View File
@@ -124,6 +124,7 @@ shortLinkTests largeLinkData = do
else it "prepare contact with a long name in profile" testShortLinkInvitationLongName
it "prepare contact via invitation and retry connecting" testShortLinkInvitationConnectRetry
it "prepare contact using address short link data and connect" testShortLinkAddressPrepareContact
it "address connect plan after contact is deleted but conversation kept" testShortLinkAddressDeleteContact
it "prepare contact via invitation and connect after it is deleted" testShortLinkDeletedInvitation
it "prepare contact via address and connect after it is deleted" testShortLinkDeletedAddress
it "prepare contact via address and connect with retry after error" testShortLinkAddressConnectRetry
@@ -3138,6 +3139,46 @@ testShortLinkAddressPrepareContact ps@TestParams {largeLinkData} = testChatCfg2
bob <## "contact address: ok to connect"
void $ getTermLine bob
testShortLinkAddressDeleteContact :: HasCallStack => TestParams -> IO ()
testShortLinkAddressDeleteContact ps@TestParams {largeLinkData} = testChatCfg2 testCfg {largeLinkData} aliceProfile bobProfile test ps
where
test alice bob = do
alice ##> "/ad"
(shortLink, fullLink) <- getContactLinks alice True
alice ##> "/pa on"
alice <## "new contact address set"
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "contact address: ok to connect"
contactSLinkData <- getTermLine bob
bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData)
bob <## "alice: contact is prepared"
bob ##> "/_connect contact @2 text hello"
bob
<### [ "alice: connection started",
WithTime "@alice hello"
]
alice
<### [ "bob (Bob) wants to connect to you!",
WithTime "bob> hello"
]
alice <## "to accept: /ac bob"
alice <## "to reject: /rc bob (the sender will NOT be notified)"
alice ##> "/ac bob"
alice <## "bob (Bob): accepting contact request, you can send messages to contact"
unless largeLinkData $
bob <## "contact alice updated bio: Alice"
concurrently_
(bob <## "alice (Alice): contact is connected")
(alice <## "bob (Bob): contact is connected")
alice <##> bob
threadDelay 250000
bob ##> "/d alice entity"
bob <## "alice: contact is deleted"
alice <## "bob (Bob) deleted contact with you"
bob ##> ("/_connect plan 1 " <> shortLink)
bob <## "contact address: ok to connect"
void $ getTermLine bob
testShortLinkDeletedInvitation :: HasCallStack => TestParams -> IO ()
testShortLinkDeletedInvitation ps@TestParams {largeLinkData} = testChatCfg2 testCfg {largeLinkData} aliceProfile bobProfile test ps
where
@@ -3682,8 +3723,8 @@ testShortLinkChangePreparedContactUser ps@TestParams {largeLinkData} = testChatC
alice @@@ [("@robert", "hey")]
alice `hasContactProfiles` ["alice", "robert"]
bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")])
bob `hasContactProfiles` ["robert", "alice", "SimpleX Chat team", "SimpleX-Status"]
bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")])
bob `hasContactProfiles` ["robert", "alice", "Ask SimpleX Team", "SimpleX Status"]
bob ##> "/user bob"
showActiveUser bob "bob (Bob)"
bob @@@ []
@@ -3741,8 +3782,8 @@ testShortLinkChangePreparedContactUserDuplicate ps@TestParams {largeLinkData} =
alice @@@ [("@robert", "hey"), ("@robert_1", "hey")]
alice `hasContactProfiles` ["alice", "robert", "robert"]
bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@alice_1", "hey"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")])
bob `hasContactProfiles` ["robert", "alice", "alice", "SimpleX Chat team", "SimpleX-Status"]
bob #$> ("/_get chats 2 pcc=on", chats, [("@alice", "hey"), ("@alice_1", "hey"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")])
bob `hasContactProfiles` ["robert", "alice", "alice", "Ask SimpleX Team", "SimpleX Status"]
bob ##> "/user bob"
showActiveUser bob "bob (Bob)"
bob @@@ []
@@ -3835,8 +3876,8 @@ testShortLinkChangePreparedGroupUser ps@TestParams {largeLinkData} = testChatCfg
alice @@@ [("#team", "3"), ("@cath","sent invitation to join group team as admin")]
alice `hasContactProfiles` ["alice", "cath", "robert"]
bob #$> ("/_get chats 2 pcc=on", chats, [("#team", "3"), ("@SimpleX Chat team", ""), ("@SimpleX-Status", ""), ("*", "")])
bob `hasContactProfiles` ["robert", "alice", "cath", "SimpleX Chat team", "SimpleX-Status"]
bob #$> ("/_get chats 2 pcc=on", chats, [("#team", "3"), ("@Ask SimpleX Team", ""), ("@SimpleX Status", ""), ("*", "")])
bob `hasContactProfiles` ["robert", "alice", "cath", "Ask SimpleX Team", "SimpleX Status"]
cath @@@ [("#team", "3"), ("@alice","received invitation to join group team as admin")]
cath `hasContactProfiles` ["cath", "alice", "robert"]
bob ##> "/user bob"
@@ -3949,7 +3990,7 @@ testShortLinkChangePreparedGroupUserDuplicate ps@TestParams {largeLinkData} = te
alice @@@ [("#team", "7"), ("@cath","sent invitation to join group team as admin")]
alice `hasContactProfiles` ["alice", "cath", "robert", "robert"]
bob `hasContactProfiles` ["robert", "robert", "robert", "alice", "alice", "cath", "cath", "SimpleX Chat team", "SimpleX-Status"]
bob `hasContactProfiles` ["robert", "robert", "robert", "alice", "alice", "cath", "cath", "Ask SimpleX Team", "SimpleX Status"]
cath @@@ [("#team", "7"), ("@alice","received invitation to join group team as admin")]
cath `hasContactProfiles` ["cath", "alice", "robert", "robert"]
bob ##> "/user bob"