ios: verify connection security code (#1542)

* ios: verify connection security code

* verification in member sheet (still crashes)

* use navigation view for members list

* ios: show verified status in the lists

* update verification status in the list of members

* verified shield layout

* update icon, make add member navigation to right

* refactor chatPreviewTitle
This commit is contained in:
Evgeny Poberezkin
2022-12-12 08:59:35 +00:00
committed by GitHub
parent c77f6100c5
commit 7b4710d198
14 changed files with 543 additions and 165 deletions

View File

@@ -356,14 +356,14 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
func apiContactInfo(contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
throw r
}
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) async throws -> (ConnectionStats?) {
let r = await chatSendCmd(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) {
let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) }
throw r
}
@@ -376,6 +376,32 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws
try await sendCommandOkResp(.apiSwitchGroupMember(groupId: groupId, groupMemberId: groupMemberId))
}
func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) {
let r = await chatSendCmd(.apiGetContactCode(contactId: contactId))
if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) }
throw r
}
func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) {
let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId))
if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) }
throw r
}
func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? {
let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode))
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
logger.error("apiVerifyContact error: \(String(describing: r))")
return nil
}
func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? {
let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode))
if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) }
logger.error("apiVerifyGroupMember error: \(String(describing: r))")
return nil
}
func apiAddContact() async -> String? {
let r = await chatSendCmd(.addContact, bgTask: false)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
@@ -782,6 +808,12 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
return []
}
func apiListMembersSync(_ groupId: Int64) -> [GroupMember] {
let r = chatSendCmdSync(.apiListMembers(groupId: groupId))
if case let .groupMembers(group) = r { return group.members }
return []
}
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
return ChatModel.shared.chats

View File

@@ -32,15 +32,26 @@ struct ChatInfoToolbar: View {
.frame(width: imageSize, height: imageSize)
.padding(.trailing, 4)
VStack {
Text(cInfo.displayName).font(.headline)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
.lineLimit(1)
if cInfo.fullName != "" && cInfo.displayName != cInfo.fullName {
Text(cInfo.fullName).font(.subheadline)
.lineLimit(1)
}
}
}
.foregroundColor(.primary)
.frame(width: 220)
}
private var contactVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
}
struct ChatInfoToolbar_Previews: PreviewProvider {

View File

@@ -55,8 +55,9 @@ struct ChatInfoView: View {
@ObservedObject var chat: Chat
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
var customUserProfile: Profile?
@Binding var customUserProfile: Profile?
@State var localAlias: String
@Binding var connectionCode: String?
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -89,7 +90,9 @@ struct ChatInfoView: View {
aliasTextFieldFocused = false
}
localAliasTextEdit()
Group {
localAliasTextEdit()
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
@@ -100,6 +103,7 @@ struct ChatInfoView: View {
}
Section {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
}
@@ -143,17 +147,23 @@ struct ChatInfoView: View {
}
}
func contactInfoHeader() -> some View {
private func contactInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(contact.profile.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
HStack {
if contact.verified {
Image(systemName: "checkmark.shield")
.foregroundColor(.secondary)
}
Text(contact.profile.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
}
if cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName {
Text(cInfo.fullName)
.font(.title2)
@@ -163,7 +173,7 @@ struct ChatInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func localAliasTextEdit() -> some View {
private func localAliasTextEdit() -> some View {
TextField("Set contact name…", text: $localAlias)
.disableAutocorrection(true)
.focused($aliasTextFieldFocused)
@@ -194,7 +204,36 @@ struct ChatInfoView: View {
}
}
func contactPreferencesButton() -> some View {
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
displayName: contact.displayName,
connectionCode: code,
connectionVerified: contact.verified,
verify: { code in
if let r = apiVerifyContact(chat.chatInfo.apiId, connectionCode: code) {
let (verified, existingCode) = r
contact.activeConn.connectionCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode
DispatchQueue.main.async {
chat.chatInfo = .direct(contact: contact)
}
return r
}
return nil
}
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
} label: {
Label(
contact.verified ? "View security code" : "Verify security code",
systemImage: contact.verified ? "checkmark.shield" : "shield"
)
}
}
private func contactPreferencesButton() -> some View {
NavigationLink {
ContactPreferencesView(
contact: $contact,
@@ -208,7 +247,7 @@ struct ChatInfoView: View {
}
}
func networkStatusRow() -> some View {
private func networkStatusRow() -> some View {
HStack {
Text("Network status")
Image(systemName: "info.circle")
@@ -221,14 +260,14 @@ struct ChatInfoView: View {
}
}
func serverImage() -> some View {
private func serverImage() -> some View {
let status = chat.serverInfo.networkStatus
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : .secondary)
.font(.system(size: 12))
}
func deleteContactButton() -> some View {
private func deleteContactButton() -> some View {
Button(role: .destructive) {
alert = .deleteContactAlert
} label: {
@@ -237,7 +276,7 @@ struct ChatInfoView: View {
}
}
func clearChatButton() -> some View {
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
} label: {
@@ -323,7 +362,9 @@ struct ChatInfoView_Previews: PreviewProvider {
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
localAlias: ""
customUserProfile: Binding.constant(nil),
localAlias: "",
connectionCode: Binding.constant(nil)
)
}
}

View File

@@ -23,6 +23,7 @@ struct ChatView: View {
@State private var showDeleteMessage = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
@State private var tableView: UITableView?
@State private var loadingItems = false
@State private var firstPage = false
@@ -33,8 +34,7 @@ struct ChatView: View {
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GroupMember? = nil
@State private var memberConnectionStats: ConnectionStats?
var body: some View {
let cInfo = chat.chatInfo
return VStack(spacing: 0) {
@@ -90,24 +90,30 @@ struct ChatView: View {
Button {
Task {
do {
let (stats, profile) = try await apiContactInfo(contactId: chat.chatInfo.apiId)
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn.connectionCode != ct.activeConn.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo error: \(responseError(error))")
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
await MainActor.run { showChatInfoSheet = true }
}
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
.sheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
connectionCode = nil
}) {
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: customUserProfile, localAlias: chat.chatInfo.localAlias)
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
}
} else if case let .group(groupInfo) = cInfo {
Button {
@@ -381,22 +387,9 @@ struct ChatView: View {
if showMember {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture {
Task {
do {
let stats = try await apiGroupMemberInfo(member.groupId, member.groupMemberId)
await MainActor.run { memberConnectionStats = stats }
} catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))")
}
await MainActor.run { selectedMember = member }
}
}
.appSheet(item: $selectedMember, onDismiss: {
selectedMember = nil
memberConnectionStats = nil
}) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $memberConnectionStats)
.onTapGesture { selectedMember = member }
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
}
} else {
Rectangle().fill(.clear)

View File

@@ -34,10 +34,24 @@ struct AddGroupMembersView: View {
}
var body: some View {
NavigationView {
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
if creatingGroup {
NavigationView {
addGroupMembersView()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb?(selectedContacts) }
}
}
}
} else {
addGroupMembersView()
}
}
let v = List {
private func addGroupMembersView() -> some View {
VStack {
let membersToAdd = filterMembersToAdd(chatModel.groupMembers)
List {
ChatInfoToolbar(chat: chat, imageSize: 48)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
@@ -80,16 +94,6 @@ struct AddGroupMembersView: View {
}
}
}
if creatingGroup {
v.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Skip") { addedMembersCb?(selectedContacts) }
}
}
} else {
v.navigationBarHidden(true)
}
}
.frame(maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alert in

View File

@@ -14,12 +14,13 @@ struct GroupChatInfoView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@State var groupInfo: GroupInfo
@State var selectedMember: Int64? = nil
@ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String?
@State private var showAddMembersSheet: Bool = false
@State private var selectedMember: GroupMember? = nil
@State private var connectionStats: ConnectionStats?
@State private var connectionCode: String?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum GroupChatInfoViewAlert: Identifiable {
@@ -65,27 +66,22 @@ struct GroupChatInfoView: View {
}
memberView(groupInfo.membership, user: true)
ForEach(members) { member in
Button {
Task {
do {
let stats = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
await MainActor.run { connectionStats = stats }
} catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))")
}
await MainActor.run { selectedMember = member }
}
} label: { memberView(member) }
NavLinkPlain(
tag: member.groupMemberId,
selection: $selectedMember,
label: { memberView(member) }
)
}
}
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
.appSheet(item: $selectedMember, onDismiss: {
selectedMember = nil
connectionStats = nil
}) { _ in
GroupMemberInfoView(groupInfo: groupInfo, member: $selectedMember, connectionStats: $connectionStats)
.background(
NavigationLink(
destination: memberInfoView(selectedMember),
isActive: Binding(
get: { selectedMember != nil },
set: { _, _ in selectedMember = nil }
)
) { EmptyView() }
.opacity(0)
)
}
Section {
@@ -125,7 +121,7 @@ struct GroupChatInfoView: View {
}
}
func groupInfoHeader() -> some View {
private func groupInfoHeader() -> some View {
VStack {
let cInfo = chat.chatInfo
ChatInfoImage(chat: chat, color: Color(uiColor: .tertiarySystemFill))
@@ -146,35 +142,32 @@ struct GroupChatInfoView: View {
}
private func addMembersButton() -> some View {
Button {
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showAddMembersSheet = true
NavigationLink {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
.onAppear {
ChatModel.shared.groupMembers = apiListMembersSync(groupInfo.groupId)
}
}
} label: {
Label("Invite members", systemImage: "plus")
}
}
func serverImage() -> some View {
private func serverImage() -> some View {
let status = chat.serverInfo.networkStatus
return Image(systemName: status.imageName)
.foregroundColor(status == .connected ? .green : .secondary)
}
func memberView(_ member: GroupMember, user: Bool = false) -> some View {
private func memberView(_ member: GroupMember, user: Bool = false) -> some View {
HStack{
ProfileImage(imageStr: member.image)
.frame(width: 38, height: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
Text(member.chatViewName)
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
.foregroundColor(member.memberIncognito ? .indigo : .primary)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
@@ -190,6 +183,21 @@ struct GroupChatInfoView: View {
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
@ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View {
if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) {
GroupMemberInfoView(groupInfo: groupInfo, member: member)
.navigationBarHidden(false)
}
}
private func groupLinkButton() -> some View {
NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink)
@@ -200,7 +208,7 @@ struct GroupChatInfoView: View {
}
}
func editGroupButton() -> some View {
private func editGroupButton() -> some View {
NavigationLink {
GroupProfileView(
groupInfo: $groupInfo,
@@ -213,7 +221,7 @@ struct GroupChatInfoView: View {
}
}
func deleteGroupButton() -> some View {
private func deleteGroupButton() -> some View {
Button(role: .destructive) {
alert = .deleteGroupAlert
} label: {
@@ -222,7 +230,7 @@ struct GroupChatInfoView: View {
}
}
func clearChatButton() -> some View {
private func clearChatButton() -> some View {
Button() {
alert = .clearChatAlert
} label: {
@@ -231,7 +239,7 @@ struct GroupChatInfoView: View {
}
}
func leaveGroupButton() -> some View {
private func leaveGroupButton() -> some View {
Button(role: .destructive) {
alert = .leaveGroupAlert
} label: {

View File

@@ -13,8 +13,10 @@ struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var groupInfo: GroupInfo
@Binding var member: GroupMember?
@Binding var connectionStats: ConnectionStats?
@State var member: GroupMember
var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -36,76 +38,93 @@ struct GroupMemberInfoView: View {
}
var body: some View {
NavigationView {
if let member = member {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
if navigation {
NavigationView { groupMemberInfoView() }
} else {
groupMemberInfoView()
}
}
private func groupMemberInfoView() -> some View {
VStack {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
Section {
if let contactId = member.memberContactId {
if let chat = chatModel.getContactChat(contactId),
chat.chatInfo.contact?.directContact ?? false {
Section {
knownDirectChatButton(chat)
}
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on {
Section {
newDirectChatButton(contactId)
}
newDirectChatButton(contactId)
}
}
if let code = connectionCode { verifyCodeButton(code) }
}
Section("Member") {
infoRow("Group", groupInfo.displayName)
Section("Member") {
infoRow("Group", groupInfo.displayName)
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
}
} else {
infoRow("Role", member.memberRole.text)
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
}
if let connStats = connectionStats {
Section("Servers") {
// TODO network connection status
Button("Change receiving address") {
alert = .switchAddressAlert
}
if let connStats = connectionStats {
smpServers("Receiving via", connStats.rcvServers)
smpServers("Sending via", connStats.sndServers)
}
}
if member.canBeRemoved(groupInfo: groupInfo) {
Section {
removeMemberButton(member)
}
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
}
smpServers("Receiving via", connStats.rcvServers)
smpServers("Sending via", connStats.sndServers)
}
}
.navigationBarHidden(true)
.onAppear { newRole = member.memberRole }
.onChange(of: newRole) { _ in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
if member.canBeRemoved(groupInfo: groupInfo) {
Section {
removeMemberButton(member)
}
}
if developerTools {
Section("For console") {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
}
}
}
.navigationBarHidden(true)
.onAppear {
newRole = member.memberRole
do {
let stats = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
member = mem
connectionStats = stats
connectionCode = code
} catch let error {
logger.error("apiGroupMemberInfo or apiGetGroupMemberCode error: \(responseError(error))")
}
}
.onChange(of: newRole) { _ in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
@@ -155,10 +174,15 @@ struct GroupMemberInfoView: View {
.frame(width: 192, height: 192)
.padding(.top, 12)
.padding()
Text(mem.displayName)
.font(.largeTitle)
.lineLimit(1)
.padding(.bottom, 2)
HStack {
if mem.verified {
Image(systemName: "checkmark.shield")
}
Text(mem.displayName)
.font(.largeTitle)
.lineLimit(1)
}
.padding(.bottom, 2)
if mem.fullName != "" && mem.fullName != mem.displayName {
Text(mem.fullName)
.font(.title2)
@@ -168,7 +192,38 @@ struct GroupMemberInfoView: View {
.frame(maxWidth: .infinity, alignment: .center)
}
func removeMemberButton(_ mem: GroupMember) -> some View {
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
displayName: member.displayName,
connectionCode: code,
connectionVerified: member.verified,
verify: { code in
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
let (verified, existingCode) = r
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode
member.activeConn?.connectionCode = connCode
if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
chatModel.groupMembers[i].activeConn?.connectionCode = connCode
}
return r
}
return nil
}
)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Security code")
} label: {
Label(
member.verified ? "View security code" : "Verify security code",
systemImage: member.verified ? "checkmark.shield" : "shield"
)
}
}
private func removeMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
alert = .removeMemberAlert(mem: mem)
} label: {
@@ -230,9 +285,7 @@ struct GroupMemberInfoView: View {
private func switchMemberAddress() {
Task {
do {
if let member = member {
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
}
try await apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
} catch let error {
logger.error("switchMemberAddress apiSwitchGroupMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error changing address")
@@ -248,8 +301,7 @@ struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
member: Binding.constant(GroupMember.sampleData),
connectionStats: Binding.constant(nil)
member: GroupMember.sampleData
)
}
}

View File

@@ -0,0 +1,60 @@
//
// ScanCodeView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 10/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import CodeScanner
struct ScanCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var connectionVerified: Bool
var verify: (String?) async -> (Bool, String)?
@State private var showCodeError = false
var body: some View {
VStack(alignment: .leading) {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
Text("Scan security code from your contact's app.")
.padding(.top)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(isPresented: $showCodeError) {
Alert(title: Text("Incorrect security code!"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
Task {
if let (ok, _) = await verify(r.string) {
await MainActor.run {
connectionVerified = ok
if ok {
dismiss()
} else {
showCodeError = true
}
}
}
}
case let .failure(e):
logger.error("ScanCodeView.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
}
}
}
struct ScanCodeView_Previews: PreviewProvider {
static var previews: some View {
ScanCodeView(connectionVerified: Binding.constant(true), verify: {_ in nil})
}
}

View File

@@ -0,0 +1,126 @@
//
// VerifyCodeView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 10/12/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct VerifyCodeView: View {
@Environment(\.dismiss) var dismiss: DismissAction
var displayName: String
@State var connectionCode: String?
@State var connectionVerified: Bool
var verify: (String?) -> (Bool, String)?
@State private var showCodeError = false
var body: some View {
if let code = connectionCode {
verifyCodeView(code)
}
}
private func verifyCodeView(_ code: String) -> some View {
ScrollView {
let splitCode = splitToParts(code, length: 24)
VStack(alignment: .leading) {
Group {
HStack {
if connectionVerified {
Image(systemName: "checkmark.shield")
.foregroundColor(.secondary)
Text("\(displayName) is verified")
} else {
Text("\(displayName) is not verified")
}
}
.frame(height: 24)
QRCode(uri: code)
.padding(.horizontal)
Text(splitCode)
.multilineTextAlignment(.leading)
.font(.body.monospaced())
.lineLimit(20)
.padding(.bottom, 8)
}
.frame(maxWidth: .infinity, alignment: .center)
Text("To verify end-to-end encryption with your contact compare (or scan) the code on your devices.")
.padding(.bottom)
Group {
if connectionVerified {
Button {
verifyCode(nil)
} label: {
Label("Clear verification", systemImage: "shield")
}
.padding()
} else {
HStack {
NavigationLink {
ScanCodeView(connectionVerified: $connectionVerified, verify: verify)
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("Scan code")
} label: {
Label("Scan code", systemImage: "qrcode")
}
.padding()
Button {
verifyCode(code) { verified in
if !verified { showCodeError = true }
}
} label: {
Label("Mark verified", systemImage: "checkmark.shield")
}
.padding()
.alert(isPresented: $showCodeError) {
Alert(title: Text("Incorrect security code!"))
}
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button {
showShareSheet(items: [splitCode])
} label: {
Image(systemName: "square.and.arrow.up")
}
}
}
.onChange(of: connectionVerified) { _ in
if connectionVerified { dismiss() }
}
}
}
private func verifyCode(_ code: String?, _ cb: ((Bool) -> Void)? = nil) {
if let (verified, existingCode) = verify(code) {
connectionVerified = verified
connectionCode = existingCode
cb?(verified)
}
}
private func splitToParts(_ s: String, length: Int) -> String {
if length >= s.count { return s }
return (0 ... (s.count - 1) / length)
.map { s.dropFirst($0 * length).prefix(length) }
.joined(separator: "\n")
}
}
struct VerifyCodeView_Previews: PreviewProvider {
static var previews: some View {
VerifyCodeView(displayName: "alice", connectionCode: "12345 67890 12345 67890", connectionVerified: false, verify: {_ in nil})
}
}

View File

@@ -79,26 +79,33 @@ struct ChatPreviewView: View {
}
@ViewBuilder private func chatPreviewTitle() -> some View {
let v = Text(chat.chatInfo.chatViewName)
.font(.title3)
.fontWeight(.bold)
.lineLimit(1)
.frame(alignment: .topLeading)
switch (chat.chatInfo) {
case .direct:
v.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case .group(groupInfo: let groupInfo):
let t = Text(chat.chatInfo.chatViewName).font(.title3).fontWeight(.bold)
switch chat.chatInfo {
case let .direct(contact):
previewTitle(contact.verified == true ? verifiedIcon + t : t)
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case let .group(groupInfo):
let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) {
case .memInvited:
chat.chatInfo.incognito ? v.foregroundColor(.indigo) : v.foregroundColor(.accentColor)
case .memAccepted:
v.foregroundColor(.secondary)
case .memInvited: v.foregroundColor(chat.chatInfo.incognito ? .indigo : .accentColor)
case .memAccepted: v.foregroundColor(.secondary)
default: v
}
default: v
default: previewTitle(t)
}
}
private func previewTitle(_ t: Text) -> some View {
t.lineLimit(1).frame(alignment: .topLeading)
}
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
@ViewBuilder private func chatPreviewText(_ cItem: ChatItem?) -> some View {
if let cItem = cItem {
let itemText = !cItem.meta.itemDeleted ? cItem.text : NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")

View File

@@ -47,7 +47,7 @@ struct ScanSMPServer: View {
showAddressError = true
}
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
logger.error("ScanSMPServer.processQRCode QR code error: \(e.localizedDescription)")
dismiss()
}
}

View File

@@ -93,6 +93,8 @@
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; };
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; };
5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; };
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */; };
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */; };
5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; };
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
@@ -306,6 +308,8 @@
5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = "<group>"; };
5CB924E327A8683A00ACCCDD /* UserAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddress.swift; sourceTree = "<group>"; };
5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = "<group>"; };
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyCodeView.swift; sourceTree = "<group>"; };
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanCodeView.swift; sourceTree = "<group>"; };
5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = "<group>"; };
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -453,6 +457,8 @@
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */,
5CE4407127ADB1D0007B033A /* Emoji.swift */,
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */,
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */,
);
path = Chat;
sourceTree = "<group>";
@@ -936,6 +942,7 @@
5C3F1D562842B68D00EC8A82 /* IntegrityErrorItemView.swift in Sources */,
5C029EAA283942EA004A9677 /* CallController.swift in Sources */,
5CCA7DF32905735700C8FEBA /* AcceptRequestsView.swift in Sources */,
5CBE6C142944CC12002D9531 /* ScanCodeView.swift in Sources */,
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
5CFA59C42860BC6200863A68 /* MigrateToAppGroupView.swift in Sources */,
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */,
@@ -943,6 +950,7 @@
5C4B3B0A285FB130003915F2 /* DatabaseView.swift in Sources */,
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,

View File

@@ -58,6 +58,10 @@ public enum ChatCommand {
case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
case apiSwitchContact(contactId: Int64)
case apiSwitchGroupMember(groupId: Int64, groupMemberId: Int64)
case apiGetContactCode(contactId: Int64)
case apiGetGroupMemberCode(groupId: Int64, groupMemberId: Int64)
case apiVerifyContact(contactId: Int64, connectionCode: String?)
case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
@@ -138,6 +142,12 @@ public enum ChatCommand {
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
case let .apiSwitchContact(contactId): return "/_switch @\(contactId)"
case let .apiSwitchGroupMember(groupId, groupMemberId): return "/_switch #\(groupId) \(groupMemberId)"
case let .apiGetContactCode(contactId): return "/_get code @\(contactId)"
case let .apiGetGroupMemberCode(groupId, groupMemberId): return "/_get code #\(groupId) \(groupMemberId)"
case let .apiVerifyContact(contactId, .some(connectionCode)): return "/_verify code @\(contactId) \(connectionCode)"
case let .apiVerifyContact(contactId, .none): return "/_verify code @\(contactId)"
case let .apiVerifyGroupMember(groupId, groupMemberId, .some(connectionCode)): return "/_verify code #\(groupId) \(groupMemberId) \(connectionCode)"
case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
@@ -217,6 +227,10 @@ public enum ChatCommand {
case .apiGroupMemberInfo: return "apiGroupMemberInfo"
case .apiSwitchContact: return "apiSwitchContact"
case .apiSwitchGroupMember: return "apiSwitchGroupMember"
case .apiGetContactCode: return "apiGetContactCode"
case .apiGetGroupMemberCode: return "apiGetGroupMemberCode"
case .apiVerifyContact: return "apiVerifyContact"
case .apiVerifyGroupMember: return "apiVerifyGroupMember"
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
@@ -300,6 +314,9 @@ public enum ChatResponse: Decodable, Error {
case networkConfig(networkConfig: NetCfg)
case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?)
case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?)
case contactCode(contact: Contact, connectionCode: String)
case groupMemberCode(groupInfo: GroupInfo, member: GroupMember, connectionCode: String)
case connectionVerified(verified: Bool, expectedCode: String)
case invitation(connReqInvitation: String)
case sentConfirmation
case sentInvitation
@@ -403,6 +420,9 @@ public enum ChatResponse: Decodable, Error {
case .networkConfig: return "networkConfig"
case .contactInfo: return "contactInfo"
case .groupMemberInfo: return "groupMemberInfo"
case .contactCode: return "contactCode"
case .groupMemberCode: return "groupMemberCode"
case .connectionVerified: return "connectionVerified"
case .invitation: return "invitation"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
@@ -506,6 +526,9 @@ public enum ChatResponse: Decodable, Error {
case let .networkConfig(networkConfig): return String(describing: networkConfig)
case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))"
case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))"
case let .contactCode(contact, connectionCode): return "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)"
case let .groupMemberCode(groupInfo, member, connectionCode): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)"
case let .connectionVerified(verified, expectedCode): return "verified: \(verified)\nconnectionCode: \(expectedCode)"
case let .invitation(connReqInvitation): return connReqInvitation
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails

View File

@@ -817,6 +817,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var fullName: String { get { profile.fullName } }
public var image: String? { get { profile.image } }
public var localAlias: String { profile.localAlias }
public var verified: Bool { activeConn.connectionCode != nil }
public var directContact: Bool {
(activeConn.connLevel == 0 && !activeConn.viaGroupLink) || contactUsed
@@ -858,6 +859,7 @@ public struct Connection: Decodable {
public var connLevel: Int
public var viaGroupLink: Bool
public var customUserProfileId: Int64?
public var connectionCode: SecurityCode?
public var id: ChatId { get { ":\(connId)" } }
@@ -869,6 +871,16 @@ public struct Connection: Decodable {
)
}
public struct SecurityCode: Decodable, Equatable {
public init(securityCode: String, verifiedAt: Date) {
self.securityCode = securityCode
self.verifiedAt = verifiedAt
}
public var securityCode: String
public var verifiedAt: Date
}
public struct UserContact: Decodable {
public var userContactLinkId: Int64
@@ -1124,6 +1136,7 @@ public struct GroupMember: Identifiable, Decodable {
}
public var fullName: String { get { memberProfile.fullName } }
public var image: String? { get { memberProfile.image } }
public var verified: Bool { activeConn?.connectionCode != nil }
var directChatId: ChatId? {
get {