From da6b3d7c3fde197df8e333775d94d40a040e2218 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Fri, 3 Apr 2026 18:04:41 +0500 Subject: [PATCH] =?UTF-8?q?=D0=93=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20=D1=87=D0=B0=D1=82=D1=8B:=20sender=20name/avatar?= =?UTF-8?q?=20=D0=B2=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B0=D1=85,=20multi?= =?UTF-8?q?-typer=20typing,=20=D1=84=D0=B8=D0=BA=D1=81=20=D1=81=D0=BA?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D1=84=D0=BE?= =?UTF-8?q?=D1=82=D0=BE/=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=20=D0=B8=20verified=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcschemes/xcschememanagement.plist | 4 +- Rosetta/Core/Crypto/MessageCrypto.swift | 5 +- .../Core/Data/Database/DatabaseManager.swift | 205 +--------- Rosetta/Core/Data/Models/Dialog.swift | 5 +- .../Data/Repositories/GroupRepository.swift | 365 ++++++++++++++++++ .../Data/Repositories/MessageRepository.swift | 102 ++++- Rosetta/Core/Layout/MessageCellLayout.swift | 53 ++- .../Core/Network/Protocol/PacketAwaiter.swift | 129 +++++++ .../Protocol/Packets/PacketWebRTC.swift | 8 + .../Network/Protocol/ProtocolManager.swift | 92 ++++- Rosetta/Core/Services/CallKitManager.swift | 31 +- .../Core/Services/CallManager+Runtime.swift | 59 +-- Rosetta/Core/Services/CallManager.swift | 69 +++- Rosetta/Core/Services/CallSoundManager.swift | 9 +- Rosetta/Core/Services/GroupService.swift | 299 ++++++++++++++ Rosetta/Core/Services/SessionManager.swift | 202 +++++++--- Rosetta/DesignSystem/Colors.swift | 33 ++ .../Chats/ChatDetail/ChatDetailView.swift | 97 ++++- .../ChatDetail/ChatDetailViewModel.swift | 18 +- .../Chats/ChatDetail/MessageCellActions.swift | 1 + .../Chats/ChatDetail/NativeMessageCell.swift | 189 ++++++++- .../Chats/ChatDetail/NativeMessageList.swift | 50 ++- .../TelegramContextMenuController.swift | 10 + .../Chats/ChatList/ChatListView.swift | 51 ++- .../Features/Chats/ChatList/ChatRowView.swift | 51 ++- .../Chats/ChatList/RequestChatsView.swift | 11 +- Rosetta/Features/Chats/ChatRoute.swift | 13 + Rosetta/Features/Groups/GroupInfoView.swift | 235 +++++++++++ .../Features/Groups/GroupInfoViewModel.swift | 96 +++++ Rosetta/Features/Groups/GroupJoinView.swift | 210 ++++++++++ .../Features/Groups/GroupJoinViewModel.swift | 86 +++++ Rosetta/Features/Groups/GroupMemberRow.swift | 58 +++ Rosetta/Features/Groups/GroupSetupView.swift | 214 ++++++++++ .../Features/Groups/GroupSetupViewModel.swift | 42 ++ Rosetta/RosettaApp.swift | 12 +- 35 files changed, 2728 insertions(+), 386 deletions(-) create mode 100644 Rosetta/Core/Data/Repositories/GroupRepository.swift create mode 100644 Rosetta/Core/Network/Protocol/PacketAwaiter.swift create mode 100644 Rosetta/Core/Services/GroupService.swift create mode 100644 Rosetta/Features/Groups/GroupInfoView.swift create mode 100644 Rosetta/Features/Groups/GroupInfoViewModel.swift create mode 100644 Rosetta/Features/Groups/GroupJoinView.swift create mode 100644 Rosetta/Features/Groups/GroupJoinViewModel.swift create mode 100644 Rosetta/Features/Groups/GroupMemberRow.swift create mode 100644 Rosetta/Features/Groups/GroupSetupView.swift create mode 100644 Rosetta/Features/Groups/GroupSetupViewModel.swift diff --git a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist index 1fb243c..414c6b6 100644 --- a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,12 +12,12 @@ RosettaLiveActivityWidget.xcscheme_^#shared#^_ orderHint - 1 + 2 RosettaNotificationService.xcscheme_^#shared#^_ orderHint - 2 + 1 SuppressBuildableAutocreation diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index 95282a6..9f5b6c3 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -176,7 +176,10 @@ enum MessageCrypto { var seen = Set() return candidates.filter { seen.insert($0).inserted } } - return [stored] + // Group key or plain password — try hex(utf8Bytes) first (Desktop parity: + // Desktop encrypts group attachments with Buffer.from(groupKey).toString('hex')). + let hexVariant = Data(stored.utf8).map { String(format: "%02x", $0) }.joined() + return hexVariant == stored ? [stored] : [hexVariant, stored] } // MARK: - Android-Compatible UTF-8 Decoder diff --git a/Rosetta/Core/Data/Database/DatabaseManager.swift b/Rosetta/Core/Data/Database/DatabaseManager.swift index 5093d37..0ce6029 100644 --- a/Rosetta/Core/Data/Database/DatabaseManager.swift +++ b/Rosetta/Core/Data/Database/DatabaseManager.swift @@ -916,6 +916,19 @@ final class DatabaseManager { } catch { return 0 } } + /// Deletes sync cursor, forcing next sync to start from timestamp=0 (full re-sync). + /// Used for one-time recovery of orphaned group keys. + func resetSyncCursor(account: String) { + do { + try writeSync { db in + try db.execute( + sql: "DELETE FROM accounts_sync_times WHERE account = ?", + arguments: [account] + ) + } + } catch {} + } + /// Save sync cursor. Monotonic only — never decreases. func saveSyncCursor(account: String, timestamp: Int64) { guard timestamp > 0 else { return } @@ -970,194 +983,4 @@ final class DatabaseManager { } } -// MARK: - Group Repository (SQLite) - -@MainActor -final class GroupRepository { - static let shared = GroupRepository() - - private static let groupInvitePassword = "rosetta_group" - private let db = DatabaseManager.shared - - private init() {} - - struct GroupMetadata: Equatable { - let title: String - let description: String - } - - private struct ParsedGroupInvite { - let groupId: String - let title: String - let encryptKey: String - let description: String - } - - func isGroupDialog(_ value: String) -> Bool { - DatabaseManager.isGroupDialogKey(value) - } - - func normalizeGroupId(_ value: String) -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = trimmed.lowercased() - if lower.hasPrefix("#group:") { - return String(trimmed.dropFirst("#group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) - } - if lower.hasPrefix("group:") { - return String(trimmed.dropFirst("group:".count)).trimmingCharacters(in: .whitespacesAndNewlines) - } - if lower.hasPrefix("conversation:") { - return String(trimmed.dropFirst("conversation:".count)).trimmingCharacters(in: .whitespacesAndNewlines) - } - return trimmed - } - - func toGroupDialogKey(_ value: String) -> String { - let normalized = normalizeGroupId(value) - guard !normalized.isEmpty else { - return value.trimmingCharacters(in: .whitespacesAndNewlines) - } - return "#group:\(normalized)" - } - - func groupKey(account: String, privateKeyHex: String, groupDialogKey: String) -> String? { - let groupId = normalizeGroupId(groupDialogKey) - guard !groupId.isEmpty else { return nil } - - do { - let storedKey = try db.read { db in - try String.fetchOne( - db, - sql: """ - SELECT "key" - FROM groups - WHERE account = ? AND group_id = ? - LIMIT 1 - """, - arguments: [account, groupId] - ) - } - guard let storedKey, !storedKey.isEmpty else { return nil } - - if let decrypted = try? CryptoManager.shared.decryptWithPassword(storedKey, password: privateKeyHex), - let plain = String(data: decrypted, encoding: .utf8), - !plain.isEmpty { - return plain - } - - // Backward compatibility: tolerate legacy plain stored keys. - return storedKey - } catch { - return nil - } - } - - func groupMetadata(account: String, groupDialogKey: String) -> GroupMetadata? { - let groupId = normalizeGroupId(groupDialogKey) - guard !groupId.isEmpty else { return nil } - - do { - return try db.read { db in - guard let row = try Row.fetchOne( - db, - sql: """ - SELECT title, description - FROM groups - WHERE account = ? AND group_id = ? - LIMIT 1 - """, - arguments: [account, groupId] - ) else { - return nil - } - return GroupMetadata( - title: row["title"], - description: row["description"] - ) - } - } catch { - return nil - } - } - - @discardableResult - func upsertFromGroupJoin( - account: String, - privateKeyHex: String, - packet: PacketGroupJoin - ) -> String? { - guard packet.status == .joined else { return nil } - guard !packet.groupString.isEmpty else { return nil } - - guard let decryptedInviteData = try? CryptoManager.shared.decryptWithPassword( - packet.groupString, - password: privateKeyHex - ), let inviteString = String(data: decryptedInviteData, encoding: .utf8), - let parsed = parseGroupInviteString(inviteString) - else { - return nil - } - - guard let encryptedGroupKey = try? CryptoManager.shared.encryptWithPassword( - Data(parsed.encryptKey.utf8), - password: privateKeyHex - ) else { - return nil - } - - do { - try db.writeSync { db in - try db.execute( - sql: """ - INSERT INTO groups (account, group_id, title, description, "key") - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(account, group_id) DO UPDATE SET - title = excluded.title, - description = excluded.description, - "key" = excluded."key" - """, - arguments: [account, parsed.groupId, parsed.title, parsed.description, encryptedGroupKey] - ) - } - } catch { - return nil - } - - return toGroupDialogKey(parsed.groupId) - } - - private func parseGroupInviteString(_ inviteString: String) -> ParsedGroupInvite? { - let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines) - let lower = trimmed.lowercased() - - let encodedPayload: String - if lower.hasPrefix("#group:") { - encodedPayload = String(trimmed.dropFirst("#group:".count)) - } else if lower.hasPrefix("group:") { - encodedPayload = String(trimmed.dropFirst("group:".count)) - } else { - return nil - } - guard !encodedPayload.isEmpty else { return nil } - - guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword( - encodedPayload, - password: Self.groupInvitePassword - ), let payload = String(data: decryptedPayload, encoding: .utf8) else { - return nil - } - - let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init) - guard parts.count >= 3 else { return nil } - - let groupId = normalizeGroupId(parts[0]) - guard !groupId.isEmpty else { return nil } - - return ParsedGroupInvite( - groupId: groupId, - title: parts[1], - encryptKey: parts[2], - description: parts.dropFirst(3).joined(separator: ":") - ) - } -} +// GroupRepository is in Core/Data/Repositories/GroupRepository.swift diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift index 5010914..2f5b31e 100644 --- a/Rosetta/Core/Data/Models/Dialog.swift +++ b/Rosetta/Core/Data/Models/Dialog.swift @@ -56,13 +56,10 @@ struct Dialog: Identifiable, Codable, Equatable { // MARK: - Computed var isSavedMessages: Bool { opponentKey == account } + var isGroup: Bool { DatabaseManager.isGroupDialogKey(opponentKey) } /// Client-side heuristic matching Android: badge shown if verified > 0 OR isRosettaOfficial. var isRosettaOfficial: Bool { - opponentTitle.caseInsensitiveCompare("Rosetta") == .orderedSame || - opponentUsername.caseInsensitiveCompare("rosetta") == .orderedSame || - opponentTitle.caseInsensitiveCompare("freddy") == .orderedSame || - opponentUsername.caseInsensitiveCompare("freddy") == .orderedSame || SystemAccounts.isSystemAccount(opponentKey) } diff --git a/Rosetta/Core/Data/Repositories/GroupRepository.swift b/Rosetta/Core/Data/Repositories/GroupRepository.swift new file mode 100644 index 0000000..22d992b --- /dev/null +++ b/Rosetta/Core/Data/Repositories/GroupRepository.swift @@ -0,0 +1,365 @@ +import Foundation +import GRDB +import os + +// MARK: - Group Repository + +/// Manages group chat metadata, invite strings, and encryption keys in SQLite. +/// Extracted from DatabaseManager — all group-related DB operations live here. +@MainActor +final class GroupRepository { + static let shared = GroupRepository() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "GroupRepo") + private static let groupInvitePassword = "rosetta_group" + private let db = DatabaseManager.shared + + /// In-memory cache: ["{account}:{groupId}"] → decrypted group key. + /// Prevents GRDB reentrancy crash when groupKey() is called from within a db.read transaction + /// (e.g., MessageRepository.lastDecryptedMessage → decryptRecord → tryReDecrypt → groupKey). + private var keyCache: [String: String] = [:] + private var metadataCache: [String: GroupMetadata] = [:] + + private init() {} + + /// Clears all in-memory caches. Call on logout/account switch. + func clearCaches() { + keyCache.removeAll() + metadataCache.removeAll() + } + + private func cacheKey(account: String, groupId: String) -> String { + "\(account):\(groupId)" + } + + // MARK: - Public Types + + struct GroupMetadata: Equatable { + let title: String + let description: String + } + + struct ParsedGroupInvite { + let groupId: String + let title: String + let encryptKey: String + let description: String + } + + // MARK: - Normalization + + func isGroupDialog(_ value: String) -> Bool { + DatabaseManager.isGroupDialogKey(value) + } + + func normalizeGroupId(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + if lower.hasPrefix("#group:") { + return String(trimmed.dropFirst("#group:".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + if lower.hasPrefix("group:") { + return String(trimmed.dropFirst("group:".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + if lower.hasPrefix("conversation:") { + return String(trimmed.dropFirst("conversation:".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed + } + + func toGroupDialogKey(_ value: String) -> String { + let normalized = normalizeGroupId(value) + guard !normalized.isEmpty else { + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "#group:\(normalized)" + } + + // MARK: - Key Management + + func groupKey(account: String, privateKeyHex: String, groupDialogKey: String) -> String? { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return nil } + + // Check in-memory cache first (prevents GRDB reentrancy crash). + let ck = cacheKey(account: account, groupId: groupId) + if let cached = keyCache[ck] { + return cached + } + + do { + let storedKey = try db.read { db in + try String.fetchOne( + db, + sql: """ + SELECT "key" + FROM groups + WHERE account = ? AND group_id = ? + LIMIT 1 + """, + arguments: [account, groupId] + ) + } + guard let storedKey, !storedKey.isEmpty else { return nil } + + if let decrypted = try? CryptoManager.shared.decryptWithPassword( + storedKey, password: privateKeyHex + ), + let plain = String(data: decrypted, encoding: .utf8), + !plain.isEmpty { + keyCache[ck] = plain + return plain + } + + // Backward compatibility: tolerate legacy plain stored keys. + keyCache[ck] = storedKey + return storedKey + } catch { + return nil + } + } + + func groupMetadata(account: String, groupDialogKey: String) -> GroupMetadata? { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return nil } + + let ck = cacheKey(account: account, groupId: groupId) + if let cached = metadataCache[ck] { + return cached + } + + do { + let result: GroupMetadata? = try db.read { db in + guard let row = try Row.fetchOne( + db, + sql: """ + SELECT title, description + FROM groups + WHERE account = ? AND group_id = ? + LIMIT 1 + """, + arguments: [account, groupId] + ) else { + return nil + } + return GroupMetadata( + title: row["title"], + description: row["description"] + ) + } + if let result { + metadataCache[ck] = result + } + return result + } catch { + return nil + } + } + + /// Returns true if the string looks like a group invite (starts with `#group:` and can be parsed). + func isValidInviteString(_ value: String) -> Bool { + parseInviteString(value) != nil + } + + // MARK: - Invite String + + /// Constructs a shareable invite string: `#group:` + /// Uses `encryptWithPasswordDesktopCompat` for cross-platform compatibility (Android/Desktop). + func constructInviteString( + groupId: String, + title: String, + encryptKey: String, + description: String = "" + ) -> String? { + let normalized = normalizeGroupId(groupId) + guard !normalized.isEmpty, !title.isEmpty, !encryptKey.isEmpty else { return nil } + + var payload = "\(normalized):\(title):\(encryptKey)" + if !description.isEmpty { + payload += ":\(description)" + } + + guard let encrypted = try? CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(payload.utf8), + password: Self.groupInvitePassword + ) else { + return nil + } + return "#group:\(encrypted)" + } + + /// Parses an invite string into its components. + func parseInviteString(_ inviteString: String) -> ParsedGroupInvite? { + let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines) + let lower = trimmed.lowercased() + + let rawPayload: String + if lower.hasPrefix("#group:") { + rawPayload = String(trimmed.dropFirst("#group:".count)) + } else if lower.hasPrefix("group:") { + rawPayload = String(trimmed.dropFirst("group:".count)) + } else { + return nil + } + // Strip internal whitespace/newlines that may be introduced when copying + // from a chat bubble (message text wraps across multiple lines). + let encodedPayload = rawPayload.components(separatedBy: .whitespacesAndNewlines).joined() + guard !encodedPayload.isEmpty else { return nil } + + guard let decryptedPayload = try? CryptoManager.shared.decryptWithPassword( + encodedPayload, + password: Self.groupInvitePassword + ), let payload = String(data: decryptedPayload, encoding: .utf8) else { + return nil + } + + let parts = payload.split(separator: ":", omittingEmptySubsequences: false).map(String.init) + guard parts.count >= 3 else { return nil } + + let groupId = normalizeGroupId(parts[0]) + guard !groupId.isEmpty else { return nil } + + return ParsedGroupInvite( + groupId: groupId, + title: parts[1], + encryptKey: parts[2], + description: parts.dropFirst(3).joined(separator: ":") + ) + } + + // MARK: - Persistence + + /// Persists group from a joined `PacketGroupJoin` (server-pushed or self-initiated). + @discardableResult + func upsertFromGroupJoin( + account: String, + privateKeyHex: String, + packet: PacketGroupJoin + ) -> String? { + guard packet.status == .joined else { return nil } + guard !packet.groupString.isEmpty else { return nil } + + guard let decryptedInviteData = try? CryptoManager.shared.decryptWithPassword( + packet.groupString, + password: privateKeyHex + ), let inviteString = String(data: decryptedInviteData, encoding: .utf8), + let parsed = parseInviteString(inviteString) + else { + return nil + } + + return persistGroup( + account: account, + privateKeyHex: privateKeyHex, + groupId: parsed.groupId, + title: parsed.title, + description: parsed.description, + encryptKey: parsed.encryptKey + ) + } + + /// Persists a group directly from parsed invite components. + @discardableResult + func persistGroup( + account: String, + privateKeyHex: String, + groupId: String, + title: String, + description: String, + encryptKey: String + ) -> String? { + guard let encryptedGroupKey = try? CryptoManager.shared.encryptWithPassword( + Data(encryptKey.utf8), + password: privateKeyHex + ) else { + return nil + } + + do { + try db.writeSync { db in + try db.execute( + sql: """ + INSERT INTO groups (account, group_id, title, description, "key") + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(account, group_id) DO UPDATE SET + title = excluded.title, + description = excluded.description, + "key" = excluded."key" + """, + arguments: [account, groupId, title, description, encryptedGroupKey] + ) + } + } catch { + Self.logger.error("Failed to persist group \(groupId): \(error.localizedDescription)") + return nil + } + + // Populate in-memory caches so subsequent reads don't hit DB + // (prevents GRDB reentrancy when called from within a transaction). + let ck = cacheKey(account: account, groupId: groupId) + keyCache[ck] = encryptKey + metadataCache[ck] = GroupMetadata(title: title, description: description) + + return toGroupDialogKey(groupId) + } + + /// Deletes a group and all its local messages/dialog. + func deleteGroup(account: String, groupDialogKey: String) { + let groupId = normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return } + let dialogKey = toGroupDialogKey(groupId) + + // Clear caches first. + let ck = cacheKey(account: account, groupId: groupId) + keyCache.removeValue(forKey: ck) + metadataCache.removeValue(forKey: ck) + + do { + try db.writeSync { db in + try db.execute( + sql: "DELETE FROM groups WHERE account = ? AND group_id = ?", + arguments: [account, groupId] + ) + try db.execute( + sql: "DELETE FROM messages WHERE account = ? AND dialog_key = ?", + arguments: [account, dialogKey] + ) + try db.execute( + sql: "DELETE FROM dialogs WHERE account = ? AND opponent_key = ?", + arguments: [account, dialogKey] + ) + } + Self.logger.info("Deleted group \(groupId) and its local data") + } catch { + Self.logger.error("Failed to delete group \(groupId): \(error.localizedDescription)") + } + } + + /// Returns all group IDs for the given account. + func allGroupIds(account: String) -> [String] { + do { + return try db.read { db in + try String.fetchAll( + db, + sql: "SELECT group_id FROM groups WHERE account = ?", + arguments: [account] + ) + } + } catch { + return [] + } + } + + // MARK: - Key Generation (Android parity: 32 random bytes → hex) + + /// Generates a random 32-byte group encryption key as a 64-char hex string. + /// Matches Android `GroupRepository.generateGroupKey()`. + func generateGroupKey() -> String { + var bytes = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + return bytes.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index f0179b8..7ba1a0a 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -10,12 +10,14 @@ final class MessageRepository: ObservableObject { /// In-memory cache of messages for active dialogs. UI binds to this. @Published private(set) var messagesByDialog: [String: [ChatMessage]] = [:] - @Published private(set) var typingDialogs: Set = [] + /// dialogKey → set of senderPublicKeys for active typing indicators. + @Published private(set) var typingDialogs: [String: Set] = [:] private var activeDialogs: Set = [] /// Dialogs that are currently eligible for interactive read: /// screen is visible and list is at the bottom (Telegram-like behavior). private var readEligibleDialogs: Set = [] + /// Per-sender typing reset timers. Key: "dialogKey::senderKey" private var typingResetTasks: [String: Task] = [:] private var currentAccount: String = "" @@ -250,9 +252,8 @@ final class MessageRepository: ObservableObject { } else { activeDialogs.remove(dialogKey) readEligibleDialogs.remove(dialogKey) - typingDialogs.remove(dialogKey) - typingResetTasks[dialogKey]?.cancel() - typingResetTasks[dialogKey] = nil + typingDialogs.removeValue(forKey: dialogKey) + cancelTypingTimers(for: dialogKey) } } @@ -546,21 +547,29 @@ final class MessageRepository: ObservableObject { // MARK: - Typing - func markTyping(from opponentKey: String) { - if !typingDialogs.contains(opponentKey) { - typingDialogs.insert(opponentKey) + func markTyping(from dialogKey: String, senderKey: String) { + var current = typingDialogs[dialogKey] ?? [] + if !current.contains(senderKey) { + current.insert(senderKey) + typingDialogs[dialogKey] = current } - typingResetTasks[opponentKey]?.cancel() - typingResetTasks[opponentKey] = Task { @MainActor [weak self] in + // Per-sender timeout (Desktop parity: each typer has own 3s timer) + let timerKey = "\(dialogKey)::\(senderKey)" + typingResetTasks[timerKey]?.cancel() + typingResetTasks[timerKey] = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(3)) guard let self, !Task.isCancelled else { return } - self.typingDialogs.remove(opponentKey) - self.typingResetTasks[opponentKey] = nil + self.typingDialogs[dialogKey]?.remove(senderKey) + if self.typingDialogs[dialogKey]?.isEmpty == true { + self.typingDialogs.removeValue(forKey: dialogKey) + } + self.typingResetTasks.removeValue(forKey: timerKey) } } func isTyping(dialogKey: String) -> Bool { - typingDialogs.contains(dialogKey) + guard let set = typingDialogs[dialogKey] else { return false } + return !set.isEmpty } // MARK: - Delete @@ -580,9 +589,17 @@ final class MessageRepository: ObservableObject { messagesByDialog.removeValue(forKey: dialogKey) activeDialogs.remove(dialogKey) readEligibleDialogs.remove(dialogKey) - typingDialogs.remove(dialogKey) - typingResetTasks[dialogKey]?.cancel() - typingResetTasks[dialogKey] = nil + typingDialogs.removeValue(forKey: dialogKey) + cancelTypingTimers(for: dialogKey) + } + + /// Cancel all per-sender typing timers for a dialog. + private func cancelTypingTimers(for dialogKey: String) { + let prefix = "\(dialogKey)::" + for (key, task) in typingResetTasks where key.hasPrefix(prefix) { + task.cancel() + typingResetTasks.removeValue(forKey: key) + } } func deleteMessage(id: String) { @@ -738,19 +755,23 @@ final class MessageRepository: ObservableObject { /// Used by DialogRepository.updateDialogFromMessages() to set lastMessage text. func lastDecryptedMessage(account: String, opponentKey: String) -> ChatMessage? { let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: opponentKey) + // Fetch raw record inside db.read, then decrypt OUTSIDE the transaction. + // decryptRecord → tryReDecrypt → GroupRepository.groupKey() opens its own db.read, + // which would crash with "Database methods are not reentrant" if still inside a transaction. + let record: MessageRecord? do { - return try db.read { db in - guard let record = try MessageRecord + record = try db.read { db in + try MessageRecord .filter( MessageRecord.Columns.account == account && MessageRecord.Columns.dialogKey == dialogKey ) .order(MessageRecord.Columns.timestamp.desc, MessageRecord.Columns.messageId.desc) .fetchOne(db) - else { return nil } - return decryptRecord(record) } } catch { return nil } + guard let record else { return nil } + return decryptRecord(record) } // MARK: - Stress Test (Debug only) @@ -995,6 +1016,49 @@ final class MessageRepository: ObservableObject { } } + // MARK: - Batch Re-Decrypt (Group Key Recovery) + + /// Re-decrypts all messages in a group dialog that have empty text but non-empty content. + /// Called when a group key becomes available AFTER messages were already stored without it. + func reDecryptGroupMessages( + account: String, + groupDialogKey: String, + groupKey: String, + privateKeyHex: String + ) { + guard !groupKey.isEmpty, !privateKeyHex.isEmpty else { return } + let dialogKey = DatabaseManager.dialogKey(account: account, opponentKey: groupDialogKey) + + let records: [MessageRecord] + do { + records = try db.read { db in + try MessageRecord + .filter( + MessageRecord.Columns.account == account && + MessageRecord.Columns.dialogKey == dialogKey && + MessageRecord.Columns.content != "" + ) + .fetchAll(db) + } + } catch { return } + + var updated = 0 + for record in records { + // Try decrypt with group key. + guard let data = try? CryptoManager.shared.decryptWithPassword( + record.content, password: groupKey + ), let text = String(data: data, encoding: .utf8), !text.isEmpty else { + continue + } + persistReDecryptedText(text, forMessageId: record.messageId, privateKey: privateKeyHex) + updated += 1 + } + + if updated > 0 { + refreshDialogCache(for: groupDialogKey) + } + } + // MARK: - Android Parity: safePlainMessageFallback /// Android parity: `safePlainMessageFallback()` in `ChatViewModel.kt` lines 1338-1349. diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 373dd06..0604154 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -13,7 +13,7 @@ struct MessageCellLayout: Sendable { // MARK: - Cell - let totalHeight: CGFloat + var totalHeight: CGFloat let groupGap: CGFloat let isOutgoing: Bool let position: BubblePosition @@ -73,6 +73,13 @@ struct MessageCellLayout: Sendable { let dateHeaderText: String let dateHeaderHeight: CGFloat + // MARK: - Group Sender (optional) + + var showsSenderName: Bool // true for .top/.single incoming in group + var showsSenderAvatar: Bool // true for .bottom/.single incoming in group + var senderName: String + var senderKey: String + // MARK: - Types enum MessageType: Sendable { @@ -612,7 +619,11 @@ extension MessageCellLayout { forwardNameFrame: fwdNameFrame, showsDateHeader: config.showsDateHeader, dateHeaderText: config.dateHeaderText, - dateHeaderHeight: dateHeaderH + dateHeaderHeight: dateHeaderH, + showsSenderName: false, + showsSenderAvatar: false, + senderName: "", + senderKey: "" ) return (layout, cachedTextLayout) } @@ -778,6 +789,11 @@ extension MessageCellLayout { return false } + // Group chats: different senders NEVER merge (Telegram parity). + if message.fromPublicKey != neighbor.fromPublicKey { + return false + } + // Keep failed messages visually isolated (external failed indicator behavior). if message.deliveryStatus == .error || neighbor.deliveryStatus == .error { return false @@ -797,17 +813,12 @@ extension MessageCellLayout { let currentKind = groupingKind(for: message, displayText: currentDisplayText) let neighborKind = groupingKind(for: neighbor, displayText: neighborDisplayText) - guard currentKind == neighborKind else { - return false - } - // Telegram-like grouping by semantic kind (except forwarded-empty blocks). - switch currentKind { - case .text, .media, .file: - return true - case .forward: + // Forwards never merge (Telegram parity). All other kinds merge freely. + if currentKind == .forward || neighborKind == .forward { return false } + return true } } @@ -823,7 +834,8 @@ extension MessageCellLayout { maxBubbleWidth: CGFloat, currentPublicKey: String, opponentPublicKey: String, - opponentTitle: String + opponentTitle: String, + isGroupChat: Bool = false ) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) { var result: [String: MessageCellLayout] = [:] var textResult: [String: CoreTextTextLayout] = [:] @@ -952,7 +964,24 @@ extension MessageCellLayout { dateHeaderText: dateHeaderText ) - let (layout, textLayout) = calculate(config: config) + var (layout, textLayout) = calculate(config: config) + + // Group sender info: name on first in run, avatar on last in run. + if isGroupChat && !isOutgoing { + layout.senderKey = message.fromPublicKey + let resolvedName = DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle + ?? String(message.fromPublicKey.prefix(8)) + layout.senderName = resolvedName + // .top or .single = first message in sender run → show name + layout.showsSenderName = (position == .top || position == .single) + // .bottom or .single = last message in sender run → show avatar + layout.showsSenderAvatar = (position == .bottom || position == .single) + // Add height for sender name so cells don't overlap. + if layout.showsSenderName { + layout.totalHeight += 20 + } + } + result[message.id] = layout if let textLayout { textResult[message.id] = textLayout } } diff --git a/Rosetta/Core/Network/Protocol/PacketAwaiter.swift b/Rosetta/Core/Network/Protocol/PacketAwaiter.swift new file mode 100644 index 0000000..6417405 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/PacketAwaiter.swift @@ -0,0 +1,129 @@ +import Foundation + +// MARK: - Packet Awaiter + +/// Async/await wrapper for request-response packet exchanges. +/// Sends a packet and waits for a typed response matching a predicate, with timeout. +/// +/// Uses ProtocolManager's one-shot handler mechanism. Thread-safe: handlers fire on +/// URLSession delegate queue, continuation resumed via `@Sendable`. +enum PacketAwaiter { + + enum AwaitError: Error, LocalizedError { + case timeout + case notConnected + + var errorDescription: String? { + switch self { + case .timeout: return "Server did not respond in time" + case .notConnected: return "Not connected to server" + } + } + } + + /// Sends `outgoing` packet and waits for a response of type `T` matching `predicate`. + /// + /// - Parameters: + /// - outgoing: The packet to send. + /// - responsePacketId: The packet ID of the expected response (e.g., `0x11` for PacketCreateGroup). + /// - timeout: Maximum wait time in seconds (default 15). + /// - predicate: Filter to match the correct response (e.g., matching groupId). + /// - Returns: The matched response packet. + @MainActor + static func send( + _ outgoing: some Packet, + awaitResponse responsePacketId: Int, + timeout: TimeInterval = 15, + where predicate: @escaping @Sendable (T) -> Bool = { _ in true } + ) async throws -> T { + let proto = ProtocolManager.shared + guard proto.connectionState == .authenticated else { + throw AwaitError.notConnected + } + + return try await withCheckedThrowingContinuation { continuation in + // Guard against double-resume (timeout + response race). + let resumed = AtomicFlag() + var handlerId: UUID? + var timeoutTask: Task? + + let id = proto.addGroupOneShotHandler(packetId: responsePacketId) { rawPacket in + guard let response = rawPacket as? T else { return false } + guard predicate(response) else { return false } + + // Matched — consume and resume. + if resumed.setIfFalse() { + timeoutTask?.cancel() + continuation.resume(returning: response) + } + return true + } + handlerId = id + + proto.sendPacket(outgoing) + + // Timeout fallback. + timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + guard !Task.isCancelled else { return } + if resumed.setIfFalse() { + if let hid = handlerId { + proto.removeGroupOneShotHandler(hid) + } + continuation.resume(throwing: AwaitError.timeout) + } + } + } + } + + /// Waits for an incoming packet of type `T` without sending anything first. + @MainActor + static func awaitIncoming( + packetId: Int, + timeout: TimeInterval = 15, + where predicate: @escaping @Sendable (T) -> Bool = { _ in true } + ) async throws -> T { + let proto = ProtocolManager.shared + + return try await withCheckedThrowingContinuation { continuation in + let resumed = AtomicFlag() + var handlerId: UUID? + var timeoutTask: Task? + + let id = proto.addGroupOneShotHandler(packetId: packetId) { rawPacket in + guard let response = rawPacket as? T else { return false } + guard predicate(response) else { return false } + + if resumed.setIfFalse() { + timeoutTask?.cancel() + continuation.resume(returning: response) + } + return true + } + handlerId = id + + timeoutTask = Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + guard !Task.isCancelled else { return } + if resumed.setIfFalse() { + if let hid = handlerId { + proto.removeGroupOneShotHandler(hid) + } + continuation.resume(throwing: AwaitError.timeout) + } + } + } + } +} + +// MARK: - AtomicFlag (lock-free double-resume guard) + +/// Simple atomic boolean flag for preventing double continuation resume. +private final class AtomicFlag: @unchecked Sendable { + private var _value: Int32 = 0 + + /// Sets the flag to true. Returns `true` if it was previously false (first caller wins). + func setIfFalse() -> Bool { + OSAtomicCompareAndSwap32(0, 1, &_value) + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketWebRTC.swift b/Rosetta/Core/Network/Protocol/Packets/PacketWebRTC.swift index b337121..b96715c 100644 --- a/Rosetta/Core/Network/Protocol/Packets/PacketWebRTC.swift +++ b/Rosetta/Core/Network/Protocol/Packets/PacketWebRTC.swift @@ -13,14 +13,22 @@ struct PacketWebRTC: Packet { var signalType: WebRTCSignalType = .offer var sdpOrCandidate: String = "" + /// Sender's public key — server validates via DeviceService (no session auth needed). + var publicKey: String = "" + /// Sender's device ID — server checks publicKey↔deviceId binding. + var deviceId: String = "" func write(to stream: Stream) { stream.writeInt8(signalType.rawValue) stream.writeString(sdpOrCandidate) + stream.writeString(publicKey) + stream.writeString(deviceId) } mutating func read(from stream: Stream) { signalType = WebRTCSignalType(rawValue: stream.readInt8()) ?? .offer sdpOrCandidate = stream.readString() + publicKey = stream.readString() + deviceId = stream.readString() } } diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 7ca3d5d..b0ba362 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -88,6 +88,7 @@ final class ProtocolManager: @unchecked Sendable { private let signalPeerHandlersLock = NSLock() private let webRTCHandlersLock = NSLock() private let iceServersHandlersLock = NSLock() + private let groupOneShotLock = NSLock() private let packetQueueLock = NSLock() private let searchRouter = SearchPacketRouter() private var resultHandlers: [UUID: (PacketResult) -> Void] = [:] @@ -95,6 +96,10 @@ final class ProtocolManager: @unchecked Sendable { private var webRTCHandlers: [UUID: (PacketWebRTC) -> Void] = [:] private var iceServersHandlers: [UUID: (PacketIceServers) -> Void] = [:] + /// Generic one-shot handlers for group packets. Key: (packetId, handlerId). + /// Handler returns `true` if it consumed the packet (auto-removed). + private var groupOneShotHandlers: [UUID: (packetId: Int, handler: (any Packet) -> Bool)] = [:] + /// Background task to keep WebSocket alive during brief background periods (active call). /// iOS gives ~30s; enough for the call to survive app switching / notification interactions. private var callBackgroundTask: UIBackgroundTaskIdentifier = .invalid @@ -140,10 +145,13 @@ final class ProtocolManager: @unchecked Sendable { return } case .connecting: - if client.isConnecting { - Self.logger.info("Connect already in progress, skipping duplicate connect()") - return - } + // Always skip if already in connecting state. Previous code only + // checked client.isConnecting which has a race gap — between + // setting connectionState=.connecting and client.connect() setting + // isConnecting=true, a second call would slip through. + // This caused 3 parallel WebSocket connections (3x every packet). + Self.logger.info("Connect already in progress, skipping duplicate connect()") + return case .disconnected: break } @@ -189,13 +197,19 @@ final class ProtocolManager: @unchecked Sendable { func forceReconnectOnForeground() { guard savedPublicKey != nil, savedPrivateHash != nil else { return } - // During an active call the WebSocket may still be alive (background task - // keeps the process running for ~30s). Tearing it down would break signaling - // and trigger server re-delivery of .call — causing endCallBecauseBusy. - // If the connection is authenticated, trust it and skip reconnect. + // During an active call, WebRTC media flows via DTLS/SRTP (not WebSocket). + // Tearing down the socket would trigger server re-delivery of .call and + // cause unnecessary signaling disruption (endCallBecauseBusy). + // For .active phase: skip entirely — WS is only needed for endCall signal, + // which will work after natural reconnect or ICE timeout ends the call. + // For other call phases: skip only if WS is authenticated (still alive). + if CallManager.shared.uiState.phase == .active { + Self.logger.info("⚡ Foreground reconnect skipped — call active, media via DTLS") + return + } if CallManager.shared.uiState.phase != .idle, connectionState == .authenticated { - Self.logger.info("⚡ Foreground reconnect skipped — active call, WS authenticated") + Self.logger.info("⚡ Foreground reconnect skipped — call in progress, WS authenticated") return } @@ -205,7 +219,8 @@ final class ProtocolManager: @unchecked Sendable { case .handshaking, .deviceVerificationRequired: return case .connecting: - if client.isConnecting { return } + // Same fix as connect() — unconditional return to prevent triple connections + return case .authenticated, .connected, .disconnected: break // Always reconnect — .authenticated/.connected may be zombie on iOS } @@ -229,6 +244,10 @@ final class ProtocolManager: @unchecked Sendable { func beginCallBackgroundTask() { guard callBackgroundTask == .invalid else { return } callBackgroundTask = UIApplication.shared.beginBackgroundTask(withName: "RosettaCall") { [weak self] in + // Don't end the call here — CallKit keeps the process alive for active calls. + // This background task only buys time for WebSocket reconnection. + // Killing the call on expiry was causing premature call termination + // during keyExchange phase (~30s before Desktop could respond). self?.endCallBackgroundTask() } Self.logger.info("📞 Background task started for call") @@ -252,8 +271,8 @@ final class ProtocolManager: @unchecked Sendable { case .authenticated, .handshaking, .deviceVerificationRequired, .connected: return case .connecting: - // Android parity: `(CONNECTING && isConnecting)` — skip if connect() is in progress. - if client.isConnecting { return } + // Unconditional return — prevent duplicate connections (same fix as connect()) + return case .disconnected: break } @@ -352,6 +371,8 @@ final class ProtocolManager: @unchecked Sendable { var packet = PacketWebRTC() packet.signalType = signalType packet.sdpOrCandidate = sdpOrCandidate + packet.publicKey = SessionManager.shared.currentPublicKey + packet.deviceId = DeviceIdentityManager.shared.currentDeviceId() sendPacket(packet) } @@ -452,6 +473,47 @@ final class ProtocolManager: @unchecked Sendable { resultHandlersLock.unlock() } + // MARK: - Group One-Shot Handlers + + /// Registers a one-shot handler for a specific packet type. + /// Handler receives the packet and returns `true` to consume (auto-remove), `false` to keep. + @discardableResult + func addGroupOneShotHandler(packetId: Int, handler: @escaping (any Packet) -> Bool) -> UUID { + let id = UUID() + groupOneShotLock.lock() + groupOneShotHandlers[id] = (packetId, handler) + groupOneShotLock.unlock() + return id + } + + func removeGroupOneShotHandler(_ id: UUID) { + groupOneShotLock.lock() + groupOneShotHandlers.removeValue(forKey: id) + groupOneShotLock.unlock() + } + + /// Called from `routeIncomingPacket` — dispatches to matching one-shot handlers. + private func notifyGroupOneShotHandlers(packetId: Int, packet: any Packet) { + groupOneShotLock.lock() + let matching = groupOneShotHandlers.filter { $0.value.packetId == packetId } + groupOneShotLock.unlock() + + var consumed: [UUID] = [] + for (id, entry) in matching { + if entry.handler(packet) { + consumed.append(id) + } + } + + if !consumed.isEmpty { + groupOneShotLock.lock() + for id in consumed { + groupOneShotHandlers.removeValue(forKey: id) + } + groupOneShotLock.unlock() + } + } + // MARK: - Private Setup private func setupClientCallbacks() { @@ -668,26 +730,32 @@ final class ProtocolManager: @unchecked Sendable { case 0x11: if let p = packet as? PacketCreateGroup { onCreateGroupReceived?(p) + notifyGroupOneShotHandlers(packetId: 0x11, packet: p) } case 0x12: if let p = packet as? PacketGroupInfo { onGroupInfoReceived?(p) + notifyGroupOneShotHandlers(packetId: 0x12, packet: p) } case 0x13: if let p = packet as? PacketGroupInviteInfo { onGroupInviteInfoReceived?(p) + notifyGroupOneShotHandlers(packetId: 0x13, packet: p) } case 0x14: if let p = packet as? PacketGroupJoin { onGroupJoinReceived?(p) + notifyGroupOneShotHandlers(packetId: 0x14, packet: p) } case 0x15: if let p = packet as? PacketGroupLeave { onGroupLeaveReceived?(p) + notifyGroupOneShotHandlers(packetId: 0x15, packet: p) } case 0x16: if let p = packet as? PacketGroupBan { onGroupBanReceived?(p) + notifyGroupOneShotHandlers(packetId: 0x16, packet: p) } case 0x0F: if let p = packet as? PacketRequestTransport { diff --git a/Rosetta/Core/Services/CallKitManager.swift b/Rosetta/Core/Services/CallKitManager.swift index 9d7c3a3..2fb6b75 100644 --- a/Rosetta/Core/Services/CallKitManager.swift +++ b/Rosetta/Core/Services/CallKitManager.swift @@ -216,7 +216,11 @@ final class CallKitManager: NSObject { // MARK: - End Call func endCall() { - guard let uuid = currentCallUUID else { return } + guard let uuid = currentCallUUID else { + Self.logger.notice("CallKit.endCall: no UUID — skipping") + return + } + Self.logger.notice("CallKit.endCall uuid=\(uuid.uuidString.prefix(8), privacy: .public)") currentCallUUID = nil uuidLock.lock() _pendingCallUUID = nil @@ -233,6 +237,7 @@ final class CallKitManager: NSObject { func reportCallEndedByRemote(reason: CXCallEndedReason = .remoteEnded) { guard let uuid = currentCallUUID else { return } + Self.logger.notice("CallKit.reportCallEndedByRemote reason=\(reason.rawValue, privacy: .public) uuid=\(uuid.uuidString.prefix(8), privacy: .public)") currentCallUUID = nil uuidLock.lock() _pendingCallUUID = nil @@ -322,15 +327,26 @@ extension CallKitManager: CXProviderDelegate { // use whatever category is currently set. Without this, it may use // .soloAmbient (default) instead of .playAndRecord → silent audio. rtcSession.lockForConfiguration() - try? rtcSession.setCategory( - .playAndRecord, mode: .voiceChat, - options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] - ) + do { + try rtcSession.setCategory( + .playAndRecord, mode: .voiceChat, + options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] + ) + } catch { + Self.logger.error("Failed to set audio category: \(error.localizedDescription)") + } rtcSession.unlockForConfiguration() // 3. NOW enable audio — ADM will init with correct .playAndRecord category. rtcSession.isAudioEnabled = true + // Guard: CallKit may fire didActivate twice (observed in logs). + // Second call is harmless for WebRTC but re-running onAudioSessionActivated + // wastes cycles and logs confusing duplicate entries. Task { @MainActor in - await CallManager.shared.onAudioSessionActivated() + if !CallManager.shared.audioSessionActivated { + await CallManager.shared.onAudioSessionActivated() + } else { + callLogger.info("[Call] didActivate: skipping duplicate (already activated)") + } } } @@ -339,5 +355,8 @@ extension CallKitManager: CXProviderDelegate { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.audioSessionDidDeactivate(audioSession) rtcSession.isAudioEnabled = false + Task { @MainActor in + CallManager.shared.didReceiveCallKitDeactivation = true + } } } diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift index c28f5d0..0d20124 100644 --- a/Rosetta/Core/Services/CallManager+Runtime.swift +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -66,7 +66,11 @@ extension CallManager { bufferedRemoteCandidates.append(candidate) return } - try? await peerConnection.add(candidate) + do { + try await peerConnection.add(candidate) + } catch { + callLogger.warning("[Call] Failed to add ICE candidate: \(error.localizedDescription, privacy: .public)") + } } } @@ -77,7 +81,7 @@ extension CallManager { /// double-async nesting (Task inside Task) that causes race conditions. func onAudioSessionActivated() async { audioSessionActivated = true - callLogger.info("[Call] didActivate: phase=\(self.uiState.phase.rawValue, privacy: .public) pendingWebRtcSetup=\(self.pendingWebRtcSetup.description, privacy: .public)") + callLogger.notice("[Call] didActivate: phase=\(self.uiState.phase.rawValue, privacy: .public) pendingWebRtcSetup=\(self.pendingWebRtcSetup.description, privacy: .public)") guard uiState.phase != .idle else { return } // Flush deferred WebRTC setup — .createRoom arrived before didActivate. @@ -105,6 +109,11 @@ extension CallManager { do { let peerConnection = try ensurePeerConnection() applySenderCryptorIfPossible() + // Apply audio routing + enable track NOW — didActivate may have already + // fired before createRoom arrived, so its applyAudioOutputRouting was a no-op + // (no track existed yet). Without this, audio is silent on both sides. + applyAudioOutputRouting() + localAudioTrack?.isEnabled = !uiState.isMuted let offer = try await createOffer(on: peerConnection) try await setLocalDescription(offer, on: peerConnection) @@ -151,7 +160,7 @@ extension CallManager { pendingCallKitAccept = false defer { isFinishingCall = false } - callLogger.info("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)") + callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)") let snapshot = uiState @@ -244,6 +253,7 @@ extension CallManager { lastPeerSharedPublicHex = "" audioSessionActivated = false pendingWebRtcSetup = false + didReceiveCallKitDeactivation = false var finalState = CallUiState() if let reason, !reason.isEmpty { @@ -284,7 +294,10 @@ extension CallManager { func applySenderCryptorIfPossible() { guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return } guard let localAudioSender else { return } - _ = WebRTCFrameCryptorBridge.attach(localAudioSender, sharedKey: sharedKey) + let attached = WebRTCFrameCryptorBridge.attach(localAudioSender, sharedKey: sharedKey) + if !attached { + callLogger.error("[Call] E2EE: FAILED to attach sender encryptor — audio may be unencrypted") + } } func startDurationTimerIfNeeded() { @@ -347,32 +360,20 @@ extension CallManager { return connection } - func configureAudioSession() throws { - let rtcSession = RTCAudioSession.sharedInstance() - rtcSession.lockForConfiguration() - defer { rtcSession.unlockForConfiguration() } - try rtcSession.setCategory( - .playAndRecord, - mode: .voiceChat, - options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] - ) - // Do NOT call setActive(true) — session is already activated by CallKit - // via audioSessionDidActivate(). Double activation increments WebRTC's - // internal activation count, causing deactivateAudioSession() to decrement - // to 1 instead of 0 — AVAudioSession never actually deactivates. - applyAudioOutputRouting() - UIDevice.current.isProximityMonitoringEnabled = true - let session = AVAudioSession.sharedInstance() - callLogger.info("[Call] AudioSession configured: category=\(session.category.rawValue, privacy: .public) mode=\(session.mode.rawValue, privacy: .public)") - } - func deactivateAudioSession() { UIDevice.current.isProximityMonitoringEnabled = false let rtcSession = RTCAudioSession.sharedInstance() - rtcSession.lockForConfiguration() - try? rtcSession.setActive(false) - rtcSession.unlockForConfiguration() + // Only call setActive(false) if CallKit's didDeactivate did NOT fire. + // CallKit's didDeactivate already calls audioSessionDidDeactivate() which + // decrements WebRTC's internal activation count. Calling setActive(false) + // again would double-decrement, breaking audio on the next call. + if !didReceiveCallKitDeactivation { + rtcSession.lockForConfiguration() + try? rtcSession.setActive(false) + rtcSession.unlockForConfiguration() + } rtcSession.isAudioEnabled = false + didReceiveCallKitDeactivation = false } func applyAudioOutputRouting() { @@ -454,7 +455,11 @@ extension CallManager { guard let currentPeerConnection = self.peerConnection else { return } guard !bufferedRemoteCandidates.isEmpty else { return } for candidate in bufferedRemoteCandidates { - try? await currentPeerConnection.add(candidate) + do { + try await currentPeerConnection.add(candidate) + } catch { + callLogger.warning("[Call] Failed to add buffered ICE candidate: \(error.localizedDescription, privacy: .public)") + } } bufferedRemoteCandidates.removeAll() } diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift index 50ff723..cb7a5e1 100644 --- a/Rosetta/Core/Services/CallManager.swift +++ b/Rosetta/Core/Services/CallManager.swift @@ -53,6 +53,11 @@ final class CallManager: NSObject, ObservableObject { /// WebRTC peer connection MUST NOT be created before this flag is true, /// otherwise AURemoteIO init fails with "Missing entitlement" (-12988). var audioSessionActivated = false + /// True after CallKit fires didDeactivate. Prevents double-deactivation in + /// deactivateAudioSession() — calling setActive(false) after CallKit already + /// deactivated decrements WebRTC's internal counter below zero, breaking + /// audio on the next call. + var didReceiveCallKitDeactivation = false /// Buffered when .createRoom arrives before didActivate. Flushed in onAudioSessionActivated(). var pendingWebRtcSetup = false /// Buffers WebRTC packets (OFFER/ANSWER/ICE) from SFU that arrive before @@ -96,7 +101,7 @@ final class CallManager: NSObject, ObservableObject { func setupIncomingCallFromPush(callerKey: String, callerName: String) { guard uiState.phase == .idle else { return } guard !callerKey.isEmpty else { return } - callLogger.info("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)") + callLogger.notice("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)") // Don't call beginCallSession() — it calls finishCall() which kills the // CallKit call that PushKit just reported. Set state directly instead. uiState = CallUiState( @@ -116,7 +121,7 @@ final class CallManager: NSObject, ObservableObject { // not yet connected, so CXAnswerCallAction fired before phase was .incoming. if pendingCallKitAccept { pendingCallKitAccept = false - callLogger.info("setupIncomingCallFromPush: auto-accepting (pendingCallKitAccept)") + callLogger.notice("setupIncomingCallFromPush: auto-accepting (pendingCallKitAccept)") let result = acceptIncomingCall() callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)") } @@ -194,7 +199,7 @@ final class CallManager: NSObject, ObservableObject { } func endCall() { - callLogger.info("[Call] endCall phase=\(self.uiState.phase.rawValue, privacy: .public)") + callLogger.notice("[Call] endCall phase=\(self.uiState.phase.rawValue, privacy: .public)") finishCall(reason: nil, notifyPeer: true) } @@ -238,25 +243,29 @@ final class CallManager: NSObject, ObservableObject { // MARK: - Protocol handlers private func wireProtocolHandlers() { + // Priority .userInitiated — call signals MUST NOT wait behind sync batch. + // During background wake-up, sync floods MainActor with hundreds of message + // Tasks (default priority). Without elevated priority, call signal Tasks + // queue behind sync → keyExchange delayed → Desktop gives up → call fails. signalToken = ProtocolManager.shared.addSignalPeerHandler { [weak self] packet in - Task { @MainActor [weak self] in + Task(priority: .userInitiated) { @MainActor [weak self] in self?.handleSignalPacket(packet) } } webRtcToken = ProtocolManager.shared.addWebRtcHandler { [weak self] packet in - Task { @MainActor [weak self] in + Task(priority: .userInitiated) { @MainActor [weak self] in await self?.handleWebRtcPacket(packet) } } iceToken = ProtocolManager.shared.addIceServersHandler { [weak self] packet in - Task { @MainActor [weak self] in + Task(priority: .userInitiated) { @MainActor [weak self] in self?.handleIceServersPacket(packet) } } } private func handleSignalPacket(_ packet: PacketSignalPeer) { - callLogger.info("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)") + callLogger.notice("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)") switch packet.signalType { case .endCallBecauseBusy: finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true) @@ -334,17 +343,33 @@ final class CallManager: NSObject, ObservableObject { roomId = incomingRoomId uiState.phase = .webRtcExchange uiState.statusText = "Connecting..." - // Defer WebRTC peer connection setup until CallKit grants audio entitlement. - // Creating RTCPeerConnection + audio track BEFORE didActivate causes - // AURemoteIO to fail with "Missing entitlement" (-12988), poisoning audio - // for the entire call. If didActivate already fired, proceed immediately. if audioSessionActivated { - Task { [weak self] in + Task(priority: .userInitiated) { [weak self] in await self?.ensurePeerConnectionAndOffer() } } else { pendingWebRtcSetup = true - callLogger.info("[Call] Deferring WebRTC setup — waiting for CallKit didActivate") + callLogger.notice("[Call] Deferring WebRTC setup — waiting for CallKit didActivate") + // Safety: if didActivate never fires (background VoIP push race), + // force audio session activation after 3 seconds. Without this, + // the call hangs in webRtcExchange forever → Desktop gives up → + // SFU room stays allocated → next call gets "busy". + Task(priority: .userInitiated) { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(3)) + guard let self else { return } + guard self.pendingWebRtcSetup, !self.audioSessionActivated else { return } + callLogger.notice("[Call] didActivate timeout (3s) — force-activating audio session") + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.audioSessionDidActivate(AVAudioSession.sharedInstance()) + rtcSession.lockForConfiguration() + try? rtcSession.setCategory( + .playAndRecord, mode: .voiceChat, + options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] + ) + rtcSession.unlockForConfiguration() + rtcSession.isAudioEnabled = true + await self.onAudioSessionActivated() + } } case .activeCall: break @@ -543,9 +568,17 @@ final class CallManager: NSObject, ObservableObject { func startE2EERebindLoop() { e2eeRebindTask?.cancel() e2eeRebindTask = Task { @MainActor [weak self] in + // First iteration runs immediately (no sleep) — attaches cryptors + // to any senders/receivers created during peer connection setup. + // Subsequent iterations run every 1.5s (Android parity). + var isFirstIteration = true while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(1500)) - guard !Task.isCancelled else { return } + if isFirstIteration { + isFirstIteration = false + } else { + try? await Task.sleep(for: .milliseconds(1500)) + guard !Task.isCancelled else { return } + } guard let self else { return } guard self.uiState.phase == .webRtcExchange || self.uiState.phase == .active else { continue } guard self.sharedKey != nil, let pc = self.peerConnection else { continue } @@ -582,8 +615,10 @@ final class CallManager: NSObject, ObservableObject { // Android waits 15s. Previous iOS code killed instantly → unstable calls. startDisconnectRecoveryTimer(timeout: 15) case .failed: - // More serious — unlikely to recover. Shorter timeout than .disconnected. - startDisconnectRecoveryTimer(timeout: 5) + // More serious — unlikely to recover. Android uses 12s for PeerConnectionState.FAILED + // (CallManager.kt:820). Previous iOS value was 5s — too aggressive, dropped calls + // that Android would recover from on poor networks. + startDisconnectRecoveryTimer(timeout: 12) case .closed: finishCall(reason: "Connection closed", notifyPeer: false) default: diff --git a/Rosetta/Core/Services/CallSoundManager.swift b/Rosetta/Core/Services/CallSoundManager.swift index fe93752..57b65d5 100644 --- a/Rosetta/Core/Services/CallSoundManager.swift +++ b/Rosetta/Core/Services/CallSoundManager.swift @@ -1,6 +1,7 @@ import AudioToolbox import AVFAudio import Foundation +import os @MainActor final class CallSoundManager { @@ -67,7 +68,7 @@ final class CallSoundManager { private func playLoop(_ name: String) { guard let url = Bundle.main.url(forResource: name, withExtension: "mp3") else { - print("[CallSound] Sound file not found: \(name).mp3") + callLogger.warning("[CallSound] Sound file not found: \(name, privacy: .public).mp3") return } do { @@ -78,13 +79,13 @@ final class CallSoundManager { player.play() loopingPlayer = player } catch { - print("[CallSound] Failed to play \(name): \(error)") + callLogger.error("[CallSound] Failed to play \(name, privacy: .public): \(error.localizedDescription, privacy: .public)") } } private func playOneShot(_ name: String) { guard let url = Bundle.main.url(forResource: name, withExtension: "mp3") else { - print("[CallSound] Sound file not found: \(name).mp3") + callLogger.warning("[CallSound] Sound file not found: \(name, privacy: .public).mp3") return } do { @@ -95,7 +96,7 @@ final class CallSoundManager { player.play() oneShotPlayer = player } catch { - print("[CallSound] Failed to play \(name): \(error)") + callLogger.error("[CallSound] Failed to play \(name, privacy: .public): \(error.localizedDescription, privacy: .public)") } } diff --git a/Rosetta/Core/Services/GroupService.swift b/Rosetta/Core/Services/GroupService.swift new file mode 100644 index 0000000..792144d --- /dev/null +++ b/Rosetta/Core/Services/GroupService.swift @@ -0,0 +1,299 @@ +import Foundation +import os + +// MARK: - GroupService + +/// Orchestrates multi-step group operations (create, join, leave, kick, members). +/// Uses PacketAwaiter for async request-response exchanges with the server. +@MainActor +final class GroupService { + static let shared = GroupService() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "GroupService") + + private let groupRepo = GroupRepository.shared + private let dialogRepo = DialogRepository.shared + private let proto = ProtocolManager.shared + + private init() {} + + // MARK: - Errors + + enum GroupError: Error, LocalizedError { + case notAuthenticated + case serverReturnedEmptyId + case failedToConstructInvite + case failedToEncryptInvite + case joinRejected(GroupStatus) + case invalidInviteString + case timeout + + var errorDescription: String? { + switch self { + case .notAuthenticated: return "Not connected to server" + case .serverReturnedEmptyId: return "Server returned empty group ID" + case .failedToConstructInvite: return "Failed to construct invite string" + case .failedToEncryptInvite: return "Failed to encrypt invite string" + case .joinRejected(let status): return "Join rejected: \(status)" + case .invalidInviteString: return "Invalid invite string" + case .timeout: return "Server did not respond in time" + } + } + } + + // MARK: - Create Group + + /// Creates a new group: requests ID from server, generates key, joins, persists locally. + /// Returns a ChatRoute for navigating to the new group chat. + func createGroup(title: String, description: String = "") async throws -> ChatRoute { + let session = SessionManager.shared + let account = session.currentPublicKey + guard let privateKeyHex = session.privateKeyHex, !account.isEmpty else { + throw GroupError.notAuthenticated + } + + Self.logger.info("Creating group: \(title)") + + // Step 1: Request group ID from server. + let createResponse: PacketCreateGroup = try await PacketAwaiter.send( + PacketCreateGroup(), + awaitResponse: 0x11, + where: { $0.groupId.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 } + ) + let groupId = createResponse.groupId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !groupId.isEmpty else { throw GroupError.serverReturnedEmptyId } + Self.logger.info("Server assigned groupId: \(groupId)") + + // Step 2: Generate group encryption key (Android parity: 32 random bytes → hex). + let groupKey = groupRepo.generateGroupKey() + + // Step 3: Construct invite string. + guard let inviteString = groupRepo.constructInviteString( + groupId: groupId, + title: title, + encryptKey: groupKey, + description: description + ) else { + throw GroupError.failedToConstructInvite + } + + // Step 4: Encrypt invite with private key for server storage (sync to other devices). + guard let encryptedGroupString = try? CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(inviteString.utf8), + password: privateKeyHex + ) else { + throw GroupError.failedToEncryptInvite + } + + // Step 5: Join the group we just created. + var joinPacket = PacketGroupJoin() + joinPacket.groupId = groupId + joinPacket.status = .notJoined + joinPacket.groupString = encryptedGroupString + + let joinResponse: PacketGroupJoin = try await PacketAwaiter.send( + joinPacket, + awaitResponse: 0x14, + where: { $0.groupId == groupId } + ) + + guard joinResponse.status == .joined else { + throw GroupError.joinRejected(joinResponse.status) + } + + // Step 6: Persist group locally. + let dialogKey = groupRepo.persistGroup( + account: account, + privateKeyHex: privateKeyHex, + groupId: groupId, + title: title, + description: description, + encryptKey: groupKey + ) ?? groupRepo.toGroupDialogKey(groupId) + + // Step 7: Ensure dialog exists in chat list. + dialogRepo.ensureDialog( + opponentKey: dialogKey, + title: title, + username: description, + myPublicKey: account + ) + + Self.logger.info("Group created successfully: \(dialogKey)") + return ChatRoute(groupDialogKey: dialogKey, title: title, description: description) + } + + // MARK: - Join Group + + /// Joins an existing group from an invite string. + func joinGroup(inviteString: String) async throws -> ChatRoute { + let session = SessionManager.shared + let account = session.currentPublicKey + guard let privateKeyHex = session.privateKeyHex, !account.isEmpty else { + throw GroupError.notAuthenticated + } + + guard let parsed = groupRepo.parseInviteString(inviteString) else { + throw GroupError.invalidInviteString + } + + Self.logger.info("Joining group: \(parsed.groupId)") + + // Encrypt invite with private key for server sync. + guard let encryptedGroupString = try? CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data(inviteString.utf8), + password: privateKeyHex + ) else { + throw GroupError.failedToEncryptInvite + } + + var joinPacket = PacketGroupJoin() + joinPacket.groupId = parsed.groupId + joinPacket.status = .notJoined + joinPacket.groupString = encryptedGroupString + + let response: PacketGroupJoin = try await PacketAwaiter.send( + joinPacket, + awaitResponse: 0x14, + where: { $0.groupId == parsed.groupId } + ) + + guard response.status == .joined else { + throw GroupError.joinRejected(response.status) + } + + let dialogKey = groupRepo.persistGroup( + account: account, + privateKeyHex: privateKeyHex, + groupId: parsed.groupId, + title: parsed.title, + description: parsed.description, + encryptKey: parsed.encryptKey + ) ?? groupRepo.toGroupDialogKey(parsed.groupId) + + dialogRepo.ensureDialog( + opponentKey: dialogKey, + title: parsed.title, + username: parsed.description, + myPublicKey: account + ) + + Self.logger.info("Joined group: \(dialogKey)") + return ChatRoute(groupDialogKey: dialogKey, title: parsed.title, description: parsed.description) + } + + // MARK: - Leave Group + + /// Leaves a group and deletes all local data. + func leaveGroup(groupDialogKey: String) async throws { + let session = SessionManager.shared + let account = session.currentPublicKey + guard !account.isEmpty else { throw GroupError.notAuthenticated } + + let groupId = groupRepo.normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return } + + Self.logger.info("Leaving group: \(groupId)") + + var leavePacket = PacketGroupLeave() + leavePacket.groupId = groupId + + let _: PacketGroupLeave = try await PacketAwaiter.send( + leavePacket, + awaitResponse: 0x15, + where: { $0.groupId == groupId } + ) + + // Delete local data. + groupRepo.deleteGroup(account: account, groupDialogKey: groupDialogKey) + dialogRepo.deleteDialog(opponentKey: groupDialogKey) + + Self.logger.info("Left group: \(groupId)") + } + + // MARK: - Kick Member (Admin) + + /// Bans a member from the group (admin only — server validates). + /// Server responds with updated PacketGroupInfo (0x12) after successful ban. + func kickMember(groupDialogKey: String, memberPublicKey: String) async throws { + let groupId = groupRepo.normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return } + + Self.logger.info("Kicking \(memberPublicKey.prefix(12)) from group \(groupId)") + + var banPacket = PacketGroupBan() + banPacket.groupId = groupId + banPacket.publicKey = memberPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + + let _: PacketGroupInfo = try await PacketAwaiter.send( + banPacket, + awaitResponse: 0x12, + where: { $0.groupId == groupId } + ) + } + + // MARK: - Request Members + + /// Fetches the member list for a group from the server. + func requestMembers(groupDialogKey: String) async throws -> [String] { + let groupId = groupRepo.normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return [] } + + var infoPacket = PacketGroupInfo() + infoPacket.groupId = groupId + + let response: PacketGroupInfo = try await PacketAwaiter.send( + infoPacket, + awaitResponse: 0x12, + where: { $0.groupId == groupId } + ) + + return response.members + } + + // MARK: - Check Invite Status + + /// Checks if the current user can join a group (pre-join status check). + func checkInviteStatus(groupId: String) async throws -> (memberCount: Int, status: GroupStatus) { + let normalized = groupRepo.normalizeGroupId(groupId) + guard !normalized.isEmpty else { return (0, .invalid) } + + var packet = PacketGroupInviteInfo() + packet.groupId = normalized + + let response: PacketGroupInviteInfo = try await PacketAwaiter.send( + packet, + awaitResponse: 0x13, + where: { $0.groupId == normalized } + ) + + return (response.membersCount, response.status) + } + + // MARK: - Generate Invite String + + /// Generates a shareable invite string for a group the user is admin of. + func generateInviteString(groupDialogKey: String) -> String? { + let session = SessionManager.shared + let account = session.currentPublicKey + guard let privateKeyHex = session.privateKeyHex, !account.isEmpty else { return nil } + + let groupId = groupRepo.normalizeGroupId(groupDialogKey) + guard !groupId.isEmpty else { return nil } + + guard let groupKey = groupRepo.groupKey( + account: account, + privateKeyHex: privateKeyHex, + groupDialogKey: groupDialogKey + ) else { return nil } + + let metadata = groupRepo.groupMetadata(account: account, groupDialogKey: groupDialogKey) + + return groupRepo.constructInviteString( + groupId: groupId, + title: metadata?.title ?? "", + encryptKey: groupKey, + description: metadata?.description ?? "" + ) + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 319517e..32c21cc 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -42,6 +42,8 @@ final class SessionManager { /// Desktop parity: exposed so chat list can suppress unread badges during sync. private(set) var syncBatchInProgress = false private var syncRequestInFlight = false + /// One-time flag: prevents infinite recovery sync loop. + private var hasTriggeredGroupRecoverySync = false private var pendingIncomingMessages: [PacketMessage] = [] private var isProcessingIncomingMessages = false /// Android parity: tracks the latest incoming message timestamp per dialog @@ -373,7 +375,18 @@ final class SessionManager { // Send via WebSocket (queued if offline, sent directly if online) ProtocolManager.shared.sendPacket(packet) - registerOutgoingRetry(for: packet) + + // Server doesn't send delivery ACK (0x08) for group messages — + // MessageDispatcher.sendGroup() only distributes to members, no ACK back. + // Mark as delivered immediately (same pattern as Saved Messages). + if DatabaseManager.isGroupDialogKey(targetDialogKey) { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, opponentKey: targetDialogKey, status: .delivered + ) + } else { + registerOutgoingRetry(for: packet) + } } /// Sends current user's avatar to a chat as a message attachment. @@ -556,15 +569,52 @@ final class SessionManager { // Android parity: no caption → encrypt empty string "". // Receivers decrypt "" → show "Photo"/"File" in chat list. let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines) - let encrypted = try MessageCrypto.encryptOutgoing( - plaintext: messageText.isEmpty ? "" : messageText, - recipientPublicKeyHex: toPublicKey - ) + let isGroup = DatabaseManager.isGroupDialogKey(toPublicKey) - // Attachment password: HEX encoding of raw 56-byte key+nonce. - // Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex'). - // HEX is lossless for all byte values (no U+FFFD data loss). - let attachmentPassword = encrypted.plainKeyAndNonce.hexString + // Group vs direct: different encryption paths. + let attachmentPassword: String + let encryptedContent: String + let outChachaKey: String + let outAesChachaKey: String + + if isGroup { + let normalizedGroup = Self.normalizedGroupDialogIdentity(toPublicKey) + guard let groupKey = GroupRepository.shared.groupKey( + account: currentPublicKey, + privateKeyHex: privKey, + groupDialogKey: normalizedGroup + ) else { + throw CryptoError.invalidData("Missing group key for \(normalizedGroup)") + } + // Group: encrypt text and attachments with shared group key. + encryptedContent = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + Data((messageText.isEmpty ? "" : messageText).utf8), + password: groupKey + ) + // Desktop parity: Buffer.from(groupKey).toString('hex') — hex of UTF-8 bytes + attachmentPassword = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() + outChachaKey = "" + outAesChachaKey = "" + } else { + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: messageText.isEmpty ? "" : messageText, + recipientPublicKeyHex: toPublicKey + ) + // Attachment password: HEX encoding of raw 56-byte key+nonce. + // Desktop commit 61e83bd: changed from Buffer.toString('utf-8') to key.toString('hex'). + attachmentPassword = encrypted.plainKeyAndNonce.hexString + encryptedContent = encrypted.content + outChachaKey = encrypted.chachaKey + + guard let latin1String = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } + let aesChachaPayload = Data(latin1String.utf8) + outAesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + aesChachaPayload, + password: privKey + ) + } #if DEBUG let pwdUtf8Bytes = Array(attachmentPassword.utf8) @@ -650,52 +700,17 @@ final class SessionManager { ) } - // Build aesChachaKey (for sync/backup — same encoding as makeOutgoingPacket). - // MUST use Latin-1 (not WHATWG UTF-8) so desktop can recover original raw bytes - // via Buffer.from(decryptedString, 'binary') which takes the low byte of each char. - // Latin-1 maps every byte 0x00-0xFF to its codepoint losslessly. - // WHATWG UTF-8 replaces invalid sequences with U+FFFD (codepoint 0xFFFD) — - // Buffer.from('\uFFFD', 'binary') recovers 0xFD, not the original byte. - guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { - throw CryptoError.encryptionFailed - } - let aesChachaPayload = Data(latin1ForSync.utf8) - let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( - aesChachaPayload, - password: privKey - ) - - #if DEBUG - // aesChachaKey round-trip self-test: simulates EXACT desktop sync chain. - do { - let rtDecrypted = try CryptoManager.shared.decryptWithPassword( - aesChachaKey, password: privKey, requireCompression: true - ) - guard let rtString = String(data: rtDecrypted, encoding: .utf8) else { - Self.logger.error("📎 aesChachaKey FAILED — not valid UTF-8") - throw CryptoError.decryptionFailed - } - // Simulate Buffer.from(string, 'binary').toString('hex') - let rtRawBytes = Data(rtString.unicodeScalars.map { UInt8($0.value & 0xFF) }) - let rtHex = rtRawBytes.hexString - let match = rtHex == attachmentPassword - Self.logger.debug("📎 aesChachaKey roundtrip: \(match ? "PASS" : "FAIL") (\(rtRawBytes.count) bytes recovered)") - } catch { - Self.logger.error("📎 aesChachaKey roundtrip FAILED: \(error)") - } - #endif - // ── Optimistic UI: show message INSTANTLY with placeholder attachments ── // Android parity: addMessageSafely(optimisticMessage) before upload. var optimisticPacket = PacketMessage() optimisticPacket.fromPublicKey = currentPublicKey - optimisticPacket.toPublicKey = toPublicKey - optimisticPacket.content = encrypted.content - optimisticPacket.chachaKey = encrypted.chachaKey + optimisticPacket.toPublicKey = isGroup ? Self.normalizedGroupDialogIdentity(toPublicKey) : toPublicKey + optimisticPacket.content = encryptedContent + optimisticPacket.chachaKey = outChachaKey optimisticPacket.timestamp = timestamp optimisticPacket.privateKey = hash optimisticPacket.messageId = messageId - optimisticPacket.aesChachaKey = aesChachaKey + optimisticPacket.aesChachaKey = outAesChachaKey optimisticPacket.attachments = placeholderAttachments let existingDialog = DialogRepository.shared.dialogs[toPublicKey] @@ -709,11 +724,13 @@ final class SessionManager { // Android parity: always insert as WAITING (fromSync: false). // Retry mechanism handles timeout (80s → ERROR). + let optimisticDialogKey = isGroup ? Self.normalizedGroupDialogIdentity(toPublicKey) : toPublicKey + let optimisticAttachmentPwd = isGroup ? attachmentPassword : ("rawkey:" + attachmentPassword) MessageRepository.shared.upsertFromMessagePacket( optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText, - attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: false + attachmentPassword: optimisticAttachmentPwd, fromSync: false ) - DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey) + DialogRepository.shared.updateDialogFromMessages(opponentKey: optimisticDialogKey) MessageRepository.shared.persistNow() if toPublicKey == currentPublicKey { @@ -774,7 +791,14 @@ final class SessionManager { packet.attachments = messageAttachments packetFlowSender.sendPacket(packet) - registerOutgoingRetry(for: packet) + if isGroup { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, opponentKey: optimisticDialogKey, status: .delivered + ) + } else { + registerOutgoingRetry(for: packet) + } MessageRepository.shared.persistNow() Self.logger.info("📤 Message with \(attachments.count) attachment(s) sent to \(toPublicKey.prefix(12))…") } @@ -951,7 +975,14 @@ final class SessionManager { } packetFlowSender.sendPacket(packet) - registerOutgoingRetry(for: packet) + if DatabaseManager.isGroupDialogKey(toPublicKey) { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, opponentKey: toPublicKey, status: .delivered + ) + } else { + registerOutgoingRetry(for: packet) + } MessageRepository.shared.persistNow() Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s)") } @@ -1144,6 +1175,7 @@ final class SessionManager { MessageRepository.shared.reset() DatabaseManager.shared.close() CryptoManager.shared.clearCaches() + GroupRepository.shared.clearCaches() AttachmentCache.shared.privateKey = nil RecentSearchesRepository.shared.clearSession() DraftManager.shared.reset() @@ -1243,7 +1275,7 @@ final class SessionManager { return } if context.fromMe { return } - MessageRepository.shared.markTyping(from: context.dialogKey) + MessageRepository.shared.markTyping(from: context.dialogKey, senderKey: context.fromKey) } } @@ -1397,6 +1429,8 @@ final class SessionManager { DialogRepository.shared.reconcileUnreadCounts() self.retryWaitingOutgoingMessagesAfterReconnect() Self.logger.debug("SYNC NOT_NEEDED") + // One-time recovery: re-sync from 0 if group keys are missing. + self.recoverOrphanedGroupsIfNeeded() // Refresh user info now that sync is done. Task { @MainActor [weak self] in try? await Task.sleep(for: .milliseconds(300)) @@ -1450,12 +1484,20 @@ final class SessionManager { myPublicKey: self.currentPublicKey ) - if MessageRepository.shared.lastDecryptedMessage( + // Re-decrypt messages that arrived before the group key was available. + if let gk = GroupRepository.shared.groupKey( account: self.currentPublicKey, - opponentKey: dialogKey - ) != nil { - DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey) + privateKeyHex: privateKeyHex, + groupDialogKey: dialogKey + ) { + MessageRepository.shared.reDecryptGroupMessages( + account: self.currentPublicKey, + groupDialogKey: dialogKey, + groupKey: gk, + privateKeyHex: privateKeyHex + ) } + DialogRepository.shared.updateDialogFromMessages(opponentKey: dialogKey) } } @@ -1825,6 +1867,42 @@ final class SessionManager { DatabaseManager.shared.saveSyncCursor(account: currentPublicKey, timestamp: raw) } + // MARK: - Orphaned Group Recovery + + /// Detects group dialogs without a key in the `groups` table and triggers a full + /// re-sync (cursor=0) to recover the missing `PacketGroupJoin` from the server buffer. + /// Runs once per session to avoid infinite sync loops. + private func recoverOrphanedGroupsIfNeeded() { + guard !hasTriggeredGroupRecoverySync else { return } + guard let privKey = privateKeyHex, !currentPublicKey.isEmpty else { return } + + let groupDialogKeys = DialogRepository.shared.dialogs.keys.filter { + DatabaseManager.isGroupDialogKey($0) + } + guard !groupDialogKeys.isEmpty else { return } + + var orphaned = false + for key in groupDialogKeys { + if GroupRepository.shared.groupKey( + account: currentPublicKey, + privateKeyHex: privKey, + groupDialogKey: key + ) == nil { + Self.logger.warning("Orphaned group detected (no key): \(key)") + orphaned = true + break + } + } + + guard orphaned else { return } + + hasTriggeredGroupRecoverySync = true + Self.logger.info("Resetting sync cursor to 0 for orphaned group key recovery") + DatabaseManager.shared.resetSyncCursor(account: currentPublicKey) + syncRequestInFlight = false + requestSynchronize() + } + // MARK: - Background Crypto (off MainActor) /// Result of background crypto operations for an incoming message. @@ -1885,6 +1963,8 @@ final class SessionManager { } } } else if let groupKey { + // Desktop parity: Buffer.from(groupKey).toString('hex') + resolvedAttachmentPassword = Data(groupKey.utf8).map { String(format: "%02x", $0) }.joined() for i in processedPacket.attachments.indices where processedPacket.attachments[i].type == .messages { let blob = processedPacket.attachments[i].blob guard !blob.isEmpty else { continue } @@ -2346,6 +2426,14 @@ final class SessionManager { if message.toPublicKey == currentPublicKey { continue } + // Group messages don't get server ACK — skip retry, mark delivered. + if DatabaseManager.isGroupDialogKey(message.toPublicKey) { + MessageRepository.shared.updateDeliveryStatus(messageId: message.id, status: .delivered) + DialogRepository.shared.updateDeliveryStatus( + messageId: message.id, opponentKey: message.toPublicKey, status: .delivered + ) + continue + } let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { continue } diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index ad97aed..9d643ea 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -157,6 +157,39 @@ enum RosettaColors { return UIColor(avatarColors[safeIndex].tint) } + /// Returns UIColor text variant for sender name labels in group chats (UIKit). + static func avatarTextColor(for index: Int) -> UIColor { + let safeIndex = index % avatarColors.count + return UIColor(avatarColors[safeIndex].text) + } + + // MARK: - Telegram PeerNameColors (group sender names) + + /// 7 Telegram-parity sender name colors for group chats. + /// Dark theme: saturation ×0.7, brightness ×1.2 (from Telegram iOS source). + static let peerNameColors: [UIColor] = [ + UIColor(red: 0xcc/255, green: 0x50/255, blue: 0x49/255, alpha: 1), // red + UIColor(red: 0xd6/255, green: 0x77/255, blue: 0x22/255, alpha: 1), // orange + UIColor(red: 0x95/255, green: 0x5c/255, blue: 0xdb/255, alpha: 1), // purple + UIColor(red: 0x40/255, green: 0xa9/255, blue: 0x20/255, alpha: 1), // green + UIColor(red: 0x30/255, green: 0x9e/255, blue: 0xba/255, alpha: 1), // teal + UIColor(red: 0x36/255, green: 0x8a/255, blue: 0xd1/255, alpha: 1), // blue + UIColor(red: 0xc7/255, green: 0x50/255, blue: 0x8b/255, alpha: 1), // pink + ].map { adjustForDarkTheme($0) } + + /// Telegram dark theme brightness adjustment. + private static func adjustForDarkTheme(_ color: UIColor) -> UIColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return UIColor(hue: h, saturation: s * 0.7, brightness: min(1.0, b * 1.2), alpha: a) + } + + /// Returns a Telegram PeerNameColor for a sender based on their public key. + static func peerNameColor(forKey publicKey: String) -> UIColor { + let index = avatarColorIndex(for: publicKey, publicKey: publicKey) + return peerNameColors[index % peerNameColors.count] + } + static func avatarText(publicKey: String) -> String { String(publicKey.prefix(2)).uppercased() } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index d8c96de..261e825 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -40,10 +40,13 @@ struct ChatDetailView: View { @State private var showNoAvatarAlert = false @State private var pendingAttachments: [PendingAttachment] = [] @State private var showOpponentProfile = false + @State private var showGroupInfo = false @State private var callErrorMessage: String? @State private var replyingToMessage: ChatMessage? @State private var showForwardPicker = false @State private var forwardingMessage: ChatMessage? + @State private var pendingGroupInvite: String? + @State private var pendingGroupInviteTitle: String? @State private var messageToDelete: ChatMessage? // Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen), // not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation. @@ -76,6 +79,14 @@ struct ChatDetailView: View { private var titleText: String { if route.isSavedMessages { return "Saved Messages" } + if route.isGroup { + if let meta = GroupRepository.shared.groupMetadata( + account: SessionManager.shared.currentPublicKey, + groupDialogKey: route.publicKey + ), !meta.title.isEmpty { + return meta.title + } + } if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle } if !route.title.isEmpty { return route.title } if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" } @@ -91,8 +102,18 @@ struct ChatDetailView: View { private var subtitleText: String { if route.isSavedMessages { return "" } - // Desktop parity: system accounts show "official account" instead of online/offline if route.isSystemAccount { return "official account" } + if route.isGroup { + let names = viewModel.typingSenderNames + if !names.isEmpty { + if names.count == 1 { + return "\(names[0]) typing..." + } else { + return "\(names[0]) and \(names.count - 1) typing..." + } + } + return "group" + } if isTyping { return "typing..." } if let dialog, dialog.isOnline { return "online" } return "offline" @@ -227,6 +248,12 @@ struct ChatDetailView: View { ) if case .alreadyInCall = result { callErrorMessage = "You are already in another call." } } + cellActions.onGroupInviteTap = { [self] invite in + if let parsed = GroupRepository.shared.parseInviteString(invite) { + pendingGroupInvite = invite + pendingGroupInviteTitle = parsed.title + } + } // Capture first unread incoming message BEFORE marking as read. if firstUnreadMessageId == nil { firstUnreadMessageId = messages.first(where: { @@ -302,6 +329,9 @@ struct ChatDetailView: View { .navigationDestination(isPresented: $showOpponentProfile) { OpponentProfileView(route: route) } + .navigationDestination(isPresented: $showGroupInfo) { + GroupInfoView(groupDialogKey: route.publicKey) + } .sheet(isPresented: $showForwardPicker) { ForwardChatPickerView { targetRoutes in showForwardPicker = false @@ -349,6 +379,34 @@ struct ChatDetailView: View { } message: { Text(callErrorMessage ?? "Failed to start call.") } + .alert("Join Group", isPresented: Binding( + get: { pendingGroupInvite != nil }, + set: { if !$0 { pendingGroupInvite = nil; pendingGroupInviteTitle = nil } } + )) { + Button("Join") { + guard let invite = pendingGroupInvite else { return } + pendingGroupInvite = nil + Task { + do { + let route = try await GroupService.shared.joinGroup(inviteString: invite) + pendingGroupInviteTitle = nil + // Navigate to the new group + NotificationCenter.default.post( + name: .openChatFromNotification, + object: ChatRoute(groupDialogKey: route.publicKey, title: route.title) + ) + } catch { + callErrorMessage = error.localizedDescription + } + } + } + Button("Cancel", role: .cancel) { + pendingGroupInvite = nil + pendingGroupInviteTitle = nil + } + } message: { + Text("Join \"\(pendingGroupInviteTitle ?? "group")\"?") + } .sheet(isPresented: $showAttachmentPanel) { AttachmentPanelView( onSend: { attachments, caption in @@ -394,6 +452,14 @@ private struct ChatDetailPrincipal: View { private var titleText: String { if route.isSavedMessages { return "Saved Messages" } + if route.isGroup { + if let meta = GroupRepository.shared.groupMetadata( + account: SessionManager.shared.currentPublicKey, + groupDialogKey: route.publicKey + ), !meta.title.isEmpty { + return meta.title + } + } if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle } if !route.title.isEmpty { return route.title } if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" } @@ -410,6 +476,17 @@ private struct ChatDetailPrincipal: View { private var subtitleText: String { if route.isSavedMessages { return "" } if route.isSystemAccount { return "official account" } + if route.isGroup { + let names = viewModel.typingSenderNames + if !names.isEmpty { + if names.count == 1 { + return "\(names[0]) typing..." + } else { + return "\(names[0]) and \(names.count - 1) typing..." + } + } + return "group" + } if viewModel.isTyping { return "typing..." } if let dialog, dialog.isOnline { return "online" } return "offline" @@ -456,6 +533,14 @@ private struct ChatDetailToolbarAvatar: View { private var titleText: String { if route.isSavedMessages { return "Saved Messages" } + if route.isGroup { + if let meta = GroupRepository.shared.groupMetadata( + account: SessionManager.shared.currentPublicKey, + groupDialogKey: route.publicKey + ), !meta.title.isEmpty { + return meta.title + } + } if let dialog, !dialog.opponentTitle.isEmpty { return dialog.opponentTitle } if !route.title.isEmpty { return route.title } if let dialog, !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" } @@ -1091,14 +1176,14 @@ private extension ChatDetailView { func openProfile() { guard !route.isSavedMessages, !route.isSystemAccount else { return } isInputFocused = false - // Force-dismiss keyboard at UIKit level immediately. - // On iOS 26+, the async resignFirstResponder via syncFocus races with - // the navigation transition — the system may re-focus the text view - // when returning from the profile, causing a ghost keyboard. UIApplication.shared.sendAction( #selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil ) - showOpponentProfile = true + if route.isGroup { + showGroupInfo = true + } else { + showOpponentProfile = true + } } func trailingAction() { diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index 1b9cf71..1275a6f 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -12,6 +12,7 @@ final class ChatDetailViewModel: ObservableObject { @Published private(set) var messages: [ChatMessage] = [] @Published private(set) var isTyping: Bool = false + @Published private(set) var typingSenderNames: [String] = [] /// Android parity: true while loading messages from DB. Shows skeleton placeholder. @Published private(set) var isLoading: Bool = true /// Pagination: true while older messages are available in SQLite. @@ -90,16 +91,19 @@ final class ChatDetailViewModel: ObservableObject { .store(in: &cancellables) // Subscribe to typing state changes, filtered to our dialog - let typingPublisher = repo.$typingDialogs - .map { (dialogs: Set) -> Bool in - dialogs.contains(key) + repo.$typingDialogs + .map { (dialogs: [String: Set]) -> [String] in + guard let senderKeys = dialogs[key], !senderKeys.isEmpty else { return [] } + return senderKeys.map { sk in + DialogRepository.shared.dialogs[sk]?.opponentTitle + ?? String(sk.prefix(8)) + } } .removeDuplicates() .receive(on: DispatchQueue.main) - - typingPublisher - .sink { [weak self] typing in - self?.isTyping = typing + .sink { [weak self] names in + self?.isTyping = !names.isEmpty + self?.typingSenderNames = names } .store(in: &cancellables) } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift index ca60de0..0cb89c7 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -14,4 +14,5 @@ final class MessageCellActions { var onRetry: (ChatMessage) -> Void = { _ in } var onRemove: (ChatMessage) -> Void = { _ in } var onCall: (String) -> Void = { _ in } // peer public key + var onGroupInviteTap: (String) -> Void = { _ in } // invite string } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index bba62e4..d31edc1 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -140,6 +140,12 @@ final class NativeMessageCell: UICollectionViewCell { private let forwardAvatarImageView = UIImageView() private let forwardNameLabel = UILabel() + // Group sender info (Telegram parity) + private let senderNameLabel = UILabel() + private let senderAvatarContainer = UIView() + private let senderAvatarImageView = UIImageView() + private let senderAvatarInitialLabel = UILabel() + // Highlight overlay (scroll-to-message flash) private let highlightOverlay = UIView() @@ -419,6 +425,25 @@ final class NativeMessageCell: UICollectionViewCell { forwardNameLabel.textColor = .white // default, overridden in configure() bubbleView.addSubview(forwardNameLabel) + // Group sender info (Telegram parity: name INSIDE bubble as first line, avatar left) + senderNameLabel.font = .systemFont(ofSize: 13, weight: .semibold) + senderNameLabel.isHidden = true + bubbleView.addSubview(senderNameLabel) // INSIDE bubble (Telegram: name is first line in bubble) + + senderAvatarContainer.layer.cornerRadius = 18 // 36pt circle + senderAvatarContainer.clipsToBounds = true + senderAvatarContainer.isHidden = true + contentView.addSubview(senderAvatarContainer) + + senderAvatarInitialLabel.font = .systemFont(ofSize: 11, weight: .medium) + senderAvatarInitialLabel.textColor = .white + senderAvatarInitialLabel.textAlignment = .center + senderAvatarContainer.addSubview(senderAvatarInitialLabel) + + senderAvatarImageView.contentMode = .scaleAspectFill + senderAvatarImageView.clipsToBounds = true + senderAvatarContainer.addSubview(senderAvatarImageView) + // Highlight overlay — on top of all bubble content highlightOverlay.backgroundColor = UIColor.white.withAlphaComponent(0.12) highlightOverlay.isUserInteractionEnabled = false @@ -764,9 +789,15 @@ final class NativeMessageCell: UICollectionViewCell { dateHeaderContainer.isHidden = true // Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment + // Group incoming: offset right by 40pt for avatar lane (Telegram parity). + let isGroupIncoming = !layout.isOutgoing && layout.senderKey.count > 0 + let groupAvatarLane: CGFloat = isGroupIncoming ? 38 : 0 let bubbleX: CGFloat if layout.isOutgoing { bubbleX = cellW - layout.bubbleSize.width - tailProtrusion - 2 - layout.deliveryFailedInset + } else if isGroupIncoming { + // Group: avatar lane replaces tail space. Avatar at 4pt, bubble after lane. + bubbleX = 4 + groupAvatarLane } else { bubbleX = tailProtrusion + 2 } @@ -981,6 +1012,103 @@ final class NativeMessageCell: UICollectionViewCell { let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2 replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter) replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20) + + // Group sender name — INSIDE bubble as first line (Telegram parity). + // When shown, shift all bubble content (text, reply, etc.) down by senderNameShift. + let senderNameShift: CGFloat = layout.showsSenderName ? 20 : 0 + if layout.showsSenderName { + senderNameLabel.isHidden = false + senderNameLabel.text = layout.senderName + let nameColorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey) + senderNameLabel.textColor = RosettaColors.avatarTextColor(for: nameColorIdx) + senderNameLabel.sizeToFit() + // Position inside bubble: top-left, with standard bubble padding + senderNameLabel.frame = CGRect( + x: 10, + y: 6, + width: min(senderNameLabel.bounds.width, bubbleView.bounds.width - 24), + height: 16 + ) + // Expand bubble to fit the name + bubbleView.frame.size.height += senderNameShift + // Shift text and other content down to make room for the name + textLabel.frame.origin.y += senderNameShift + timestampLabel.frame.origin.y += senderNameShift + checkSentView.frame.origin.y += senderNameShift + checkReadView.frame.origin.y += senderNameShift + clockFrameView.frame.origin.y += senderNameShift + clockMinView.frame.origin.y += senderNameShift + statusBackgroundView.frame.origin.y += senderNameShift + if layout.hasReplyQuote { + replyContainer.frame.origin.y += senderNameShift + } + if layout.hasPhoto { + photoContainer.frame.origin.y += senderNameShift + } + if layout.hasFile { + fileContainer.frame.origin.y += senderNameShift + } + if layout.isForward { + forwardLabel.frame.origin.y += senderNameShift + forwardAvatarView.frame.origin.y += senderNameShift + forwardNameLabel.frame.origin.y += senderNameShift + } + // Re-apply bubble image with tail protrusion after height expansion + let expandedImageFrame: CGRect + if layout.isOutgoing { + expandedImageFrame = CGRect(x: 0, y: 0, + width: bubbleView.bounds.width + tailProtrusion, + height: bubbleView.bounds.height) + } else { + expandedImageFrame = CGRect(x: -tailProtrusion, y: 0, + width: bubbleView.bounds.width + tailProtrusion, + height: bubbleView.bounds.height) + } + bubbleImageView.frame = expandedImageFrame.insetBy(dx: -1, dy: -1) + highlightOverlay.frame = bubbleView.bounds + // Update shadow/outline layers for expanded height + bubbleLayer.frame = bubbleView.bounds + bubbleLayer.path = BubblePathCache.shared.path( + size: expandedImageFrame.size, origin: expandedImageFrame.origin, + mergeType: layout.mergeType, + isOutgoing: layout.isOutgoing, + metrics: Self.bubbleMetrics + ) + bubbleLayer.shadowPath = bubbleLayer.path + bubbleOutlineLayer.frame = bubbleView.bounds + bubbleOutlineLayer.path = bubbleLayer.path + } else { + senderNameLabel.isHidden = true + } + + // Group sender avatar (left of bubble, last in run, Telegram parity) + // Size: 36pt (Android 42dp, Desktop 40px — average ~36pt on iOS) + if layout.showsSenderAvatar { + let avatarSize: CGFloat = 36 + senderAvatarContainer.isHidden = false + senderAvatarContainer.frame = CGRect( + x: 4, + y: bubbleView.frame.maxY - avatarSize, + width: avatarSize, + height: avatarSize + ) + senderAvatarInitialLabel.frame = senderAvatarContainer.bounds + senderAvatarImageView.frame = senderAvatarContainer.bounds + senderAvatarImageView.layer.cornerRadius = avatarSize / 2 + + let colorIdx = RosettaColors.avatarColorIndex(for: layout.senderName, publicKey: layout.senderKey) + senderAvatarContainer.backgroundColor = RosettaColors.avatarColor(for: colorIdx) + senderAvatarInitialLabel.text = RosettaColors.initials(name: layout.senderName, publicKey: layout.senderKey) + if let image = AvatarRepository.shared.loadAvatar(publicKey: layout.senderKey) { + senderAvatarImageView.image = image + senderAvatarImageView.isHidden = false + } else { + senderAvatarImageView.image = nil + senderAvatarImageView.isHidden = true + } + } else { + senderAvatarContainer.isHidden = true + } } private static func formattedDuration(seconds: Int) -> String { @@ -1042,18 +1170,49 @@ final class NativeMessageCell: UICollectionViewCell { /// Tries each password candidate to decrypt avatar image data. private static func decryptAvatarImage(encryptedString: String, passwords: [String]) -> UIImage? { let crypto = CryptoManager.shared + #if DEBUG + print("[AVATAR-DBG] decryptAvatarImage blob=\(encryptedString.count) chars, \(passwords.count) candidates") + #endif for password in passwords { guard let data = try? crypto.decryptWithPassword( encryptedString, password: password, requireCompression: true - ) else { continue } + ) else { + #if DEBUG + print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → nil") + #endif + continue + } + #if DEBUG + let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined() + let utf8 = String(data: data.prefix(60), encoding: .utf8) ?? "" + print("[AVATAR-DBG] pwd=\(password.prefix(8))… requireCompression=true → \(data.count) bytes, hex=\(hex), utf8=\(utf8.prefix(60))") + #endif if let img = parseAvatarImageData(data) { return img } + #if DEBUG + print("[AVATAR-DBG] parseAvatarImageData returned nil for requireCompression=true data") + #endif } for password in passwords { guard let data = try? crypto.decryptWithPassword( encryptedString, password: password - ) else { continue } + ) else { + #if DEBUG + print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → nil") + #endif + continue + } + #if DEBUG + let hex = data.prefix(30).map { String(format: "%02x", $0) }.joined() + print("[AVATAR-DBG] pwd=\(password.prefix(8))… noCompression → \(data.count) bytes, hex=\(hex)") + #endif if let img = parseAvatarImageData(data) { return img } + #if DEBUG + print("[AVATAR-DBG] parseAvatarImageData returned nil for noCompression data") + #endif } + #if DEBUG + print("[AVATAR-DBG] ❌ All candidates failed") + #endif return nil } @@ -1231,16 +1390,26 @@ final class NativeMessageCell: UICollectionViewCell { if AttachmentCache.shared.cachedImage(forAttachmentId: id) != nil { return } let tag = avatarAtt.effectiveDownloadTag guard !tag.isEmpty else { return } - guard let password = message.attachmentPassword, !password.isEmpty else { return } + guard let password = message.attachmentPassword, !password.isEmpty else { + #if DEBUG + print("[AVATAR-DBG] ❌ No attachmentPassword for avatar id=\(id)") + #endif + return + } + #if DEBUG + print("[AVATAR-DBG] Starting download tag=\(tag.prefix(12))… pwd=\(password.prefix(8))… len=\(password.count)") + #endif fileSizeLabel.text = "Downloading..." let messageId = message.id - let senderKey = message.fromPublicKey + // Desktop parity: group avatar saves to group dialog key, not sender key + let avatarTargetKey = DatabaseManager.isGroupDialogKey(message.toPublicKey) + ? message.toPublicKey : message.fromPublicKey let server = avatarAtt.transportServer Task.detached(priority: .userInitiated) { let downloaded = await Self.downloadAndCacheAvatar( tag: tag, attachmentId: id, - storedPassword: password, senderKey: senderKey, + storedPassword: password, senderKey: avatarTargetKey, server: server ) await MainActor.run { [weak self] in @@ -1250,6 +1419,10 @@ final class NativeMessageCell: UICollectionViewCell { self.avatarImageView.isHidden = false self.fileIconView.isHidden = true self.fileSizeLabel.text = "Shared profile photo" + // Trigger refresh of sender avatar circles in visible cells + NotificationCenter.default.post( + name: Notification.Name("avatarDidUpdate"), object: nil + ) } else { self.fileSizeLabel.text = "Tap to retry" } @@ -1815,6 +1988,9 @@ final class NativeMessageCell: UICollectionViewCell { private func downloadPhotoAttachment(attachment: MessageAttachment, message: ChatMessage) { if photoDownloadTasks[attachment.id] != nil { return } let tag = attachment.effectiveDownloadTag + #if DEBUG + print("[PHOTO-DBG] downloadPhoto tag=\(tag.prefix(12))… pwd=\(message.attachmentPassword?.prefix(8) ?? "nil") len=\(message.attachmentPassword?.count ?? 0)") + #endif guard !tag.isEmpty, let storedPassword = message.attachmentPassword, !storedPassword.isEmpty else { @@ -2135,6 +2311,9 @@ final class NativeMessageCell: UICollectionViewCell { forwardLabel.isHidden = true forwardAvatarView.isHidden = true forwardNameLabel.isHidden = true + senderNameLabel.isHidden = true + senderAvatarContainer.isHidden = true + senderAvatarImageView.image = nil photoContainer.isHidden = true bubbleView.transform = .identity replyCircleView.alpha = 0 diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 0434afb..8342b3f 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -30,6 +30,7 @@ final class NativeMessageListController: UIViewController { var highlightedMessageId: String? var isSavedMessages: Bool var isSystemAccount: Bool + var isGroupChat: Bool = false var opponentPublicKey: String var opponentTitle: String var opponentUsername: String @@ -60,6 +61,22 @@ final class NativeMessageListController: UIViewController { private(set) var messages: [ChatMessage] = [] var hasMoreMessages: Bool = true + /// Cached group attachment password (hex-encoded group key, Desktop parity). + private lazy var resolvedGroupKey: String? = { + guard config.isGroupChat else { return nil } + let session = SessionManager.shared + guard let privKey = session.privateKeyHex else { return nil } + let account = session.currentPublicKey ?? "" + guard !account.isEmpty else { return nil } + guard let gk = GroupRepository.shared.groupKey( + account: account, + privateKeyHex: privKey, + groupDialogKey: config.opponentPublicKey + ) else { return nil } + // Desktop parity: Buffer.from(groupKey).toString('hex') + return Data(gk.utf8).map { String(format: "%02x", $0) }.joined() + }() + // MARK: - UIKit private var collectionView: UICollectionView! @@ -162,6 +179,14 @@ final class NativeMessageListController: UIViewController { self, selector: #selector(interactiveKeyboardHeightChanged(_:)), name: NSNotification.Name("InteractiveKeyboardHeightChanged"), object: nil ) + NotificationCenter.default.addObserver( + self, selector: #selector(handleAvatarDidUpdate), + name: Notification.Name("avatarDidUpdate"), object: nil + ) + } + + @objc private func handleAvatarDidUpdate() { + reconfigureVisibleCells() } // Dismiss keyboard on swipe-back. Let keyboardWillChangeFrame update insets @@ -260,13 +285,22 @@ final class NativeMessageListController: UIViewController { [weak self] cell, indexPath, message in guard let self else { return } + // Patch group messages without attachment password (legacy messages before fix). + var msg = message + if self.config.isGroupChat, + msg.attachmentPassword == nil, + !msg.attachments.isEmpty, + let gk = self.resolvedGroupKey { + msg.attachmentPassword = gk + } + // Apply pre-calculated layout (just sets frames — no computation) - if let layout = self.layoutCache[message.id] { + if let layout = self.layoutCache[msg.id] { cell.apply(layout: layout) } // Parse reply data for quote display - let replyAtt = message.attachments.first { $0.type == .messages } + let replyAtt = msg.attachments.first { $0.type == .messages } var replyName: String? var replyText: String? var replyMessageId: String? @@ -290,7 +324,7 @@ final class NativeMessageListController: UIViewController { ?? String(senderKey.prefix(8)) + "…" } - let displayText = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text + let displayText = MessageCellLayout.isGarbageOrEncrypted(msg.text) ? "" : msg.text if displayText.isEmpty { // Forward forwardSenderName = name @@ -308,9 +342,9 @@ final class NativeMessageListController: UIViewController { cell.isSavedMessages = self.config.isSavedMessages cell.isSystemAccount = self.config.isSystemAccount cell.configure( - message: message, - timestamp: self.formatTimestamp(message.timestamp), - textLayout: self.textLayoutCache[message.id], + message: msg, + timestamp: self.formatTimestamp(msg.timestamp), + textLayout: self.textLayoutCache[msg.id], actions: self.config.actions, replyName: replyName, replyText: replyText, @@ -933,7 +967,8 @@ final class NativeMessageListController: UIViewController { maxBubbleWidth: config.maxBubbleWidth, currentPublicKey: config.currentPublicKey, opponentPublicKey: config.opponentPublicKey, - opponentTitle: config.opponentTitle + opponentTitle: config.opponentTitle, + isGroupChat: config.isGroupChat ) layoutCache = layouts textLayoutCache = textLayouts @@ -1322,6 +1357,7 @@ struct NativeMessageListView: UIViewControllerRepresentable { highlightedMessageId: highlightedMessageId, isSavedMessages: route.isSavedMessages, isSystemAccount: route.isSystemAccount, + isGroupChat: route.isGroup, opponentPublicKey: route.publicKey, opponentTitle: route.title, opponentUsername: route.username, diff --git a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift index 711820d..a606b77 100644 --- a/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift +++ b/Rosetta/Features/Chats/ChatDetail/TelegramContextMenuController.swift @@ -33,6 +33,16 @@ enum TelegramContextMenuBuilder { )) } + // Desktop parity: detect #group: invite strings and offer "Join Group" action. + if message.text.hasPrefix("#group:") { + items.append(TelegramContextMenuItem( + title: "Join Group", + iconName: "person.2.badge.plus", + isDestructive: false, + handler: { actions.onGroupInviteTap(message.text) } + )) + } + if !message.text.isEmpty { items.append(TelegramContextMenuItem( title: "Copy", diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 1e92b03..6b0a718 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -32,6 +32,9 @@ struct ChatListView: View { @State private var searchText = "" @State private var hasPinnedChats = false @State private var showRequestChats = false + @State private var showNewGroupSheet = false + @State private var showJoinGroupSheet = false + @State private var showNewChatActionSheet = false @FocusState private var isSearchFocused: Bool var body: some View { @@ -103,6 +106,35 @@ struct ChatListView: View { } } .tint(RosettaColors.figmaBlue) + .confirmationDialog("New", isPresented: $showNewChatActionSheet) { + Button("New Group") { showNewGroupSheet = true } + Button("Join Group") { showJoinGroupSheet = true } + Button("Cancel", role: .cancel) {} + } + .sheet(isPresented: $showNewGroupSheet) { + NavigationStack { + GroupSetupView { route in + showNewGroupSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + navigationState.path = [route] + } + } + } + .presentationDetents([.large]) + .preferredColorScheme(.dark) + } + .sheet(isPresented: $showJoinGroupSheet) { + NavigationStack { + GroupJoinView { route in + showJoinGroupSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + navigationState.path = [route] + } + } + } + .presentationDetents([.large]) + .preferredColorScheme(.dark) + } .onReceive(NotificationCenter.default.publisher(for: .openChatFromNotification)) { notification in guard let route = notification.object as? ChatRoute else { return } // Navigate to the chat from push notification tap (fast path) @@ -307,7 +339,7 @@ private extension ChatListView { .foregroundStyle(RosettaColors.Adaptive.text) } .accessibilityLabel("Camera") - Button { } label: { + Button { showNewChatActionSheet = true } label: { Image(systemName: "square.and.pencil") .font(.system(size: 17, weight: .regular)) .foregroundStyle(RosettaColors.Adaptive.text) @@ -350,7 +382,7 @@ private extension ChatListView { .buttonStyle(.plain) .accessibilityLabel("Add chat") - Button { } label: { + Button { showNewChatActionSheet = true } label: { Image("toolbar-compose") .renderingMode(.template) .resizable() @@ -589,7 +621,7 @@ private struct ChatListDialogContent: View { var onPinnedStateChange: (Bool) -> Void = { _ in } /// Desktop parity: track typing dialogs from MessageRepository (@Published). - @State private var typingDialogs: Set = [] + @State private var typingDialogs: [String: Set] = [:] var body: some View { let _ = PerformanceLogger.shared.track("chatList.bodyEval") @@ -687,7 +719,14 @@ private struct ChatListDialogContent: View { /// stable class references don't trigger row re-evaluation on parent re-render. SyncAwareChatRow( dialog: dialog, - isTyping: typingDialogs.contains(dialog.opponentKey), + isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true), + typingSenderNames: { + guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] } + return senderKeys.map { sk in + DialogRepository.shared.dialogs[sk]?.opponentTitle + ?? String(sk.prefix(8)) + } + }(), isFirst: isFirst, viewModel: viewModel, navigationState: navigationState @@ -713,6 +752,7 @@ private struct ChatListDialogContent: View { struct SyncAwareChatRow: View { let dialog: Dialog let isTyping: Bool + let typingSenderNames: [String] let isFirst: Bool let viewModel: ChatListViewModel let navigationState: ChatListNavigationState @@ -725,7 +765,8 @@ struct SyncAwareChatRow: View { ChatRowView( dialog: dialog, isSyncing: isSyncing, - isTyping: isTyping + isTyping: isTyping, + typingSenderNames: typingSenderNames ) } .buttonStyle(.plain) diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index 8edf086..8af87e7 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -24,10 +24,19 @@ struct ChatRowView: View { var isSyncing: Bool = false /// Desktop parity: show "typing..." instead of last message. var isTyping: Bool = false + /// Group typing: sender names for "Name typing..." / "Name and N typing..." display. + var typingSenderNames: [String] = [] var displayTitle: String { if dialog.isSavedMessages { return "Saved Messages" } + if dialog.isGroup { + let meta = GroupRepository.shared.groupMetadata( + account: dialog.account, + groupDialogKey: dialog.opponentKey + ) + if let title = meta?.title, !title.isEmpty { return title } + } if !dialog.opponentTitle.isEmpty { return dialog.opponentTitle } if !dialog.opponentUsername.isEmpty { return "@\(dialog.opponentUsername)" } return String(dialog.opponentKey.prefix(12)) @@ -57,9 +66,17 @@ private struct ChatRowAvatar: View { let dialog: Dialog var body: some View { + if dialog.isGroup { + groupAvatarView + } else { + directAvatarView + } + } + + private var directAvatarView: some View { // Establish @Observable tracking — re-renders this view on avatar save/remove. let _ = AvatarRepository.shared.avatarVersion - AvatarView( + return AvatarView( initials: dialog.initials, colorIndex: dialog.avatarColorIndex, size: 62, @@ -68,6 +85,27 @@ private struct ChatRowAvatar: View { image: dialog.isSavedMessages ? nil : AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) ) } + + private var groupAvatarView: some View { + let _ = AvatarRepository.shared.avatarVersion + let groupImage = AvatarRepository.shared.loadAvatar(publicKey: dialog.opponentKey) + return ZStack { + if let image = groupImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 62, height: 62) + .clipShape(Circle()) + } else { + Circle() + .fill(RosettaColors.avatarColors[dialog.avatarColorIndex % RosettaColors.avatarColors.count].tint) + .frame(width: 62, height: 62) + Image(systemName: "person.2.fill") + .font(.system(size: 24, weight: .medium)) + .foregroundStyle(.white.opacity(0.9)) + } + } + } } private extension ChatRowView { @@ -152,12 +190,23 @@ private extension ChatRowView { var messageText: String { // Desktop parity: show "typing..." in chat list row when opponent is typing. if isTyping && !dialog.isSavedMessages { + if dialog.isGroup && !typingSenderNames.isEmpty { + if typingSenderNames.count == 1 { + return "\(typingSenderNames[0]) typing..." + } else { + return "\(typingSenderNames[0]) and \(typingSenderNames.count - 1) typing..." + } + } return "typing..." } let raw = dialog.lastMessage.trimmingCharacters(in: .whitespacesAndNewlines) if raw.isEmpty { return "No messages yet" } + // Desktop parity: show "Group invite" for #group: invite messages. + if raw.hasPrefix("#group:") { + return "Group invite" + } // Safety net: never show encrypted ciphertext (ivBase64:ctBase64) to user. // This catches stale data persisted before isGarbageText was improved. if Self.looksLikeCiphertext(raw) { diff --git a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift index 3350aab..55e4498 100644 --- a/Rosetta/Features/Chats/ChatList/RequestChatsView.swift +++ b/Rosetta/Features/Chats/ChatList/RequestChatsView.swift @@ -9,7 +9,7 @@ struct RequestChatsView: View { @Environment(\.dismiss) private var dismiss /// Desktop parity: track typing dialogs from MessageRepository (@Published). - @State private var typingDialogs: Set = [] + @State private var typingDialogs: [String: Set] = [:] var body: some View { Group { @@ -79,7 +79,14 @@ struct RequestChatsView: View { private func requestRow(_ dialog: Dialog, isFirst: Bool) -> some View { SyncAwareChatRow( dialog: dialog, - isTyping: typingDialogs.contains(dialog.opponentKey), + isTyping: !(typingDialogs[dialog.opponentKey]?.isEmpty ?? true), + typingSenderNames: { + guard let senderKeys = typingDialogs[dialog.opponentKey] else { return [] } + return senderKeys.map { sk in + DialogRepository.shared.dialogs[sk]?.opponentTitle + ?? String(sk.prefix(8)) + } + }(), isFirst: isFirst, viewModel: viewModel, navigationState: navigationState diff --git a/Rosetta/Features/Chats/ChatRoute.swift b/Rosetta/Features/Chats/ChatRoute.swift index 9ab71e9..516c807 100644 --- a/Rosetta/Features/Chats/ChatRoute.swift +++ b/Rosetta/Features/Chats/ChatRoute.swift @@ -41,10 +41,23 @@ struct ChatRoute: Hashable { ) } + init(groupDialogKey: String, title: String, description: String = "") { + self.init( + publicKey: groupDialogKey, + title: title, + username: description, + verified: 0 + ) + } + var isSavedMessages: Bool { publicKey == SessionManager.shared.currentPublicKey } + var isGroup: Bool { + DatabaseManager.isGroupDialogKey(publicKey) + } + var isSystemAccount: Bool { SystemAccounts.isSystemAccount(publicKey) } diff --git a/Rosetta/Features/Groups/GroupInfoView.swift b/Rosetta/Features/Groups/GroupInfoView.swift new file mode 100644 index 0000000..20d759f --- /dev/null +++ b/Rosetta/Features/Groups/GroupInfoView.swift @@ -0,0 +1,235 @@ +import SwiftUI + +// MARK: - GroupInfoView + +/// Telegram-style group detail screen: header, members, invite link, leave. +struct GroupInfoView: View { + @StateObject private var viewModel: GroupInfoViewModel + @Environment(\.dismiss) private var dismiss + + @State private var showLeaveAlert = false + @State private var memberToKick: GroupMember? + + init(groupDialogKey: String) { + _viewModel = StateObject(wrappedValue: GroupInfoViewModel(groupDialogKey: groupDialogKey)) + } + + var body: some View { + ZStack { + RosettaColors.Dark.background.ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + headerSection + if !viewModel.groupDescription.isEmpty { + descriptionSection + } + inviteLinkSection + membersSection + leaveSection + } + .padding(.vertical, 16) + } + + if viewModel.isLeaving { + Color.black.opacity(0.5) + .ignoresSafeArea() + .overlay { + ProgressView().tint(.white).scaleEffect(1.2) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Group Info") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + .task { await viewModel.loadMembers() } + .onChange(of: viewModel.didLeaveGroup) { left in + if left { dismiss() } + } + .alert("Leave Group", isPresented: $showLeaveAlert) { + Button("Leave", role: .destructive) { + Task { await viewModel.leaveGroup() } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Are you sure you want to leave this group? You will lose access to all messages.") + } + .alert("Remove Member", isPresented: .init( + get: { memberToKick != nil }, + set: { if !$0 { memberToKick = nil } } + )) { + Button("Remove", role: .destructive) { + if let member = memberToKick { + Task { await viewModel.kickMember(publicKey: member.id) } + } + memberToKick = nil + } + Button("Cancel", role: .cancel) { memberToKick = nil } + } message: { + if let member = memberToKick { + Text("Remove \(member.title) from this group?") + } + } + .alert("Error", isPresented: .init( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + if let msg = viewModel.errorMessage { Text(msg) } + } + } +} + +// MARK: - Sections + +private extension GroupInfoView { + var headerSection: some View { + VStack(spacing: 12) { + // Group avatar + ZStack { + Circle() + .fill(RosettaColors.figmaBlue.opacity(0.2)) + .frame(width: 90, height: 90) + Image(systemName: "person.2.fill") + .font(.system(size: 36)) + .foregroundStyle(RosettaColors.figmaBlue) + } + + Text(viewModel.groupTitle) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Text("\(viewModel.members.count) members") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + var descriptionSection: some View { + GlassCard(cornerRadius: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Description") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + Text(viewModel.groupDescription) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + } + + var inviteLinkSection: some View { + GlassCard(cornerRadius: 16) { + VStack(alignment: .leading, spacing: 12) { + Text("Invite Link") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + if let invite = viewModel.inviteString { + Text(invite) + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(2) + + HStack(spacing: 12) { + Button { + UIPasteboard.general.string = invite + } label: { + Label("Copy", systemImage: "doc.on.doc") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RosettaColors.figmaBlue) + } + } + } else { + Button { + viewModel.generateInvite() + } label: { + Label("Generate Link", systemImage: "link") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RosettaColors.figmaBlue) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 16) + } + + var membersSection: some View { + GlassCard(cornerRadius: 16) { + VStack(alignment: .leading, spacing: 0) { + Text("Members (\(viewModel.members.count))") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 8) + + if viewModel.isLoading { + HStack { + Spacer() + ProgressView().tint(.white) + Spacer() + } + .padding(.vertical, 20) + } else { + ForEach(viewModel.members) { member in + memberRow(member) + } + } + } + } + .padding(.horizontal, 16) + } + + @ViewBuilder + func memberRow(_ member: GroupMember) -> some View { + let myKey = SessionManager.shared.currentPublicKey + let canKick = viewModel.isAdmin && member.id != myKey && !member.isAdmin + + GroupMemberRow(member: member) + .padding(.horizontal, 16) + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + if canKick { + Button(role: .destructive) { + memberToKick = member + } label: { + Label("Remove", systemImage: "person.badge.minus") + } + } + } + } + + var leaveSection: some View { + Button { + showLeaveAlert = true + } label: { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + Text("Leave Group") + } + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.red) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .background { + GlassCard(cornerRadius: 16) { + Color.clear.frame(height: 50) + } + } + .padding(.horizontal, 16) + } +} diff --git a/Rosetta/Features/Groups/GroupInfoViewModel.swift b/Rosetta/Features/Groups/GroupInfoViewModel.swift new file mode 100644 index 0000000..9d33c2a --- /dev/null +++ b/Rosetta/Features/Groups/GroupInfoViewModel.swift @@ -0,0 +1,96 @@ +import Combine +import Foundation + +// MARK: - GroupMember + +struct GroupMember: Identifiable { + let id: String // publicKey + var title: String + var username: String + var isAdmin: Bool + var isOnline: Bool + var verified: Int +} + +// MARK: - GroupInfoViewModel + +@MainActor +final class GroupInfoViewModel: ObservableObject { + let groupDialogKey: String + + @Published var groupTitle: String = "" + @Published var groupDescription: String = "" + @Published var members: [GroupMember] = [] + @Published var isAdmin: Bool = false + @Published var isLoading: Bool = false + @Published var isLeaving: Bool = false + @Published var errorMessage: String? + @Published var didLeaveGroup: Bool = false + @Published var inviteString: String? + + private let groupService = GroupService.shared + private let groupRepo = GroupRepository.shared + + init(groupDialogKey: String) { + self.groupDialogKey = groupDialogKey + loadLocalMetadata() + } + + private func loadLocalMetadata() { + let account = SessionManager.shared.currentPublicKey + if let meta = groupRepo.groupMetadata(account: account, groupDialogKey: groupDialogKey) { + groupTitle = meta.title + groupDescription = meta.description + } + } + + func loadMembers() async { + isLoading = true + do { + let memberKeys = try await groupService.requestMembers(groupDialogKey: groupDialogKey) + let myKey = SessionManager.shared.currentPublicKey + + var resolved: [GroupMember] = [] + for (index, key) in memberKeys.enumerated() { + let dialog = DialogRepository.shared.dialogs[key] + resolved.append(GroupMember( + id: key, + title: dialog?.opponentTitle ?? String(key.prefix(12)), + username: dialog?.opponentUsername ?? "", + isAdmin: index == 0, + isOnline: dialog?.isOnline ?? false, + verified: dialog?.verified ?? 0 + )) + } + members = resolved + isAdmin = memberKeys.first == myKey + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + + func leaveGroup() async { + isLeaving = true + do { + try await groupService.leaveGroup(groupDialogKey: groupDialogKey) + didLeaveGroup = true + } catch { + errorMessage = error.localizedDescription + } + isLeaving = false + } + + func kickMember(publicKey: String) async { + do { + try await groupService.kickMember(groupDialogKey: groupDialogKey, memberPublicKey: publicKey) + members.removeAll { $0.id == publicKey } + } catch { + errorMessage = error.localizedDescription + } + } + + func generateInvite() { + inviteString = groupService.generateInviteString(groupDialogKey: groupDialogKey) + } +} diff --git a/Rosetta/Features/Groups/GroupJoinView.swift b/Rosetta/Features/Groups/GroupJoinView.swift new file mode 100644 index 0000000..63637d8 --- /dev/null +++ b/Rosetta/Features/Groups/GroupJoinView.swift @@ -0,0 +1,210 @@ +import SwiftUI + +// MARK: - GroupJoinView + +/// Join a group by pasting an invite string. +struct GroupJoinView: View { + @StateObject private var viewModel = GroupJoinViewModel() + @Environment(\.dismiss) private var dismiss + + var onGroupJoined: ((ChatRoute) -> Void)? + + @FocusState private var isFieldFocused: Bool + + var body: some View { + ZStack { + RosettaColors.Dark.background.ignoresSafeArea() + + VStack(spacing: 24) { + Spacer().frame(height: 20) + + // Invite string input + VStack(alignment: .leading, spacing: 8) { + Text("Invite Link") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + TextField("Paste invite link", text: $viewModel.inviteString) + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.text) + .focused($isFieldFocused) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .onChange(of: viewModel.inviteString) { _ in + viewModel.parseInvite() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background { + GlassCard(cornerRadius: 12) { + Color.clear.frame(height: 44) + } + } + + Text("Enter the group invite link shared with you.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .padding(.horizontal, 16) + + // Preview card (shown after parsing) + if viewModel.hasParsedInvite { + previewCard + } + + Spacer() + + // Join button + if viewModel.hasParsedInvite { + joinButton + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + + if viewModel.isJoining { + Color.black.opacity(0.5) + .ignoresSafeArea() + .overlay { + ProgressView() + .tint(.white) + .scaleEffect(1.2) + } + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { dismiss() } + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + ToolbarItem(placement: .principal) { + Text("Join Group") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + .onAppear { isFieldFocused = true } + .onChange(of: viewModel.joinedRoute) { route in + if let route { + onGroupJoined?(route) + dismiss() + } + } + .alert("Error", isPresented: .init( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + if let msg = viewModel.errorMessage { + Text(msg) + } + } + } +} + +// MARK: - Preview Card + +private extension GroupJoinView { + var previewCard: some View { + GlassCard(cornerRadius: 16) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + // Group icon + ZStack { + Circle() + .fill(RosettaColors.figmaBlue.opacity(0.2)) + .frame(width: 48, height: 48) + Image(systemName: "person.2.fill") + .font(.system(size: 20)) + .foregroundStyle(RosettaColors.figmaBlue) + } + + VStack(alignment: .leading, spacing: 2) { + if let title = viewModel.parsedTitle { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + if let count = viewModel.memberCount { + Text("\(count) members") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + } + + Spacer() + + if viewModel.isChecking { + ProgressView() + .tint(.white) + } + } + + if let desc = viewModel.parsedDescription, !desc.isEmpty { + Text(desc) + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(3) + } + + if let status = viewModel.inviteStatus { + statusBadge(status) + } + } + .padding(16) + } + .padding(.horizontal, 16) + .task { await viewModel.checkStatus() } + } + + @ViewBuilder + func statusBadge(_ status: GroupStatus) -> some View { + switch status { + case .joined: + Label("Already a member", systemImage: "checkmark.circle.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.green) + case .banned: + Label("You are banned", systemImage: "xmark.circle.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.red) + case .invalid: + Label("Group not found", systemImage: "exclamationmark.circle.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.orange) + case .notJoined: + EmptyView() + } + } +} + +// MARK: - Join Button + +private extension GroupJoinView { + var joinButton: some View { + Button { + Task { await viewModel.joinGroup() } + } label: { + Text("Join Group") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(canJoin ? RosettaColors.figmaBlue : RosettaColors.figmaBlue.opacity(0.4)) + ) + } + .disabled(!canJoin) + } + + var canJoin: Bool { + viewModel.hasParsedInvite + && !viewModel.isJoining + && viewModel.inviteStatus != .banned + && viewModel.inviteStatus != .invalid + } +} diff --git a/Rosetta/Features/Groups/GroupJoinViewModel.swift b/Rosetta/Features/Groups/GroupJoinViewModel.swift new file mode 100644 index 0000000..c661d1a --- /dev/null +++ b/Rosetta/Features/Groups/GroupJoinViewModel.swift @@ -0,0 +1,86 @@ +import Combine +import Foundation + +// MARK: - GroupJoinViewModel + +/// ViewModel for joining a group via invite string. +@MainActor +final class GroupJoinViewModel: ObservableObject { + @Published var inviteString: String = "" + @Published var isJoining: Bool = false + @Published var isChecking: Bool = false + @Published var errorMessage: String? + @Published var joinedRoute: ChatRoute? + + /// Preview info after parsing invite. + @Published var parsedTitle: String? + @Published var parsedDescription: String? + @Published var memberCount: Int? + @Published var inviteStatus: GroupStatus? + + var hasParsedInvite: Bool { parsedTitle != nil } + + func parseInvite() { + let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines) + guard let parsed = GroupRepository.shared.parseInviteString(trimmed) else { + parsedTitle = nil + parsedDescription = nil + memberCount = nil + inviteStatus = nil + // Show error if user typed something but it didn't parse. + if !trimmed.isEmpty { + errorMessage = "Invalid invite link" + } else { + errorMessage = nil + } + return + } + errorMessage = nil + parsedTitle = parsed.title + parsedDescription = parsed.description.isEmpty ? nil : parsed.description + } + + func checkStatus() async { + guard let parsed = GroupRepository.shared.parseInviteString( + inviteString.trimmingCharacters(in: .whitespacesAndNewlines) + ) else { return } + + isChecking = true + do { + let result = try await GroupService.shared.checkInviteStatus(groupId: parsed.groupId) + memberCount = result.memberCount + inviteStatus = result.status + } catch { + errorMessage = error.localizedDescription + } + isChecking = false + } + + func joinGroup() async { + let trimmed = inviteString.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + isJoining = true + errorMessage = nil + + do { + let route = try await GroupService.shared.joinGroup(inviteString: trimmed) + joinedRoute = route + } catch let error as GroupService.GroupError { + switch error { + case .joinRejected(let status): + switch status { + case .banned: errorMessage = "You are banned from this group" + case .invalid: errorMessage = "This group no longer exists" + default: errorMessage = error.localizedDescription + } + default: + errorMessage = error.localizedDescription + } + } catch { + errorMessage = error.localizedDescription + } + + isJoining = false + } +} diff --git a/Rosetta/Features/Groups/GroupMemberRow.swift b/Rosetta/Features/Groups/GroupMemberRow.swift new file mode 100644 index 0000000..71c1af6 --- /dev/null +++ b/Rosetta/Features/Groups/GroupMemberRow.swift @@ -0,0 +1,58 @@ +import SwiftUI + +// MARK: - GroupMemberRow + +/// Reusable row for displaying a group member with avatar, name, role badge. +struct GroupMemberRow: View { + let member: GroupMember + + var body: some View { + HStack(spacing: 12) { + // Avatar + AvatarView( + initials: RosettaColors.initials(name: member.title, publicKey: member.id), + colorIndex: RosettaColors.avatarColorIndex(for: member.title, publicKey: member.id), + size: 44, + isOnline: member.isOnline, + isSavedMessages: false, + image: AvatarRepository.shared.loadAvatar(publicKey: member.id) + ) + + // Name + username + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(member.title) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if member.isAdmin { + Text("admin") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + Capsule().fill(RosettaColors.figmaBlue) + ) + } + + if member.verified > 0 { + VerifiedBadge(verified: member.verified, size: 14) + } + } + + if !member.username.isEmpty { + Text("@\(member.username)") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .lineLimit(1) + } + } + + Spacer() + } + .padding(.vertical, 6) + .contentShape(Rectangle()) + } +} diff --git a/Rosetta/Features/Groups/GroupSetupView.swift b/Rosetta/Features/Groups/GroupSetupView.swift new file mode 100644 index 0000000..4f0c7e2 --- /dev/null +++ b/Rosetta/Features/Groups/GroupSetupView.swift @@ -0,0 +1,214 @@ +import SwiftUI + +// MARK: - GroupSetupView + +/// Two-step Telegram-style group creation form. +/// Step 1: Group name (required). Step 2: Description (optional) + Create. +struct GroupSetupView: View { + @StateObject private var viewModel = GroupSetupViewModel() + @Environment(\.dismiss) private var dismiss + + /// Called when group is created — passes the ChatRoute for navigation. + var onGroupCreated: ((ChatRoute) -> Void)? + + @State private var step: SetupStep = .name + @FocusState private var isTitleFocused: Bool + + private enum SetupStep { + case name + case description + } + + var body: some View { + ZStack { + RosettaColors.Dark.background.ignoresSafeArea() + + VStack(spacing: 0) { + stepContent + } + + if viewModel.isCreating { + loadingOverlay + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .onChange(of: viewModel.createdRoute) { route in + if let route { + onGroupCreated?(route) + dismiss() + } + } + .alert("Error", isPresented: .init( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + if let msg = viewModel.errorMessage { + Text(msg) + } + } + } +} + +// MARK: - Step Content + +private extension GroupSetupView { + @ViewBuilder + var stepContent: some View { + switch step { + case .name: + nameStep + case .description: + descriptionStep + } + } + + var nameStep: some View { + VStack(spacing: 24) { + Spacer().frame(height: 20) + + // Group avatar placeholder + ZStack { + Circle() + .fill(RosettaColors.figmaBlue.opacity(0.2)) + .frame(width: 80, height: 80) + Image(systemName: "camera.fill") + .font(.system(size: 28)) + .foregroundStyle(RosettaColors.figmaBlue) + } + + // Group name field + VStack(alignment: .leading, spacing: 8) { + Text("Group Name") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + TextField("Enter group name", text: $viewModel.title) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + .focused($isTitleFocused) + .onChange(of: viewModel.title) { newValue in + if newValue.count > GroupSetupViewModel.maxTitleLength { + viewModel.title = String(newValue.prefix(GroupSetupViewModel.maxTitleLength)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background { + GlassCard(cornerRadius: 12) { + Color.clear.frame(height: 44) + } + } + + Text("\(viewModel.title.count)/\(GroupSetupViewModel.maxTitleLength)") + .font(.system(size: 12)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 16) + + Spacer() + } + .onAppear { isTitleFocused = true } + } + + var descriptionStep: some View { + VStack(spacing: 24) { + Spacer().frame(height: 20) + + VStack(alignment: .leading, spacing: 8) { + Text("Description (optional)") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + TextField("Enter group description", text: $viewModel.groupDescription, axis: .vertical) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(3...6) + .onChange(of: viewModel.groupDescription) { newValue in + if newValue.count > GroupSetupViewModel.maxDescriptionLength { + viewModel.groupDescription = String( + newValue.prefix(GroupSetupViewModel.maxDescriptionLength) + ) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background { + GlassCard(cornerRadius: 12) { + Color.clear.frame(height: 80) + } + } + + Text("You can provide an optional description for your group.") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + .padding(.horizontal, 16) + + Spacer() + } + } +} + +// MARK: - Loading Overlay + +private extension GroupSetupView { + var loadingOverlay: some View { + Color.black.opacity(0.5) + .ignoresSafeArea() + .overlay { + ProgressView() + .tint(.white) + .scaleEffect(1.2) + } + } +} + +// MARK: - Toolbar + +private extension GroupSetupView { + @ToolbarContentBuilder + var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + switch step { + case .name: dismiss() + case .description: step = .name + } + } label: { + Text(step == .name ? "Cancel" : "Back") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + + ToolbarItem(placement: .principal) { + Text(step == .name ? "New Group" : "Description") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button { + switch step { + case .name: + step = .description + case .description: + Task { await viewModel.createGroup() } + } + } label: { + Text(step == .name ? "Next" : "Create") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle( + viewModel.canCreate || step == .name + ? RosettaColors.figmaBlue + : RosettaColors.Adaptive.textSecondary + ) + } + .disabled(step == .name ? viewModel.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty : !viewModel.canCreate) + } + } +} diff --git a/Rosetta/Features/Groups/GroupSetupViewModel.swift b/Rosetta/Features/Groups/GroupSetupViewModel.swift new file mode 100644 index 0000000..dc58da8 --- /dev/null +++ b/Rosetta/Features/Groups/GroupSetupViewModel.swift @@ -0,0 +1,42 @@ +import Combine +import Foundation +import SwiftUI + +// MARK: - GroupSetupViewModel + +/// ViewModel for the two-step group creation flow. +/// Matches Android `GroupSetupScreen.kt` behavior. +@MainActor +final class GroupSetupViewModel: ObservableObject { + @Published var title: String = "" + @Published var groupDescription: String = "" + @Published var isCreating: Bool = false + @Published var errorMessage: String? + @Published var createdRoute: ChatRoute? + + /// Maximum character limits (Android parity). + static let maxTitleLength = 80 + static let maxDescriptionLength = 400 + + var canCreate: Bool { + !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isCreating + } + + func createGroup() async { + guard canCreate else { return } + isCreating = true + errorMessage = nil + + do { + let route = try await GroupService.shared.createGroup( + title: title.trimmingCharacters(in: .whitespacesAndNewlines), + description: groupDescription.trimmingCharacters(in: .whitespacesAndNewlines) + ) + createdRoute = route + } catch { + errorMessage = error.localizedDescription + } + + isCreating = false + } +} diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index 189f7bd..9ecbdc8 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -1,3 +1,4 @@ +import CallKit import FirebaseCore import FirebaseCrashlytics import FirebaseMessaging @@ -567,8 +568,17 @@ extension AppDelegate: PKPushRegistryDelegate { // If callerKey is empty/invalid, immediately end the orphaned call. // Apple still required us to call reportNewIncomingCall, but we can't - // connect a call without a valid peer key. + // connect a call without a valid peer key. Without this, the CallKit + // call stays visible → user taps Accept → pendingCallKitAccept stuck + // forever → app in broken state until force-quit. if callerKey.isEmpty || error != nil { + Task { @MainActor in + CallKitManager.shared.reportCallEndedByRemote(reason: .failed) + // Clear stale accept flag — user may have tapped Accept + // on the orphaned CallKit UI before it was dismissed. + // Without this, the flag persists and auto-accepts the NEXT call. + CallManager.shared.pendingCallKitAccept = false + } return }