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
}