1// AppState.swift2// SophaxChat3//4// Observable app state and ChatManager lifecycle.5// Created once and injected via @EnvironmentObject.67import SwiftUI8import UIKit9import Network10import AVFoundation11import CryptoKit12import LocalAuthentication13import UserNotifications14import ReplayKit15import UniformTypeIdentifiers16import SophaxChatCore1718/// Notification payload shown to the user when a contact sends an SSS share to hold.19struct SSSShareNotification: Identifiable {20 let id = UUID()21 let shareID: String22 let fromPeerID: String23 let threshold: Int24 let total: Int25}2627@MainActor28final class AppState: ObservableObject {2930// MARK: - Published state3132@Published var isSetupComplete: Bool = false33@Published var isBlurred: Bool = false34@Published var isAppLocked: Bool = false35/// When true, all in-memory state is cleared and the app displays as empty.36/// Incoming messages are silently dropped until the next real unlock.37@Published var isDuressActive: Bool = false38@Published var peers:[KnownPeer]=[]39@Published var pendingContactRequests:[KnownPeer]=[]40@Published var linkedDevices:[KnownPeer]=[]41@Published var messages:[String:[StoredMessage]]=[:]// peerID → messages42@Published var onlinePeers: Set<String>=[]43@Published var blockedPeers: Set<String>=[]44@Published var unreadCounts:[String: Int]=[:]45@Published var typingPeers: Set<String>=[]46@Published var peerAliases:[String: String]=[:]47@Published var errorMessage: String?= nil48@Published var groups:[GroupInfo]=[]49/// Groups announced by nearby peers that the local user is NOT a member of.50/// Keyed by groupID; stale entries (>5 min old) are replaced on each announcement.51@Published var discoveredChannels:[String: ChannelAnnouncement]=[:]5253/// Pending channel join requests received from peers who want to join a group we created.54/// Keyed by groupID + ":" + requesterPeerID for deduplication.55@Published var pendingGroupJoinRequests:[ChannelJoinRequestMessage]=[]5657/// Safety Number pinning: peerID → safety number at time of verification.58/// Nil entry = never verified. Different value = key changed warning.59@Published var verifiedPeers:[String: String]=[:]6061/// Pinned messages: conversationID → pinned messageID. Local-only, never broadcast.62@Published var pinnedMessages:[String: String]=[:]6364/// peerID → true when their session was established without a one-time prekey (reduced entropy).65@Published var noOPKSessions: Set<String>=[]6667/// peerID → JPEG avatar data received from that peer's PreKeyBundle.68@Published var peerAvatars:[String: Data]=[:]69/// JPEG data for the local user's own avatar. nil = no avatar set.70@Published var myAvatarData: Data?= nil71/// peerIDs whose identity key changed since last known state — shown as security alerts.72@Published var keyChangeAlerts:[String]=[]73/// Set when biometric/passcode evaluation is unavailable during unlock — shown in AppLockView.74@Published var unlockError: String?= nil75/// True while a biometric/passcode prompt is in progress — prevents multiple simultaneous prompts.76@Published var isUnlocking: Bool = false77/// Non-nil when unlock is rate-limited after too many failures. UI shows a countdown.78@Published var unlockLockedUntil: Date?= nil7980/// Non-nil when a contact sent us an SSS share request to hold. UI should prompt accept/reject.81@Published var pendingSSSShareRequest: SSSShareNotification?= nil82/// Non-nil after a successful SSS recovery — 64 raw bytes (Ed25519 || X25519).83/// Caller MUST zero this after use.84@Published var sssRecoveredSecret: Data?= nil8586 private var failedUnlockAttempts: Int = 08788// MARK: - TCP / internet mode8990/// Whether the TCP internet transport is active.91@Published var tcpEnabled: Bool = false {92 didSet { applyTCPConfig(); UserDefaults.standard.set(tcpEnabled, forKey: tcpEnabledKey)}93}94/// Whether to route TCP traffic through the embedded Tor SOCKS5 proxy.95@AppStorage("com.sophax.torEnabled") var torEnabled: Bool = true {96 didSet {97#if !targetEnvironment(macCatalyst)98 if torEnabled {99 TorManager.shared.start()100 if tcpSocksProxy.isEmpty {101 tcpSocksProxy = TorManager.socksProxy102}103} else {104 TorManager.shared.stop()105 if tcpSocksProxy == TorManager.socksProxy {106 tcpSocksProxy =""107}108// Tor-enforced mode requires Tor — stop TCP immediately if Tor is disabled109 if torEnforcedMode { chatManager?.stopTCP()}110}111#endif112}113}114115/// When true, TCP transport will not start until Tor is fully bootstrapped.116/// If Tor stops while TCP is running, the TCP transport is also stopped (fail-closed).117@AppStorage("com.sophax.torEnforcedMode") var torEnforcedMode: Bool = false {118 didSet {119#if !targetEnvironment(macCatalyst)120 if torEnforcedMode {121// Enforce immediately: if Tor is not ready, stop TCP122 if case .ready = TorManager.shared.state {/* Tor is up, keep TCP running */}123 else { chatManager?.stopTCP()}124} else {125// Enforcement lifted: restart TCP if it should be running126 applyTCPConfig()127}128#endif129}130}131/// Local listen port (default 25519).132@Published var tcpPort: String ="25519"{133 didSet { applyTCPConfig(); UserDefaults.standard.set(tcpPort, forKey: tcpPortKey)}134}135/// Optional SOCKS5 proxy for Tor ("host:port", e.g. "127.0.0.1:9050").136@Published var tcpSocksProxy: String =""{137 didSet { applyTCPConfig(); UserDefaults.standard.set(tcpSocksProxy, forKey: tcpSocksProxyKey)}138}139/// User-entered public address ("IP:port") included in Hello so peers learn our internet address.140@Published var myTCPAddress: String =""{141 didSet {142 chatManager?.myTCPAddress = myTCPAddress.isEmpty ? nil : myTCPAddress143 UserDefaults.standard.set(myTCPAddress, forKey: tcpAddressKey)144}145}146147 private let tcpEnabledKey ="com.sophax.tcp.enabled"148 private let tcpPortKey ="com.sophax.tcp.port"149 private let tcpSocksProxyKey ="com.sophax.tcp.socksProxy"150 private let tcpAddressKey ="com.sophax.tcp.address"151152/// When true, sender name and message body are included in local notifications.153/// Off by default: protects conversation metadata on the lock screen / Notification Centre.154@Published var notifShowSender: Bool = false {155 didSet { UserDefaults.standard.set(notifShowSender, forKey: notifShowSenderKey)}156}157 private let notifShowSenderKey ="com.sophax.notif.showSender"158159/// Set to a peer that just came back online; triggers reconnect banner in UI.160@Published var reconnectedPeer: KnownPeer?= nil161162/// When true, the app content is wrapped in a UITextField(isSecureTextEntry:true) layer so163/// iOS excludes it from screenshots and screen recordings. Opt-in — not enabled by default.164/// NOTE: This is a documented-by-practice technique using a private CALayer flag in UITextField.165/// It may break in a future OS. Toggle off if content ever appears blank unexpectedly.166@AppStorage("com.sophax.screenshotPreventionEnabled") var screenshotPreventionEnabled = false167168/// True while iOS screen recording is active — shown as a security warning banner.169@Published var isScreenBeingRecorded: Bool = false170/// Momentarily true after the user takes a screenshot — shown as a brief warning.171@Published var didTakeScreenshot: Bool = false172/// Momentarily non-nil after a contact card link is successfully parsed — shown as a toast.173@Published var lastAddedContactAddress: String?= nil174/// Non-nil when a sophaxchat:// link is waiting for user confirmation before connecting.175@Published var pendingDeepLink: PendingDeepLink?= nil176177/// Username cache for blocked peers (persisted so they're still readable after restart).178 private(set) var blockedPeerNames:[String: String]=[:]179 private var typingTimeouts:[String: Task<Void, Never>]=[:]180181// MARK: - Core182183 private(set) var chatManager: ChatManager?184 private let keychain = KeychainManager()185186// MARK: - Init187188 init(){189// Load unlock rate-limit state (persisted in Keychain, survives app restarts)190 let (attempts, lockedUntil)= keychain.loadUnlockAttempts()191 self.failedUnlockAttempts = attempts192 self.unlockLockedUntil =(lockedUntil.map { $0 > Date()}?? false)? lockedUntil : nil193194 loadSavedPeers()195 loadPendingRequests()196 loadGroupJoinRequests()197 loadBlockedPeers()198 loadAliases()199 loadGroups()200 loadVerifiedPeers()201 loadPinnedMessages()202 loadTCPSettings()203 if keychain.hasIdentity(){204 setupChatManager(username: nil)205}206// Start embedded Tor immediately (iOS only — Mac Catalyst uses system network directly).207// Respects the torEnabled toggle stored in UserDefaults.208#if !targetEnvironment(macCatalyst)209 if torEnabled {210 TorManager.shared.start()211}212 observeTorState()213#endif214}215216// MARK: - Setup217218 func createIdentity(username: String){219 setupChatManager(username: username)220}221222 private func setupChatManager(username: String?){223 do {224 let identity = try IdentityManager(keychain: keychain)225 if let username {226 try identity.setUsername(username)227}228// Bind the Tor hidden service address to the identity key so .onion == identity.229#if !targetEnvironment(macCatalyst)230 if let seed = try? identity.signingPrivateKeyData(){231 TorManager.shared.configureHiddenService(ed25519PrivateKeySeed: seed)232}233#endif234 let preKeys = try PreKeyManager(identity: identity, keychain: keychain)235 let mesh = MeshManager(localIdentityHash: identity.publicIdentity.peerID)236 let store = try MessageStore(keychain: keychain)237 let attachStore = try AttachmentStore(keychain: keychain)238239 let manager = ChatManager(240 identity: identity,241 preKeys: preKeys,242 mesh: mesh,243 messageStore: store,244 attachmentStore: attachStore,245 keychain: keychain246)247 manager.delegate = self248 manager.deviceLabel = UIDevice.current.name249 manager.myTCPAddress = myTCPAddress.isEmpty ? nil : myTCPAddress250 manager.registerKnownGroups(groups)251// Re-register pending contact requests so ChatManager knows to gate their messages252 for peer in pendingContactRequests { manager.registerPendingPeer(peer)}253 manager.start()254// Rebuild linked device peer list from known peers + ChatManager's linked set255 linkedDevices = peers.filter { manager.linkedDevices.contains($0.id)}256 if tcpEnabled { startTCPTransport(on: manager)}257258 self.chatManager = manager259 self.isSetupComplete = true260 self.myAvatarData = identity.loadAvatar()261 requestNotificationPermission()262 startScreenSecurityMonitor()263264 loadExistingMessages(from: store)265} catch {266 self.errorMessage = error.localizedDescription267}268}269270// MARK: - Screen security271272/// Polls RPScreenRecorder every 1.5 s to detect active screen recording.273/// On macOS Catalyst, screen recording is normal OS behaviour — skip the warning.274 private func startScreenSecurityMonitor(){275#if !targetEnvironment(macCatalyst)276 Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true){[weak self] _ in277 let recording = RPScreenRecorder.shared().isRecording278 Task {@MainActor [weak self] in279 self?.isScreenBeingRecorded = recording280}281}282#endif283}284285/// Call when the OS reports a screenshot was taken.286 func handleScreenshot(){287 guard isSetupComplete else { return }288 didTakeScreenshot = true289 Task {@MainActor in290 try? await Task.sleep(for:.seconds(4))291 didTakeScreenshot = false292}293}294295// MARK: - Background operation296297/// Called by the BGAppRefreshTask when iOS re-wakes the app after suspension.298/// Restarts the mesh briefly so any store-and-forward deliveries can complete299/// and outbound pending queues can be drained if peers are in range.300@MainActor301 func handleBackgroundMeshRefresh() async {302// If setup is not complete (first launch) there's nothing to do.303 guard isSetupComplete, let manager = chatManager else { return }304305// Restart the mesh if it isn't already running306 manager.start()307308// Give MPC ~10 seconds to connect to any nearby peer and drain queues,309// then stop to avoid draining the battery further.310 try? await Task.sleep(for:.seconds(10))311 manager.stop()312}313314// MARK: - Note to Self315316/// Synthetic peerID for the local encrypted notepad. Never transmitted over the network.317 static let noteToSelfID ="__note_to_self__"318319 var noteToSelfMessages:[StoredMessage]{320 messages[AppState.noteToSelfID]??[]321}322323/// Save a note locally. Goes directly to MessageStore, never to the network.324 func sendNoteToSelf(_ text: String){325 let msg = StoredMessage(326 peerID: AppState.noteToSelfID,327 direction:.sent,328 body: text,329 status:.delivered330)331 try? chatManager?.messageStore.append(message: msg)332 appendMessage(msg)333}334335 func deleteNoteToSelf(_ message: StoredMessage){336 try? chatManager?.messageStore.deleteMessage(id: message.id, peerID: AppState.noteToSelfID)337 messages[AppState.noteToSelfID]?.removeAll { $0.id == message.id }338}339340// MARK: - Message sending341342 func sendMessage(_ text: String, toPeerID peerID: String, expiresAt: Date?= nil, replyToID: String?= nil){343 chatManager?.sendMessage(text, toPeerID: peerID, expiresAt: expiresAt, replyToID: replyToID)344}345346 func sendDeadDrop(text: String, toPeerID peerID: String){347 try? chatManager?.sendDeadDrop(toPeerID: peerID, text: text)348}349350 func sendTypingIndicator(toPeerID peerID: String, isTyping: Bool){351 chatManager?.sendTypingIndicator(toPeerID: peerID, isTyping: isTyping)352}353354 func sendReaction(emoji: String?, messageID: String, peerID: String){355 chatManager?.sendReaction(emoji: emoji, toMessageID: messageID, toPeerID: peerID)356}357358// MARK: - Group messaging359360 func createGroup(name: String, memberPeerIDs:[String]){361 guard let group = chatManager?.createGroup(name: name, memberPeerIDs: memberPeerIDs) else { return }362 chatManager?.broadcastChannelAnnouncement(for: group)363}364365/// Whether the given peer has an MLS KeyPackage available.366/// Required before creating an MLS group — all members must have exchanged keys at least once.367 func peerHasMLSKeyPackage(_ peerID: String)-> Bool {368 chatManager?.peerBundles[peerID]?.mlsKeyPackage != nil369}370371/// Create a new MLS (RFC 9420) group. No channel announcement — MLS groups are372/// closed-membership and onboard members via Welcome messages.373 func createMLSGroup(name: String, memberPeerIDs:[String]){374 chatManager?.createMLSGroup(name: name, memberPeerIDs: memberPeerIDs)375}376377/// Attempt to migrate an SKv2 group to MLS. Only the group creator can call this.378 func migrateGroupToMLS(_ group: GroupInfo, completion:@escaping (GroupMigrationResult)-> Void){379 guard let cm = chatManager else { return }380 Task {381 let result =(try? migrateGroupToMLS(group, in: cm))??.requiresAllOnline([])382 await MainActor.run { completion(result)}383}384}385386 func sendGroupMessage(_ text: String, group: GroupInfo, expiresAt: Date?= nil, replyToID: String?= nil){387 if group.cryptoVersion ==.mls {388 chatManager?.sendMLSGroupMessage(text, group: group, expiresAt: expiresAt, replyToID: replyToID)389} else {390 chatManager?.sendGroupMessage(text, groupID: group.id, members: group.memberIDs,391 expiresAt: expiresAt, replyToID: replyToID)392}393}394395 func sendGroupReaction(emoji: String?, messageID: String, group: GroupInfo){396 if group.cryptoVersion ==.mls {397 chatManager?.sendMLSGroupReaction(emoji: emoji, toMessageID: messageID, group: group)398} else {399 chatManager?.sendGroupReaction(emoji: emoji, toMessageID: messageID,400 groupID: group.id, members: group.memberIDs)401}402}403404 func sendGroupImage(_ image: UIImage, group: GroupInfo, expiresAt: Date?= nil, replyToID: String?= nil){405 guard let data = compressedJPEG(image) else { return }406 if group.cryptoVersion ==.mls {407 chatManager?.sendMLSGroupAttachment(data, mimeType:"image/jpeg",408 group: group, expiresAt: expiresAt, replyToID: replyToID)409} else {410 chatManager?.sendGroupAttachment(data, mimeType:"image/jpeg",411 groupID: group.id, members: group.memberIDs,412 expiresAt: expiresAt, replyToID: replyToID)413}414}415416 func sendGroupAudio(_ data: Data, duration: Double, group: GroupInfo, expiresAt: Date?= nil, replyToID: String?= nil){417 if group.cryptoVersion ==.mls {418 chatManager?.sendMLSGroupAttachment(data, mimeType:"audio/m4a",419 audioDuration: duration,420 group: group, expiresAt: expiresAt, replyToID: replyToID)421} else {422 chatManager?.sendGroupAttachment(data, mimeType:"audio/m4a",423 audioDuration: duration,424 groupID: group.id, members: group.memberIDs,425 expiresAt: expiresAt, replyToID: replyToID)426}427}428429// MARK: - Username change430431 var myUsername: String?{ chatManager?.identity.publicIdentity.username }432433 func changeUsername(_ newUsername: String){434 let trimmed = newUsername.trimmingCharacters(in:.whitespacesAndNewlines)435 guard !trimmed.isEmpty, trimmed.count <= 64 else { return }436 guard let identity = chatManager?.identity else { return }437 try? identity.setUsername(trimmed)438 chatManager?.broadcastHello()439}440441 func leaveGroup(_ group: GroupInfo){442 chatManager?.leaveGroup(group)443 groups.removeAll { $0.id == group.id }444 saveGroups()445 messages.removeValue(forKey: group.conversationID)446 unreadCounts.removeValue(forKey: group.conversationID)447}448449 func deleteGroup(_ group: GroupInfo){450 chatManager?.deleteGroup(group)451 groups.removeAll { $0.id == group.id }452 saveGroups()453 messages.removeValue(forKey: group.conversationID)454 unreadCounts.removeValue(forKey: group.conversationID)455}456457 func deleteGroupLocally(_ group: GroupInfo){458 chatManager?.deleteGroupLocally(group)459 groups.removeAll { $0.id == group.id }460 saveGroups()461 messages.removeValue(forKey: group.conversationID)462 unreadCounts.removeValue(forKey: group.conversationID)463}464465 func handoffGroupCoordinator(_ group: GroupInfo, to newCoordinatorID: String){466 chatManager?.handoffCoordinator(group: group, newCoordinatorID: newCoordinatorID)467}468469// MARK: - Channel join requests470471/// Send a join request to the creator of a channel we discovered nearby.472 func sendChannelJoinRequest(for channel: ChannelAnnouncement){473 chatManager?.sendChannelJoinRequest(for: channel)474}475476/// Approve a pending join request — invites the requester to the group.477 func approveGroupJoinRequest(_ request: ChannelJoinRequestMessage){478 guard let group = groups.first(where:{ $0.id == request.groupID }) else { return }479 chatManager?.addMemberToGroup(group, newMemberPeerID: request.requesterPeerID)480 dismissGroupJoinRequest(request)481// Update local group membership so the UI reflects the new member immediately482 if let idx = groups.firstIndex(where:{ $0.id == group.id }){483 var updated = group484 let newMembers = group.memberIDs +[request.requesterPeerID]485 updated = GroupInfo(486 id: group.id,487 name: group.name,488 memberIDs: newMembers,489 creatorID: group.creatorID,490 cryptoVersion: group.cryptoVersion,491 currentCoordinatorID: group.currentCoordinatorID492)493 groups[idx]= updated494 saveGroups()495}496}497498/// Dismiss (decline) a pending join request without inviting the requester.499 func dismissGroupJoinRequest(_ request: ChannelJoinRequestMessage){500 pendingGroupJoinRequests.removeAll {501 $0.groupID == request.groupID && $0.requesterPeerID == request.requesterPeerID502}503 saveGroupJoinRequests()504}505506 var myPeerID: String?{ chatManager?.identity.publicIdentity.peerID }507508 func isCoordinator(of group: GroupInfo)-> Bool {509 guard let id = myPeerID else { return false }510 return group.currentCoordinatorID == id511}512513 func groupMessages(for group: GroupInfo)->[StoredMessage]{514 messages[group.conversationID]??[]515}516517 func markGroupAsRead(group: GroupInfo){518 let convID = group.conversationID519 unreadCounts[convID]= 0520 let ids = messages[convID]?.map(\.id)??[]521 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)522 UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)523 UIApplication.shared.applicationIconBadgeNumber = totalUnreadCount524525// Send true read receipts for received messages we haven't acknowledged yet.526// Group by senderID so each receipt goes unicast to the original sender.527 let myID = myPeerID ??""528 let unread = messages[convID]?.filter {529 $0.direction ==.received &&530!($0.readBy?.contains(myID)?? false)531}??[]532 let bySender = Dictionary(grouping: unread){ $0.senderID ??""}533 for (senderID, msgs) in bySender where !senderID.isEmpty {534 chatManager?.sendGroupReadReceipts(535 messageIDs: msgs.map(\.id),536 senderPeerID: senderID,537 groupID: group.id538)539}540}541542 func displayName(forPeerID peerID: String)-> String {543 if let peer = peers.first(where:{ $0.id == peerID }){544 return displayName(for: peer)545}546 return String(peerID.prefix(8))+"…"547}548549 private let groupsDefaultsKey ="com.sophax.groups"550551 private func loadGroups(){552 guard let data = UserDefaults.standard.data(forKey: groupsDefaultsKey),553 let saved = try? JSONDecoder().decode([GroupInfo].self, from: data) else { return }554 groups = saved555// Group messages are loaded later in loadExistingMessages(from:) once the store is ready556}557558 private func saveGroups(){559 if let data = try? JSONEncoder().encode(groups){560 UserDefaults.standard.set(data, forKey: groupsDefaultsKey)561}562}563564 func sendImage(_ image: UIImage, toPeerID peerID: String, expiresAt: Date?= nil){565 guard let data = compressedJPEG(image) else { return }566 chatManager?.sendAttachment(data, mimeType:"image/jpeg", toPeerID: peerID, expiresAt: expiresAt)567}568569 private func compressedJPEG(_ image: UIImage)-> Data?{570 var quality: CGFloat = 0.75571 var data: Data?= image.jpegData(compressionQuality: quality)572 while let d = data, d.count > 400_000, quality > 0.1 {573 quality -= 0.1574 data = image.jpegData(compressionQuality: quality)575}576 return data577}578579/// Send a recorded M4A audio clip as an encrypted attachment.580 func sendAudio(_ data: Data, duration: Double, toPeerID peerID: String, expiresAt: Date?= nil){581 chatManager?.sendAttachment(582 data, mimeType:"audio/m4a", audioDuration: duration,583 toPeerID: peerID, expiresAt: expiresAt584)585}586587/// Send an edit for a previously sent text message.588 func sendEditMessage(messageID: String, newBody: String, toPeerID peerID: String){589 chatManager?.sendEditMessage(messageID: messageID, newBody: newBody, toPeerID: peerID)590// Update local in-memory state immediately for instant UI feedback591 if let idx = messages[peerID]?.firstIndex(where:{ $0.id == messageID }){592 messages[peerID]?[idx].body = newBody593 messages[peerID]?[idx].isEdited = true594 messages[peerID]?[idx].editedAt = Date()595}596}597598/// Rotate the local user's sender key for a group (break-in recovery).599 func rotateSenderKey(forGroup group: GroupInfo){600 chatManager?.rotateSenderKey(forGroup: group)601}602603/// Send a video file as an encrypted attachment.604 func sendVideo(_ url: URL, toPeerID peerID: String, expiresAt: Date?= nil){605 Task {606 guard let data = await compressVideo(url) else { return }607 await MainActor.run {608 chatManager?.sendAttachment(data, mimeType:"video/mp4", toPeerID: peerID, expiresAt: expiresAt)609}610}611}612613/// Send a video file as an encrypted group attachment.614 func sendGroupVideo(_ url: URL, group: GroupInfo, expiresAt: Date?= nil){615 Task {616 guard let data = await compressVideo(url) else { return }617 await MainActor.run {618 chatManager?.sendGroupAttachment(data, mimeType:"video/mp4",619 groupID: group.id, members: group.memberIDs,620 expiresAt: expiresAt)621}622}623}624625/// Send an arbitrary file (PDF, document, etc.) as an encrypted attachment.626 func sendFile(_ url: URL, toPeerID peerID: String, expiresAt: Date?= nil){627 guard url.startAccessingSecurityScopedResource() else { return }628 defer { url.stopAccessingSecurityScopedResource()}629 guard let data = try? Data(contentsOf: url),630 data.count <= ChatManager.maxFileAttachmentBytes else { return }631 let mime = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ??"application/octet-stream"632 chatManager?.sendAttachment(data, mimeType: mime, filename: url.lastPathComponent,633 toPeerID: peerID, expiresAt: expiresAt)634}635636/// Send an arbitrary file to a group.637 func sendGroupFile(_ url: URL, group: GroupInfo, expiresAt: Date?= nil){638 guard url.startAccessingSecurityScopedResource() else { return }639 defer { url.stopAccessingSecurityScopedResource()}640 guard let data = try? Data(contentsOf: url),641 data.count <= ChatManager.maxFileAttachmentBytes else { return }642 let mime = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ??"application/octet-stream"643 chatManager?.sendGroupAttachment(data, mimeType: mime, filename: url.lastPathComponent,644 groupID: group.id, members: group.memberIDs,645 expiresAt: expiresAt)646}647648 private func compressVideo(_ url: URL) async -> Data?{649 await withCheckedContinuation { continuation in650 let asset = AVURLAsset(url: url)651 guard let session = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetMediumQuality) else {652 continuation.resume(returning: nil)653 return654}655 let tmp = FileManager.default.temporaryDirectory656.appendingPathComponent(UUID().uuidString +".mp4")657 session.outputURL = tmp658 session.outputFileType =.mp4659 session.shouldOptimizeForNetworkUse = true660 session.exportAsynchronously {661 defer { try? FileManager.default.removeItem(at: tmp)}662 guard session.status ==.completed,663 let data = try? Data(contentsOf: tmp),664 data.count <= 30 * 1024 * 1024 else {665 continuation.resume(returning: nil)666 return667}668 continuation.resume(returning: data)669}670}671}672673// MARK: - Avatar674675/// Set the local user's avatar from a UIImage.676/// Resizes to 64×64 JPEG and broadcasts an updated Hello to all peers.677 func setMyAvatar(_ image: UIImage){678 guard let data = resizedAvatarJPEG(image) else { return }679 try? chatManager?.identity.setAvatar(data)680 myAvatarData = data681 chatManager?.broadcastHello()682}683684 func removeMyAvatar(){685 chatManager?.identity.deleteAvatar()686 myAvatarData = nil687 chatManager?.broadcastHello()688}689690 private func resizedAvatarJPEG(_ image: UIImage)-> Data?{691 let targetSize = CGSize(width: 64, height: 64)692 let renderer = UIGraphicsImageRenderer(size: targetSize)693 let resized = renderer.image { _ in694 image.draw(in: CGRect(origin:.zero, size: targetSize))695}696 return resized.jpegData(compressionQuality: 0.8)697}698699// MARK: - Invite link700701/// Generate a `sophaxchat://meet?...` URL encoding this device's public identity.702/// The recipient can tap the link to pre-populate contact info without being in Bluetooth range.703 func generateInviteLink()-> URL?{704 guard let id = chatManager?.identity.publicIdentity else { return nil }705 var components = URLComponents()706 components.scheme ="sophaxchat"707 components.host ="meet"708 components.queryItems =[709 URLQueryItem(name:"pid", value: id.peerID),710 URLQueryItem(name:"name", value: Data(id.username.utf8).base64EncodedString()),711 URLQueryItem(name:"sk", value: id.signingKeyPublic.base64EncodedString()),712 URLQueryItem(name:"dk", value: id.dhKeyPublic.base64EncodedString())713]714 return components.url715}716717/// Load attachment data from the local encrypted store (used by bubble views).718 func loadAttachment(id: String)-> Data?{719 try? chatManager?.attachmentStore.load(id: id)720}721722// MARK: - Conversation management723724 func deleteConversation(peerID: String){725 try? chatManager?.messageStore.deleteConversation(peerID: peerID)726 messages.removeValue(forKey: peerID)727 unreadCounts.removeValue(forKey: peerID)728}729730 func deleteMessage(_ message: StoredMessage){731 try? chatManager?.messageStore.deleteMessage(id: message.id, peerID: message.peerID)732 messages[message.peerID]?.removeAll { $0.id == message.id }733}734735// MARK: - Contact request accept / reject736737 func acceptContact(_ peer: KnownPeer){738 chatManager?.acceptContactRequest(peerID: peer.id)739 pendingContactRequests.removeAll { $0.id == peer.id }740 savePendingRequests()741// peer will appear in peers[] via the didDiscoverPeer callback742}743744 func rejectContact(_ peer: KnownPeer){745 chatManager?.rejectContactRequest(peerID: peer.id)746 pendingContactRequests.removeAll { $0.id == peer.id }747 savePendingRequests()748}749750// MARK: - Multi-device linking751752/// Generate QR payload data for showing on the "link device" screen.753 func generateDeviceLinkQR()-> Data?{754 let label = UIDevice.current.name755 return try? chatManager?.generateDeviceLinkPayload(label: label)756}757758/// Called after scanning a device-link QR from another device.759 func acceptDeviceLink(_ data: Data){760 do {761 try chatManager?.acceptDeviceLink(data)762} catch {763 errorMessage = error.localizedDescription764}765}766767 func unlinkDevice(_ peer: KnownPeer){768 chatManager?.unlinkDevice(peerID: peer.id)769 linkedDevices.removeAll { $0.id == peer.id }770}771772// MARK: - Blocking773774 func blockPeer(peerID: String){775 if let peer = peers.first(where:{ $0.id == peerID }){776 blockedPeerNames[peerID]= peer.username777}778 blockedPeers.insert(peerID)779 saveBlockedPeers()780// Remove from active peers list — they'll reappear if unblocked and online781 peers.removeAll { $0.id == peerID }782 let notifIDs = messages[peerID]?.map(\.id)??[]783 messages.removeValue(forKey: peerID)784 unreadCounts.removeValue(forKey: peerID)785 UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: notifIDs)786 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: notifIDs)787 savePeers()788}789790 func unblockPeer(peerID: String){791 blockedPeers.remove(peerID)792 blockedPeerNames.removeValue(forKey: peerID)793 saveBlockedPeers()794}795796 func isBlocked(_ peerID: String)-> Bool {797 blockedPeers.contains(peerID)798}799800// MARK: - Unread counts801802 func markAsRead(peerID: String){803 unreadCounts[peerID]= 0804// Clear any delivered notifications for this conversation805 let ids = messages[peerID]?.map(\.id)??[]806 UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)807 UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids)808// Update app badge to reflect remaining unread count809 UIApplication.shared.applicationIconBadgeNumber = totalUnreadCount810// Send read receipts for received messages still showing as .delivered811 let unread = messages[peerID]?.filter { $0.direction ==.received && $0.status ==.delivered }??[]812 if !unread.isEmpty {813 chatManager?.sendReadReceipts(messageIDs: unread.map(\.id), toPeerID: peerID)814}815}816817// MARK: - Forward message818819/// Re-sends a stored message (text or attachment) to a different peer.820 func forwardMessage(_ message: StoredMessage, toPeerID peerID: String, expiresAt: Date?= nil){821 if let id = message.attachmentID,822 let mime = message.attachmentMimeType,823 let data = loadAttachment(id: id){824 chatManager?.sendAttachment(data, mimeType: mime, caption: message.body,825 audioDuration: message.audioDuration,826 toPeerID: peerID, expiresAt: expiresAt)827} else if !message.body.isEmpty {828 chatManager?.sendMessage(message.body, toPeerID: peerID, expiresAt: expiresAt)829}830}831832// MARK: - Notifications833834 private func requestNotificationPermission(){835// Register category with a placeholder so the message body is hidden836// when the user has "Show Previews: When Unlocked" or "Never" set in system Settings.837 let category = UNNotificationCategory(838 identifier:"SOPHAX_MSG",839 actions:[],840 intentIdentifiers:[],841 hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment:""),842 options:[]843)844 UNUserNotificationCenter.current().setNotificationCategories([category])845 UNUserNotificationCenter.current().requestAuthorization(options:[.alert,.sound,.badge]){ _, _ in }846}847848 private var totalUnreadCount: Int { unreadCounts.values.reduce(0,+)}849850 private func scheduleNotification(for message: StoredMessage, fromPeer peerID: String){851 let (title, body):(String, String)= notifShowSender852?(peers.first(where:{ $0.id == peerID }).map { displayName(for: $0)}??"SophaxChat",853 message.body.isEmpty ?"Attachment": message.body)854:("SophaxChat","New message")855 postNotification(id: message.id, title: title, body: body, threadKey: peerID)856}857858 private func scheduleGroupNotification(for message: StoredMessage, groupID: String){859 guard let group = groups.first(where:{ $0.id == groupID }) else { return }860 let (title, body):(String, String)= notifShowSender861?(group.name, message.body.isEmpty ?"Attachment": message.body)862:("SophaxChat","New group message")863 postNotification(id: message.id, title: title, body: body, threadKey: groupID)864}865866/// Common notification posting logic. `threadKey` is hashed before use so raw867/// peer/group IDs are not exposed in Notification Centre grouping on the lock screen.868 private func postNotification(id: String, title: String, body: String, threadKey: String){869// Strip ASCII control characters and truncate to prevent lock-screen injection870 let safeBody = String(body.filter { c in871!c.isASCII ||(c.asciiValue.map { $0 >= 0x20 && $0 != 0x7F }?? true)872}.prefix(200))873 let content = UNMutableNotificationContent()874 content.title = title875 content.body = safeBody876 content.sound =.default877 content.badge =(totalUnreadCount + 1) as NSNumber878 content.threadIdentifier = Data(SHA256.hash(data: Data(threadKey.utf8))).prefix(8).hexString879 content.categoryIdentifier ="SOPHAX_MSG"880 UNUserNotificationCenter.current().add(881 UNNotificationRequest(identifier: id, content: content, trigger: nil)882)883}884885// MARK: - Contact aliases886887 func displayName(for peer: KnownPeer)-> String {888 peerAliases[peer.id]?? peer.username889}890891 func setAlias(_ alias: String?, for peerID: String){892 let trimmed = alias?.trimmingCharacters(in:.whitespacesAndNewlines)893 if let name = trimmed,!name.isEmpty {894 peerAliases[peerID]= name895} else {896 peerAliases.removeValue(forKey: peerID)897}898 saveAliases()899}900901// MARK: - Safety Number pinning902903/// Mark a peer's safety number as verified (called after successful QR or manual comparison).904 func markPeerVerified(_ peerID: String, safetyNumber: String){905 verifiedPeers[peerID]= safetyNumber906 saveVerifiedPeers()907}908909/// Returns true if this peer has been verified and their safety number hasn't changed.910 func isVerified(_ peerID: String, currentSafetyNumber: String)-> Bool {911 verifiedPeers[peerID]== currentSafetyNumber912}913914/// Returns true if a peer was previously verified but their safety number has since changed.915 func hasKeyChanged(for peerID: String, currentSafetyNumber: String)-> Bool {916 guard let pinned = verifiedPeers[peerID] else { return false }917 return pinned != currentSafetyNumber918}919920/// Returns the full key-change history for a peer from KeyTransparencyLog.921 func keyHistory(for peerID: String)->[KeyLogEntry]{922 chatManager?.keyLog.history(for: peerID)??[]923}924925// MARK: - TCP internet mode926927/// Initiate an outbound TCP connection to a peer at "host:port" or "host.onion:port".928/// Returns an error message string on failure, nil on success.929@discardableResult930 func connectViaTCP(address: String)-> String?{931 guard Self.isValidTCPAddress(address) else {932 return "Invalid address. Use host:port format, e.g. 192.168.1.1:25519 or xyz.onion:25519"933}934 do {935 try chatManager?.connectViaTCP(address: address)936 return nil937} catch {938 return error.localizedDescription939}940}941942/// Validates "host:port" format. Splits on the last colon to support .onion and IPv4.943/// Rejects private/loopback addresses to prevent SSRF via peer-advertised TCP addresses.944 static func isValidTCPAddress(_ address: String)-> Bool {945 guard let colonIdx = address.lastIndex(of:":") else { return false }946 let host = String(address[..<colonIdx])947 let portStr = String(address[address.index(after: colonIdx)...])948 guard !host.isEmpty, let port = UInt16(portStr), port > 0 else { return false }949// Allow .onion addresses (Tor hidden services) — these are always safe to connect to950 if host.hasSuffix(".onion"){ return true }951// Block loopback, link-local, and private RFC-1918/RFC-4193 ranges to prevent SSRF952 let privateRanges =["127.","10.","169.254.","0.0.0.0",953"::1","0:0:0:0:0:0:0:1","fc","fd","fe80"]954 let lc = host.lowercased()955 if privateRanges.contains(where:{ lc.hasPrefix($0)}){ return false }956 if lc =="0"|| lc =="localhost"{ return false }957 if lc.hasPrefix("172."){958 let parts = lc.split(separator:".")959 if parts.count >= 2, let second = Int(parts[1]),(16...31).contains(second){ return false }960}961 if lc.hasPrefix("192.168."){ return false }962 return true963}964965/// The Tor v3 .onion hostname derived from this device's identity key.966/// Nil only before identity is created (first launch before onboarding completes).967 var derivedOnionHostname: String?{ chatManager?.identity.onionHostname }968969/// Human-readable fingerprint derived from the first 6 bytes of peerID.970/// Format: SXPH-XXXX-XXXX-XXXX (3 × 4 uppercase hex chars).971/// Purely for verbal recognition — cannot be used to add a contact standalone.972 var friendlyCode: String {973 guard let pid = chatManager?.identity.publicIdentity.peerID,974 pid.count >= 12 else { return "——"}975 let h = pid.uppercased()976 return "SXPH-\(h.prefix(4))-\(h.dropFirst(4).prefix(4))-\(h.dropFirst(8).prefix(4))"977}978979/// Number of one-time prekeys remaining. Below 5 means reduced X3DH entropy.980 var opkCount: Int { chatManager?.preKeys.opkCount ?? 0 }981982/// The full advertised Tor address (hostname:port) for display and sharing.983 var derivedOnionAddress: String?{984 guard let host = derivedOnionHostname else { return nil }985 let port = tcpPort.isEmpty ?"25519": tcpPort986 return "\(host):\(port)"987}988989/// Called on `didBecomeActive` — reconnect to all known peers that have a TCP address.990/// No-op if TCP is disabled or no peers have an address.991/// Decentralized: connects directly peer-to-peer, no server involved.992 func reconnectTCPPeers(){993 guard tcpEnabled, let tcp = chatManager?.tcpTransport else { return }994 for peer in peers {995 guard let addr = peer.tcpAddress,996!tcp.isConnected(peerID: peer.id),997 Self.isValidTCPAddress(addr) else { continue }998 try? chatManager?.connectViaTCP(address: addr)999}1000}10011002// MARK: - App lock10031004 var appLockEnabled: Bool {1005 get { keychain.loadAppLockEnabled()?? false }1006 set { try? keychain.saveAppLockEnabled(newValue)}1007}10081009/// Whether a custom numeric real-lock PIN has been set (enables the PIN entry path in AppLockView).1010 var hasRealLockPIN: Bool { keychain.hasRealLockPIN()}10111012/// Whether a duress PIN has been set.1013 var hasDuressPIN: Bool { keychain.hasDuressPIN()}10141015/// Set or replace the real lock PIN. Throws if Keychain write fails.1016 func setRealLockPIN(_ pin: String) throws { try keychain.saveRealLockPIN(pin)}10171018/// Clear the real lock PIN (and implicitly the duress PIN — duress requires a real PIN).1019 func clearRealLockPIN(){1020 keychain.clearRealLockPIN()1021 keychain.clearDuressPIN()1022}10231024/// Set or replace the duress PIN.1025 func setDuressPIN(_ pin: String) throws { try keychain.saveDuressPIN(pin)}10261027/// Check if a string matches the real lock PIN (used by DuressPINSetupView to enforce they differ).1028 func verifyRealLockPIN(_ pin: String)-> Bool { keychain.verifyRealLockPIN(pin)}10291030/// Clear only the duress PIN.1031 func clearDuressPIN(){ keychain.clearDuressPIN()}10321033// MARK: - Account wipe10341035/// Permanently delete all identity keys, messages, attachments, and settings.1036/// The app returns to OnboardingView because `isSetupComplete` is reset to false.1037 func wipeAccount(){1038 try? chatManager?.wipeAllData()1039 chatManager = nil10401041// Delete Tor data directory — contains the hidden service private key.1042// Without this, the old .onion address could be reconstructed after a wipe.1043 if let appSupport = FileManager.default.urls(for:.applicationSupportDirectory, in:.userDomainMask).first {1044 try? FileManager.default.removeItem(at: appSupport.appendingPathComponent("tor_data"))1045}10461047 let ud = UserDefaults.standard1048 for key in ud.dictionaryRepresentation().keys where key.hasPrefix("com.sophax.")|| key.hasPrefix("sophax."){1049 ud.removeObject(forKey: key)1050}10511052 clearInMemoryState()1053 blockedPeers =[]1054 blockedPeerNames =[:]1055 peerAliases =[:]1056 myAvatarData = nil10571058 UNUserNotificationCenter.current().removeAllPendingNotificationRequests()1059 UNUserNotificationCenter.current().removeAllDeliveredNotifications()1060 UIApplication.shared.applicationIconBadgeNumber = 010611062 isSetupComplete = false1063}10641065 func lockApp(){1066 guard appLockEnabled else { return }1067 chatManager?.stop()1068 chatManager = nil // release all session state and key material from RAM10691070// Clear sensitive published state so plaintext is not readable from RAM while locked.1071// Data is reloaded from encrypted storage after successful authentication.1072 clearInMemoryState()10731074 isAppLocked = true1075 pendingDeepLink = nil1076 UNUserNotificationCenter.current().removeAllDeliveredNotifications()1077}10781079/// Zero out all in-memory conversation/peer state. Called on both lock and wipe.1080 private func clearInMemoryState(){1081 peers =[]1082 pendingContactRequests =[]1083 pendingGroupJoinRequests =[]1084 linkedDevices =[]1085 messages =[:]1086 groups =[]1087 peerAvatars =[:]1088 onlinePeers =[]1089 unreadCounts =[:]1090 typingPeers =[]1091 keyChangeAlerts =[]1092}10931094 func tryUnlock(){1095// Rate-limit: check lockout before allowing another attempt1096 if let until = unlockLockedUntil, Date()< until {1097 let remaining = Int(until.timeIntervalSinceNow.rounded(.up))1098 let mins = remaining / 60, secs = remaining % 601099 unlockError = mins > 01100?"Too many failed attempts. Try again in \(mins)m \(secs)s."1101:"Too many failed attempts. Try again in \(secs)s."1102 return1103}11041105 guard !isUnlocking else { return }// prevent multiple simultaneous prompts11061107 let ctx = LAContext()1108 var error: NSError?1109 guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error:&error) else {1110// Device has no passcode or biometrics configured.1111// Do NOT silently unlock — keep the app locked and surface an error.1112 unlockError ="Device authentication unavailable. Set a passcode in iOS Settings."1113 return1114}1115 unlockError = nil1116 isUnlocking = true1117 ctx.evaluatePolicy(.deviceOwnerAuthentication,1118 localizedReason:"Unlock SophaxChat"){ success, _ in1119 DispatchQueue.main.async {[weak self] in1120 guard let self else { return }1121 self.isUnlocking = false1122 if success {1123// Reset rate-limit on successful unlock1124 self.failedUnlockAttempts = 01125 self.unlockLockedUntil = nil1126 self.keychain.saveUnlockAttempts(0, lockedUntil: nil)1127 self.unlockError = nil1128 self.isAppLocked = false1129 self.setupChatManager(username: nil)1130} else {1131// Increment failure counter; apply exponential lockout after 3 failures1132 self.failedUnlockAttempts += 11133 let delays:[TimeInterval]=[0, 0, 0, 300, 900, 3600, 3600]1134 let delay = delays[min(self.failedUnlockAttempts, delays.count - 1)]1135 self.unlockLockedUntil = delay > 0 ? Date().addingTimeInterval(delay): nil1136 self.keychain.saveUnlockAttempts(1137 self.failedUnlockAttempts, lockedUntil: self.unlockLockedUntil)1138}1139}1140}1141}11421143// MARK: - Duress mode11441145/// Activate duress mode: clears all in-memory state, stops the chat engine,1146/// and keeps the app open but appearing empty.1147/// Incoming messages are dropped while duress is active (they remain on disk).1148 func activateDuress(){1149 chatManager?.stop()1150 chatManager = nil1151 clearInMemoryState()1152 isDuressActive = true1153 isAppLocked = false // remove the lock overlay so the empty state is visible1154}11551156/// Unlock using a custom numeric PIN (alternative to biometrics).1157/// Checks the duress PIN first; if it matches, activates duress mode.1158/// Then checks the real lock PIN; if it matches, performs a normal unlock.1159/// - Returns: `true` if the PIN was accepted (either duress or real).1160@discardableResult1161 func tryUnlockWithPIN(_ pin: String)-> Bool {1162// Duress PIN takes priority — silent activation, no error shown1163 if keychain.verifyDuressPIN(pin){1164 activateDuress()1165 return true1166}1167// Real PIN — normal unlock1168 if keychain.verifyRealLockPIN(pin){1169 failedUnlockAttempts = 01170 unlockLockedUntil = nil1171 keychain.saveUnlockAttempts(0, lockedUntil: nil)1172 unlockError = nil1173 isAppLocked = false1174 setupChatManager(username: nil)1175 return true1176}1177// Wrong PIN — increment failure counter1178 failedUnlockAttempts += 11179 let delays:[TimeInterval]=[0, 0, 0, 300, 900, 3600, 3600]1180 let delay = delays[min(failedUnlockAttempts, delays.count - 1)]1181 unlockLockedUntil = delay > 0 ? Date().addingTimeInterval(delay): nil1182 keychain.saveUnlockAttempts(failedUnlockAttempts, lockedUntil: unlockLockedUntil)1183 unlockError ="Wrong PIN."1184 return false1185}11861187// MARK: - Private helpers11881189 private func loadExistingMessages(from store: MessageStore){1190 let peerIDs = store.allConversationPeerIDs()1191 for peerID in peerIDs {1192 guard !blockedPeers.contains(peerID) else { continue }1193 if let msgs = try? store.messages(forPeer: peerID){1194 messages[peerID]= msgs1195}1196}1197// Also load group conversations1198 for group in groups {1199 if let msgs = try? store.messages(forPeer: group.conversationID){1200 messages[group.conversationID]= msgs1201}1202}1203}12041205 private func appendMessage(_ message: StoredMessage){1206 var existing = messages[message.peerID]??[]1207 guard !existing.contains(where:{ $0.id == message.id }) else { return }1208 existing.append(message)1209// Sort by receivedAt (local wall-clock) when available; fall back to sender timestamp.1210// This prevents a clock-skewed or malicious sender from reordering our conversation view.1211 existing.sort {1212($0.receivedAt ?? $0.timestamp)<($1.receivedAt ?? $1.timestamp)1213}1214 messages[message.peerID]= existing1215}12161217// MARK: - TCP persistence + lifecycle12181219 private func loadTCPSettings(){1220 let ud = UserDefaults.standard1221 tcpEnabled = ud.bool(forKey: tcpEnabledKey)1222 tcpPort = ud.string(forKey: tcpPortKey)??"25519"1223 tcpSocksProxy = ud.string(forKey: tcpSocksProxyKey)??""1224 myTCPAddress = ud.string(forKey: tcpAddressKey)??""1225 notifShowSender = ud.bool(forKey: notifShowSenderKey)1226}12271228 private func makeTCPConfig()-> TCPTransport.Config {1229 let port = UInt16(tcpPort)?? 255191230 let proxy = tcpSocksProxy.trimmingCharacters(in:.whitespaces)1231 return TCPTransport.Config(port: port, socksProxy: proxy.isEmpty ? nil : proxy)1232}12331234 private func startTCPTransport(on manager: ChatManager){1235 let transport = TCPTransport(config: makeTCPConfig())1236 manager.startTCP(transport)1237 manager.startDHT()1238}12391240 private func applyTCPConfig(){1241 guard let manager = chatManager else { return }1242// Auto-populate our advertised address from the identity-derived .onion hostname1243// if the user has not set a custom address. This means zero configuration:1244// the user's Tor address is their identity key, no manual entry needed.1245 if myTCPAddress.isEmpty,1246 let hostname = chatManager?.identity.onionHostname {1247 let port = tcpPort.isEmpty ?"25519": tcpPort1248 manager.myTCPAddress ="\(hostname):\(port)"1249}1250 if tcpEnabled {1251#if !targetEnvironment(macCatalyst)1252// Tor-enforced mode: refuse to start TCP until Tor is fully bootstrapped.1253 if torEnforcedMode {1254 guard case .ready = TorManager.shared.state else {1255// Tor not ready — hold off; observeTorState will retry when Tor is ready.1256 return1257}1258}1259#endif1260 startTCPTransport(on: manager)1261} else {1262 manager.stopTCP()1263}1264}12651266// MARK: - Embedded Tor12671268#if !targetEnvironment(macCatalyst)1269/// Observes TorManager state. When Tor becomes ready, auto-wires the SOCKS5 proxy1270/// into tcpSocksProxy if the user has not manually overridden it.1271/// When Tor-enforced mode is on, also starts/stops TCP as Tor transitions.1272 private func observeTorState(){1273 Task {[weak self] in1274 for await state in TorManager.shared.$state.values {1275 guard let self else { return }1276 switch state {1277 case .ready:1278 if self.torEnabled, self.tcpSocksProxy.isEmpty {1279 self.tcpSocksProxy = TorManager.socksProxy1280}1281// In Tor-enforced mode, start TCP now that Tor is ready1282 if self.torEnforcedMode && self.tcpEnabled {1283 await MainActor.run { self.applyTCPConfig()}1284}1285 case .stopped,.failed:1286// In Tor-enforced mode, stop TCP if Tor goes down (fail-closed)1287 if self.torEnforcedMode {1288 await MainActor.run { self.chatManager?.stopTCP()}1289}1290 default:1291 break1292}1293}1294}1295// When the hidden service hostname is confirmed by Tor, auto-populate myTCPAddress.1296 Task {[weak self] in1297 for await hostname in TorManager.shared.$hiddenServiceHostname.values {1298 guard let self, let hostname else { continue }1299 let port = self.tcpPort.isEmpty ?"25519": self.tcpPort1300 let addr ="\(hostname):\(port)"1301// Only overwrite if empty or already matches our identity-derived address1302// (i.e. don't clobber a user-set custom address).1303 if self.myTCPAddress.isEmpty || self.myTCPAddress == addr {1304 self.myTCPAddress = addr1305}1306}1307}1308}1309#endif13101311// MARK: - Deep link handling13121313/// A parsed contact card link waiting for user confirmation before any TCP connection is made.1314 struct PendingDeepLink: Identifiable {1315 let id = UUID()1316 let peerID: String1317 let address: String // "host.onion:port"1318 let onionHost: String // display-only1319}13201321/// Handles `sophaxchat://meet?pid=...&name=...&sk=...&dk=...` mesh identity invite links.1322/// Adds the contact to the known peers list without requiring a TCP connection.1323 private func handleMeetLink(_ url: URL){1324 let components = URLComponents(url: url, resolvingAgainstBaseURL: false)1325 guard let items = components?.queryItems,1326 let pid = items.first(where:{ $0.name =="pid"})?.value,1327 let nameB64 = items.first(where:{ $0.name =="name"})?.value,1328 let skB64 = items.first(where:{ $0.name =="sk"})?.value,1329 let dkB64 = items.first(where:{ $0.name =="dk"})?.value,1330!pid.isEmpty,1331 let nameData = Data(base64Encoded: nameB64),1332 let username = String(data: nameData, encoding:.utf8),1333 username.count <= 64,1334 let signingKey = Data(base64Encoded: skB64),1335 let dhKey = Data(base64Encoded: dkB64),1336 signingKey.count == 32, dhKey.count == 32,1337(try? Curve25519.Signing.PublicKey(rawRepresentation: signingKey))!= nil,1338(try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: dhKey))!= nil1339 else { return }1340// Don't add ourselves1341 guard pid != chatManager?.identity.publicIdentity.peerID else { return }1342// If already known, just surface them1343 guard !peers.contains(where:{ $0.id == pid }) else { return }1344 let combined = signingKey + dhKey1345 let hash = Data(CryptoKit.SHA512.hash(data: combined))1346 let groups = stride(from: 0, to: 30, by: 5).map { i -> String in1347 let chunk = hash[i..<(i + 5)]1348 let value = chunk.reduce(UInt64(0)){($0 << 8)| UInt64($1)}% 100_0001349 return String(format:"%05d", value)1350}1351 let safetyNumber = groups.joined(separator:" ")1352 var peer = KnownPeer(1353 id: pid,1354 username: username,1355 signingKeyPublic: signingKey,1356 dhKeyPublic: dhKey,1357 safetyNumber: safetyNumber,1358 lastSeen: nil,1359 isOnline: false,1360 isDirectlyConnected: false1361)1362 peer.tcpAddress = nil1363 peers.append(peer)1364 savePeers()1365 lastAddedContactAddress = username1366 Task {@MainActor in1367 try? await Task.sleep(for:.seconds(4))1368 if lastAddedContactAddress == username { lastAddedContactAddress = nil }1369}1370}13711372/// Handles `sophaxchat://add?id=<peerID>&onion=<host>&port=<port>` contact card links.1373/// Parsing is immediate; connecting requires explicit user confirmation via `confirmDeepLink()`.1374 func handleIncomingLink(_ url: URL){1375 guard url.scheme?.lowercased()=="sophaxchat" else { return }1376 if url.host?.lowercased()=="meet"{1377 handleMeetLink(url)1378 return1379}1380 guard url.host?.lowercased()=="add" else { return }1381 let components = URLComponents(url: url, resolvingAgainstBaseURL: false)1382 guard let items = components?.queryItems,1383 let peerID = items.first(where:{ $0.name =="id"})?.value,1384 let rawOnionHost = items.first(where:{ $0.name =="onion"})?.value,1385!peerID.isEmpty else { return }1386 let onionHost = rawOnionHost.lowercased()1387 guard onionHost.hasSuffix(".onion") else { return }1388 let portStr = items.first(where:{ $0.name =="port"})?.value ??"25519"1389 guard let port = UInt16(portStr), port > 1023 else { return }1390 let address ="\(onionHost):\(port)"1391 guard Self.isValidTCPAddress(address) else { return }1392// Ask the user before making any TCP connection — prevents IP disclosure to attacker-1393// controlled addresses embedded in crafted sophaxchat:// links.1394 pendingDeepLink = PendingDeepLink(peerID: peerID, address: address, onionHost: onionHost)1395}13961397/// DHT lookup: resolve a peerID to a KnownPeer via the Kademlia network.1398/// Returns the peer on success, throws on failure. @MainActor-safe.1399 func lookupPeerViaDHT(peerID: String) async throws -> KnownPeer {1400 guard let manager = chatManager else {1401 throw SophaxError.invalidMessageFormat("Not connected")1402}1403 let peer = try await manager.lookupPeer(peerID: peerID)1404// Merge into published peers list if not already present1405 if !peers.contains(where:{ $0.id == peer.id }){1406 peers.append(peer)1407 savePeers()1408}1409 return peer1410}14111412/// Called when the user taps "Add" in the deep-link confirmation alert.1413 func confirmDeepLink(){1414 guard let pending = pendingDeepLink else { return }1415 pendingDeepLink = nil1416// Store the address on the peer if we already know them1417 if let idx = peers.firstIndex(where:{ $0.id == pending.peerID }){1418 peers[idx].tcpAddress = pending.address1419 savePeers()1420}1421// Attempt immediate TCP connect if TCP is enabled1422 if tcpEnabled {1423 connectViaTCP(address: pending.address)1424}1425// Show a brief toast so the user knows the card was added1426 lastAddedContactAddress = pending.onionHost1427 Task {@MainActor in1428 try? await Task.sleep(for:.seconds(4))1429 lastAddedContactAddress = nil1430}1431}14321433// MARK: - Peer persistence14341435 private let peersDefaultsKey ="com.sophax.knownPeers"1436 private let pendingRequestsKey ="com.sophax.pendingRequests"1437 private let groupJoinRequestsKey ="com.sophax.groupJoinRequests"14381439 private func loadSavedPeers(){1440 guard let data = UserDefaults.standard.data(forKey: peersDefaultsKey),1441 let saved = try? JSONDecoder().decode([KnownPeer].self, from: data) else { return }1442 peers = saved.map { peer in1443 var p = peer1444 p.isOnline = false1445 p.isDirectlyConnected = false1446 return p1447}1448// Restore avatar cache from persisted peers1449 for peer in peers {1450 if let avatar = peer.avatarData { peerAvatars[peer.id]= avatar }1451}1452}14531454 private func savePeers(){1455 if let data = try? JSONEncoder().encode(peers){1456 UserDefaults.standard.set(data, forKey: peersDefaultsKey)1457}1458}14591460 private func loadPendingRequests(){1461 guard let data = UserDefaults.standard.data(forKey: pendingRequestsKey),1462 let saved = try? JSONDecoder().decode([KnownPeer].self, from: data) else { return }1463 pendingContactRequests = saved1464}14651466 private func savePendingRequests(){1467 if let data = try? JSONEncoder().encode(pendingContactRequests){1468 UserDefaults.standard.set(data, forKey: pendingRequestsKey)1469}1470}14711472// MARK: - Group join request persistence14731474 func loadGroupJoinRequests(){1475 guard let data = UserDefaults.standard.data(forKey: groupJoinRequestsKey),1476 let saved = try? JSONDecoder().decode([ChannelJoinRequestMessage].self, from: data)1477 else { return }1478// Drop stale requests (>48 h) on load so they don't clog up the list1479 let cutoff = Date().addingTimeInterval(-172_800)1480 pendingGroupJoinRequests = saved.filter { $0.timestamp > cutoff }1481}14821483 func saveGroupJoinRequests(){1484 if let data = try? JSONEncoder().encode(pendingGroupJoinRequests){1485 UserDefaults.standard.set(data, forKey: groupJoinRequestsKey)1486}1487}14881489// MARK: - Alias persistence14901491 private func loadAliases(){1492// Primary: Keychain1493 let fromKeychain = keychain.loadPeerAliases()1494 if !fromKeychain.isEmpty {1495 peerAliases = fromKeychain1496 return1497}1498// One-time migration from UserDefaults → Keychain1499 let legacyKey ="com.sophax.peerAliases"1500 if let data = UserDefaults.standard.data(forKey: legacyKey),1501 let saved = try? JSONDecoder().decode([String: String].self, from: data),1502!saved.isEmpty {1503 peerAliases = saved1504 keychainSave("peerAliases"){1505 try keychain.savePeerAliases(saved)1506 UserDefaults.standard.removeObject(forKey: legacyKey)1507}1508}1509}15101511 private func saveAliases(){1512 keychainSave("peerAliases"){ try keychain.savePeerAliases(peerAliases)}1513}15141515// MARK: - Blocked peers persistence15161517 private func loadBlockedPeers(){1518// Primary: Keychain1519 let (ids, names)= keychain.loadBlockedPeers()1520 if !ids.isEmpty ||!names.isEmpty {1521 blockedPeers = ids1522 blockedPeerNames = names1523 return1524}1525// One-time migration from UserDefaults → Keychain1526 let legacyIDsKey ="com.sophax.blockedPeers"1527 let legacyNamesKey ="com.sophax.blockedPeerNames"1528 let savedIDs = Set(UserDefaults.standard.stringArray(forKey: legacyIDsKey)??[])1529 var savedNames =[String: String]()1530 if let data = UserDefaults.standard.data(forKey: legacyNamesKey),1531 let names = try? JSONDecoder().decode([String: String].self, from: data){1532 savedNames = names1533}1534 if !savedIDs.isEmpty ||!savedNames.isEmpty {1535 blockedPeers = savedIDs1536 blockedPeerNames = savedNames1537 keychainSave("blockedPeers"){1538 try keychain.saveBlockedPeers(savedIDs, names: savedNames)1539 UserDefaults.standard.removeObject(forKey: legacyIDsKey)1540 UserDefaults.standard.removeObject(forKey: legacyNamesKey)1541}1542}1543}15441545 private func saveBlockedPeers(){1546 keychainSave("blockedPeers"){ try keychain.saveBlockedPeers(blockedPeers, names: blockedPeerNames)}1547}15481549// MARK: - Keychain helpers15501551/// Executes a Keychain save, logging failures in debug builds.1552/// Silent discard is intentional in release — Keychain errors are transient1553/// (locked device, quota) and must not crash the app or block the call site.1554@inline(__always)1555 private func keychainSave(_ label: String, _ operation:() throws -> Void){1556 do {1557 try operation()1558} catch {1559#if DEBUG1560 print("[SophaxChat] ⚠️ Keychain save failed (\(label)): \(error)")1561#endif1562}1563}15641565// MARK: - Verified peers persistence15661567 private func loadVerifiedPeers(){1568// Primary: Keychain (device-local, excluded from iCloud backup)1569 let fromKeychain = keychain.loadVerifiedPeers()1570 if !fromKeychain.isEmpty {1571 verifiedPeers = fromKeychain1572 return1573}1574// One-time migration from UserDefaults → Keychain1575 let legacyKey ="com.sophax.verifiedPeers"1576 if let data = UserDefaults.standard.data(forKey: legacyKey),1577 let saved = try? JSONDecoder().decode([String: String].self, from: data),1578!saved.isEmpty {1579 verifiedPeers = saved1580 do {1581 try keychain.saveVerifiedPeers(saved)1582 UserDefaults.standard.removeObject(forKey: legacyKey)1583} catch {1584// Leave in UserDefaults and retry on next launch1585#if DEBUG1586 print("[AppState] ⚠️ verifiedPeers migration to Keychain failed: \(error)")1587#endif1588}1589}1590}15911592 private func saveVerifiedPeers(){1593 keychainSave("verifiedPeers"){ try keychain.saveVerifiedPeers(verifiedPeers)}1594}15951596// MARK: - Pinned Messages15971598 func pinMessage(_ messageID: String, inConversation convID: String){1599 pinnedMessages[convID]= messageID1600 keychainSave("pinnedMessages"){ try keychain.savePinnedMessages(pinnedMessages)}1601}16021603 func unpinMessage(inConversation convID: String){1604 pinnedMessages.removeValue(forKey: convID)1605 keychainSave("pinnedMessages"){ try keychain.savePinnedMessages(pinnedMessages)}1606}16071608 private func loadPinnedMessages(){1609 let saved = keychain.loadPinnedMessages()1610 if !saved.isEmpty { pinnedMessages = saved }1611}16121613// MARK: - Backup16141615/// Build an encrypted backup blob. Caller presents the ShareSheet.1616 func exportBackup(passphrase: String) throws -> Data {1617 guard let cm = chatManager else {1618 throw SophaxError.sessionNotInitialized1619}1620 let store = cm.messageStore1621 let peerIDs = store.allConversationPeerIDs()1622 var messages =[String:[StoredMessage]]()1623 for pid in peerIDs {1624 messages[pid]=(try? store.messages(forPeer: pid))??[]1625}1626// Include identity fingerprint so restore can warn if the backup belongs to a different identity.1627 let pub = cm.identity.publicIdentity1628 let fingerprint = cm.identity.identityFingerprint1629 let backup = SophaxBackup(1630 version: 1,1631 createdAt: Date(),1632 username: pub.username,1633 peers: peers,1634 messages: messages,1635 identityFingerprint: fingerprint1636)1637 return try BackupManager.export(backup: backup, passphrase: passphrase)1638}16391640// MARK: - Full export (identity + messages in one file)16411642/// Export identity keys + full message history into a single encrypted `.sxfe` blob.1643 func exportFullBackup(passphrase: String) throws -> Data {1644 guard let cm = chatManager else { throw SophaxError.sessionNotInitialized }1645 let store = cm.messageStore1646 let peerIDs = store.allConversationPeerIDs()1647 var messages =[String:[StoredMessage]]()1648 for pid in peerIDs {1649 messages[pid]=(try? store.messages(forPeer: pid))??[]1650}1651 let pub = cm.identity.publicIdentity1652 let fingerprint = cm.identity.identityFingerprint1653 let backup = SophaxBackup(1654 version: 1,1655 createdAt: Date(),1656 username: pub.username,1657 peers: peers,1658 messages: messages,1659 identityFingerprint: fingerprint1660)1661 return try FullExportManager.export(1662 identity: cm.identity,1663 backup: backup,1664 passphrase: passphrase1665)1666}16671668/// Restore from a `.sxfe` full export — replaces identity keys and message history.1669 func importFullBackup(data: Data, passphrase: String) throws {1670 guard let cm = chatManager else { throw SophaxError.sessionNotInitialized }1671 chatManager?.stop()1672 chatManager = nil1673 try FullExportManager.restore(1674 data: data,1675 passphrase: passphrase,1676 keychain: keychain,1677 messageStore: cm.messageStore1678)1679 setupChatManager(username: nil)1680}16811682// MARK: - Identity backup / restore16831684/// Export the local identity keypair + username as an encrypted blob (file extension `.sophaxid`).1685/// - Parameter passphrase: Must be at least 12 characters (enforced in UI).1686 func exportIdentity(passphrase: String) throws -> Data {1687 guard let cm = chatManager else { throw SophaxError.sessionNotInitialized }1688 return try IdentityExportManager.export(identity: cm.identity, passphrase: passphrase)1689}16901691/// Import an identity backup, replacing the current keypair.1692/// Stops the ChatManager, writes new keys to Keychain, restarts with the restored identity.1693/// All existing DR sessions become invalid after this call.1694 func importIdentity(data: Data, passphrase: String) throws {1695 chatManager?.stop()1696 chatManager = nil1697 try IdentityExportManager.import(data: data, passphrase: passphrase, keychain: keychain)1698 setupChatManager(username: nil)1699}17001701/// Restore message history and contacts from an encrypted backup blob.1702/// Does NOT replace identity keys — a new session will be needed with each contact.1703/// Throws `SophaxError.identityMismatch` (as a warning) if the backup fingerprint differs.1704 func importBackup(data: Data, passphrase: String) throws {1705 guard let cm = chatManager else {1706 throw SophaxError.sessionNotInitialized1707}1708 let backup = try BackupManager.import(data: data, passphrase: passphrase)17091710// Warn if restoring from a different identity (e.g., accidental wrong backup).1711 if let backupFP = backup.identityFingerprint,1712 backupFP != cm.identity.identityFingerprint {1713 throw SophaxError.identityMismatch1714}17151716// Restore messages1717 let store = cm.messageStore1718 for (peerID, msgs) in backup.messages {1719 for msg in msgs { try? store.append(message: msg)}1720}1721// Restore contacts (merge — don't overwrite existing)1722 for peer in backup.peers where !peers.contains(where:{ $0.id == peer.id }){1723 peers.append(peer)1724}1725 savePeers()1726}1727}17281729// MARK: - ChatManagerDelegate17301731extension AppState:@preconcurrency ChatManagerDelegate {17321733 func chatManager(_ manager: ChatManager, didDiscoverPeer peer: KnownPeer){1734 guard !blockedPeers.contains(peer.id) else { return }1735// Update avatar cache whenever a fresh Hello arrives1736 if let avatar = peer.avatarData { peerAvatars[peer.id]= avatar }1737 if let idx = peers.firstIndex(where:{ $0.id == peer.id }){1738 let existing = peers[idx]1739// TOFU key-change detection: if the signing key is different from what we knew,1740// inject the old safety number into verifiedPeers so hasKeyChanged() fires in the UI.1741 if existing.signingKeyPublic != peer.signingKeyPublic {1742 if verifiedPeers[peer.id]== nil {1743 verifiedPeers[peer.id]= existing.safetyNumber1744 saveVerifiedPeers()1745}1746 peers[idx]= peer1747 savePeers()1748} else {1749 peers[idx].isOnline = true1750 if peer.avatarData != nil { peers[idx].avatarData = peer.avatarData }1751}1752} else {1753 peers.append(peer)1754 savePeers()1755}1756 onlinePeers.insert(peer.id)1757}17581759 func chatManager(_ manager: ChatManager, peerDidDisconnect peerID: String){1760 onlinePeers.remove(peerID)1761 let now = Date()1762 if let idx = peers.firstIndex(where:{ $0.id == peerID }){1763 peers[idx].isOnline = false1764 peers[idx].lastSeen = now1765}1766 if let idx = linkedDevices.firstIndex(where:{ $0.id == peerID }){1767 linkedDevices[idx].isOnline = false1768 linkedDevices[idx].lastSeen = now1769}1770}17711772 func chatManager(_ manager: ChatManager, didSendMessage message: StoredMessage, toPeer peerID: String){1773 appendMessage(message)1774}17751776 func chatManager(_ manager: ChatManager, didReceiveMessage message: StoredMessage, fromPeer peerID: String){1777 guard !isDuressActive else { return }// silently drop while duress is active1778 guard !blockedPeers.contains(peerID) else { return }1779 appendMessage(message)1780 unreadCounts[peerID, default: 0]+= 11781 UIImpactFeedbackGenerator(style:.soft).impactOccurred()1782 let appState = UIApplication.shared.applicationState1783 if appState ==.background || appState ==.inactive {1784 scheduleNotification(for: message, fromPeer: peerID)1785}1786}17871788 func chatManager(_ manager: ChatManager, messageDelivered messageID: String, toPeer peerID: String){1789 if let idx = messages[peerID]?.firstIndex(where:{ $0.id == messageID }){1790 messages[peerID]?[idx].status =.delivered1791}1792}17931794 func chatManager(_ manager: ChatManager, messagesRead messageIDs:[String], byPeer peerID: String){1795 for messageID in messageIDs {1796 if let idx = messages[peerID]?.firstIndex(where:{ $0.id == messageID }){1797 messages[peerID]?[idx].status =.read1798}1799}1800}18011802 func chatManager(_ manager: ChatManager, didUpdateReactions reactions:[String: String], onMessageID messageID: String, peerID: String){1803 if let idx = messages[peerID]?.firstIndex(where:{ $0.id == messageID }){1804 messages[peerID]?[idx].reactions = reactions.isEmpty ? nil : reactions1805}1806}18071808 func chatManager(_ manager: ChatManager, peerDidUpdateTyping peerID: String, isTyping: Bool){1809 guard !blockedPeers.contains(peerID) else { return }1810 typingTimeouts[peerID]?.cancel()1811 if isTyping {1812 typingPeers.insert(peerID)1813// Auto-clear after 8 seconds in case the stop signal is lost1814 typingTimeouts[peerID]= Task {@MainActor [weak self] in1815 try? await Task.sleep(for:.seconds(8))1816 self?.typingPeers.remove(peerID)1817 self?.typingTimeouts.removeValue(forKey: peerID)1818}1819} else {1820 typingPeers.remove(peerID)1821 typingTimeouts.removeValue(forKey: peerID)1822}1823}18241825 func chatManager(_ manager: ChatManager, sessionEstablishedWithPeer peerID: String, usedOPK: Bool){1826 if !usedOPK {1827 noOPKSessions.insert(peerID)1828}1829}18301831 func chatManager(_ manager: ChatManager, peerDidReconnect peer: KnownPeer){1832 reconnectedPeer = peer1833// Auto-clear after 4 seconds1834 Task {@MainActor [weak self] in1835 try? await Task.sleep(for:.seconds(4))1836 if self?.reconnectedPeer?.id == peer.id {1837 self?.reconnectedPeer = nil1838}1839}1840}18411842 func chatManager(_ manager: ChatManager, didUpdateGroupReactions reactions:[String: String],1843 onMessageID messageID: String, groupID: String){1844 let convID ="group.\(groupID)"1845 if let idx = messages[convID]?.firstIndex(where:{ $0.id == messageID }){1846 messages[convID]?[idx].reactions = reactions.isEmpty ? nil : reactions1847}1848}18491850 func chatManager(_ manager: ChatManager, peer leavingPeerID: String,1851 leftGroupID groupID: String, remainingMemberIDs:[String]){1852 guard let idx = groups.firstIndex(where:{ $0.id == groupID }) else { return }1853 let old = groups[idx]1854 groups[idx]= GroupInfo(1855 id: old.id,1856 name: old.name,1857 memberIDs: remainingMemberIDs,1858 creatorID: old.creatorID1859)1860 saveGroups()1861}18621863 func chatManager(_ manager: ChatManager, didJoinGroup group: GroupInfo){1864 if !groups.contains(where:{ $0.id == group.id }){1865 groups.append(group)1866 saveGroups()1867}1868}18691870 func chatManager(_ manager: ChatManager, didReceiveGroupMessage message: StoredMessage, inGroup groupID: String){1871 guard !isDuressActive else { return }// silently drop while duress is active1872 appendMessage(message)1873 let convID ="group.\(groupID)"1874 unreadCounts[convID, default: 0]+= message.direction ==.received ? 1 : 01875 if message.direction ==.received {1876 UIImpactFeedbackGenerator(style:.soft).impactOccurred()1877 let appStatus = UIApplication.shared.applicationState1878 if appStatus ==.background || appStatus ==.inactive {1879 scheduleGroupNotification(for: message, groupID: groupID)1880}1881}1882}18831884 func chatManager(_ manager: ChatManager, didEncounterError error: Error){1885 if case SophaxError.decryptionFailed = error { return }1886 errorMessage = error.localizedDescription1887 Task {@MainActor in1888 try? await Task.sleep(for:.seconds(4))1889 if errorMessage == error.localizedDescription {1890 errorMessage = nil1891}1892}1893}18941895 func chatManager(_ manager: ChatManager, didDiscoverChannel announcement: ChannelAnnouncement){1896// Only show groups the local user is not already a member of1897 let myID = manager.identity.publicIdentity.peerID1898 let alreadyMember = groups.contains { $0.id == announcement.groupID }1899 guard !alreadyMember, announcement.creatorID != myID else { return }1900 discoveredChannels[announcement.groupID]= announcement1901}19021903 func chatManager(_ manager: ChatManager, groupMessageDelivered messageID: String,1904 inGroup groupID: String, byPeer peerID: String, isRead: Bool){1905 let convID ="group.\(groupID)"1906 guard var msgs = messages[convID],1907 let idx = msgs.firstIndex(where:{ $0.id == messageID }) else { return }1908 if isRead {1909 var set = msgs[idx].readBy ??[]1910 guard !set.contains(peerID) else { return }1911 set.append(peerID)1912 msgs[idx].readBy = set1913} else {1914 var set = msgs[idx].deliveredBy ??[]1915 guard !set.contains(peerID) else { return }1916 set.append(peerID)1917 msgs[idx].deliveredBy = set1918}1919 messages[convID]= msgs1920}19211922 func chatManager(_ manager: ChatManager, didReceiveEditedMessage messageID: String,1923 newBody: String, editedAt: Date, peerID: String){1924 if let idx = messages[peerID]?.firstIndex(where:{ $0.id == messageID }){1925 messages[peerID]?[idx].body = newBody1926 messages[peerID]?[idx].isEdited = true1927 messages[peerID]?[idx].editedAt = editedAt1928}1929}19301931 func chatManager(_ manager: ChatManager, didReceiveEditedGroupMessage messageID: String,1932 newBody: String, editedAt: Date, groupID: String){1933 let convID ="group.\(groupID)"1934 if let idx = messages[convID]?.firstIndex(where:{ $0.id == messageID }){1935 messages[convID]?[idx].body = newBody1936 messages[convID]?[idx].isEdited = true1937 messages[convID]?[idx].editedAt = editedAt1938}1939}19401941 func sendGroupEditMessage(messageID: String, newBody: String, group: GroupInfo){1942 guard let cm = chatManager else { return }1943 if group.cryptoVersion ==.mls {1944 cm.sendMLSGroupEditMessage(messageID: messageID, newBody: newBody, group: group)1945} else {1946 cm.sendEditGroupMessage(messageID: messageID, newBody: newBody,1947 groupID: group.id, members: group.memberIDs)1948}1949}19501951 func chatManager(_ manager: ChatManager, didDetectKeyChange forPeerID: String){1952 guard !keyChangeAlerts.contains(forPeerID) else { return }1953 keyChangeAlerts.append(forPeerID)1954}19551956 func chatManager(_ manager: ChatManager, didRotateSenderKey forGroupID: String){1957// No persistent state update needed — rotation is confirmed by the Keychain write in ChatManager.1958}19591960 func chatManager(_ manager: ChatManager, groupDeletedWithID groupID: String){1961 let convID ="group.\(groupID)"1962 groups.removeAll { $0.id == groupID }1963 saveGroups()1964 messages.removeValue(forKey: convID)1965 unreadCounts.removeValue(forKey: convID)1966}19671968 func chatManager(_ manager: ChatManager, didReceiveAvatarData data: Data, fromPeerID peerID: String){1969 peerAvatars[peerID]= data1970}19711972 func chatManager(_ manager: ChatManager, didUpdateCoordinator newCoordinatorID: String, inGroupID groupID: String){1973 if let idx = groups.firstIndex(where:{ $0.id == groupID }){1974 groups[idx].currentCoordinatorID = newCoordinatorID1975 saveGroups()1976}1977}19781979 func chatManager(_ manager: ChatManager, didLinkDevice peer: KnownPeer){1980 guard !linkedDevices.contains(where:{ $0.id == peer.id }) else { return }1981 linkedDevices.append(peer)1982}19831984 func chatManager(_ manager: ChatManager, didReceiveSyncedMessage message: StoredMessage, conversationID: String){1985 appendMessage(message)1986// Don't increment unread or schedule a notification — this is our own message on another device1987}19881989 func chatManager(_ manager: ChatManager, didReceiveContactRequest peer: KnownPeer){1990 guard !blockedPeers.contains(peer.id) else { return }1991 guard !pendingContactRequests.contains(where:{ $0.id == peer.id }) else { return }1992 pendingContactRequests.append(peer)1993 savePendingRequests()1994 postNotification(1995 id:"req_\(peer.id)",1996 title:"Contact Request",1997 body:"\(peer.username) wants to connect",1998 threadKey:"requests"1999)2000}20012002// MARK: - SSS Backup Delegate20032004 func chatManager(_ manager: ChatManager, didReceiveSSSShare shareID: String,2005 fromPeerID: String, threshold: Int, total: Int){2006// Surface to UI — future: show accept/reject sheet2007 pendingSSSShareRequest = SSSShareNotification(2008 shareID: shareID, fromPeerID: fromPeerID, threshold: threshold, total: total2009)2010}20112012 func chatManager(_ manager: ChatManager, didRecoverSSSSecret secret: Data, shareID: String){2013 var s = secret2014 defer { s.resetBytes(in: s.startIndex..<s.endIndex)}2015 sssRecoveredSecret = s2016}20172018 func chatManagerDidReceiveRemoteWipeRequest(_ manager: ChatManager){2019 wipeAccount()2020}20212022 func chatManager(_ manager: ChatManager, didReceiveGroupJoinRequest request: ChannelJoinRequestMessage){2023// Deduplicate: one pending request per (group, requester) pair2024 let key = request.groupID +":"+ request.requesterPeerID2025 if !pendingGroupJoinRequests.contains(where:{2026 $0.groupID +":"+ $0.requesterPeerID == key2027}){2028 pendingGroupJoinRequests.append(request)2029 saveGroupJoinRequests()2030}2031}2032}20332034// MARK: - Remote Wipe helpers (public surface for SettingsView)20352036extension AppState {2037 var trustedWipePeers:[KnownPeer]{2038 let ids = chatManager?.trustedWipePeers ??[]2039 return ids.compactMap { id in knownPeers.first { $0.id == id }}2040}20412042 func addTrustedWipePeer(_ peer: KnownPeer){2043 chatManager?.addTrustedWipePeer(peer.id)2044 objectWillChange.send()2045}20462047 func removeTrustedWipePeer(_ peerID: String){2048 chatManager?.removeTrustedWipePeer(peerID)2049 objectWillChange.send()2050}20512052 func sendRemoteWipe(toPeerID: String){2053 chatManager?.sendRemoteWipe(toPeerID: toPeerID)2054}2055}