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

2
.gitignore vendored
View File

@@ -10,6 +10,8 @@ server
docs
Telegram-iOS
AGENTS.md
voip.p12
CertificateSigningRequest.certSigningRequest
# Xcode
build/

View File

@@ -441,9 +441,20 @@ final class MessageRepository: ObservableObject {
}
}
// 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? {
guard !currentAccount.isEmpty else { return nil }

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.
// 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")
}

View File

@@ -0,0 +1,252 @@
import Testing
@testable import Rosetta
// MARK: - Push Notification Extended Tests
struct PushNotificationExtendedTests {
@Test("Realistic FCM token with device ID round-trip")
func fcmTokenWithDeviceIdRoundTrip() throws {
// Real FCM tokens are ~163 chars
let fcmToken = "dQw4w9WgXcQ:APA91bHnzPc5Y0z4R8kP3mN6vX2tL7wJ1qA5sD8fG0hK3lZ9xC2vB4nM7oP1iU8yT6rE5wQ3jF4kL2mN0bV7cX9sD1aF3gH5jK7lP9oI2uY4tR6eW8qZ0xC"
var packet = PacketPushNotification()
packet.notificationsToken = fcmToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == fcmToken)
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop")
}
@Test("Realistic VoIP hex token round-trip")
func voipTokenWithDeviceIdRoundTrip() throws {
// PushKit tokens are 32 bytes = 64 hex chars
let voipToken = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
var packet = PacketPushNotification()
packet.notificationsToken = voipToken
packet.action = .subscribe
packet.tokenType = .voipApns
packet.deviceId = "device-xyz-123"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == voipToken)
#expect(decoded.tokenType == .voipApns)
}
@Test("Long token (256 chars) round-trip — stress test UInt32 string length")
func longTokenRoundTrip() throws {
let longToken = String(repeating: "x", count: 256)
var packet = PacketPushNotification()
packet.notificationsToken = longToken
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "dev"
let decoded = try decode(packet)
#expect(decoded.notificationsToken == longToken)
#expect(decoded.notificationsToken.count == 256)
}
@Test("Unicode device ID with emoji and Cyrillic round-trip")
func unicodeDeviceIdRoundTrip() throws {
let unicodeId = "Телефон Гайдара 📱"
var packet = PacketPushNotification()
packet.notificationsToken = "token"
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = unicodeId
let decoded = try decode(packet)
#expect(decoded.deviceId == unicodeId)
}
@Test("Unsubscribe action round-trip for both token types",
arguments: [PushTokenType.fcm, PushTokenType.voipApns])
func unsubscribeRoundTrip(tokenType: PushTokenType) throws {
var packet = PacketPushNotification()
packet.notificationsToken = "test-token"
packet.action = .unsubscribe
packet.tokenType = tokenType
packet.deviceId = "dev"
let decoded = try decode(packet)
#expect(decoded.action == .unsubscribe)
#expect(decoded.tokenType == tokenType)
}
private func decode(_ packet: PacketPushNotification) throws -> PacketPushNotification {
let data = PacketRegistry.encode(packet)
guard let result = PacketRegistry.decode(from: data),
let decoded = result.packet as? PacketPushNotification
else { throw TestError("Failed to decode PacketPushNotification") }
#expect(result.packetId == 0x10)
return decoded
}
}
// MARK: - Signal Peer Call Flow Tests
struct SignalPeerCallFlowTests {
@Test("Incoming call signal with realistic secp256k1 keys")
func incomingCallSignalRoundTrip() throws {
let caller = "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
let callee = "03f0e1d2c3b4a5968778695a4b3c2d1e0f9e8d7c6b5a49382716051a2b3c4d5e6f"
let packet = PacketSignalPeer(src: caller, dst: callee, sharedPublic: "",
signalType: .call, roomId: "")
let decoded = try decode(packet)
#expect(decoded.signalType == .call)
#expect(decoded.src == caller)
#expect(decoded.dst == callee)
#expect(decoded.sharedPublic == "")
#expect(decoded.roomId == "")
}
@Test("Key exchange with X25519 public key")
func keyExchangeRoundTrip() throws {
let x25519Key = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
let packet = PacketSignalPeer(src: "02src", dst: "02dst", sharedPublic: x25519Key,
signalType: .keyExchange, roomId: "")
let decoded = try decode(packet)
#expect(decoded.signalType == .keyExchange)
#expect(decoded.sharedPublic == x25519Key)
#expect(decoded.roomId == "")
}
@Test("Create room with UUID room ID")
func createRoomRoundTrip() throws {
let roomId = "550e8400-e29b-41d4-a716-446655440000"
let packet = PacketSignalPeer(src: "02src", dst: "02dst", sharedPublic: "",
signalType: .createRoom, roomId: roomId)
let decoded = try decode(packet)
#expect(decoded.signalType == .createRoom)
#expect(decoded.roomId == roomId)
#expect(decoded.sharedPublic == "")
}
@Test("endCallBecauseBusy short format — 3 bytes wire size, no src/dst")
func endCallBusyShortFormat() throws {
let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored",
signalType: .endCallBecauseBusy, roomId: "ignored")
let data = PacketRegistry.encode(packet)
// Short form: 2 bytes packetId + 1 byte signalType = 3 bytes
#expect(data.count == 3)
let decoded = try decode(packet)
#expect(decoded.signalType == .endCallBecauseBusy)
#expect(decoded.src == "")
#expect(decoded.dst == "")
}
@Test("endCallBecausePeerDisconnected short format — 3 bytes wire size")
func endCallDisconnectedShortFormat() throws {
let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored",
signalType: .endCallBecausePeerDisconnected, roomId: "ignored")
let data = PacketRegistry.encode(packet)
#expect(data.count == 3)
let decoded = try decode(packet)
#expect(decoded.signalType == .endCallBecausePeerDisconnected)
}
private func decode(_ packet: PacketSignalPeer) throws -> PacketSignalPeer {
let data = PacketRegistry.encode(packet)
guard let result = PacketRegistry.decode(from: data),
let decoded = result.packet as? PacketSignalPeer
else { throw TestError("Failed to decode PacketSignalPeer") }
#expect(result.packetId == 0x1A)
return decoded
}
}
// MARK: - Enum Parity Tests
struct CallPushEnumParityTests {
@Test("SignalType enum values match server",
arguments: [
(SignalType.call, 0), (SignalType.keyExchange, 1), (SignalType.activeCall, 2),
(SignalType.endCall, 3), (SignalType.createRoom, 4),
(SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6)
])
func signalTypeEnumValues(pair: (SignalType, Int)) {
#expect(pair.0.rawValue == pair.1)
}
@Test("WebRTCSignalType enum values match server",
arguments: [(WebRTCSignalType.offer, 0), (WebRTCSignalType.answer, 1),
(WebRTCSignalType.iceCandidate, 2)])
func webRTCSignalTypeValues(pair: (WebRTCSignalType, Int)) {
#expect(pair.0.rawValue == pair.1)
}
@Test("PushTokenType enum values match server")
func pushTokenTypeValues() {
#expect(PushTokenType.fcm.rawValue == 0)
#expect(PushTokenType.voipApns.rawValue == 1)
}
}
// MARK: - Wire Format Byte-Level Tests
struct CallPushWireFormatTests {
@Test("PushNotification byte layout: token→action→tokenType→deviceId")
func pushNotificationByteLayout() {
var packet = PacketPushNotification()
packet.notificationsToken = "A"
packet.action = .unsubscribe
packet.tokenType = .fcm
packet.deviceId = "B"
let data = PacketRegistry.encode(packet)
#expect(data.count == 16)
// packetId = 0x0010
#expect(data[0] == 0x00); #expect(data[1] == 0x10)
// token "A": length=1, 'A'=0x0041
#expect(data[2] == 0x00); #expect(data[3] == 0x00)
#expect(data[4] == 0x00); #expect(data[5] == 0x01)
#expect(data[6] == 0x00); #expect(data[7] == 0x41)
// action = 1 (unsubscribe)
#expect(data[8] == 0x01)
// tokenType = 0 (fcm)
#expect(data[9] == 0x00)
// deviceId "B": length=1, 'B'=0x0042
#expect(data[10] == 0x00); #expect(data[11] == 0x00)
#expect(data[12] == 0x00); #expect(data[13] == 0x01)
#expect(data[14] == 0x00); #expect(data[15] == 0x42)
}
@Test("SignalPeer call byte layout: signalType→src→dst")
func signalPeerCallByteLayout() {
let packet = PacketSignalPeer(src: "S", dst: "D", sharedPublic: "",
signalType: .call, roomId: "")
let data = PacketRegistry.encode(packet)
#expect(data.count == 15)
// packetId = 0x001A
#expect(data[0] == 0x00); #expect(data[1] == 0x1A)
// signalType = 0 (call)
#expect(data[2] == 0x00)
// src "S": length=1, 'S'=0x0053
#expect(data[3] == 0x00); #expect(data[4] == 0x00)
#expect(data[5] == 0x00); #expect(data[6] == 0x01)
#expect(data[7] == 0x00); #expect(data[8] == 0x53)
// dst "D": length=1, 'D'=0x0044
#expect(data[9] == 0x00); #expect(data[10] == 0x00)
#expect(data[11] == 0x00); #expect(data[12] == 0x01)
#expect(data[13] == 0x00); #expect(data[14] == 0x44)
}
}
// MARK: - Helpers
private struct TestError: Error, CustomStringConvertible {
let description: String
init(_ message: String) { self.description = message }
}

View File

@@ -0,0 +1,167 @@
import Testing
@testable import Rosetta
/// Verifies PacketPushNotification wire format matches server
/// (im.rosetta.packet.Packet16PushNotification).
///
/// Server wire format:
/// writeInt16(packetId=0x10)
/// writeString(notificationToken)
/// writeInt8(action) 0=subscribe, 1=unsubscribe
/// writeInt8(tokenType) 0=FCM, 1=VoIPApns
/// writeString(deviceId)
struct PushNotificationPacketTests {
// MARK: - Enum Value Parity
@Test("PushNotificationAction.subscribe == 0 (server: SUBSCRIBE)")
func subscribeActionValue() {
#expect(PushNotificationAction.subscribe.rawValue == 0)
}
@Test("PushNotificationAction.unsubscribe == 1 (server: UNSUBSCRIBE)")
func unsubscribeActionValue() {
#expect(PushNotificationAction.unsubscribe.rawValue == 1)
}
@Test("PushTokenType.fcm == 0 (server: FCM)")
func fcmTokenTypeValue() {
#expect(PushTokenType.fcm.rawValue == 0)
}
@Test("PushTokenType.voipApns == 1 (server: VoIPApns)")
func voipTokenTypeValue() {
#expect(PushTokenType.voipApns.rawValue == 1)
}
// MARK: - Round Trip (encode decode)
@Test("FCM subscribe round-trip preserves all fields")
func fcmSubscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "test-fcm-token-abc123"
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = "device-id-xyz"
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "test-fcm-token-abc123")
#expect(decoded.action == .subscribe)
#expect(decoded.tokenType == .fcm)
#expect(decoded.deviceId == "device-id-xyz")
}
@Test("VoIP unsubscribe round-trip preserves all fields")
func voipUnsubscribeRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = "voip-hex-token-deadbeef"
packet.action = .unsubscribe
packet.tokenType = .voipApns
packet.deviceId = "another-device-id"
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "voip-hex-token-deadbeef")
#expect(decoded.action == .unsubscribe)
#expect(decoded.tokenType == .voipApns)
#expect(decoded.deviceId == "another-device-id")
}
@Test("Empty token and deviceId round-trip")
func emptyFieldsRoundTrip() throws {
var packet = PacketPushNotification()
packet.notificationsToken = ""
packet.action = .subscribe
packet.tokenType = .fcm
packet.deviceId = ""
let decoded = try decodePushNotification(packet)
#expect(decoded.notificationsToken == "")
#expect(decoded.deviceId == "")
}
// MARK: - Wire Format Byte Verification
@Test("Packet ID is 0x10 in encoded data")
func packetIdInEncodedData() {
#expect(PacketPushNotification.packetId == 0x10)
let packet = PacketPushNotification()
let data = PacketRegistry.encode(packet)
// First 2 bytes = packetId in big-endian: 0x00 0x10
#expect(data.count >= 2)
#expect(data[0] == 0x00)
#expect(data[1] == 0x10)
}
@Test("Wire format field order matches server: token → action → tokenType → deviceId")
func wireFormatFieldOrder() throws {
// Use known short values so we can verify byte positions.
var packet = PacketPushNotification()
packet.notificationsToken = "T" // 1 UTF-16 code unit
packet.action = .subscribe // 0
packet.tokenType = .voipApns // 1
packet.deviceId = "D" // 1 UTF-16 code unit
let data = PacketRegistry.encode(packet)
// Expected layout:
// [0-1] packetId = 0x0010 (2 bytes)
// [2-5] string length = 1 (UInt32 big-endian) for "T"
// [6-7] 'T' = 0x0054 (UInt16 big-endian)
// [8] action = 0 (subscribe)
// [9] tokenType = 1 (voipApns)
// [10-13] string length = 1 for "D"
// [14-15] 'D' = 0x0044 (UInt16 big-endian)
#expect(data.count == 16)
// packetId
#expect(data[0] == 0x00)
#expect(data[1] == 0x10)
// token string length = 1
#expect(data[2] == 0x00)
#expect(data[3] == 0x00)
#expect(data[4] == 0x00)
#expect(data[5] == 0x01)
// 'T' in UTF-16 BE
#expect(data[6] == 0x00)
#expect(data[7] == 0x54)
// action = 0 (subscribe)
#expect(data[8] == 0x00)
// tokenType = 1 (voipApns)
#expect(data[9] == 0x01)
// deviceId string length = 1
#expect(data[10] == 0x00)
#expect(data[11] == 0x00)
#expect(data[12] == 0x00)
#expect(data[13] == 0x01)
// 'D' in UTF-16 BE
#expect(data[14] == 0x00)
#expect(data[15] == 0x44)
}
// MARK: - Helper
private func decodePushNotification(
_ packet: PacketPushNotification
) throws -> PacketPushNotification {
let encoded = PacketRegistry.encode(packet)
guard let decoded = PacketRegistry.decode(from: encoded),
let decodedPacket = decoded.packet as? PacketPushNotification
else {
throw NSError(
domain: "PushNotificationPacketTests", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to decode PacketPushNotification"]
)
}
#expect(decoded.packetId == 0x10)
return decodedPacket
}
}