diff --git a/.gitignore b/.gitignore
index a723ed0..ed7f004 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,7 @@ sprints/
CLAUDE.md
.claude.local.md
desktop
+server
AGENTS.md
# Xcode
diff --git a/Info.plist b/Info.plist
index de5787b..896913a 100644
--- a/Info.plist
+++ b/Info.plist
@@ -14,5 +14,9 @@
remote-notification
+ NSUserActivityTypes
+
+ INSendMessageIntent
+
diff --git a/Rosetta/Core/Data/Database/DatabaseManager.swift b/Rosetta/Core/Data/Database/DatabaseManager.swift
index a8eb671..cf84dae 100644
--- a/Rosetta/Core/Data/Database/DatabaseManager.swift
+++ b/Rosetta/Core/Data/Database/DatabaseManager.swift
@@ -119,6 +119,21 @@ final class DatabaseManager {
}
}
+ // v3: Android/Desktop parity — split delivery_status into delivered (0/1/2) + read (0/1).
+ // Previously iOS combined both into delivery_status (0=WAITING, 1=DELIVERED, 2=ERROR, 3=READ).
+ // Android and Desktop use separate columns: delivered (0/1/2) + read (0/1).
+ migrator.registerMigration("v3_split_read_status") { db in
+ // Step 1: Outgoing non-READ messages had is_read=1 due to `|| fromMe` in insert.
+ // Reset to 0 — is_read for outgoing now means "opponent has read" (Android parity).
+ try db.execute(sql: "UPDATE messages SET is_read = 0 WHERE from_me = 1 AND delivery_status != 3")
+
+ // Step 2: Convert READ (3) → DELIVERED (1). is_read stays 1 for these.
+ try db.execute(sql: "UPDATE messages SET delivery_status = 1 WHERE delivery_status = 3")
+
+ // Step 3: Add last_message_read to dialogs (Android: lastMessageRead column).
+ try db.execute(sql: "ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0")
+ }
+
try migrator.migrate(pool)
dbPool = pool
diff --git a/Rosetta/Core/Data/Database/DialogRecord.swift b/Rosetta/Core/Data/Database/DialogRecord.swift
index be453ea..4b0d1f5 100644
--- a/Rosetta/Core/Data/Database/DialogRecord.swift
+++ b/Rosetta/Core/Data/Database/DialogRecord.swift
@@ -22,6 +22,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
var isMuted: Int
var lastMessageFromMe: Int
var lastMessageDelivered: Int
+ var lastMessageRead: Int
// MARK: - Column mapping
@@ -41,6 +42,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
case isMuted = "is_muted"
case lastMessageFromMe = "last_message_from_me"
case lastMessageDelivered = "last_message_delivered"
+ case lastMessageRead = "last_message_read"
}
enum CodingKeys: String, CodingKey {
@@ -59,6 +61,7 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
case isMuted = "is_muted"
case lastMessageFromMe = "last_message_from_me"
case lastMessageDelivered = "last_message_delivered"
+ case lastMessageRead = "last_message_read"
}
// MARK: - Auto-increment
@@ -86,7 +89,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
isPinned: isPinned != 0,
isMuted: isMuted != 0,
lastMessageFromMe: lastMessageFromMe != 0,
- lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting
+ lastMessageDelivered: DeliveryStatus(rawValue: lastMessageDelivered) ?? .waiting,
+ lastMessageRead: lastMessageRead != 0
)
}
@@ -107,7 +111,8 @@ struct DialogRecord: Codable, FetchableRecord, MutablePersistableRecord, Sendabl
isPinned: dialog.isPinned ? 1 : 0,
isMuted: dialog.isMuted ? 1 : 0,
lastMessageFromMe: dialog.lastMessageFromMe ? 1 : 0,
- lastMessageDelivered: dialog.lastMessageDelivered.rawValue
+ lastMessageDelivered: dialog.lastMessageDelivered.rawValue,
+ lastMessageRead: dialog.lastMessageRead ? 1 : 0
)
}
}
diff --git a/Rosetta/Core/Data/Models/Dialog.swift b/Rosetta/Core/Data/Models/Dialog.swift
index bf446d2..af0150f 100644
--- a/Rosetta/Core/Data/Models/Dialog.swift
+++ b/Rosetta/Core/Data/Models/Dialog.swift
@@ -16,11 +16,12 @@ enum SystemAccounts {
// MARK: - DeliveryStatus
+/// Android/Desktop parity: delivery_status is 0/1/2 only.
+/// Read status is stored separately in the `is_read` / `read` column.
enum DeliveryStatus: Int, Codable {
case waiting = 0
case delivered = 1
case error = 2
- case read = 3
}
// MARK: - Dialog
@@ -49,6 +50,8 @@ struct Dialog: Identifiable, Codable, Equatable {
var lastMessageFromMe: Bool
var lastMessageDelivered: DeliveryStatus
+ /// Android parity: separate read flag for last outgoing message.
+ var lastMessageRead: Bool
// MARK: - Computed
diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift
index db50733..ba24f4c 100644
--- a/Rosetta/Core/Data/Repositories/DialogRepository.swift
+++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift
@@ -179,7 +179,8 @@ final class DialogRepository {
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
isOnline: false, lastSeen: 0, verified: 0,
iHaveSent: false, isPinned: false, isMuted: false,
- lastMessageFromMe: false, lastMessageDelivered: .waiting
+ lastMessageFromMe: false, lastMessageDelivered: .waiting,
+ lastMessageRead: false
)
// Update computed fields
@@ -189,6 +190,8 @@ final class DialogRepository {
dialog.iHaveSent = hasSent || isSystem
dialog.lastMessageFromMe = lastFromMe
dialog.lastMessageDelivered = lastFromMe ? lastMsg.deliveryStatus : .delivered
+ // Android parity: separate read flag from last outgoing message's is_read column.
+ dialog.lastMessageRead = lastFromMe ? lastMsg.isRead : false
dialogs[opponentKey] = dialog
_sortedKeysCache = nil
@@ -223,7 +226,8 @@ final class DialogRepository {
lastMessage: "", lastMessageTimestamp: 0, unreadCount: 0,
isOnline: false, lastSeen: 0, verified: verified,
iHaveSent: false, isPinned: false, isMuted: false,
- lastMessageFromMe: false, lastMessageDelivered: .waiting
+ lastMessageFromMe: false, lastMessageDelivered: .waiting,
+ lastMessageRead: false
)
dialogs[opponentKey] = dialog
_sortedKeysCache = nil
@@ -354,8 +358,9 @@ final class DialogRepository {
sql: """
INSERT INTO dialogs (account, opponent_key, opponent_title, opponent_username,
last_message, last_message_timestamp, unread_count, is_online, last_seen,
- verified, i_have_sent, is_pinned, is_muted, last_message_from_me, last_message_delivered)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ verified, i_have_sent, is_pinned, is_muted, last_message_from_me,
+ last_message_delivered, last_message_read)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account, opponent_key) DO UPDATE SET
opponent_title = excluded.opponent_title,
opponent_username = excluded.opponent_username,
@@ -369,14 +374,16 @@ final class DialogRepository {
is_pinned = excluded.is_pinned,
is_muted = excluded.is_muted,
last_message_from_me = excluded.last_message_from_me,
- last_message_delivered = excluded.last_message_delivered
+ last_message_delivered = excluded.last_message_delivered,
+ last_message_read = excluded.last_message_read
""",
arguments: [
dialog.account, dialog.opponentKey, dialog.opponentTitle, dialog.opponentUsername,
dialog.lastMessage, dialog.lastMessageTimestamp, dialog.unreadCount,
dialog.isOnline ? 1 : 0, dialog.lastSeen, dialog.verified,
dialog.iHaveSent ? 1 : 0, dialog.isPinned ? 1 : 0, dialog.isMuted ? 1 : 0,
- dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue
+ dialog.lastMessageFromMe ? 1 : 0, dialog.lastMessageDelivered.rawValue,
+ dialog.lastMessageRead ? 1 : 0
]
)
}
diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift
index 74f7e45..ba64ab1 100644
--- a/Rosetta/Core/Data/Repositories/MessageRepository.swift
+++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift
@@ -273,8 +273,10 @@ final class MessageRepository: ObservableObject {
]
)
} else {
- // Insert new message — Android parity: read = 0 for incoming by default
- let isRead = incomingRead || fromMe
+ // Android/Desktop parity: is_read means different things per direction:
+ // - Incoming (from_me=0): "I have read this message" (true if dialog active)
+ // - Outgoing (from_me=1): "Opponent has read this message" (always starts false)
+ let isRead = incomingRead
var record = MessageRecord(
id: nil,
account: myPublicKey,
@@ -328,8 +330,8 @@ final class MessageRepository: ObservableObject {
).fetchOne(db) else { return nil }
let current = DeliveryStatus(rawValue: existing.deliveryStatus) ?? .waiting
- if current == .read && (status == .delivered || status == .waiting) { return nil }
- if current == .delivered && status == .waiting { return nil }
+ // Android parity: don't downgrade delivery status.
+ if current == .delivered && (status == .waiting || status == .error) { return nil }
var sql = "UPDATE messages SET delivery_status = ?"
var args: [any DatabaseValueConvertible] = [status.rawValue]
@@ -375,9 +377,12 @@ final class MessageRepository: ObservableObject {
let dialogKey = DatabaseManager.dialogKey(account: myPublicKey, opponentKey: opponentKey)
do {
try db.writeSync { db in
+ // Android/Desktop parity: set is_read = 1 for outgoing messages (separate column).
+ // delivery_status stays unchanged (WAITING/DELIVERED/ERROR).
+ // Android: `UPDATE messages SET read = 1 WHERE from_me = 1 AND read != 1`
try db.execute(
- sql: "UPDATE messages SET delivery_status = ? WHERE account = ? AND dialog_key = ? AND from_me = 1",
- arguments: [DeliveryStatus.read.rawValue, myPublicKey, dialogKey]
+ sql: "UPDATE messages SET is_read = 1 WHERE account = ? AND dialog_key = ? AND from_me = 1 AND is_read = 0",
+ arguments: [myPublicKey, dialogKey]
)
}
} catch {
@@ -451,6 +456,32 @@ final class MessageRepository: ObservableObject {
}
}
+ /// Android parity: `markExpiredWaitingAsError()` — batch SQL UPDATE for old WAITING messages.
+ /// Returns the number of rows updated.
+ @discardableResult
+ func markExpiredWaitingAsError(myPublicKey: String, maxTimestamp: Int64) -> Int {
+ do {
+ return try db.writeSync { db in
+ try db.execute(
+ sql: """
+ UPDATE messages SET delivery_status = ?
+ WHERE account = ? AND from_me = 1 AND delivery_status = ? AND timestamp < ?
+ """,
+ arguments: [
+ DeliveryStatus.error.rawValue,
+ myPublicKey,
+ DeliveryStatus.waiting.rawValue,
+ maxTimestamp,
+ ]
+ )
+ return db.changesCount
+ }
+ } catch {
+ print("[DB] markExpiredWaitingAsError error: \(error)")
+ return 0
+ }
+ }
+
func resolveRetryableOutgoingMessages(
myPublicKey: String,
nowMs: Int64,
diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift
index 2f63b5c..1a14834 100644
--- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift
+++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift
@@ -124,6 +124,35 @@ final class ProtocolManager: @unchecked Sendable {
}
}
+ /// Android parity: `reconnectNowIfNeeded(reason: "foreground")`.
+ /// On foreground resume, always force reconnect — iOS suspends the process in
+ /// background, server RSTs TCP, but `didCloseWith` never fires (zombie socket).
+ /// Android doesn't have this because OkHttp fires onFailure in background.
+ /// Previously iOS used ping-first (3s timeout) which was too slow.
+ func forceReconnectOnForeground() {
+ guard savedPublicKey != nil, savedPrivateHash != nil else { return }
+
+ // Android parity: skip if handshake or device verification is in progress.
+ // These are active flows that should not be interrupted.
+ switch connectionState {
+ case .handshaking, .deviceVerificationRequired:
+ return
+ case .connecting:
+ if client.isConnecting { return }
+ case .authenticated, .connected, .disconnected:
+ break // Always reconnect — .authenticated/.connected may be zombie on iOS
+ }
+
+ Self.logger.info("⚡ Foreground reconnect — tearing down potential zombie socket")
+ pingVerificationInProgress = false
+ pingTimeoutTask?.cancel()
+ pingTimeoutTask = nil
+ handshakeComplete = false
+ heartbeatTask?.cancel()
+ connectionState = .connecting
+ client.forceReconnect()
+ }
+
/// Android parity: `reconnectNowIfNeeded()` — if already in an active state,
/// skip reconnect. Otherwise reset backoff and connect immediately.
func reconnectIfNeeded() {
@@ -191,7 +220,13 @@ final class ProtocolManager: @unchecked Sendable {
handshakeComplete = false
heartbeatTask?.cancel()
Task { @MainActor in
- self.connectionState = .connecting
+ // Guard: only downgrade to .connecting if reconnect hasn't already progressed.
+ // forceReconnect() is called synchronously below — if it completes fast,
+ // this async Task could overwrite .authenticated/.handshaking with .connecting.
+ let s = self.connectionState
+ if s != .authenticated && s != .handshaking && s != .connected {
+ self.connectionState = .connecting
+ }
}
client.forceReconnect()
}
@@ -291,7 +326,11 @@ final class ProtocolManager: @unchecked Sendable {
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
- self.connectionState = .connecting
+ // Guard: only downgrade to .connecting if reconnect hasn't already progressed.
+ let s = self.connectionState
+ if s != .authenticated && s != .handshaking && s != .connected {
+ self.connectionState = .connecting
+ }
}
self.client.forceReconnect()
}
@@ -339,7 +378,11 @@ final class ProtocolManager: @unchecked Sendable {
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
- self.connectionState = .connecting
+ // Guard: only downgrade to .connecting if reconnect hasn't already progressed.
+ let s = self.connectionState
+ if s != .authenticated && s != .handshaking && s != .connected {
+ self.connectionState = .connecting
+ }
}
self.client.forceReconnect()
}
diff --git a/Rosetta/Core/Network/Protocol/WebSocketClient.swift b/Rosetta/Core/Network/Protocol/WebSocketClient.swift
index 68eb70b..3d172eb 100644
--- a/Rosetta/Core/Network/Protocol/WebSocketClient.swift
+++ b/Rosetta/Core/Network/Protocol/WebSocketClient.swift
@@ -12,6 +12,7 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
private var webSocketTask: URLSessionWebSocketTask?
private var isManuallyClosed = false
private var reconnectTask: Task?
+ private var connectTimeoutTask: Task?
private var hasNotifiedConnected = false
private(set) var isConnected = false
/// Android parity: prevents concurrent connect() calls and suppresses handleDisconnect
@@ -82,6 +83,24 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
task.resume()
receiveLoop()
+
+ // Safety net: if didOpenWithProtocol never fires within 15s, clean up
+ // and trigger reconnect. Matches URLSession's timeoutIntervalForResource
+ // but provides better logging and guaranteed cleanup of isConnecting flag.
+ connectTimeoutTask?.cancel()
+ connectTimeoutTask = Task { [weak self] in
+ try? await Task.sleep(nanoseconds: 15_000_000_000)
+ guard let self, !Task.isCancelled, self.isConnecting else { return }
+ Self.logger.warning("Connection establishment timeout (15s)")
+ self.isConnecting = false
+ self.webSocketTask?.cancel(with: .goingAway, reason: nil)
+ self.webSocketTask = nil
+ self.isConnected = false
+ self.handleDisconnect(error: NSError(
+ domain: "WebSocketClient", code: -2,
+ userInfo: [NSLocalizedDescriptionKey: "Connection establishment timeout"]
+ ))
+ }
}
func disconnect() {
@@ -90,6 +109,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
isConnecting = false
reconnectTask?.cancel()
reconnectTask = nil
+ connectTimeoutTask?.cancel()
+ connectTimeoutTask = nil
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
isConnected = false
@@ -101,6 +122,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return }
reconnectTask?.cancel()
reconnectTask = nil
+ connectTimeoutTask?.cancel()
+ connectTimeoutTask = nil
// Always tear down and reconnect — connection may be zombie after background
webSocketTask?.cancel(with: .goingAway, reason: nil)
webSocketTask = nil
@@ -175,6 +198,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return }
// Android parity: reset isConnecting on successful open (Protocol.kt onOpen).
isConnecting = false
+ connectTimeoutTask?.cancel()
+ connectTimeoutTask = nil
hasNotifiedConnected = true
isConnected = true
disconnectHandledForCurrentSocket = false
@@ -197,6 +222,18 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
handleDisconnect(error: nil)
}
+ /// Safety net for transport-level failures (DNS, TLS, TCP refused) that may
+ /// bypass didCloseWith when the connection never opened successfully.
+ nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
+ guard let error else { return }
+ // Ignore callbacks from old (cancelled) sockets after forceReconnect.
+ guard task === self.webSocketTask else { return }
+ Self.logger.warning("URLSession task failed: \(error.localizedDescription)")
+ isConnecting = false
+ isConnected = false
+ handleDisconnect(error: error)
+ }
+
// MARK: - Receive Loop
private func receiveLoop() {
@@ -221,6 +258,10 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
case .failure(let error):
Self.logger.error("Receive error: \(error.localizedDescription)")
+ // Android parity (onFailure): clear isConnecting before handleDisconnect.
+ // Without this, if connection fails before didOpenWithProtocol (DNS/TLS error),
+ // isConnecting stays true → handleDisconnect returns early → no reconnect ever scheduled.
+ self.isConnecting = false
self.handleDisconnect(error: error)
}
}
diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift
index 920e42c..f9479a8 100644
--- a/Rosetta/Core/Services/SessionManager.swift
+++ b/Rosetta/Core/Services/SessionManager.swift
@@ -36,6 +36,12 @@ final class SessionManager {
/// PacketRead can arrive BEFORE the sync messages, so markIncomingAsRead
/// updates 0 rows. Re-applying after sync ensures the read state sticks.
private var pendingSyncReads: Set = []
+ /// Opponent read receipts received during sync — re-applied at BATCH_END.
+ /// Mirrors `pendingSyncReads` for own-device reads. Without this,
+ /// markOutgoingAsRead() races with message insertion (0 rows affected)
+ /// because iOS processes reads via separate Task, not the inbound queue.
+ /// Android/Desktop don't need this — all packets go through one FIFO queue.
+ private var pendingOpponentReads: Set = []
private var requestedUserInfoKeys: Set = []
private var onlineSubscribedKeys: Set = []
private var pendingOutgoingRetryTasks: [String: Task] = [:]
@@ -80,6 +86,23 @@ final class SessionManager {
}
}
+ /// Re-apply opponent read receipts that raced with message insertion during sync.
+ /// Android parity: Android doesn't need this because all packets go through one FIFO
+ /// queue (launchInboundPacketTask). iOS processes reads via separate Task, so they
+ /// can execute before messages are committed to DB → markOutgoingAsRead updates 0 rows.
+ private func reapplyPendingOpponentReads() {
+ guard !pendingOpponentReads.isEmpty else { return }
+ let keys = pendingOpponentReads
+ pendingOpponentReads.removeAll()
+ let myKey = currentPublicKey
+ for opponentKey in keys {
+ MessageRepository.shared.markOutgoingAsRead(
+ opponentKey: opponentKey, myPublicKey: myKey
+ )
+ DialogRepository.shared.markOutgoingAsRead(opponentKey: opponentKey)
+ }
+ }
+
/// Android parity (ON_RESUME): re-mark active dialogs as read and send read receipts.
/// Called on foreground resume. Android has no idle detection — just re-marks on resume.
func markActiveDialogsAsRead() {
@@ -199,27 +222,19 @@ final class SessionManager {
myPublicKey: currentPublicKey
)
- // Desktop parity: if not connected, set initial status to ERROR (not WAITING).
- // The packet is still queued and will be sent on reconnect;
- // delivery ACK will update status to DELIVERED.
- let isConnected = connState == .authenticated
- let offlineAsSend = !isConnected
-
- // Android parity: insert message FIRST, then recalculate dialog from DB.
+ // Android parity: always insert as WAITING (status=0) regardless of connection state.
+ // Packet is queued in ProtocolManager and sent on reconnect; retry mechanism
+ // handles timeout (80s → ERROR). Previously iOS marked offline sends as ERROR
+ // immediately (Desktop parity), but this caused false ❌ on brief WebSocket drops.
+ // Android keeps WAITING (⏳) and delivers automatically — much better UX.
MessageRepository.shared.upsertFromMessagePacket(
packet,
myPublicKey: currentPublicKey,
decryptedText: text,
- fromSync: offlineAsSend
+ fromSync: false
)
DialogRepository.shared.updateDialogFromMessages(opponentKey: packet.toPublicKey)
- // Desktop parity: mark as ERROR when offline (fromSync sets .delivered, override to .error)
- if offlineAsSend {
- MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
- DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
- }
-
// Android parity: persist IMMEDIATELY after inserting outgoing message.
// Without this, if app is killed within 800ms debounce window,
// the message is lost forever (only in memory, not on disk).
@@ -558,21 +573,17 @@ final class SessionManager {
opponentKey: toPublicKey, title: title, username: username, myPublicKey: currentPublicKey
)
- let isConnected = ProtocolManager.shared.connectionState == .authenticated
- let offlineAsSend = !isConnected
let displayText = messageText
+ // Android parity: always insert as WAITING (fromSync: false).
+ // Retry mechanism handles timeout (80s → ERROR).
MessageRepository.shared.upsertFromMessagePacket(
optimisticPacket, myPublicKey: currentPublicKey, decryptedText: displayText,
- attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: offlineAsSend
+ attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString, fromSync: false
)
DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey)
MessageRepository.shared.persistNow()
- if offlineAsSend {
- MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
- DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .error)
- }
if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
DialogRepository.shared.updateDeliveryStatus(messageId: messageId, opponentKey: toPublicKey, status: .delivered)
@@ -885,26 +896,18 @@ final class SessionManager {
myPublicKey: currentPublicKey
)
- // Optimistic UI update — use localPacket (decrypted blob) for storage
- let isConnected = ProtocolManager.shared.connectionState == .authenticated
- let offlineAsSend = !isConnected
+ // Optimistic UI update — use localPacket (decrypted blob) for storage.
+ // Android parity: always insert as WAITING (fromSync: false).
let displayText = messageText
MessageRepository.shared.upsertFromMessagePacket(
localPacket,
myPublicKey: currentPublicKey,
decryptedText: displayText,
attachmentPassword: "rawkey:" + encrypted.plainKeyAndNonce.hexString,
- fromSync: offlineAsSend
+ fromSync: false
)
DialogRepository.shared.updateDialogFromMessages(opponentKey: localPacket.toPublicKey)
- if offlineAsSend {
- MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .error)
- DialogRepository.shared.updateDeliveryStatus(
- messageId: messageId, opponentKey: toPublicKey, status: .error
- )
- }
-
// Saved Messages: local-only
if toPublicKey == currentPublicKey {
MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered)
@@ -998,6 +1001,8 @@ final class SessionManager {
lastTypingSentAt.removeAll()
syncBatchInProgress = false
syncRequestInFlight = false
+ pendingSyncReads.removeAll()
+ pendingOpponentReads.removeAll()
pendingIncomingMessages.removeAll()
isProcessingIncomingMessages = false
lastReadReceiptTimestamp.removeAll()
@@ -1091,6 +1096,13 @@ final class SessionManager {
opponentKey: opponentKey,
myPublicKey: ownKey
)
+ // Race fix: if sync is in progress, messages may not be in DB yet.
+ // markOutgoingAsRead() may have updated 0 rows. Store for re-application
+ // at BATCH_END after waitForInboundQueueToDrain() ensures all messages
+ // are committed. Android doesn't need this — single FIFO queue.
+ if self.syncBatchInProgress {
+ self.pendingOpponentReads.insert(opponentKey)
+ }
// Resolve pending retry timers for all messages to this opponent —
// read receipt proves delivery, no need to retry further.
self.resolveAllOutgoingRetries(toPublicKey: opponentKey)
@@ -1223,6 +1235,7 @@ final class SessionManager {
// PacketRead can arrive BEFORE sync messages — markIncomingAsRead
// updates 0 rows because the message isn't in DB yet.
self.reapplyPendingSyncReads()
+ self.reapplyPendingOpponentReads()
// Android parity: reconcile unread counts after each batch.
DialogRepository.shared.reconcileUnreadCounts()
Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)")
@@ -1233,6 +1246,7 @@ final class SessionManager {
self.syncBatchInProgress = false
// Re-apply cross-device reads one final time.
self.reapplyPendingSyncReads()
+ self.reapplyPendingOpponentReads()
DialogRepository.shared.reconcileDeliveryStatuses()
DialogRepository.shared.reconcileUnreadCounts()
self.retryWaitingOutgoingMessagesAfterReconnect()
@@ -1871,9 +1885,19 @@ final class SessionManager {
else { return }
let now = Int64(Date().timeIntervalSince1970 * 1000)
- // Android parity: retry messages within 80-second window (MESSAGE_MAX_TIME_TO_DELIVERED_MS).
- // Messages older than 80s are marked as error — user can retry manually.
+ // Android parity: MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000.
let maxRetryAgeMs: Int64 = 80_000
+
+ // Android parity: batch-mark expired WAITING messages as ERROR first (SQL UPDATE).
+ let expiredCount = MessageRepository.shared.markExpiredWaitingAsError(
+ myPublicKey: currentPublicKey,
+ maxTimestamp: now - maxRetryAgeMs
+ )
+ if expiredCount > 0 {
+ Self.logger.warning("Marked \(expiredCount) expired WAITING messages as ERROR")
+ }
+
+ // Android parity: get remaining WAITING messages within the window.
let retryable = MessageRepository.shared.resolveRetryableOutgoingMessages(
myPublicKey: currentPublicKey,
nowMs: now,
@@ -1896,18 +1920,15 @@ final class SessionManager {
)
do {
- // Use fresh timestamp for the packet so the 80s delivery timeout
- // starts from NOW, not from the original send time.
- // Without this, messages sent 70s before reconnect would expire
- // in ~10s — not enough time for the server to respond with 0x08.
- // Also fixes server-side messageId dedup: fresh timestamp lets the
- // server accept the retried message instead of silently dropping it.
- let retryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
+ // Fresh timestamp for retry: server silently rejects messages older than 30s
+ // (Executor6Message maxPaddingSec=30). Original timestamp would fail validation
+ // if reconnect took >30s. Server overwrites timestamp with System.currentTimeMillis()
+ // anyway (Executor6Message:102), so client timestamp is only for age validation.
let packet = try makeOutgoingPacket(
text: text,
toPublicKey: message.toPublicKey,
messageId: message.id,
- timestamp: retryTimestamp,
+ timestamp: Int64(Date().timeIntervalSince1970 * 1000),
privateKeyHex: privateKeyHex,
privateKeyHash: privateKeyHash
)
@@ -1943,12 +1964,10 @@ final class SessionManager {
guard let packet = self.pendingOutgoingPackets[messageId] else { return }
let attempts = self.pendingOutgoingAttempts[messageId] ?? 0
- // Android parity: 80s × max(1, attachmentCount).
+ // Android parity: flat 80s timeout (MESSAGE_MAX_TIME_TO_DELIVERED_MS).
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
let ageMs = nowMs - packet.timestamp
- let attachCount = max(1, Int64(packet.attachments.count))
- let timeoutMs = self.maxOutgoingWaitingLifetimeMs * attachCount
- if ageMs >= timeoutMs {
+ if ageMs >= self.maxOutgoingWaitingLifetimeMs {
// Android parity: mark as ERROR after timeout, not DELIVERED.
// If the message was actually delivered, server will send 0x08 on reconnect
// (or sync will restore the message). Marking DELIVERED optimistically
@@ -1967,11 +1986,10 @@ final class SessionManager {
}
guard ProtocolManager.shared.connectionState == .authenticated else {
- // Android parity: don't resolve (cancel) retry — keep message as .waiting.
- // retryWaitingOutgoingMessagesAfterReconnect() will pick it up on next handshake.
- // Previously: resolveOutgoingRetry() cancelled everything → message stuck as
- // .delivered (optimistic) → never retried → lost.
- Self.logger.debug("Message \(messageId) retry deferred — not authenticated, keeping .waiting")
+ // Android parity: cancel retry when not authenticated — clean up in-memory state.
+ // retryWaitingOutgoingMessagesAfterReconnect() re-reads from DB after sync completes.
+ Self.logger.debug("Message \(messageId) retry deferred — not authenticated")
+ self.resolveOutgoingRetry(messageId: messageId)
return
}
@@ -1990,13 +2008,6 @@ final class SessionManager {
let fromMe = packet.fromPublicKey == currentPublicKey
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
- let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
- if currentStatus == .read {
- // Already read — don't downgrade to delivered.
- resolveOutgoingRetry(messageId: messageId)
- return
- }
-
let deliveryTimestamp = Int64(Date().timeIntervalSince1970 * 1000)
MessageRepository.shared.updateDeliveryStatus(
messageId: messageId,
@@ -2019,8 +2030,8 @@ final class SessionManager {
let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey
let currentStatus = MessageRepository.shared.deliveryStatus(forMessageId: messageId)
- if currentStatus == .delivered || currentStatus == .read {
- Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already \(currentStatus?.rawValue ?? -1)")
+ if currentStatus == .delivered {
+ Self.logger.info("Skipping markOutgoingAsError for \(messageId.prefix(8))… — already delivered")
resolveOutgoingRetry(messageId: messageId)
return
}
@@ -2149,20 +2160,14 @@ final class SessionManager {
// Android: ON_RESUME calls markVisibleMessagesAsRead() for active dialog.
self?.markActiveDialogsAsRead()
- // iOS-specific: after long background, iOS suspends the process and the
- // server RSTs the TCP connection. But `didCloseWith` never fires (process
- // frozen), so connectionState stays `.authenticated` (stale zombie socket).
- // Android doesn't have this problem because OkHttp callbacks fire in background.
- //
- // Fix: if state looks "alive" (.authenticated/.connected), ping-first to
- // verify. If no pong → zombie → forceReconnect. If disconnected, reconnect
- // immediately (Android parity: reconnectNowIfNeeded).
- let state = ProtocolManager.shared.connectionState
- if state == .authenticated || state == .connected {
- ProtocolManager.shared.verifyConnectionOrReconnect()
- } else {
- ProtocolManager.shared.reconnectIfNeeded()
- }
+ // Android parity: on foreground resume, always force reconnect.
+ // Android's OkHttp fires onFailure in background → state is accurate.
+ // iOS URLSession does NOT fire didCloseWith when process is suspended →
+ // connectionState stays .authenticated (zombie socket). Ping-first
+ // added 3 seconds of delay waiting for pong that never comes.
+ // Match Android: just tear down + reconnect. If connection was alive,
+ // small cost (reconnect <1s). If zombie, no wasted 3s.
+ ProtocolManager.shared.forceReconnectOnForeground()
// syncOnForeground() has its own `.authenticated` guard — safe to call.
// If ping-first triggers reconnect, sync won't fire (state is .connecting).
diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift
index 73da861..f4c5d5d 100644
--- a/Rosetta/Core/Utils/ReleaseNotes.swift
+++ b/Rosetta/Core/Utils/ReleaseNotes.swift
@@ -11,10 +11,17 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
- **Чат**
- Нативная прокрутка без инверсии — плавная работа клавиатуры, корректное позиционирование при открытии чата. Интерактивное скрытие клавиатуры свайпом вниз.
+ **Клавиатура и поле ввода**
+ Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима. Исправлено ложное срабатывание многострочного режима.
- Поле ввода теперь двигается вместе с клавиатурой без задержки — анимация происходит в одной транзакции с клавиатурой через UIKit-контейнер.
+ **Интерфейс чата**
+ Тёмные градиенты по краям экрана — контент плавно уходит под навбар и home indicator. Аватарки в списке чатов обновляются мгновенно без перезахода. На iOS 26 поле ввода корректно внизу экрана, клавиатура работает нативно.
+
+ **Доставка сообщений**
+ Сообщения больше не помечаются ошибкой при кратковременном обрыве — показываются часики и автодоставка при реконнекте. Свежий timestamp при повторной отправке — сервер больше не отклоняет. Быстрый реконнект на foreground без 3-секундной задержки.
+
+ **Синхронизация**
+ Прочтения от оппонента больше не теряются при синке — переприменяются после вставки сообщений. Схема БД приведена к Android/Desktop паритету (delivered + read). Прочтение больше не перезаписывает ошибочные сообщения.
"""
)
]
diff --git a/Rosetta/Core/Utils/StressTestGenerator.swift b/Rosetta/Core/Utils/StressTestGenerator.swift
index e21b151..cb996f9 100644
--- a/Rosetta/Core/Utils/StressTestGenerator.swift
+++ b/Rosetta/Core/Utils/StressTestGenerator.swift
@@ -72,7 +72,7 @@ enum StressTestGenerator {
toPublicKey: isOutgoing ? dialogKey : myKey,
text: attachments.isEmpty ? text : (i % 2 == 0 ? text : ""),
timestamp: timestamp,
- deliveryStatus: isOutgoing ? [.delivered, .read, .delivered][i % 3] : .delivered,
+ deliveryStatus: .delivered,
isRead: true,
attachments: attachments,
attachmentPassword: nil
diff --git a/Rosetta/DesignSystem/Components/ChatTextInput.swift b/Rosetta/DesignSystem/Components/ChatTextInput.swift
index 5c1cb9e..8dcb16a 100644
--- a/Rosetta/DesignSystem/Components/ChatTextInput.swift
+++ b/Rosetta/DesignSystem/Components/ChatTextInput.swift
@@ -250,15 +250,32 @@ struct ChatTextInput: UIViewRepresentable {
func invalidateHeight(_ tv: UITextView) {
tv.invalidateIntrinsicContentSize()
+ // Force contentSize recalculation before checking multiline.
+ // Without this, contentSize still reflects the OLD text after deletion.
+ tv.setNeedsLayout()
+ tv.layoutIfNeeded()
checkMultiline(tv)
}
private func checkMultiline(_ tv: UITextView) {
let lineHeight = tv.font?.lineHeight ?? 20
let singleLineHeight = lineHeight + tv.textContainerInset.top + tv.textContainerInset.bottom
- let isMultiline = tv.contentSize.height > singleLineHeight + 0.5
+ let threshold = singleLineHeight + lineHeight * 0.5
+ // Use sizeThatFits instead of contentSize — contentSize reflects the current
+ // FRAME (still multiline-sized), not the actual text height. sizeThatFits
+ // calculates the needed height for the text, independent of the current frame.
+ let fittingHeight = tv.sizeThatFits(CGSize(width: tv.bounds.width, height: .greatestFiniteMagnitude)).height
+ let isMultiline = fittingHeight > threshold
+ #if DEBUG
+ let textLen = tv.text?.count ?? 0
+ let textRepr = tv.text?.replacingOccurrences(of: "\n", with: "\\n").prefix(20) ?? ""
+ print("📐 checkMultiline | fitH=\(String(format: "%.1f", fittingHeight)) contentH=\(String(format: "%.1f", tv.contentSize.height)) singleH=\(String(format: "%.1f", singleLineHeight)) threshold=\(String(format: "%.1f", threshold)) | isMulti=\(isMultiline) was=\(wasMultiline) | len=\(textLen) text=\"\(textRepr)\"")
+ #endif
if isMultiline != wasMultiline {
wasMultiline = isMultiline
+ #if DEBUG
+ print("📐 MULTILINE CHANGED → \(isMultiline)")
+ #endif
parent.onMultilineChange(isMultiline)
}
}
diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift
index 37da665..7a621ad 100644
--- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift
+++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift
@@ -8,12 +8,11 @@ import UIKit
/// - `keyboardPadding`: bottom padding to apply when keyboard is visible
///
/// Animation strategy:
-/// - Notification (show/hide): A hidden UIView is animated with the keyboard's
-/// exact `UIViewAnimationCurve` (rawValue 7) inside the same Core Animation
-/// transaction. CADisplayLink samples the presentation layer at 60fps,
-/// giving pixel-perfect curve sync. Cubic bezier fallback if no window.
+/// - Notification (show/hide): `spacerPadding` driven by CADisplayLink with curve-predicted
+/// bezier evaluation. SHOW uses 2.5-frame advance to stay ahead of UIKit composer
+/// (prevents message overlap). HIDE uses 1-frame advance. `keyboardPadding`
+/// driven by sync view reading (for ComposerHostView consumers).
/// - KVO (interactive dismiss): raw assignment at 30fps via coalescing.
-/// - NO `withAnimation` / `.animation()` — these cause LazyVStack cell recycling gaps.
@MainActor
final class KeyboardTracker: ObservableObject {
@@ -173,9 +172,6 @@ final class KeyboardTracker: ObservableObject {
let current = pendingKVOPadding ?? keyboardPadding
guard newPadding < current else { return }
- // Drop spacerPadding floor so max() follows keyboardPadding during dismiss
- if spacerPadding != 0 { spacerPadding = 0 }
-
// Move composer immediately (UIKit, no SwiftUI overhead)
composerHostView?.setKeyboardOffset(rawPadding)
@@ -202,8 +198,10 @@ final class KeyboardTracker: ObservableObject {
guard pending.isFinite, pending >= 0 else { return }
guard pending != keyboardPadding else { return }
keyboardPadding = pending
- // spacerPadding NOT updated from KVO — only from handleNotification
- // with withAnimation to stay in sync with keyboard CA transaction.
+ // Also update spacerPadding so KeyboardSpacer follows during interactive dismiss
+ if pending != spacerPadding {
+ spacerPadding = pending
+ }
}
/// Immediately applies any buffered KVO value (used when KVO stops).
@@ -245,22 +243,32 @@ final class KeyboardTracker: ObservableObject {
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0
- // Configure bezier early — needed for curve-aware initial spacerPadding below.
+ // Configure bezier early — needed for HIDE head-start and animationTick fallback.
configureBezier(curveRaw: curveRaw)
- // Direction-dependent spacerPadding initialization:
+ // Direction-dependent initialization:
let isShow = targetPadding > keyboardPadding
if isShow {
- // Curve-aware head start: evaluate bezier at ~1 frame into animation.
- // Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz, ~16ms on 60Hz).
- // Smooth (follows curve shape), not a jarring jump.
+ // Head-start: immediately set spacerPadding to ~1 frame into animation.
+ // Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz).
+ // Without this, spacerPadding = 0 for the first frame → messages overlap composer.
let initialT = min(0.016 / max(duration, 0.05), 1.0)
let initialEased = cubicBezierEase(initialT)
let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased
spacerPadding = max(keyboardPadding, round(initialValue))
+ #if DEBUG
+ print("⌨️ 🎬 SHOW headstart: spacer=\(Int(spacerPadding)) target=\(Int(targetPadding))")
+ #endif
} else {
- // HIDE: instant drop, keyboardPadding drives smooth descent
- spacerPadding = 0
+ // spacerPadding handled by animationTick via curve prediction
+ // Keep keyboardPadding headstart for iOS 26+ consumers
+ let initialT = min(0.016 / max(duration, 0.05), 1.0)
+ let initialEased = cubicBezierEase(initialT)
+ let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased
+ #if DEBUG
+ print("⌨️ 🎬 HIDE headstart: kbPad \(Int(keyboardPadding))→\(Int(round(initialValue)))")
+ #endif
+ keyboardPadding = round(initialValue)
}
let delta = targetPadding - lastNotificationPadding
@@ -280,7 +288,9 @@ final class KeyboardTracker: ObservableObject {
return
}
- // UIKit: animate composer in same CA transaction as keyboard → zero lag.
+ // UIKit compositor: animate in the SAME CA transaction as the keyboard.
+ // This ensures pixel-perfect sync — render server interpolates both
+ // the keyboard and compositor with identical timing curves.
// SwiftUI ComposerOverlay (iOS 26+) doesn't use this — composerHostView is nil.
if let hostView = composerHostView {
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
@@ -441,14 +451,13 @@ final class KeyboardTracker: ObservableObject {
return
}
- // Velocity prediction: compensates for SwiftUI's ~8ms render lag.
- // Composer is UIKit (zero lag), but KeyboardSpacer still needs prediction
- // to keep messages moving in sync. Any over/under prediction shows as
- // slight gap between messages and composer — much less visible than
- // the old keyboard-composer gap.
let rawEased = eased
- if animTickCount > 1 {
- let velocity = eased - previousMonotonicEased
+ let prevMonotonicEased = previousMonotonicEased
+
+ // Velocity prediction: compensates for SwiftUI's ~8ms render lag.
+ // Only for HIDE — SHOW uses spacerPadding advance instead (no compound overshoot).
+ if animTickCount > 1 && animTargetPadding < animStartPadding {
+ let velocity = eased - prevMonotonicEased
if velocity > 0 {
eased = min(eased + velocity * 1.0, 1.0)
}
@@ -498,24 +507,30 @@ final class KeyboardTracker: ObservableObject {
#endif
keyboardPadding = rounded
#if DEBUG
- print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")")
+ print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt spacer=\(Int(spacerPadding)) max=\(Int(max(rounded, spacerPadding))) | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")")
#endif
}
- // Time-advanced spacerPadding: leads keyboardPadding by ~1 frame.
- // Prevents compositor-message overlap during SHOW — UIKit composer has
- // zero lag (same CA transaction), but SwiftUI spacer has 15-25ms render
- // delay. Without this advance, messages lag behind the rising composer.
- if animTargetPadding > animStartPadding {
- let advanceMs: CFTimeInterval = 0.016
- let advancedT = min((elapsed + advanceMs) / animDuration, 1.0)
- let advancedEased = cubicBezierEase(advancedT)
- let advancedRaw = animStartPadding + (animTargetPadding - animStartPadding) * advancedEased
- let advancedRounded = max(0, round(advancedRaw))
- // Monotonic: only increase during SHOW (no jitter from bezier approximation)
- if advancedRounded > spacerPadding {
- spacerPadding = advancedRounded
- }
+ // Curve-predicted spacerPadding: predicts where the animation will be
+ // when SwiftUI finishes rendering (~8-16ms from now).
+ // SHOW: 2.5-frame advance — spacer must stay AHEAD of composer (UIKit)
+ // to prevent message overlap. SwiftUI pipeline lag = ~8ms + render.
+ // HIDE: 0 advance — SwiftUI lag keeps spacer slightly ABOVE compositor,
+ // creating a small gap instead of overlap. Gap > overlap.
+ let frameInterval = displayLinkProxy?.currentFrameInterval ?? 0.008
+ let isShowDirection = animTargetPadding > animStartPadding
+ let advanceFrames: CGFloat = isShowDirection ? 2.5 : 0
+ let predictedElapsed = elapsed + frameInterval * advanceFrames
+ let predictedT = min(predictedElapsed / animDuration, 1.0)
+ let predictedEased = cubicBezierEase(predictedT)
+ let predictedSpacer = animStartPadding + (animTargetPadding - animStartPadding) * predictedEased
+ var predictedRounded = max(0, round(predictedSpacer))
+ // During SHOW: floor at current keyboardPadding — absolute guarantee of no overlap.
+ if isShowDirection {
+ predictedRounded = max(predictedRounded, rounded)
+ }
+ if predictedRounded != spacerPadding {
+ spacerPadding = predictedRounded
}
}
@@ -577,6 +592,10 @@ private class DisplayLinkProxy {
private var callback: (() -> Void)?
private var displayLink: CADisplayLink?
+ /// Frame interval from CADisplayLink (targetTimestamp - timestamp).
+ /// Used for curve prediction in animationTick.
+ private(set) var currentFrameInterval: CFTimeInterval = 0.008
+
/// - Parameter maxFPS: Max frame rate. 0 = device native (120Hz on ProMotion).
/// Non-zero values cap via preferredFrameRateRange.
init(maxFPS: Int = 0, callback: @escaping () -> Void) {
@@ -593,6 +612,9 @@ private class DisplayLinkProxy {
}
@objc private func tick() {
+ if let dl = displayLink {
+ currentFrameInterval = dl.targetTimestamp - dl.timestamp
+ }
callback?()
}
diff --git a/Rosetta/DesignSystem/Components/TelegramGlassView.swift b/Rosetta/DesignSystem/Components/TelegramGlassView.swift
index 9f3b566..3e2fcc3 100644
--- a/Rosetta/DesignSystem/Components/TelegramGlassView.swift
+++ b/Rosetta/DesignSystem/Components/TelegramGlassView.swift
@@ -68,8 +68,14 @@ struct TelegramGlassRoundedRect: UIViewRepresentable {
}
func updateUIView(_ uiView: TelegramGlassUIView, context: Context) {
+ #if DEBUG
+ let oldRadius = uiView.fixedCornerRadius ?? -1
+ let boundsH = uiView.bounds.height
+ print("🔲 GlassRoundedRect.updateUIView | radius \(Int(oldRadius))→\(Int(cornerRadius)) boundsH=\(Int(boundsH))")
+ #endif
uiView.isFrozen = !context.environment.telegramGlassActive
uiView.fixedCornerRadius = cornerRadius
+ uiView.applyCornerRadius()
uiView.updateGlass()
}
}
@@ -99,10 +105,10 @@ final class TelegramGlassUIView: UIView {
private var backdropLayer: CALayer?
private let clippingContainer = CALayer()
private let foregroundLayer = CALayer()
- private let borderLayer = CAShapeLayer()
// iOS 26+ native glass
private var nativeGlassView: UIVisualEffectView?
+ private var glassMaskLayer: CAShapeLayer?
override init(frame: CGRect) {
super.init(frame: frame)
@@ -131,6 +137,7 @@ final class TelegramGlassUIView: UIView {
// Telegram dark mode tint: UIColor(white: 1.0, alpha: 0.025)
effect.tintColor = UIColor(white: 1.0, alpha: 0.025)
let glassView = UIVisualEffectView(effect: effect)
+ glassView.clipsToBounds = true
glassView.layer.cornerCurve = .continuous
glassView.isUserInteractionEnabled = false
addSubview(glassView)
@@ -143,7 +150,7 @@ final class TelegramGlassUIView: UIView {
// Clipping container — holds backdrop + foreground, clips to pill shape.
// Border is added to main layer OUTSIDE the clip so it's fully visible.
clippingContainer.masksToBounds = true
- clippingContainer.cornerCurve = .circular
+ clippingContainer.cornerCurve = .continuous
layer.addSublayer(clippingContainer)
// 1. CABackdropLayer — blurs content behind this view
@@ -165,13 +172,12 @@ final class TelegramGlassUIView: UIView {
foregroundLayer.backgroundColor = UIColor(white: 0.11, alpha: 0.85).cgColor
clippingContainer.addSublayer(foregroundLayer)
- // 3. Border — on main layer, NOT inside clipping container
- borderLayer.fillColor = UIColor.clear.cgColor
- borderLayer.strokeColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
- borderLayer.lineWidth = 0.5
- layer.addSublayer(borderLayer)
-
- layer.cornerCurve = .circular
+ // 3. Border — on main layer via CALayer border properties.
+ // Using layer.borderWidth + cornerCurve ensures the border follows
+ // the same .continuous curve as the clipping container fill.
+ layer.borderWidth = 0.5
+ layer.borderColor = UIColor(red: 0x38/255.0, green: 0x38/255.0, blue: 0x38/255.0, alpha: 1.0).cgColor
+ layer.cornerCurve = .continuous
}
// MARK: - Layout
@@ -180,6 +186,38 @@ final class TelegramGlassUIView: UIView {
setNeedsLayout()
}
+ /// Directly applies current fixedCornerRadius to all layers without waiting for layout.
+ /// Call when cornerRadius changes but bounds may not — ensures immediate visual update.
+ func applyCornerRadius() {
+ let bounds = bounds
+ guard bounds.width > 0, bounds.height > 0 else { return }
+ let radius: CGFloat
+ if let fixed = fixedCornerRadius {
+ radius = min(fixed, bounds.height / 2)
+ } else if isCircle {
+ radius = min(bounds.width, bounds.height) / 2
+ } else {
+ radius = bounds.height / 2
+ }
+ if #available(iOS 26.0, *), let glassView = nativeGlassView {
+ let mask: CAShapeLayer
+ if let existing = glassMaskLayer {
+ mask = existing
+ } else {
+ mask = CAShapeLayer()
+ glassMaskLayer = mask
+ glassView.layer.mask = mask
+ }
+ mask.path = UIBezierPath(
+ roundedRect: bounds,
+ cornerRadius: radius
+ ).cgPath
+ } else {
+ clippingContainer.cornerRadius = radius
+ layer.cornerRadius = radius
+ }
+ }
+
override func layoutSubviews() {
super.layoutSubviews()
let bounds = bounds
@@ -187,7 +225,8 @@ final class TelegramGlassUIView: UIView {
let cornerRadius: CGFloat
if let fixed = fixedCornerRadius {
- cornerRadius = fixed
+ // Cap at half-height to guarantee capsule shape when radius >= height/2.
+ cornerRadius = min(fixed, bounds.height / 2)
} else if isCircle {
cornerRadius = min(bounds.width, bounds.height) / 2
} else {
@@ -196,23 +235,33 @@ final class TelegramGlassUIView: UIView {
if #available(iOS 26.0, *), let glassView = nativeGlassView {
glassView.frame = bounds
- glassView.layer.cornerRadius = cornerRadius
+ // UIGlassEffect ignores layer.cornerRadius changes after initial layout.
+ // Use CAShapeLayer mask — guaranteed to clip the glass to any shape.
+ let mask: CAShapeLayer
+ if let existing = glassMaskLayer {
+ mask = existing
+ } else {
+ mask = CAShapeLayer()
+ glassMaskLayer = mask
+ glassView.layer.mask = mask
+ }
+ mask.path = UIBezierPath(
+ roundedRect: bounds,
+ cornerRadius: cornerRadius
+ ).cgPath
return
}
- // Legacy layout
+ // Legacy layout — clippingContainer.masksToBounds clips all children,
+ // so foregroundLayer needs no cornerRadius (avoids double-rounding artifacts).
clippingContainer.frame = bounds
clippingContainer.cornerRadius = cornerRadius
backdropLayer?.frame = bounds
foregroundLayer.frame = bounds
- foregroundLayer.cornerRadius = cornerRadius
- let halfBorder = borderLayer.lineWidth / 2
- let borderRect = bounds.insetBy(dx: halfBorder, dy: halfBorder)
- let borderRadius = max(0, cornerRadius - halfBorder)
- let borderPath = UIBezierPath(roundedRect: borderRect, cornerRadius: borderRadius)
- borderLayer.path = borderPath.cgPath
- borderLayer.frame = bounds
+ // Border follows .continuous curve via main layer's cornerRadius.
+ // clipsToBounds is false, so this only affects visual border — not child clipping.
+ layer.cornerRadius = cornerRadius
}
// MARK: - Shadow (drawn as separate image — Telegram parity)
diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
index 03b0067..5908236 100644
--- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
+++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift
@@ -27,7 +27,7 @@ private struct KeyboardSpacer: View {
// Inverted scroll: spacer at VStack START. Growing it pushes
// messages away from offset=0 → visually UP. CADisplayLink
// animates keyboardPadding in sync with keyboard curve.
- return composerHeight + max(keyboard.keyboardPadding, keyboard.spacerPadding) + 8
+ return composerHeight + keyboard.spacerPadding + 8
}
}()
#if DEBUG
@@ -220,22 +220,9 @@ struct ChatDetailView: View {
}
.modifier(IgnoreKeyboardSafeAreaLegacy())
.background {
- ZStack(alignment: .bottom) {
+ ZStack {
RosettaColors.Adaptive.background
tiledChatBackground
- // Telegram-style: dark gradient at screen bottom (home indicator area).
- // In background (not overlay) so it never moves with keyboard.
- if #unavailable(iOS 26) {
- LinearGradient(
- colors: [
- Color.black.opacity(0.0),
- Color.black.opacity(0.55)
- ],
- startPoint: .top,
- endPoint: .bottom
- )
- .frame(height: 34)
- }
}
.ignoresSafeArea()
}
@@ -594,7 +581,39 @@ private extension ChatDetailView {
@ViewBuilder
var chatEdgeGradients: some View {
if #available(iOS 26, *) {
- EmptyView()
+ VStack(spacing: 0) {
+ // Top: dark gradient behind nav bar (same style as iOS < 26).
+ LinearGradient(
+ stops: [
+ .init(color: Color.black.opacity(0.85), location: 0.0),
+ .init(color: Color.black.opacity(0.75), location: 0.2),
+ .init(color: Color.black.opacity(0.55), location: 0.4),
+ .init(color: Color.black.opacity(0.3), location: 0.6),
+ .init(color: Color.black.opacity(0.12), location: 0.78),
+ .init(color: Color.black.opacity(0.0), location: 1.0),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(height: 100)
+
+ Spacer()
+
+ // Bottom: dark gradient for home indicator area.
+ LinearGradient(
+ stops: [
+ .init(color: Color.black.opacity(0.0), location: 0.0),
+ .init(color: Color.black.opacity(0.3), location: 0.3),
+ .init(color: Color.black.opacity(0.65), location: 0.6),
+ .init(color: Color.black.opacity(0.85), location: 1.0),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(height: 54)
+ }
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
} else {
VStack(spacing: 0) {
// Telegram-style: dark gradient that smoothly fades content into
@@ -615,8 +634,20 @@ private extension ChatDetailView {
.frame(height: 90)
Spacer()
+
+ // Bottom: dark gradient for home indicator area below composer.
+ LinearGradient(
+ stops: [
+ .init(color: Color.black.opacity(0.0), location: 0.0),
+ .init(color: Color.black.opacity(0.55), location: 0.35),
+ .init(color: Color.black.opacity(0.85), location: 1.0),
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(height: 44)
}
- .ignoresSafeArea(edges: .top)
+ .ignoresSafeArea()
.allowsHitTesting(false)
}
}
@@ -1443,7 +1474,7 @@ private extension ChatDetailView {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
- deliveryIndicator(message.deliveryStatus)
+ deliveryIndicator(message.deliveryStatus, read: message.isRead)
}
}
}
@@ -1463,7 +1494,7 @@ private extension ChatDetailView {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
- mediaDeliveryIndicator(message.deliveryStatus)
+ mediaDeliveryIndicator(message.deliveryStatus, read: message.isRead)
}
}
}
@@ -1600,6 +1631,9 @@ private extension ChatDetailView {
},
onUserTextInsertion: handleComposerUserTyping,
onMultilineChange: { multiline in
+ #if DEBUG
+ print("📐 onMultilineChange callback: \(multiline) (was \(isMultilineInput)) → radius will be \(multiline ? 16 : 21)")
+ #endif
withAnimation(.easeInOut(duration: 0.2)) {
isMultilineInput = multiline
}
@@ -1611,7 +1645,9 @@ private extension ChatDetailView {
.frame(maxWidth: .infinity, minHeight: 36, alignment: .bottomLeading)
HStack(alignment: .center, spacing: 0) {
- Button { } label: {
+ Button {
+ switchToEmojiKeyboard()
+ } label: {
TelegramVectorIcon(
pathData: TelegramIconPath.emojiMoon,
viewBox: CGSize(width: 19, height: 19),
@@ -1620,8 +1656,8 @@ private extension ChatDetailView {
.frame(width: 19, height: 19)
.frame(width: 20, height: 36)
}
- .accessibilityLabel("Quick actions")
- .buttonStyle(ChatDetailGlassPressButtonStyle())
+ .accessibilityLabel("Emoji")
+ .buttonStyle(.plain)
}
.padding(.trailing, 8 + (sendButtonWidth * sendButtonProgress))
.frame(height: 36, alignment: .center)
@@ -1804,6 +1840,12 @@ private extension ChatDetailView {
else { isInputFocused = true }
}
+ /// Opens the keyboard — emoji button acts as a focus trigger.
+ /// System emoji keyboard is accessible via the 🌐 key on the keyboard.
+ func switchToEmojiKeyboard() {
+ if !isInputFocused { isInputFocused = true }
+ }
+
var composerDismissGesture: some Gesture {
DragGesture(minimumDistance: 10)
.onChanged { value in
@@ -1815,9 +1857,10 @@ private extension ChatDetailView {
}
}
- func deliveryTint(_ status: DeliveryStatus) -> Color {
+ /// Android/Desktop parity: delivery tint depends on both delivery status AND read flag.
+ func deliveryTint(_ status: DeliveryStatus, read: Bool) -> Color {
+ if status == .delivered && read { return Color(hex: 0xA4E2FF) }
switch status {
- case .read: return Color(hex: 0xA4E2FF)
case .delivered: return Color.white.opacity(0.5)
case .error: return RosettaColors.error
default: return Color.white.opacity(0.78)
@@ -1825,47 +1868,51 @@ private extension ChatDetailView {
}
@ViewBuilder
- func deliveryIndicator(_ status: DeliveryStatus) -> some View {
- switch status {
- case .read:
+ func deliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
+ if status == .delivered && read {
DoubleCheckmarkShape()
- .fill(deliveryTint(status))
+ .fill(deliveryTint(status, read: read))
.frame(width: 16, height: 8.7)
- case .delivered:
- SingleCheckmarkShape()
- .fill(deliveryTint(status))
- .frame(width: 12, height: 8.8)
- case .waiting:
- Image(systemName: "clock")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(deliveryTint(status))
- case .error:
- Image(systemName: "exclamationmark.circle.fill")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(deliveryTint(status))
+ } else {
+ switch status {
+ case .delivered:
+ SingleCheckmarkShape()
+ .fill(deliveryTint(status, read: read))
+ .frame(width: 12, height: 8.8)
+ case .waiting:
+ Image(systemName: "clock")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(deliveryTint(status, read: read))
+ case .error:
+ Image(systemName: "exclamationmark.circle.fill")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(deliveryTint(status, read: read))
+ }
}
}
/// Delivery indicator with white tint for on-image media overlay.
@ViewBuilder
- func mediaDeliveryIndicator(_ status: DeliveryStatus) -> some View {
- switch status {
- case .read:
+ func mediaDeliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
+ if status == .delivered && read {
DoubleCheckmarkShape()
.fill(Color.white)
.frame(width: 16, height: 8.7)
- case .delivered:
- SingleCheckmarkShape()
- .fill(Color.white.opacity(0.8))
- .frame(width: 12, height: 8.8)
- case .waiting:
- Image(systemName: "clock")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(.white.opacity(0.8))
- case .error:
- Image(systemName: "exclamationmark.circle.fill")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(RosettaColors.error)
+ } else {
+ switch status {
+ case .delivered:
+ SingleCheckmarkShape()
+ .fill(Color.white.opacity(0.8))
+ .frame(width: 12, height: 8.8)
+ case .waiting:
+ Image(systemName: "clock")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(.white.opacity(0.8))
+ case .error:
+ Image(systemName: "exclamationmark.circle.fill")
+ .font(.system(size: 10, weight: .semibold))
+ .foregroundStyle(RosettaColors.error)
+ }
}
}
@@ -1980,7 +2027,7 @@ private extension ChatDetailView {
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
- guard outgoing, message.deliveryStatus == .read else { return nil }
+ guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
return "Read"
}
@@ -2788,11 +2835,17 @@ enum TelegramIconPath {
/// iOS 26+: SwiftUI handles keyboard natively.
private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier {
func body(content: Content) -> some View {
- // Both iOS versions: disable SwiftUI's native keyboard avoidance.
- // Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes
- // content in the wrong direction. KeyboardSpacer + ComposerOverlay handle
- // keyboard offset manually via KeyboardTracker.
- content.ignoresSafeArea(.keyboard)
+ if #available(iOS 26, *) {
+ // iOS 26+: SwiftUI handles keyboard natively — don't block it.
+ // KeyboardTracker is inert on iOS 26 (init returns early).
+ content
+ } else {
+ // iOS < 26: disable SwiftUI's native keyboard avoidance.
+ // Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes
+ // content in the wrong direction. KeyboardSpacer + ComposerHostView handle
+ // keyboard offset manually via KeyboardTracker.
+ content.ignoresSafeArea(.keyboard)
+ }
}
}
@@ -2846,6 +2899,7 @@ private struct ComposerOverlay: View {
}
)
.padding(.bottom, pad)
+ .frame(maxHeight: .infinity, alignment: .bottom)
}
}
diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift
index e734a1f..15cd6c5 100644
--- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift
+++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift
@@ -50,8 +50,15 @@ struct ChatRowView: View {
// MARK: - Avatar
-private extension ChatRowView {
- var avatarSection: some View {
+/// Observation-isolated: reads `AvatarRepository.avatarVersion` in its own
+/// scope so only the avatar re-renders when opponent avatar changes — not the
+/// entire ChatRowView (title, message preview, badge, etc.).
+private struct ChatRowAvatar: View {
+ let dialog: Dialog
+
+ var body: some View {
+ // Establish @Observable tracking — re-renders this view on avatar save/remove.
+ let _ = AvatarRepository.shared.avatarVersion
AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
@@ -63,6 +70,12 @@ private extension ChatRowView {
}
}
+private extension ChatRowView {
+ var avatarSection: some View {
+ ChatRowAvatar(dialog: dialog)
+ }
+}
+
// MARK: - Content Section
// Figma "Contents": flex-col, h-full, items-start, justify-center, pb-px
// └─ "Title and Trailing Accessories": flex-1, gap-6, items-center
@@ -209,22 +222,24 @@ private extension ChatRowView {
@ViewBuilder
var deliveryIcon: some View {
- switch dialog.lastMessageDelivered {
- case .waiting:
- // Timer isolated to sub-view — only .waiting rows create a timer.
- DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
- case .delivered:
- SingleCheckmarkShape()
- .fill(RosettaColors.Adaptive.textSecondary)
- .frame(width: 14, height: 10.3)
- case .read:
+ if dialog.lastMessageDelivered == .delivered && dialog.lastMessageRead {
DoubleCheckmarkShape()
.fill(RosettaColors.figmaBlue)
.frame(width: 17, height: 9.3)
- case .error:
- Image(systemName: "exclamationmark.circle.fill")
- .font(.system(size: 14))
- .foregroundStyle(RosettaColors.error)
+ } else {
+ switch dialog.lastMessageDelivered {
+ case .waiting:
+ // Timer isolated to sub-view — only .waiting rows create a timer.
+ DeliveryWaitingIcon(sentTimestamp: dialog.lastMessageTimestamp)
+ case .delivered:
+ SingleCheckmarkShape()
+ .fill(RosettaColors.Adaptive.textSecondary)
+ .frame(width: 14, height: 10.3)
+ case .error:
+ Image(systemName: "exclamationmark.circle.fill")
+ .font(.system(size: 14))
+ .foregroundStyle(RosettaColors.error)
+ }
}
}
@@ -341,7 +356,8 @@ private extension ChatRowView {
unreadCount: 3, isOnline: true, lastSeen: 0,
verified: 1, iHaveSent: true,
isPinned: false, isMuted: false,
- lastMessageFromMe: true, lastMessageDelivered: .read
+ lastMessageFromMe: true, lastMessageDelivered: .delivered,
+ lastMessageRead: true
)
VStack(spacing: 0) {
diff --git a/Rosetta/Rosetta.entitlements b/Rosetta/Rosetta.entitlements
index 293b951..84aba9d 100644
--- a/Rosetta/Rosetta.entitlements
+++ b/Rosetta/Rosetta.entitlements
@@ -4,6 +4,8 @@
aps-environment
development
+ com.apple.developer.usernotifications.communication
+
com.apple.security.application-groups
group.com.rosetta.dev
diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift
index 00ce0d3..c72564e 100644
--- a/Rosetta/RosettaApp.swift
+++ b/Rosetta/RosettaApp.swift
@@ -45,9 +45,10 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
}
}
- // Request notification permission
+ // Request notification permission (including CarPlay display).
+ // .carPlay enables "Show in CarPlay" toggle in Settings > Notifications > Rosetta.
UNUserNotificationCenter.current().requestAuthorization(
- options: [.alert, .badge, .sound]
+ options: [.alert, .badge, .sound, .carPlay]
) { granted, _ in
if granted {
DispatchQueue.main.async {
diff --git a/RosettaNotificationService/Info.plist b/RosettaNotificationService/Info.plist
index 17bfad7..cf5199b 100644
--- a/RosettaNotificationService/Info.plist
+++ b/RosettaNotificationService/Info.plist
@@ -26,6 +26,13 @@
com.apple.usernotifications.service
NSExtensionPrincipalClass
$(PRODUCT_MODULE_NAME).NotificationService
+ NSExtensionAttributes
+
+ IntentsSupported
+
+ INSendMessageIntent
+
+