CallKit/PushKit интеграция + фикс PacketPushNotification (tokenType, deviceId)

This commit is contained in:
2026-04-01 00:39:34 +05:00
parent 0470b306a9
commit 8f69781a66
16 changed files with 1058 additions and 63 deletions

View File

@@ -441,8 +441,19 @@ final class MessageRepository: ObservableObject {
}
}
// Debounced cache refresh batch during sync
scheduleCacheRefresh(for: opponentKey)
// Outgoing user-sent messages: immediate cache refresh (bypass 100ms debounce)
// so the bubble appears instantly. Sync/incoming still use debounced path.
if fromMe && !fromSync {
refreshDialogCache(for: opponentKey)
NotificationCenter.default.post(
name: .sentMessageInserted,
object: nil,
userInfo: ["opponentKey": opponentKey]
)
} else {
// Debounced cache refresh batch during sync
scheduleCacheRefresh(for: opponentKey)
}
}
func deliveryStatus(forMessageId messageId: String) -> DeliveryStatus? {

View File

@@ -861,7 +861,9 @@ extension MessageCellLayout {
: ""
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text
// Convert emoji shortcodes (:emoji_1f631: 😱) Android/Desktop send shortcodes.
let rawText = isGarbageOrEncrypted(message.text) ? "" : message.text
let displayText = EmojiParser.replaceShortcodes(in: rawText)
// Calculate position (Telegram-like grouping rules)
let position: BubblePosition = {
@@ -916,7 +918,7 @@ extension MessageCellLayout {
let first = replies.first {
let fwdText = first.message.trimmingCharacters(in: .whitespacesAndNewlines)
if !fwdText.isEmpty && !isGarbageOrEncrypted(fwdText) {
forwardCaption = fwdText
forwardCaption = EmojiParser.replaceShortcodes(in: fwdText)
}
forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count
forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count

View File

@@ -6,23 +6,35 @@ enum PushNotificationAction: Int {
case unsubscribe = 1
}
/// Token type for push notification registration.
/// Server parity: im.rosetta.packet.runtime.TokenType
enum PushTokenType: Int {
case fcm = 0 // FCM token (iOS + Android)
case voipApns = 1 // VoIP APNs token (iOS only)
}
/// PushNotification packet (0x10) registers or unregisters APNs/FCM token on server.
/// Sent after successful handshake to enable push notifications.
/// Cross-platform compatible with Android PacketPushNotification.
/// Server stores tokens at device level (PushToken entity linked to Device).
struct PacketPushNotification: Packet {
static let packetId = 0x10
var notificationsToken: String = ""
var action: PushNotificationAction = .subscribe
var tokenType: PushTokenType = .fcm
var deviceId: String = ""
func write(to stream: Stream) {
stream.writeString(notificationsToken)
stream.writeInt8(action.rawValue)
stream.writeInt8(tokenType.rawValue)
stream.writeString(deviceId)
}
mutating func read(from stream: Stream) {
notificationsToken = stream.readString()
let actionValue = stream.readInt8()
action = PushNotificationAction(rawValue: actionValue) ?? .subscribe
action = PushNotificationAction(rawValue: stream.readInt8()) ?? .subscribe
tokenType = PushTokenType(rawValue: stream.readInt8()) ?? .fcm
deviceId = stream.readString()
}
}

View File

@@ -98,6 +98,12 @@ final class ProtocolManager: @unchecked Sendable {
// Saved credentials for auto-reconnect
private var savedPublicKey: String?
private var savedPrivateHash: String?
/// Pre-built handshake packet for instant send on socket open.
/// Built once in connect() on MainActor (safe UIDevice access), reused across reconnects.
private var cachedHandshakeData: Data?
/// Timestamp of last successful authentication used to decide whether to reset backoff.
/// If connection was short-lived (<10s), don't reset backoff counter (server RST loop).
private var lastAuthenticatedTime: CFAbsoluteTime = 0
var publicKey: String? { savedPublicKey }
var privateHash: String? { savedPrivateHash }
@@ -118,6 +124,7 @@ final class ProtocolManager: @unchecked Sendable {
savedPublicKey = publicKey
savedPrivateHash = privateKeyHash
cachedHandshakeData = buildHandshakeData()
switch connectionState {
case .authenticated, .handshaking, .deviceVerificationRequired:
@@ -163,6 +170,8 @@ final class ProtocolManager: @unchecked Sendable {
connectionState = .disconnected
savedPublicKey = nil
savedPrivateHash = nil
cachedHandshakeData = nil
lastAuthenticatedTime = 0
Task { @MainActor in
TransportManager.shared.reset()
}
@@ -194,6 +203,8 @@ final class ProtocolManager: @unchecked Sendable {
handshakeComplete = false
heartbeatTask?.cancel()
searchRouter.resetPending()
// User-initiated foreground allow fast retry on next disconnect.
lastAuthenticatedTime = 0
connectionState = .connecting
client.forceReconnect()
}
@@ -419,8 +430,16 @@ final class ProtocolManager: @unchecked Sendable {
self.connectionState = .connected
}
// Auto-handshake with saved credentials
if let pk = savedPublicKey, let hash = savedPrivateHash {
// Send pre-built handshake immediately no packet construction on critical path.
if let data = cachedHandshakeData {
Self.logger.info("⚡ Sending pre-built handshake packet")
Task { @MainActor in
self.connectionState = .handshaking
}
client.send(data)
startHandshakeTimeout()
} else if let pk = savedPublicKey, let hash = savedPrivateHash {
// Fallback: build handshake on the fly
startHandshake(publicKey: pk, privateHash: hash)
}
}
@@ -468,6 +487,27 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Handshake
/// Build serialized handshake packet from saved credentials.
/// Called from MainActor context safe to access UIDevice.
private func buildHandshakeData() -> Data? {
guard let pk = savedPublicKey, let hash = savedPrivateHash else { return nil }
let device = HandshakeDevice(
deviceId: DeviceIdentityManager.shared.currentDeviceId(),
deviceName: UIDevice.current.name,
deviceOs: "iOS \(UIDevice.current.systemVersion)"
)
let handshake = PacketHandshake(
privateKey: hash,
publicKey: pk,
protocolVersion: 1,
heartbeatInterval: 15,
device: device,
handshakeState: .needDeviceVerification
)
return PacketRegistry.encode(handshake)
}
/// Fallback handshake builds packet on the fly when cached data is unavailable.
private func startHandshake(publicKey: String, privateHash: String) {
Self.logger.info("Starting handshake for \(publicKey.prefix(20))...")
@@ -491,24 +531,25 @@ final class ProtocolManager: @unchecked Sendable {
)
sendPacketDirect(handshake)
startHandshakeTimeout()
}
// Timeout force reconnect instead of permanent disconnect.
// `client.disconnect()` sets `isManuallyClosed = true` which kills all
// future reconnection attempts. Use `forceReconnect()` to retry.
private func startHandshakeTimeout() {
// 5s is generous for a single packet round-trip. Faster detection
// means faster recovery via instant first retry (0ms backoff).
handshakeTimeoutTask?.cancel()
handshakeTimeoutTask = Task { [weak self] in
do {
try await Task.sleep(nanoseconds: 10_000_000_000)
try await Task.sleep(nanoseconds: 5_000_000_000)
} catch {
return
}
guard let self, !Task.isCancelled else { return }
if !self.handshakeComplete {
Self.logger.error("Handshake timeout — forcing reconnect")
Self.logger.error("Handshake timeout (5s) — forcing reconnect")
self.handshakeComplete = false
self.heartbeatTask?.cancel()
Task { @MainActor in
// 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
@@ -719,8 +760,17 @@ final class ProtocolManager: @unchecked Sendable {
switch packet.handshakeState {
case .completed:
handshakeComplete = true
// Android parity: reset backoff counter on successful authentication.
client.resetReconnectAttempts()
// Reset backoff only if previous connection was stable (>10s).
// Prevents tight reconnect loop when server/proxy RSTs connections
// shortly after sync. Without this, resetReconnectAttempts on every auth
// means backoff always starts at 1s (attempt #1) = infinite 1s loop.
let connectionAge = CFAbsoluteTimeGetCurrent() - lastAuthenticatedTime
if lastAuthenticatedTime == 0 || connectionAge > 10 {
client.resetReconnectAttempts()
} else {
Self.logger.info("Short-lived connection (\(Int(connectionAge))s) — keeping backoff counter")
}
lastAuthenticatedTime = CFAbsoluteTimeGetCurrent()
Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s")
flushPacketQueue()
@@ -758,13 +808,20 @@ final class ProtocolManager: @unchecked Sendable {
private func startHeartbeat(interval: Int) {
heartbeatTask?.cancel()
// Android parity: heartbeat at 1/3 the server-specified interval (more aggressive keep-alive).
let intervalNs = UInt64(interval) * 1_000_000_000 / 3
// Send heartbeat every 5 seconds aggressive keep-alive to prevent
// server/proxy idle timeouts. Server timeout is heartbeat*2 = 60s,
// so 5s gives 12× safety margin.
let intervalNs: UInt64 = 5_000_000_000
// Send first heartbeat SYNCHRONOUSLY on current thread (URLSession delegate queue).
// This bypasses the connectionState race: startHeartbeat() is called BEFORE
// the MainActor task sets .authenticated, so sendHeartbeat()'s guard would
// skip the first heartbeat. Direct sendText avoids this.
if client.isConnected {
client.sendText("heartbeat")
}
heartbeatTask = Task { [weak self] in
// Send first heartbeat immediately
self?.sendHeartbeat()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: intervalNs)
guard !Task.isCancelled else { break }
@@ -773,10 +830,11 @@ final class ProtocolManager: @unchecked Sendable {
}
}
/// Android parity: send heartbeat and trigger disconnect on failure.
/// Send heartbeat and trigger disconnect on failure.
private func sendHeartbeat() {
let state = connectionState
guard state == .authenticated || state == .deviceVerificationRequired else { return }
// Allow heartbeat when handshake is complete (covers the gap before
// MainActor sets .authenticated) or in device verification.
guard handshakeComplete || connectionState == .deviceVerificationRequired else { return }
guard client.isConnected else {
Self.logger.warning("💔 Heartbeat failed: socket not connected — triggering reconnect")
handleHeartbeatFailure()

View File

@@ -95,14 +95,13 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
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.
// Safety net: if didOpenWithProtocol never fires within 8s, clean up
// and trigger reconnect. 8s is generous for TCP+TLS even on slow cellular.
connectTimeoutTask?.cancel()
connectTimeoutTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 15_000_000_000)
try? await Task.sleep(nanoseconds: 8_000_000_000)
guard let self, !Task.isCancelled, self.isConnecting else { return }
Self.logger.warning("Connection establishment timeout (15s)")
Self.logger.warning("Connection establishment timeout (8s)")
self.interruptConnecting()
self.webSocketTask?.cancel(
with: .normalClosure,
@@ -299,8 +298,8 @@ final class WebSocketClient: NSObject, @unchecked Sendable, URLSessionWebSocketD
guard !isManuallyClosed else { return }
guard reconnectTask == nil else { return }
// Android parity: exponential backoff 1s, 2s, 4s, 8s, 16s, 30s (cap).
// No instant first attempt. Formula: min(1000 * 2^(n-1), 30000).
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s (cap).
// forceReconnect() resets counter for instant retry on user action.
reconnectAttempts += 1
if reconnectAttempts > 20 {

View File

@@ -0,0 +1,250 @@
import AVFAudio
import CallKit
import os
/// CallKit integration layer wraps CXProvider and CXCallController.
/// Reports incoming/outgoing calls to the system so they appear in the native call UI,
/// integrate with CarPlay, and satisfy Apple's PushKit requirement (every VoIP push
/// MUST result in reportNewIncomingCall or the app gets terminated).
///
/// This class does NOT own call logic it delegates to CallManager for actual call operations.
@MainActor
final class CallKitManager: NSObject {
static let shared = CallKitManager()
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "CallKit")
private let provider: CXProvider
private let callController = CXCallController()
private(set) var currentCallUUID: UUID?
/// Thread-safe UUID storage for synchronous PushKit access.
/// Prevents race where WebSocket signal arrives before MainActor assigns currentCallUUID.
/// Protected by uuidLock accessed from nonisolated PushKit delegate methods.
private nonisolated(unsafe) let uuidLock = NSLock()
private nonisolated(unsafe) var _pendingCallUUID: UUID?
private override init() {
let config = CXProviderConfiguration()
config.supportsVideo = false
config.maximumCallsPerCallGroup = 1
config.maximumCallGroups = 1
config.supportedHandleTypes = [.generic]
// Privacy: don't write peer public keys to system call log / iCloud.
config.includesCallsInRecents = false
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
/// Thread-safe check if a call UUID is pending (set synchronously from PushKit).
nonisolated func hasPendingCall() -> Bool {
uuidLock.lock()
let has = _pendingCallUUID != nil
uuidLock.unlock()
return has
}
// MARK: - Incoming Call (synchronous for PushKit)
/// Reports an incoming call to CallKit SYNCHRONOUSLY. Called directly from
/// PushKit delegate (NOT via Task/@MainActor) to meet Apple's requirement
/// that reportNewIncomingCall is invoked before the PushKit handler returns.
nonisolated func reportIncomingCallSynchronously(
callerKey: String,
callerName: String,
completion: @escaping (Error?) -> Void
) {
let uuid = UUID()
// Assign UUID synchronously to prevent race with WebSocket signal.
uuidLock.lock()
_pendingCallUUID = uuid
uuidLock.unlock()
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerKey)
update.localizedCallerName = callerName.isEmpty ? "Rosetta" : callerName
update.hasVideo = false
update.supportsHolding = false
update.supportsGrouping = false
update.supportsUngrouping = false
update.supportsDTMF = false
provider.reportNewIncomingCall(with: uuid, update: update) { [weak self] error in
if let error {
Self.logger.error("Failed to report incoming call: \(error.localizedDescription)")
self?.uuidLock.lock()
self?._pendingCallUUID = nil
self?.uuidLock.unlock()
} else {
Self.logger.info("Incoming call reported to CallKit (uuid=\(uuid.uuidString.prefix(8)))")
}
// Assign to MainActor-isolated property.
Task { @MainActor in
if error == nil {
self?.currentCallUUID = uuid
}
}
completion(error)
}
}
// MARK: - Incoming Call (from WebSocket signal)
/// Reports an incoming call to CallKit. Called from CallManager when a `.call`
/// signal arrives via WebSocket (app already running, MainActor available).
func reportIncomingCall(
callerKey: String,
callerName: String,
completion: ((Error?) -> Void)? = nil
) {
let uuid = currentCallUUID ?? UUID()
currentCallUUID = uuid
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerKey)
update.localizedCallerName = callerName.isEmpty ? "Rosetta" : callerName
update.hasVideo = false
update.supportsHolding = false
update.supportsGrouping = false
update.supportsUngrouping = false
update.supportsDTMF = false
provider.reportNewIncomingCall(with: uuid, update: update) { [weak self] error in
if let error {
Self.logger.error("Failed to report incoming call: \(error.localizedDescription)")
Task { @MainActor in
self?.currentCallUUID = nil
}
} else {
Self.logger.info("Incoming call reported to CallKit (uuid=\(uuid.uuidString.prefix(8)))")
}
completion?(error)
}
}
// MARK: - Outgoing Call
func startOutgoingCall(peerKey: String) {
let uuid = UUID()
currentCallUUID = uuid
let handle = CXHandle(type: .generic, value: peerKey)
let action = CXStartCallAction(call: uuid, handle: handle)
action.isVideo = false
callController.request(CXTransaction(action: action)) { error in
if let error {
Self.logger.error("Failed to start outgoing call: \(error.localizedDescription)")
}
}
provider.reportOutgoingCall(with: uuid, startedConnectingAt: nil)
}
func reportOutgoingCallConnected() {
guard let uuid = currentCallUUID else { return }
provider.reportOutgoingCall(with: uuid, connectedAt: nil)
}
// MARK: - End Call
func endCall() {
guard let uuid = currentCallUUID else { return }
currentCallUUID = nil
uuidLock.lock()
_pendingCallUUID = nil
uuidLock.unlock()
let action = CXEndCallAction(call: uuid)
callController.request(CXTransaction(action: action)) { error in
if let error {
Self.logger.warning("CXEndCallAction failed: \(error.localizedDescription)")
self.provider.reportCall(with: uuid, endedAt: nil, reason: .remoteEnded)
}
}
}
func reportCallEndedByRemote(reason: CXCallEndedReason = .remoteEnded) {
guard let uuid = currentCallUUID else { return }
currentCallUUID = nil
uuidLock.lock()
_pendingCallUUID = nil
uuidLock.unlock()
provider.reportCall(with: uuid, endedAt: nil, reason: reason)
}
// MARK: - Mute
func setMuted(_ muted: Bool) {
guard let uuid = currentCallUUID else { return }
let action = CXSetMutedCallAction(call: uuid, muted: muted)
callController.request(CXTransaction(action: action)) { error in
if let error {
Self.logger.warning("CXSetMutedCallAction failed: \(error.localizedDescription)")
}
}
}
}
// MARK: - CXProviderDelegate
extension CallKitManager: CXProviderDelegate {
nonisolated func providerDidReset(_ provider: CXProvider) {
Self.logger.info("CXProvider did reset")
Task { @MainActor in
self.currentCallUUID = nil
self.uuidLock.lock()
self._pendingCallUUID = nil
self.uuidLock.unlock()
// notifyPeer: false provider reset is system-initiated, peer connection
// is already gone. Sending endCall signal would be spurious.
CallManager.shared.finishCall(reason: nil, notifyPeer: false)
}
}
nonisolated func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
Self.logger.info("CXAnswerCallAction")
Task { @MainActor in
CallManager.shared.acceptIncomingCall()
action.fulfill()
}
}
nonisolated func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
Self.logger.info("CXEndCallAction")
action.fulfill()
Task { @MainActor in
self.currentCallUUID = nil
self.uuidLock.lock()
self._pendingCallUUID = nil
self.uuidLock.unlock()
CallManager.shared.endCall()
}
}
nonisolated func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
Task { @MainActor in
if CallManager.shared.uiState.isMuted != action.isMuted {
CallManager.shared.toggleMute()
}
action.fulfill()
}
}
nonisolated func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
Self.logger.info("CXStartCallAction")
action.fulfill()
}
nonisolated func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
Self.logger.info("Audio session activated by CallKit")
}
nonisolated func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
Self.logger.info("Audio session deactivated by CallKit")
}
}

View File

@@ -83,10 +83,23 @@ extension CallManager {
}
func finishCall(reason: String?, notifyPeer: Bool, skipAttachment: Bool = false) {
// Guard: finishCall can be called twice when CXEndCallAction callback
// re-enters via CallManager.endCall(). Skip if already idle.
guard uiState.phase != .idle else { return }
print("[CallBar] finishCall(reason=\(reason ?? "nil")) — phase=\(uiState.phase.rawValue) isMinimized=\(uiState.isMinimized)")
// Log call stack to identify WHO triggered finishCall
let symbols = Thread.callStackSymbols.prefix(8).joined(separator: "\n ")
print("[CallBar] stack:\n \(symbols)")
// Report call ended to CallKit. Use reportCallEndedByRemote when we're not
// the initiator of the end (avoids CXEndCallAction endCall() loop).
if notifyPeer {
CallKitManager.shared.endCall()
} else {
CallKitManager.shared.reportCallEndedByRemote()
}
pendingMinimizeTask?.cancel()
pendingMinimizeTask = nil
cancelRingTimeout()

View File

@@ -79,6 +79,8 @@ final class CallManager: NSObject, ObservableObject {
uiState.phase = .outgoing
uiState.statusText = "Calling..."
CallKitManager.shared.startOutgoingCall(peerKey: target)
ProtocolManager.shared.sendCallSignal(
signalType: .call,
src: ownPublicKey,
@@ -135,6 +137,7 @@ final class CallManager: NSObject, ObservableObject {
let nextMuted = !uiState.isMuted
uiState.isMuted = nextMuted
localAudioTrack?.isEnabled = !nextMuted
CallKitManager.shared.setMuted(nextMuted)
updateLiveActivity()
print("[Call] toggleMute: isMuted=\(nextMuted), trackEnabled=\(localAudioTrack?.isEnabled ?? false), trackState=\(localAudioTrack?.readyState.rawValue ?? -1)")
}
@@ -229,6 +232,16 @@ final class CallManager: NSObject, ObservableObject {
}
uiState.statusText = "Incoming call..."
hydratePeerIdentity(for: incomingPeer)
// Report to CallKit (skipped if already reported via VoIP push).
// Use hasPendingCall() for thread-safe check PushKit sets the UUID
// synchronously before MainActor assigns currentCallUUID.
if CallKitManager.shared.currentCallUUID == nil,
!CallKitManager.shared.hasPendingCall() {
CallKitManager.shared.reportIncomingCall(
callerKey: incomingPeer,
callerName: uiState.displayName
)
}
CallSoundManager.shared.playRingtone()
startRingTimeout()
startLiveActivity()
@@ -324,6 +337,7 @@ final class CallManager: NSObject, ObservableObject {
uiState.statusText = "Call active"
cancelRingTimeout()
CallSoundManager.shared.playConnected()
CallKitManager.shared.reportOutgoingCallConnected()
startDurationTimerIfNeeded()
updateLiveActivity()
}

View File

@@ -66,6 +66,9 @@ final class SessionManager {
private let maxOutgoingWaitingLifetimeMs: Int64 = ProtocolConstants.messageDeliveryTimeoutS * 1000
var attachmentFlowTransport: AttachmentFlowTransporting = LiveAttachmentFlowTransport()
var packetFlowSender: PacketFlowSending = LivePacketFlowSender()
/// Guards onHandshakeCompleted from running before repositories are ready.
/// Set after DialogRepository + MessageRepository bootstraps complete in startSession().
private(set) var repositoriesReady = false
// MARK: - Foreground & Idle Detection (Desktop/Android parity)
@@ -177,8 +180,13 @@ final class SessionManager {
func startSession(password: String) async throws {
let accountManager = AccountManager.shared
let crypto = CryptoManager.shared
repositoriesReady = false
// Decrypt private key
#if DEBUG
let sessionStart = CFAbsoluteTimeGetCurrent()
#endif
// 1. Decrypt private key
let privateKeyHex: String
do {
privateKeyHex = try await accountManager.decryptPrivateKey(password: password)
@@ -188,13 +196,16 @@ final class SessionManager {
self.privateKeyHex = privateKeyHex
// Android parity: provide private key to caches for encryption at rest
AttachmentCache.shared.privateKey = privateKeyHex
Self.logger.info("Private key decrypted")
#if DEBUG
Self.logger.info("⏱ CONN_PERF: decryptPrivateKey \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
#endif
guard let account = accountManager.currentAccount else {
throw CryptoError.decryptionFailed
}
// Open SQLite database for this account (must happen before repository bootstrap).
// 2. Open SQLite database for this account (must happen before repository bootstrap).
do {
try DatabaseManager.shared.bootstrap(accountPublicKey: account.publicKey)
} catch {
@@ -204,12 +215,28 @@ final class SessionManager {
throw StartSessionError.databaseBootstrapFailed(underlying: error)
}
#if DEBUG
Self.logger.info("⏱ CONN_PERF: databaseBootstrap \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
#endif
// 3. Generate hash + start TCP+TLS EARLY overlaps with repository bootstraps.
// connect() is non-blocking: creates URLSessionWebSocketTask and returns immediately.
// TCP+TLS handshake (200-500ms) runs in parallel with steps 4-6 below.
let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
privateKeyHash = hash
ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash)
#if DEBUG
Self.logger.info("⏱ CONN_PERF: connectCalled \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
#endif
// 4. Setup account state (fast, in-memory only)
currentPublicKey = account.publicKey
displayName = account.displayName ?? ""
username = account.username ?? ""
CallManager.shared.bindAccount(publicKey: account.publicKey)
// Migrate legacy JSON SQLite on first launch (before repositories read from DB).
// 5. Migrate legacy JSON SQLite on first launch (before repositories read from DB).
let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded(
accountPublicKey: account.publicKey,
storagePassword: privateKeyHex
@@ -218,15 +245,24 @@ final class SessionManager {
Self.logger.info("Migrated \(migrated) messages from JSON to SQLite")
}
// Warm local state immediately, then let network sync reconcile updates.
await DialogRepository.shared.bootstrap(
// 6. Parallel repository bootstraps TCP+TLS runs concurrently in background.
// GRDB DatabasePool supports concurrent reads via WAL mode. No shared state.
async let dialogBoot: Void = DialogRepository.shared.bootstrap(
accountPublicKey: account.publicKey,
storagePassword: privateKeyHex
)
await MessageRepository.shared.bootstrap(
async let messageBoot: Void = MessageRepository.shared.bootstrap(
accountPublicKey: account.publicKey,
storagePassword: privateKeyHex
)
await dialogBoot
await messageBoot
repositoriesReady = true
#if DEBUG
Self.logger.info("⏱ CONN_PERF: repositoriesReady \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
#endif
RecentSearchesRepository.shared.setAccount(account.publicKey)
// Desktop parity: send release notes as a system message from "Rosetta Updates"
@@ -242,16 +278,11 @@ final class SessionManager {
_ = CryptoManager.shared.cachedPBKDF2(password: pkForCache)
}
// Generate private key hash for handshake
let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex)
privateKeyHash = hash
Self.logger.info("Connecting to server...")
// Connect + handshake
ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash)
isAuthenticated = true
#if DEBUG
Self.logger.info("⏱ CONN_PERF: sessionReady \(Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000))ms")
#endif
}
// MARK: - Message Sending
@@ -1088,6 +1119,7 @@ final class SessionManager {
privateKeyHash = nil
privateKeyHex = nil
lastTypingSentAt.removeAll()
repositoriesReady = false
syncBatchInProgress = false
syncRequestInFlight = false
pendingSyncReads.removeAll()
@@ -1247,6 +1279,25 @@ final class SessionManager {
Task { @MainActor in
Self.logger.info("Handshake completed")
// Wait for repositories if connect() was started early (Phase 4 optimization).
// TCP+TLS (200-500ms) almost always exceeds repository bootstrap (50-150ms),
// so this loop rarely executes. 10s safety timeout prevents infinite wait.
if !self.repositoriesReady {
Self.logger.info("⏳ Waiting for repositories to finish bootstrap...")
var waitCount = 0
while !self.repositoriesReady {
waitCount += 1
if waitCount > 1000 { // 10s safety timeout (1000 × 10ms)
Self.logger.error("Repository bootstrap timeout — proceeding anyway")
break
}
try? await Task.sleep(for: .milliseconds(10))
}
if waitCount > 0 {
Self.logger.info("⏱ Repository wait: \(waitCount * 10)ms")
}
}
guard let hash = self.privateKeyHash else { return }
// Only send UserInfo if we have profile data to update
@@ -1295,8 +1346,9 @@ final class SessionManager {
self.requestedUserInfoKeys.removeAll()
self.onlineSubscribedKeys.removeAll()
// Send push token to server for push notifications (Android parity).
// Send push tokens to server for push notifications (Android parity).
self.sendPushTokenToServer()
self.sendVoIPTokenToServer()
CallManager.shared.onAuthenticated()
// Desktop parity: user info refresh is deferred until sync completes.
@@ -2469,8 +2521,51 @@ final class SessionManager {
var packet = PacketPushNotification()
packet.notificationsToken = token
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
ProtocolManager.shared.sendPacket(packet)
Self.logger.info("Push token sent to server")
Self.logger.info("FCM push token sent to server")
}
// MARK: - VoIP Push Token (PushKit)
/// Stores the VoIP push token received from PushKit.
func setVoIPToken(_ token: String) {
UserDefaults.standard.set(token, forKey: "voip_push_token")
if ProtocolManager.shared.connectionState == .authenticated {
sendVoIPTokenToServer()
}
}
/// Sends the stored VoIP push token to the server via PacketPushNotification (0x10).
private func sendVoIPTokenToServer() {
guard let token = UserDefaults.standard.string(forKey: "voip_push_token"),
!token.isEmpty,
ProtocolManager.shared.connectionState == .authenticated
else { return }
var packet = PacketPushNotification()
packet.notificationsToken = token
packet.action = .subscribe
packet.tokenType = .voipApns
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
ProtocolManager.shared.sendPacket(packet)
Self.logger.info("VoIP push token sent to server")
}
/// Sends unsubscribe for a stale VoIP token (called when PushKit invalidates token).
func unsubscribeVoIPToken(_ token: String) {
guard !token.isEmpty,
ProtocolManager.shared.connectionState == .authenticated
else { return }
var packet = PacketPushNotification()
packet.notificationsToken = token
packet.action = .unsubscribe
packet.tokenType = .voipApns
packet.deviceId = DeviceIdentityManager.shared.currentDeviceId()
ProtocolManager.shared.sendPacket(packet)
Self.logger.info("VoIP token unsubscribed from server")
}
// MARK: - Release Notes (Desktop Parity)

View File

@@ -1447,7 +1447,9 @@ private extension ChatDetailView {
shouldScrollOnNextMessage = true
messageText = ""
pendingAttachments = []
replyingToMessage = nil
// replyingToMessage cleared INSIDE Task after message is inserted into cache.
// This ensures reply panel disappears in the same SwiftUI render pass as
// the new bubble appears no empty gap (Telegram parity).
sendError = nil
// Desktop parity: delete draft after sending.
DraftManager.shared.deleteDraft(for: route.publicKey)
@@ -1482,7 +1484,13 @@ private extension ChatDetailView {
opponentUsername: route.username
)
}
// Clear reply panel AFTER send message is already in cache
// (upsertFromMessagePacket + refreshDialogCache + notification).
// SwiftUI batches this with the ViewModel's messages update
// reply bar disappears and bubble appears in the same frame.
replyingToMessage = nil
} catch {
replyingToMessage = nil
sendError = "Failed to send message"
if messageText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
messageText = message

View File

@@ -70,6 +70,25 @@ final class ChatDetailViewModel: ObservableObject {
}
.store(in: &cancellables)
// Fast path: immediately update messages when a sent message is inserted,
// bypassing the 50ms Combine debounce for instant bubble appearance.
// No .receive(on:) notification is always posted from @MainActor
// (SessionManager upsertFromMessagePacket), so subscriber fires
// synchronously on main thread. This is critical: the message must be
// in ViewModel.messages BEFORE sendCurrentMessage() clears replyingToMessage,
// so SwiftUI batches both changes into one render pass.
NotificationCenter.default.publisher(for: .sentMessageInserted)
.compactMap { $0.userInfo?["opponentKey"] as? String }
.filter { $0 == key }
.sink { [weak self] _ in
let fresh = repo.messages(for: key)
self?.messages = fresh
if self?.isLoading == true {
self?.isLoading = false
}
}
.store(in: &cancellables)
// Subscribe to typing state changes, filtered to our dialog
let typingPublisher = repo.$typingDialogs
.map { (dialogs: Set<String>) -> Bool in

View File

@@ -298,7 +298,8 @@ final class NativeMessageListController: UIViewController {
} else {
// Reply quote
replyName = name
replyText = first.message.isEmpty ? "Photo" : first.message
let rawReplyMsg = first.message.isEmpty ? "Photo" : first.message
replyText = EmojiParser.replaceShortcodes(in: rawReplyMsg)
replyMessageId = first.message_id
}
}
@@ -861,11 +862,11 @@ final class NativeMessageListController: UIViewController {
updateScrollToBottomBadge()
}
/// Telegram-style message insertion animation.
/// New messages: slide up from below (-height*1.6 offset) + alpha fade (0.2s).
/// Telegram-style message insertion animation (iOS 26+ parity).
/// New messages: slide up from below (-height*1.2 offset) + alpha fade (0.12s).
/// Existing messages: spring position animation from old Y to new Y.
/// All position animations use CASpringAnimation (stiffness=443.7, damping=31.82).
/// Source: ChatMessageItemView.animateInsertion + ListView.insertNodeAtIndex.
/// All position animations use CASpringAnimation (stiffness=555, damping=47).
/// Source: UIKitUtils.m (iOS 26+ branch) + ListView.insertNodeAtIndex.
private func applyInsertionAnimations(newIds: Set<String>, oldPositions: [String: CGFloat]) {
for ip in collectionView.indexPathsForVisibleItems {
guard let cellId = dataSource.itemIdentifier(for: ip),
@@ -874,25 +875,25 @@ final class NativeMessageListController: UIViewController {
if newIds.contains(cellId) {
// NEW cell: slide up from below + alpha fade
// In inverted CV: negative offset = below on screen
let slideOffset = -cell.bounds.height * 1.6
let slideOffset = -cell.bounds.height * 1.2
let slide = CASpringAnimation(keyPath: "position.y")
slide.fromValue = slideOffset
slide.toValue = 0.0
slide.isAdditive = true
slide.stiffness = 443.7
slide.damping = 31.82
slide.stiffness = 555.0
slide.damping = 47.0
slide.mass = 1.0
slide.initialVelocity = 0
slide.duration = slide.settlingDuration
slide.fillMode = .backwards
cell.layer.add(slide, forKey: "insertionSlide")
// Alpha fade: 0 1 (0.2s)
// Alpha fade: 0 1 (Telegram-parity: fast fade)
let alpha = CABasicAnimation(keyPath: "opacity")
alpha.fromValue = 0.0
alpha.toValue = 1.0
alpha.duration = 0.2
alpha.duration = 0.12
alpha.fillMode = .backwards
cell.contentView.layer.add(alpha, forKey: "insertionAlpha")
@@ -905,8 +906,8 @@ final class NativeMessageListController: UIViewController {
move.fromValue = delta
move.toValue = 0.0
move.isAdditive = true
move.stiffness = 443.7
move.damping = 31.82
move.stiffness = 555.0
move.damping = 47.0
move.mass = 1.0
move.initialVelocity = 0
move.duration = move.settlingDuration

View File

@@ -2,6 +2,7 @@ import FirebaseCore
import FirebaseCrashlytics
import FirebaseMessaging
import Intents
import PushKit
import SwiftUI
import UserNotifications
@@ -15,6 +16,9 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
/// and background app (fallback if .onReceive misses the synchronous post).
static var pendingChatRoute: ChatRoute?
/// PushKit registry must be retained for VoIP push token delivery.
private var voipRegistry: PKPushRegistry?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
@@ -57,6 +61,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
}
}
// Register for VoIP push notifications (PushKit).
// Apple requires CallKit integration: every VoIP push MUST result in
// reportNewIncomingCall or the app gets terminated.
let registry = PKPushRegistry(queue: .main)
registry.delegate = self
registry.desiredPushTypes = [.voIP]
voipRegistry = registry
return true
}
@@ -394,6 +406,82 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
}
}
// MARK: - PKPushRegistryDelegate (VoIP Push)
extension AppDelegate: PKPushRegistryDelegate {
/// Called when PushKit delivers a VoIP token (or refreshes it).
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
guard type == .voIP else { return }
let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined()
Task { @MainActor in
SessionManager.shared.setVoIPToken(token)
}
}
/// Called when a VoIP push arrives. MUST call reportNewIncomingCall or Apple
/// terminates the app. Server sends: { "dialog": callerKey, "title": callerName }.
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
let data = payload.dictionaryPayload
let callerKey = data["dialog"] as? String ?? ""
let callerName = data["title"] as? String ?? "Rosetta"
// Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY.
// Using Task { @MainActor } would introduce an async hop that may be
// delayed if the main actor is busy, causing Apple to terminate the app.
CallKitManager.shared.reportIncomingCallSynchronously(
callerKey: callerKey.isEmpty ? "unknown" : callerKey,
callerName: callerName
) { error in
completion()
// 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.
if callerKey.isEmpty || error != nil {
return
}
// Trigger WebSocket reconnection so the actual .call signal packet
// arrives and CallManager can handle the call. Without this, the app
// wakes from killed state but CallManager stays idle Accept does nothing.
Task { @MainActor in
if ProtocolManager.shared.connectionState != .authenticated {
ProtocolManager.shared.forceReconnectOnForeground()
}
}
}
}
func pushRegistry(
_ registry: PKPushRegistry,
didInvalidatePushTokenFor type: PKPushType
) {
guard type == .voIP else { return }
// Notify server to unsubscribe the stale VoIP token before clearing it.
let oldToken = UserDefaults.standard.string(forKey: "voip_push_token")
if let oldToken, !oldToken.isEmpty {
Task { @MainActor in
SessionManager.shared.unsubscribeVoIPToken(oldToken)
}
}
UserDefaults.standard.removeObject(forKey: "voip_push_token")
}
}
// MARK: - App State
private enum AppState {
@@ -561,4 +649,8 @@ extension Notification.Name {
static let triggerAttachmentDownload = Notification.Name("triggerAttachmentDownload")
/// Posted when user taps "Chats" toolbar title triggers scroll-to-top.
static let chatListScrollToTop = Notification.Name("chatListScrollToTop")
/// Posted immediately when an outgoing message is inserted into the DB cache.
/// Bypasses the 100ms repo + 50ms ViewModel debounce for instant bubble appearance.
/// userInfo: ["opponentKey": String]
static let sentMessageInserted = Notification.Name("sentMessageInserted")
}