CallKit/PushKit интеграция + фикс PacketPushNotification (tokenType, deviceId)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,8 @@ server
|
||||
docs
|
||||
Telegram-iOS
|
||||
AGENTS.md
|
||||
voip.p12
|
||||
CertificateSigningRequest.certSigningRequest
|
||||
|
||||
# Xcode
|
||||
build/
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
250
Rosetta/Core/Services/CallKitManager.swift
Normal file
250
Rosetta/Core/Services/CallKitManager.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
252
RosettaTests/CallPushIntegrationTests.swift
Normal file
252
RosettaTests/CallPushIntegrationTests.swift
Normal 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 }
|
||||
}
|
||||
167
RosettaTests/PushNotificationPacketTests.swift
Normal file
167
RosettaTests/PushNotificationPacketTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user