Темизация: adaptive цвета чата, context menu, attachment picker, auth + instant отклик DarkMode кнопки
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// Call signaling packet (0x1A / 26).
|
||||
/// Wire format mirrors desktop/android:
|
||||
/// `signalType` always first, then short-form for busy/disconnected,
|
||||
/// otherwise `src`, `dst`, optional `sharedPublic`, optional `roomId`.
|
||||
/// Wire format mirrors server/desktop legacy signaling:
|
||||
/// `signalType` always first, then short-form for busy/disconnected/ringingTimeout,
|
||||
/// otherwise `src`, `dst`, optional `sharedPublic`, optional `callId/joinToken`,
|
||||
/// optional `roomId` for create-room fallback compatibility.
|
||||
enum SignalType: Int, Sendable {
|
||||
case call = 0
|
||||
case keyExchange = 1
|
||||
@@ -12,6 +13,8 @@ enum SignalType: Int, Sendable {
|
||||
case createRoom = 4
|
||||
case endCallBecausePeerDisconnected = 5
|
||||
case endCallBecauseBusy = 6
|
||||
case accept = 7
|
||||
case ringingTimeout = 8
|
||||
}
|
||||
|
||||
struct PacketSignalPeer: Packet {
|
||||
@@ -21,11 +24,13 @@ struct PacketSignalPeer: Packet {
|
||||
var dst: String = ""
|
||||
var sharedPublic: String = ""
|
||||
var signalType: SignalType = .call
|
||||
var callId: String = ""
|
||||
var joinToken: String = ""
|
||||
var roomId: String = ""
|
||||
|
||||
func write(to stream: Stream) {
|
||||
stream.writeInt8(signalType.rawValue)
|
||||
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected {
|
||||
if isShortSignal {
|
||||
return
|
||||
}
|
||||
stream.writeString(src)
|
||||
@@ -33,14 +38,27 @@ struct PacketSignalPeer: Packet {
|
||||
if signalType == .keyExchange {
|
||||
stream.writeString(sharedPublic)
|
||||
}
|
||||
if signalType == .createRoom {
|
||||
if hasLegacyCallMetadata {
|
||||
stream.writeString(callId)
|
||||
stream.writeString(joinToken)
|
||||
}
|
||||
// Signal code 4 is mode-aware:
|
||||
// - legacy ACTIVE: no roomId on wire
|
||||
// - create-room fallback: roomId present on wire
|
||||
if signalType == .createRoom && !roomId.isEmpty {
|
||||
stream.writeString(roomId)
|
||||
}
|
||||
}
|
||||
|
||||
mutating func read(from stream: Stream) {
|
||||
src = ""
|
||||
dst = ""
|
||||
sharedPublic = ""
|
||||
callId = ""
|
||||
joinToken = ""
|
||||
roomId = ""
|
||||
signalType = SignalType(rawValue: stream.readInt8()) ?? .call
|
||||
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected {
|
||||
if isShortSignal {
|
||||
return
|
||||
}
|
||||
src = stream.readString()
|
||||
@@ -48,8 +66,25 @@ struct PacketSignalPeer: Packet {
|
||||
if signalType == .keyExchange {
|
||||
sharedPublic = stream.readString()
|
||||
}
|
||||
if hasLegacyCallMetadata {
|
||||
callId = stream.readString()
|
||||
joinToken = stream.readString()
|
||||
}
|
||||
// Signal code 4 is mode-aware on read:
|
||||
// - empty roomId => legacy ACTIVE
|
||||
// - non-empty roomId => create-room fallback
|
||||
if signalType == .createRoom {
|
||||
roomId = stream.readString()
|
||||
}
|
||||
}
|
||||
|
||||
private var isShortSignal: Bool {
|
||||
signalType == .endCallBecauseBusy
|
||||
|| signalType == .endCallBecausePeerDisconnected
|
||||
|| signalType == .ringingTimeout
|
||||
}
|
||||
|
||||
private var hasLegacyCallMetadata: Bool {
|
||||
signalType == .call || signalType == .accept || signalType == .endCall
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,6 +356,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = "",
|
||||
roomId: String = ""
|
||||
) {
|
||||
var packet = PacketSignalPeer()
|
||||
@@ -363,6 +365,8 @@ final class ProtocolManager: @unchecked Sendable {
|
||||
packet.src = src
|
||||
packet.dst = dst
|
||||
packet.sharedPublic = sharedPublic
|
||||
packet.callId = callId
|
||||
packet.joinToken = joinToken
|
||||
packet.roomId = roomId
|
||||
sendPacket(packet)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,10 @@ extension CallManager {
|
||||
|
||||
func beginCallSession(peerPublicKey: String, title: String, username: String) {
|
||||
finishCall(reason: nil, notifyPeer: false)
|
||||
signalingMode = .undecided
|
||||
callId = ""
|
||||
joinToken = ""
|
||||
pendingIncomingAccept = false
|
||||
uiState = CallUiState(
|
||||
phase: .idle,
|
||||
peerPublicKey: peerPublicKey,
|
||||
@@ -158,11 +162,14 @@ extension CallManager {
|
||||
guard !isFinishingCall, uiState.phase != .idle else { return }
|
||||
isFinishingCall = true
|
||||
pendingCallKitAccept = false
|
||||
pendingIncomingAccept = false
|
||||
defer { isFinishingCall = false }
|
||||
|
||||
callLogger.notice("[Call] finishCall(reason=\(reason ?? "nil", privacy: .public)) phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
|
||||
let snapshot = uiState
|
||||
let snapshotCallId = callId
|
||||
let snapshotJoinToken = joinToken
|
||||
|
||||
// Step 0: Cancel recovery/rebind tasks and clear packet buffer.
|
||||
disconnectRecoveryTask?.cancel()
|
||||
@@ -221,7 +228,9 @@ extension CallManager {
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .endCall,
|
||||
src: ownPublicKey,
|
||||
dst: snapshot.peerPublicKey
|
||||
dst: snapshot.peerPublicKey,
|
||||
callId: snapshotCallId,
|
||||
joinToken: snapshotJoinToken
|
||||
)
|
||||
}
|
||||
|
||||
@@ -244,7 +253,10 @@ extension CallManager {
|
||||
|
||||
// Step 6: Reset all state.
|
||||
role = nil
|
||||
signalingMode = .undecided
|
||||
roomId = ""
|
||||
callId = ""
|
||||
joinToken = ""
|
||||
localPrivateKey = nil
|
||||
localPublicKeyHex = ""
|
||||
sharedKey = nil
|
||||
|
||||
@@ -18,7 +18,10 @@ final class CallManager: NSObject, ObservableObject {
|
||||
|
||||
var ownPublicKey: String = ""
|
||||
var role: CallRole?
|
||||
var signalingMode: CallSignalingMode = .undecided
|
||||
var roomId: String = ""
|
||||
var callId: String = ""
|
||||
var joinToken: String = ""
|
||||
var localPrivateKey: Curve25519.KeyAgreement.PrivateKey?
|
||||
var localPublicKeyHex: String = ""
|
||||
var sharedKey: Data?
|
||||
@@ -49,6 +52,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
/// Pending accept: user tapped Accept on CallKit before WebSocket delivered .call signal.
|
||||
/// When handleSignalPacket(.call) arrives, auto-accept if this is true.
|
||||
var pendingCallKitAccept = false
|
||||
/// Deferred incoming accept: user accepted, but call signaling metadata/mode is
|
||||
/// not yet sufficient to send the next signal (legacy ACCEPT or create-room KEY_EXCHANGE).
|
||||
var pendingIncomingAccept = false
|
||||
/// True after CallKit fires didActivate — audio session has the entitlement.
|
||||
/// WebRTC peer connection MUST NOT be created before this flag is true,
|
||||
/// otherwise AURemoteIO init fails with "Missing entitlement" (-12988).
|
||||
@@ -98,22 +104,39 @@ final class CallManager: NSObject, ObservableObject {
|
||||
/// Sets up incoming call state directly from VoIP push payload.
|
||||
/// Called when app was killed → PushKit wakes it → WebSocket not yet connected.
|
||||
/// The .call signal may never arrive (fire-and-forget), so we set up state from push.
|
||||
func setupIncomingCallFromPush(callerKey: String, callerName: String) {
|
||||
guard uiState.phase == .idle else { return }
|
||||
guard !callerKey.isEmpty else { return }
|
||||
callLogger.notice("setupIncomingCallFromPush: callerKey=\(callerKey.prefix(12), privacy: .public) name=\(callerName, privacy: .public)")
|
||||
func setupIncomingCallFromPush(
|
||||
callerKey: String,
|
||||
callerName: String,
|
||||
callId: String? = nil,
|
||||
joinToken: String? = nil
|
||||
) {
|
||||
let peer = callerKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !peer.isEmpty else { return }
|
||||
mergeLegacySessionMetadata(callId: callId, joinToken: joinToken, source: "push")
|
||||
if hasLegacySessionMetadata {
|
||||
setSignalingMode(.legacy, reason: "push metadata")
|
||||
}
|
||||
|
||||
guard uiState.phase == .idle else {
|
||||
completePendingIncomingAcceptIfPossible(trigger: "push update")
|
||||
return
|
||||
}
|
||||
|
||||
callLogger.notice(
|
||||
"setupIncomingCallFromPush: callerKey=\(peer.prefix(12), privacy: .public) name=\(callerName, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)"
|
||||
)
|
||||
// Don't call beginCallSession() — it calls finishCall() which kills the
|
||||
// CallKit call that PushKit just reported. Set state directly instead.
|
||||
uiState = CallUiState(
|
||||
phase: .incoming,
|
||||
peerPublicKey: callerKey,
|
||||
peerPublicKey: peer,
|
||||
peerTitle: callerName,
|
||||
peerUsername: ""
|
||||
)
|
||||
role = .callee
|
||||
uiState.statusText = "Incoming call..."
|
||||
ProtocolManager.shared.beginCallBackgroundTask()
|
||||
hydratePeerIdentity(for: callerKey)
|
||||
hydratePeerIdentity(for: peer)
|
||||
startRingTimeout()
|
||||
|
||||
// Auto-accept if user already tapped Accept on CallKit before this ran.
|
||||
@@ -125,6 +148,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
let result = acceptIncomingCall()
|
||||
callLogger.info("setupIncomingCallFromPush: auto-accept result=\(String(describing: result), privacy: .public)")
|
||||
}
|
||||
completePendingIncomingAcceptIfPossible(trigger: "push setup")
|
||||
}
|
||||
|
||||
func onAuthenticated() {
|
||||
@@ -143,6 +167,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
|
||||
beginCallSession(peerPublicKey: target, title: title, username: username)
|
||||
role = .caller
|
||||
setSignalingMode(.undecided, reason: "outgoing call started")
|
||||
ensureLocalSessionKeys()
|
||||
uiState.phase = .outgoing
|
||||
uiState.statusText = "Calling..."
|
||||
@@ -172,16 +197,14 @@ final class CallManager: NSObject, ObservableObject {
|
||||
ensureLocalSessionKeys()
|
||||
guard localPublicKeyHex.isEmpty == false else { return .invalidTarget }
|
||||
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .keyExchange,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
sharedPublic: localPublicKeyHex
|
||||
)
|
||||
|
||||
pendingIncomingAccept = true
|
||||
uiState.phase = .keyExchange
|
||||
uiState.isMinimized = false // Show full-screen custom overlay after accept
|
||||
uiState.statusText = "Exchanging keys..."
|
||||
uiState.statusText = "Connecting..."
|
||||
callLogger.notice(
|
||||
"[Call] acceptIncomingCall: queued mode=\(self.signalingMode.rawValue, privacy: .public) hasMetadata=\(self.hasLegacySessionMetadata.description, privacy: .public)"
|
||||
)
|
||||
completePendingIncomingAcceptIfPossible(trigger: "acceptIncomingCall")
|
||||
return .started
|
||||
}
|
||||
|
||||
@@ -192,7 +215,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .endCall,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey
|
||||
dst: uiState.peerPublicKey,
|
||||
callId: callId,
|
||||
joinToken: joinToken
|
||||
)
|
||||
}
|
||||
finishCall(reason: nil, notifyPeer: false)
|
||||
@@ -264,8 +289,80 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private var hasLegacySessionMetadata: Bool {
|
||||
!callId.isEmpty && !joinToken.isEmpty
|
||||
}
|
||||
|
||||
private func setSignalingMode(_ mode: CallSignalingMode, reason: String) {
|
||||
guard signalingMode != mode else { return }
|
||||
callLogger.notice(
|
||||
"[Call] signaling mode \(self.signalingMode.rawValue, privacy: .public) -> \(mode.rawValue, privacy: .public) (\(reason, privacy: .public))"
|
||||
)
|
||||
signalingMode = mode
|
||||
}
|
||||
|
||||
private func mergeLegacySessionMetadata(callId: String?, joinToken: String?, source: String) {
|
||||
let normalizedCallId = callId?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let normalizedJoinToken = joinToken?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
if !normalizedCallId.isEmpty && self.callId != normalizedCallId {
|
||||
callLogger.info(
|
||||
"[Call] merge callId from \(source, privacy: .public): \(normalizedCallId.prefix(12), privacy: .public)"
|
||||
)
|
||||
self.callId = normalizedCallId
|
||||
}
|
||||
if !normalizedJoinToken.isEmpty && self.joinToken != normalizedJoinToken {
|
||||
callLogger.info("[Call] merge joinToken from \(source, privacy: .public)")
|
||||
self.joinToken = normalizedJoinToken
|
||||
}
|
||||
}
|
||||
|
||||
private func completePendingIncomingAcceptIfPossible(trigger: String) {
|
||||
guard pendingIncomingAccept else { return }
|
||||
guard ownPublicKey.isEmpty == false else { return }
|
||||
guard uiState.peerPublicKey.isEmpty == false else { return }
|
||||
|
||||
switch signalingMode {
|
||||
case .legacy:
|
||||
guard hasLegacySessionMetadata else {
|
||||
callLogger.info("[Call] pending accept waits for legacy metadata (\(trigger, privacy: .public))")
|
||||
return
|
||||
}
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .accept,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
callId: callId,
|
||||
joinToken: joinToken
|
||||
)
|
||||
pendingIncomingAccept = false
|
||||
uiState.statusText = "Waiting for key exchange..."
|
||||
callLogger.notice("[Call] pending accept completed via ACCEPT (\(trigger, privacy: .public))")
|
||||
|
||||
case .createRoom:
|
||||
ensureLocalSessionKeys()
|
||||
guard !localPublicKeyHex.isEmpty else { return }
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .keyExchange,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
sharedPublic: localPublicKeyHex
|
||||
)
|
||||
pendingIncomingAccept = false
|
||||
uiState.statusText = "Exchanging keys..."
|
||||
callLogger.notice("[Call] pending accept completed via KEY_EXCHANGE (\(trigger, privacy: .public))")
|
||||
|
||||
case .undecided:
|
||||
callLogger.info("[Call] pending accept waits for signaling mode decision (\(trigger, privacy: .public))")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSignalPacket(_ packet: PacketSignalPeer) {
|
||||
callLogger.notice("[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public)")
|
||||
callLogger.notice(
|
||||
"[Call] handleSignalPacket: type=\(String(describing: packet.signalType), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) mode=\(self.signalingMode.rawValue, privacy: .public)"
|
||||
)
|
||||
switch packet.signalType {
|
||||
case .endCallBecauseBusy:
|
||||
finishCall(reason: "User is busy", notifyPeer: false, skipAttachment: true)
|
||||
@@ -273,6 +370,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
case .endCallBecausePeerDisconnected:
|
||||
finishCall(reason: "Peer disconnected", notifyPeer: false)
|
||||
return
|
||||
case .ringingTimeout:
|
||||
finishCall(reason: "No answer", notifyPeer: false, skipAttachment: true)
|
||||
return
|
||||
case .endCall:
|
||||
finishCall(reason: "Call ended", notifyPeer: false)
|
||||
return
|
||||
@@ -290,12 +390,23 @@ final class CallManager: NSObject, ObservableObject {
|
||||
case .call:
|
||||
let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard incomingPeer.isEmpty == false else { return }
|
||||
mergeLegacySessionMetadata(
|
||||
callId: packet.callId,
|
||||
joinToken: packet.joinToken,
|
||||
source: "signal.call"
|
||||
)
|
||||
if hasLegacySessionMetadata {
|
||||
setSignalingMode(.legacy, reason: "incoming .call with call metadata")
|
||||
} else if signalingMode == .undecided {
|
||||
setSignalingMode(.createRoom, reason: "incoming .call without metadata")
|
||||
}
|
||||
guard uiState.phase == .idle else {
|
||||
// Already in a call with this peer — ignore duplicate .call signal.
|
||||
// Server re-delivers .call after WebSocket reconnect; without this guard,
|
||||
// the code sends .endCallBecauseBusy which terminates the active call.
|
||||
if incomingPeer == uiState.peerPublicKey {
|
||||
callLogger.info("Ignoring duplicate .call signal — already in call with this peer (phase=\(self.uiState.phase.rawValue, privacy: .public))")
|
||||
completePendingIncomingAcceptIfPossible(trigger: "duplicate .call")
|
||||
return
|
||||
}
|
||||
// Different peer trying to call — send busy.
|
||||
@@ -306,7 +417,26 @@ final class CallManager: NSObject, ObservableObject {
|
||||
)
|
||||
return
|
||||
}
|
||||
let cachedCallId = callId
|
||||
let cachedJoinToken = joinToken
|
||||
beginCallSession(peerPublicKey: incomingPeer, title: "", username: "")
|
||||
mergeLegacySessionMetadata(
|
||||
callId: packet.callId,
|
||||
joinToken: packet.joinToken,
|
||||
source: "signal.call post-reset"
|
||||
)
|
||||
if !hasLegacySessionMetadata {
|
||||
mergeLegacySessionMetadata(
|
||||
callId: cachedCallId,
|
||||
joinToken: cachedJoinToken,
|
||||
source: "signal.call cached metadata"
|
||||
)
|
||||
}
|
||||
if hasLegacySessionMetadata {
|
||||
setSignalingMode(.legacy, reason: "incoming .call session start")
|
||||
} else {
|
||||
setSignalingMode(.createRoom, reason: "incoming .call session start")
|
||||
}
|
||||
role = .callee
|
||||
uiState.phase = .incoming
|
||||
ProtocolManager.shared.beginCallBackgroundTask()
|
||||
@@ -335,12 +465,50 @@ final class CallManager: NSObject, ObservableObject {
|
||||
let result = acceptIncomingCall()
|
||||
callLogger.info("Auto-accept: result=\(String(describing: result), privacy: .public) ownKey=\(self.ownPublicKey.isEmpty ? "EMPTY" : String(self.ownPublicKey.prefix(12)), privacy: .public) phase=\(self.uiState.phase.rawValue, privacy: .public) wsState=\(String(describing: ProtocolManager.shared.connectionState), privacy: .public)")
|
||||
}
|
||||
completePendingIncomingAcceptIfPossible(trigger: "incoming .call")
|
||||
case .accept:
|
||||
mergeLegacySessionMetadata(
|
||||
callId: packet.callId,
|
||||
joinToken: packet.joinToken,
|
||||
source: "signal.accept"
|
||||
)
|
||||
guard role == .caller else { return }
|
||||
if signalingMode == .undecided {
|
||||
setSignalingMode(.legacy, reason: "caller received ACCEPT")
|
||||
} else if signalingMode == .createRoom {
|
||||
callLogger.notice("[Call] ACCEPT ignored — mode already createRoom")
|
||||
return
|
||||
}
|
||||
ensureLocalSessionKeys()
|
||||
guard !localPublicKeyHex.isEmpty else { return }
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .keyExchange,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
sharedPublic: localPublicKeyHex
|
||||
)
|
||||
uiState.phase = .keyExchange
|
||||
uiState.statusText = "Exchanging keys..."
|
||||
callLogger.notice("[Call] legacy path: ACCEPT -> KEY_EXCHANGE")
|
||||
case .keyExchange:
|
||||
if role == .caller, signalingMode == .undecided {
|
||||
setSignalingMode(.createRoom, reason: "caller received KEY_EXCHANGE before ACCEPT")
|
||||
}
|
||||
handleKeyExchange(packet)
|
||||
case .createRoom:
|
||||
let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard incomingRoomId.isEmpty == false else { return }
|
||||
roomId = incomingRoomId
|
||||
if incomingRoomId.isEmpty {
|
||||
if signalingMode == .undecided {
|
||||
setSignalingMode(.legacy, reason: "signal 4 without roomId")
|
||||
}
|
||||
callLogger.notice("[Call] signal 4 handled as ACTIVE (legacy)")
|
||||
} else {
|
||||
roomId = incomingRoomId
|
||||
if signalingMode == .undecided {
|
||||
setSignalingMode(.createRoom, reason: "signal 4 with roomId")
|
||||
}
|
||||
callLogger.notice("[Call] signal 4 handled as CREATE_ROOM roomId=\(incomingRoomId.prefix(16), privacy: .public)")
|
||||
}
|
||||
uiState.phase = .webRtcExchange
|
||||
uiState.statusText = "Connecting..."
|
||||
if audioSessionActivated {
|
||||
@@ -373,7 +541,7 @@ final class CallManager: NSObject, ObservableObject {
|
||||
}
|
||||
case .activeCall:
|
||||
break
|
||||
case .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy:
|
||||
case .ringingTimeout, .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -406,22 +574,66 @@ final class CallManager: NSObject, ObservableObject {
|
||||
|
||||
switch role {
|
||||
case .caller:
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .keyExchange,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
sharedPublic: localPublicKeyHex
|
||||
)
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .createRoom,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey
|
||||
)
|
||||
uiState.phase = .webRtcExchange
|
||||
uiState.statusText = "Creating room..."
|
||||
switch signalingMode {
|
||||
case .legacy:
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .createRoom,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey
|
||||
)
|
||||
uiState.phase = .keyExchange
|
||||
uiState.statusText = "Finalizing call..."
|
||||
callLogger.notice("[Call] legacy path: KEY_EXCHANGE complete -> ACTIVE")
|
||||
case .createRoom:
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .keyExchange,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
sharedPublic: localPublicKeyHex
|
||||
)
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .createRoom,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
roomId: roomId
|
||||
)
|
||||
uiState.phase = .webRtcExchange
|
||||
uiState.statusText = "Creating room..."
|
||||
callLogger.notice("[Call] create-room path: KEY_EXCHANGE complete -> CREATE_ROOM")
|
||||
case .undecided:
|
||||
setSignalingMode(.createRoom, reason: "fallback on caller key exchange")
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .keyExchange,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
sharedPublic: localPublicKeyHex
|
||||
)
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .createRoom,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
roomId: roomId
|
||||
)
|
||||
uiState.phase = .webRtcExchange
|
||||
uiState.statusText = "Creating room..."
|
||||
}
|
||||
case .callee:
|
||||
uiState.phase = .keyExchange
|
||||
uiState.statusText = "Waiting for room..."
|
||||
switch signalingMode {
|
||||
case .legacy:
|
||||
ProtocolManager.shared.sendCallSignal(
|
||||
signalType: .keyExchange,
|
||||
src: ownPublicKey,
|
||||
dst: uiState.peerPublicKey,
|
||||
sharedPublic: localPublicKeyHex
|
||||
)
|
||||
uiState.phase = .keyExchange
|
||||
uiState.statusText = "Waiting for active signal..."
|
||||
callLogger.notice("[Call] legacy path: sent KEY_EXCHANGE, waiting for ACTIVE")
|
||||
case .createRoom, .undecided:
|
||||
uiState.phase = .keyExchange
|
||||
uiState.statusText = "Waiting for room..."
|
||||
callLogger.notice("[Call] create-room path: waiting for room signal")
|
||||
}
|
||||
case .none:
|
||||
break
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ enum CallRole: Sendable {
|
||||
case callee
|
||||
}
|
||||
|
||||
enum CallSignalingMode: String, Sendable {
|
||||
case undecided
|
||||
case legacy
|
||||
case createRoom
|
||||
}
|
||||
|
||||
enum CallActionResult: Sendable {
|
||||
case started
|
||||
case alreadyInCall
|
||||
|
||||
@@ -17,7 +17,7 @@ struct DarkModeWrapper<Content: View>: View {
|
||||
content
|
||||
.onAppear {
|
||||
if overlayWindow == nil {
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
||||
if let windowScene = activeWindowScene {
|
||||
let overlayWindow = UIWindow(windowScene: windowScene)
|
||||
overlayWindow.tag = 0320
|
||||
overlayWindow.isHidden = false
|
||||
@@ -27,31 +27,41 @@ struct DarkModeWrapper<Content: View>: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: activateDarkMode, initial: true) { _, newValue in
|
||||
if let keyWindow = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) {
|
||||
keyWindow.overrideUserInterfaceStyle = newValue ? .dark : .light
|
||||
if let windowScene = activeWindowScene {
|
||||
let style: UIUserInterfaceStyle = newValue ? .dark : .light
|
||||
for window in windowScene.windows {
|
||||
window.overrideUserInterfaceStyle = style
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var activeWindowScene: UIWindowScene? {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
||||
}
|
||||
}
|
||||
|
||||
/// Theme toggle button with sun/moon icon and circular reveal animation.
|
||||
struct DarkModeButton: View {
|
||||
@State private var buttonRect: CGRect = .zero
|
||||
@AppStorage("rosetta_dark_mode") private var toggleDarkMode: Bool = true
|
||||
/// Local icon state — changes INSTANTLY on tap (no round-trip through UserDefaults).
|
||||
@State private var showMoonIcon: Bool = true
|
||||
@AppStorage("rosetta_dark_mode") private var activateDarkMode: Bool = true
|
||||
|
||||
var body: some View {
|
||||
Button(action: {
|
||||
toggleDarkMode.toggle()
|
||||
showMoonIcon.toggle()
|
||||
animateScheme()
|
||||
}, label: {
|
||||
Image(systemName: toggleDarkMode ? "moon.fill" : "sun.max.fill")
|
||||
Image(systemName: showMoonIcon ? "moon.fill" : "sun.max.fill")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.symbolEffect(.bounce, value: toggleDarkMode)
|
||||
.symbolEffect(.bounce, value: showMoonIcon)
|
||||
.frame(width: 44, height: 44)
|
||||
})
|
||||
.buttonStyle(.plain)
|
||||
.onAppear { showMoonIcon = activateDarkMode }
|
||||
.darkModeButtonRect { rect in
|
||||
buttonRect = rect
|
||||
}
|
||||
@@ -59,45 +69,52 @@ struct DarkModeButton: View {
|
||||
|
||||
@MainActor
|
||||
func animateScheme() {
|
||||
guard let windowScene = activeWindowScene,
|
||||
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
||||
let overlayWindow = windowScene.windows.first(where: { $0.tag == 0320 })
|
||||
else { return }
|
||||
|
||||
let targetDark = showMoonIcon
|
||||
let frameSize = window.frame.size
|
||||
|
||||
// 1. Capture old state SYNCHRONOUSLY — afterScreenUpdates:false is fast (~5ms).
|
||||
let previousImage = window.darkModeSnapshotFast(frameSize)
|
||||
|
||||
// 2. Show freeze frame IMMEDIATELY — user sees instant response.
|
||||
// Overlay stays non-interactive so button taps always pass through.
|
||||
let imageView = UIImageView(image: previousImage)
|
||||
imageView.frame = window.frame
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
overlayWindow.addSubview(imageView)
|
||||
|
||||
// 3. Switch theme underneath the freeze frame (invisible to user).
|
||||
activateDarkMode = targetDark
|
||||
|
||||
// 4. Capture new state asynchronously after layout settles.
|
||||
Task {
|
||||
if let windows = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows,
|
||||
let window = windows.first(where: { $0.isKeyWindow }),
|
||||
let overlayWindow = windows.first(where: { $0.tag == 0320 }) {
|
||||
try? await Task.sleep(for: .seconds(0.06))
|
||||
window.layoutIfNeeded()
|
||||
let currentImage = window.darkModeSnapshot(frameSize)
|
||||
|
||||
overlayWindow.isUserInteractionEnabled = true
|
||||
let imageView = UIImageView()
|
||||
imageView.frame = window.frame
|
||||
imageView.image = window.darkModeSnapshot(window.frame.size)
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
overlayWindow.addSubview(imageView)
|
||||
let swiftUIView = DarkModeOverlayView(
|
||||
buttonRect: buttonRect,
|
||||
previousImage: previousImage,
|
||||
currentImage: currentImage
|
||||
)
|
||||
|
||||
let frameSize = window.frame.size
|
||||
// Capture old state
|
||||
activateDarkMode = !toggleDarkMode
|
||||
let previousImage = window.darkModeSnapshot(frameSize)
|
||||
// Switch to new state
|
||||
activateDarkMode = toggleDarkMode
|
||||
// Allow layout to settle
|
||||
try await Task.sleep(for: .seconds(0.01))
|
||||
let currentImage = window.darkModeSnapshot(frameSize)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.01))
|
||||
|
||||
let swiftUIView = DarkModeOverlayView(
|
||||
buttonRect: buttonRect,
|
||||
previousImage: previousImage,
|
||||
currentImage: currentImage
|
||||
)
|
||||
|
||||
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.view.frame = window.frame
|
||||
hostingController.view.tag = 1009
|
||||
overlayWindow.addSubview(hostingController.view)
|
||||
imageView.removeFromSuperview()
|
||||
}
|
||||
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||
hostingController.view.backgroundColor = .clear
|
||||
hostingController.view.frame = window.frame
|
||||
hostingController.view.tag = 1009
|
||||
overlayWindow.addSubview(hostingController.view)
|
||||
imageView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
private var activeWindowScene: UIWindowScene? {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
return scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Button Rect Tracking
|
||||
@@ -175,7 +192,6 @@ private struct DarkModeOverlayView: View {
|
||||
for view in window.subviews {
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
window.isUserInteractionEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,10 +214,20 @@ private struct DarkModeOverlayView: View {
|
||||
// MARK: - UIView Snapshot
|
||||
|
||||
private extension UIView {
|
||||
/// Full-fidelity snapshot — waits for pending layout. Use for the NEW state.
|
||||
func darkModeSnapshot(_ size: CGSize) -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { _ in
|
||||
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast snapshot of CURRENT appearance — no layout wait (~5ms vs ~80ms).
|
||||
/// Use for the OLD state (already on screen, nothing pending).
|
||||
func darkModeSnapshotFast(_ size: CGSize) -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
return renderer.image { _ in
|
||||
drawHierarchy(in: .init(origin: .zero, size: size), afterScreenUpdates: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,11 +54,11 @@ private extension ConfirmSeedPhraseView {
|
||||
VStack(spacing: 12) {
|
||||
Text("Confirm Backup")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text("Enter words #2, #5, #9, #12 to confirm\nyou've backed up your phrase.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(3)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ private extension ConfirmSeedPhraseView {
|
||||
|
||||
TextField("enter", text: $confirmationInputs[inputIndex])
|
||||
.font(.system(size: 17, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focusedInputIndex, equals: inputIndex)
|
||||
|
||||
@@ -53,11 +53,11 @@ private extension ImportSeedPhraseView {
|
||||
VStack(spacing: 12) {
|
||||
Text("Import Account")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text("Enter your 12-word recovery phrase\nto restore your account.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(3)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ private extension ImportSeedPhraseView {
|
||||
|
||||
TextField("word", text: $importedWords[index])
|
||||
.font(.system(size: 17, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(hasContent ? color : .white)
|
||||
.foregroundStyle(hasContent ? color : RosettaColors.Adaptive.text)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.focused($focusedWordIndex, equals: index)
|
||||
|
||||
@@ -52,7 +52,7 @@ struct PasswordStrengthIndicator: View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3, id: \.self) { index in
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(index <= strength.rawValue ? strength.color : Color.white.opacity(0.1))
|
||||
.fill(index <= strength.rawValue ? strength.color : RosettaColors.Adaptive.text.opacity(0.1))
|
||||
.frame(height: 4)
|
||||
.animation(.easeInOut(duration: 0.25), value: strength)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ struct WeakPasswordWarning: View {
|
||||
|
||||
Text("Your password is too weak. Consider using at least 6 characters for better security.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
.padding(14)
|
||||
|
||||
@@ -48,13 +48,13 @@ private extension SeedPhraseView {
|
||||
VStack(spacing: 12) {
|
||||
Text("Your Recovery Phrase")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.opacity(isContentVisible ? 1.0 : 0.0)
|
||||
.animation(.easeOut(duration: 0.3), value: isContentVisible)
|
||||
|
||||
Text("Write down these 12 words in order.\nYou'll need them to restore your account.")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(3)
|
||||
.opacity(isContentVisible ? 1.0 : 0.0)
|
||||
|
||||
@@ -111,13 +111,13 @@ private extension SetPasswordView {
|
||||
VStack(spacing: 8) {
|
||||
Text(isImportMode ? "Recover Account" : "Protect Your Account")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
Text(isImportMode
|
||||
? "Set a password to protect your recovered account.\nYou'll need it to unlock Rosetta."
|
||||
: "This password encrypts your keys locally.\nYou'll need it to unlock Rosetta.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
@@ -178,7 +178,7 @@ private extension SetPasswordView {
|
||||
|
||||
Text("Your password is never stored or sent anywhere. It's only used to encrypt your keys locally.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.lineSpacing(2)
|
||||
}
|
||||
.padding(16)
|
||||
@@ -291,7 +291,7 @@ private struct SecureToggleField: UIViewRepresentable {
|
||||
|
||||
tf.isSecureTextEntry = true
|
||||
tf.font = .systemFont(ofSize: 16)
|
||||
tf.textColor = .white
|
||||
tf.textColor = .label
|
||||
tf.tintColor = UIColor(RosettaColors.primaryBlue)
|
||||
tf.autocapitalizationType = .none
|
||||
tf.autocorrectionType = .no
|
||||
@@ -318,7 +318,7 @@ private struct SecureToggleField: UIViewRepresentable {
|
||||
let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .thin)
|
||||
let eyeButton = UIButton(type: .system)
|
||||
eyeButton.setImage(UIImage(systemName: "eye", withConfiguration: config), for: .normal)
|
||||
eyeButton.tintColor = UIColor.white.withAlphaComponent(0.5)
|
||||
eyeButton.tintColor = UIColor.secondaryLabel
|
||||
eyeButton.addTarget(
|
||||
context.coordinator,
|
||||
action: #selector(Coordinator.toggleSecure),
|
||||
|
||||
@@ -80,7 +80,7 @@ private extension WelcomeView {
|
||||
var titleSection: some View {
|
||||
Text("Your Keys,\nYour Messages")
|
||||
.font(.system(size: 32, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.center)
|
||||
.opacity(isVisible ? 1.0 : 0.0)
|
||||
.offset(y: isVisible ? 0 : 16)
|
||||
@@ -90,7 +90,7 @@ private extension WelcomeView {
|
||||
var subtitleSection: some View {
|
||||
Text("Secure messaging with\ncryptographic keys")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.opacity(isVisible ? 1.0 : 0.0)
|
||||
.offset(y: isVisible ? 0 : 12)
|
||||
@@ -122,7 +122,7 @@ private extension WelcomeView {
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(label)
|
||||
|
||||
@@ -132,7 +132,7 @@ struct AttachmentPanelView: View {
|
||||
VStack(spacing: 0) {
|
||||
// Custom drag indicator (replaces system .presentationDragIndicator)
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.3))
|
||||
.fill(RosettaColors.Adaptive.text.opacity(0.3))
|
||||
.frame(width: 36, height: 5)
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 14)
|
||||
@@ -144,7 +144,7 @@ struct AttachmentPanelView: View {
|
||||
dismiss()
|
||||
} label: {
|
||||
CloseIconShape()
|
||||
.fill(.white)
|
||||
.fill(RosettaColors.Adaptive.text)
|
||||
.frame(width: 14, height: 14)
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
@@ -157,10 +157,10 @@ struct AttachmentPanelView: View {
|
||||
HStack(spacing: 5) {
|
||||
Text(tabTitle)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text.opacity(0.45))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -195,9 +195,9 @@ struct AttachmentPanelView: View {
|
||||
Text("\(selectedAssets.count)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(.white) // White on blue badge is always correct
|
||||
.frame(width: 44, height: 28)
|
||||
.background(Color(hex: 0x008BFF), in: Capsule())
|
||||
.background(RosettaColors.primaryBlue, in: Capsule())
|
||||
}
|
||||
|
||||
/// Glass circle background matching GlassBackButton (ButtonStyles.swift lines 22–34).
|
||||
@@ -233,12 +233,12 @@ struct AttachmentPanelView: View {
|
||||
// Title
|
||||
Text("Send Avatar")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
// Subtitle
|
||||
Text("Share your profile avatar\nwith this contact")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Send button (capsule style matching File tab's "Browse Files" button)
|
||||
@@ -253,10 +253,10 @@ struct AttachmentPanelView: View {
|
||||
} label: {
|
||||
Text(hasAvatar ? "Send Avatar" : "Set Avatar")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(.white) // White on blue button is always correct
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(hex: 0x008BFF), in: Capsule())
|
||||
.background(RosettaColors.primaryBlue, in: Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -284,12 +284,12 @@ struct AttachmentPanelView: View {
|
||||
// Title
|
||||
Text("Send File")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
|
||||
// Subtitle
|
||||
Text("Select a file to send")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Browse button (capsule style matching avatar tab)
|
||||
@@ -298,10 +298,10 @@ struct AttachmentPanelView: View {
|
||||
} label: {
|
||||
Text("Browse Files")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.foregroundStyle(.white) // White on blue button is always correct
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color(hex: 0x008BFF), in: Capsule())
|
||||
.background(RosettaColors.primaryBlue, in: Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -337,14 +337,14 @@ struct AttachmentPanelView: View {
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.25), value: hasSelection)
|
||||
.background {
|
||||
// iOS < 26: gradient fade behind tab bar (dark theme).
|
||||
// iOS < 26: gradient fade behind tab bar.
|
||||
// iOS 26+: no gradient — Liquid Glass pill is self-contained.
|
||||
if #unavailable(iOS 26) {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .black.opacity(0.6), location: 0.3),
|
||||
.init(color: .black, location: 0.8),
|
||||
.init(color: RosettaColors.Adaptive.surface.opacity(0.85), location: 0.3),
|
||||
.init(color: RosettaColors.Adaptive.surface, location: 0.8),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
@@ -364,8 +364,8 @@ struct AttachmentPanelView: View {
|
||||
// Caption text field
|
||||
TextField("Add a caption...", text: $captionText)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.white)
|
||||
.tint(Color(hex: 0x008BFF))
|
||||
.foregroundStyle(RosettaColors.Adaptive.text)
|
||||
.tint(RosettaColors.primaryBlue)
|
||||
.padding(.leading, 6)
|
||||
|
||||
// Emoji icon (exact ChatDetail match: TelegramVectorIcon emojiMoon)
|
||||
@@ -389,7 +389,7 @@ struct AttachmentPanelView: View {
|
||||
)
|
||||
.frame(width: 22, height: 19)
|
||||
.frame(width: 38, height: 36)
|
||||
.background { Capsule().fill(Color(hex: 0x008BFF)) }
|
||||
.background { Capsule().fill(RosettaColors.primaryBlue) }
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
@@ -457,7 +457,7 @@ struct AttachmentPanelView: View {
|
||||
|
||||
private func legacyTabButton(_ tab: AttachmentTab, icon: String, unselectedIcon: String, label: String) -> some View {
|
||||
let isSelected = selectedTab == tab
|
||||
let tint = isSelected ? Color(hex: 0x008BFF) : .white
|
||||
let tint = isSelected ? RosettaColors.primaryBlue : RosettaColors.Adaptive.text
|
||||
|
||||
return Button {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
|
||||
@@ -66,6 +66,10 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
// Mic button (glass circle, 42×42)
|
||||
private let micButton = UIButton(type: .system)
|
||||
private let micGlass = TelegramGlassUIView(frame: .zero)
|
||||
private var attachIconLayer: CAShapeLayer?
|
||||
private var emojiIconLayer: CAShapeLayer?
|
||||
private var sendIconLayer: CAShapeLayer?
|
||||
private var micIconLayer: CAShapeLayer?
|
||||
|
||||
// MARK: - Layout Constants
|
||||
|
||||
@@ -113,6 +117,7 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
color: .label
|
||||
)
|
||||
attachButton.layer.addSublayer(attachIcon)
|
||||
attachIconLayer = attachIcon
|
||||
attachButton.tag = 1 // for icon centering in layoutSubviews
|
||||
attachButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside)
|
||||
addSubview(attachButton)
|
||||
@@ -188,6 +193,7 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
color: .secondaryLabel
|
||||
)
|
||||
emojiButton.layer.addSublayer(emojiIcon)
|
||||
emojiIconLayer = emojiIcon
|
||||
emojiButton.tag = 2
|
||||
emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside)
|
||||
inputContainer.addSubview(emojiButton)
|
||||
@@ -204,6 +210,7 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
color: .white
|
||||
)
|
||||
sendButton.layer.addSublayer(sendIcon)
|
||||
sendIconLayer = sendIcon
|
||||
sendButton.tag = 3
|
||||
sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside)
|
||||
sendButton.alpha = 0
|
||||
@@ -221,9 +228,19 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
color: .label
|
||||
)
|
||||
micButton.layer.addSublayer(micIcon)
|
||||
micIconLayer = micIcon
|
||||
micButton.tag = 4
|
||||
micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside)
|
||||
addSubview(micButton)
|
||||
|
||||
updateThemeColors()
|
||||
|
||||
if #available(iOS 17.0, *) {
|
||||
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: ComposerView, previousTraitCollection: UITraitCollection) in
|
||||
guard self.traitCollection.userInterfaceStyle != previousTraitCollection.userInterfaceStyle else { return }
|
||||
self.updateThemeColors()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
@@ -426,6 +443,19 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateThemeColors() {
|
||||
attachIconLayer?.fillColor = UIColor.label.cgColor
|
||||
emojiIconLayer?.fillColor = UIColor.secondaryLabel.cgColor
|
||||
sendIconLayer?.fillColor = UIColor.white.cgColor
|
||||
micIconLayer?.fillColor = UIColor.label.cgColor
|
||||
|
||||
replyTitleLabel.textColor = .label
|
||||
replyPreviewLabel.textColor = .label
|
||||
replyCancelButton.tintColor = .secondaryLabel
|
||||
textView.textColor = .label
|
||||
textView.placeholderLabel.textColor = .placeholderText
|
||||
}
|
||||
|
||||
// MARK: - Text Height
|
||||
|
||||
private func recalculateTextHeight() {
|
||||
@@ -498,6 +528,12 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||
if #available(iOS 17.0, *) { return }
|
||||
guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return }
|
||||
updateThemeColors()
|
||||
}
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
|
||||
func textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
|
||||
|
||||
@@ -66,13 +66,46 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
ctx.fillPath()
|
||||
let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
|
||||
UIGraphicsEndImageContext()
|
||||
return image.withRenderingMode(.alwaysOriginal)
|
||||
return image.withRenderingMode(.alwaysTemplate)
|
||||
}()
|
||||
// Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail)
|
||||
private static let bubbleImages = BubbleImageFactory.generate(
|
||||
// Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail).
|
||||
// `var` so they can be regenerated on theme switch (colors baked into raster at generation time).
|
||||
private static var bubbleImages = BubbleImageFactory.generate(
|
||||
outgoingColor: outgoingColor,
|
||||
incomingColor: incomingColor
|
||||
)
|
||||
private static var bubbleImagesStyle: UIUserInterfaceStyle = .unspecified
|
||||
|
||||
/// Regenerate cached bubble images after theme change.
|
||||
/// Must be called on main thread. `performAsCurrent` ensures dynamic
|
||||
/// `incomingColor` resolves with the correct light/dark traits.
|
||||
static func regenerateBubbleImages(with traitCollection: UITraitCollection) {
|
||||
traitCollection.performAsCurrent {
|
||||
bubbleImages = BubbleImageFactory.generate(
|
||||
outgoingColor: outgoingColor,
|
||||
incomingColor: incomingColor
|
||||
)
|
||||
}
|
||||
bubbleImagesStyle = normalizedInterfaceStyle(from: traitCollection)
|
||||
}
|
||||
|
||||
/// Ensure bubble image cache matches the current interface style.
|
||||
/// Covers cases where the theme was changed while chat screen was not mounted.
|
||||
static func ensureBubbleImages(for traitCollection: UITraitCollection) {
|
||||
let style = normalizedInterfaceStyle(from: traitCollection)
|
||||
guard bubbleImagesStyle != style else { return }
|
||||
regenerateBubbleImages(with: traitCollection)
|
||||
}
|
||||
|
||||
private static func normalizedInterfaceStyle(from traitCollection: UITraitCollection) -> UIUserInterfaceStyle {
|
||||
switch traitCollection.userInterfaceStyle {
|
||||
case .dark, .light:
|
||||
return traitCollection.userInterfaceStyle
|
||||
default:
|
||||
let prefersDark = UserDefaults.standard.object(forKey: "rosetta_dark_mode") as? Bool ?? true
|
||||
return prefersDark ? .dark : .light
|
||||
}
|
||||
}
|
||||
private static let blurHashCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 200
|
||||
@@ -456,13 +489,20 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
bubbleView.addSubview(highlightOverlay)
|
||||
|
||||
// Swipe reply icon — circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
|
||||
replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
replyCircleView.backgroundColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor.white.withAlphaComponent(0.12)
|
||||
: UIColor.black.withAlphaComponent(0.12)
|
||||
}
|
||||
replyCircleView.layer.cornerRadius = 17 // 34pt / 2
|
||||
replyCircleView.alpha = 0
|
||||
contentView.addSubview(replyCircleView)
|
||||
|
||||
replyIconView.image = Self.telegramReplyArrowImage
|
||||
replyIconView.contentMode = .scaleAspectFit
|
||||
replyIconView.tintColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? .white : .black
|
||||
}
|
||||
replyIconView.alpha = 0
|
||||
contentView.addSubview(replyIconView)
|
||||
|
||||
@@ -513,16 +553,18 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
// Same CTTypesetter pipeline → identical line breaks, zero recomputation.
|
||||
textLabel.textLayout = textLayout
|
||||
|
||||
// Timestamp
|
||||
// Timestamp — dynamic UIColor for incoming so theme changes resolve instantly
|
||||
timestampLabel.text = timestamp
|
||||
if isMediaStatus {
|
||||
timestampLabel.textColor = .white
|
||||
} else if isOutgoing {
|
||||
timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55)
|
||||
} else {
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
let tsAlpha: CGFloat = isOutgoing ? 0.55 : 0.6
|
||||
timestampLabel.textColor = (isOutgoing || isDark)
|
||||
? UIColor.white.withAlphaComponent(tsAlpha)
|
||||
: UIColor.black.withAlphaComponent(0.45)
|
||||
timestampLabel.textColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor.white.withAlphaComponent(0.6)
|
||||
: UIColor.black.withAlphaComponent(0.45)
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery checkmarks (Telegram two-node pattern: checkSent + checkRead)
|
||||
@@ -566,7 +608,6 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
// Reply quote — Telegram parity colors
|
||||
if let replyName {
|
||||
replyContainer.isHidden = false
|
||||
let isDark = traitCollection.userInterfaceStyle == .dark
|
||||
replyContainer.backgroundColor = isOutgoing
|
||||
? UIColor.white.withAlphaComponent(0.12)
|
||||
: Self.outgoingColor.withAlphaComponent(0.12)
|
||||
@@ -574,7 +615,11 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
replyNameLabel.text = replyName
|
||||
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
|
||||
replyTextLabel.text = replyText ?? ""
|
||||
replyTextLabel.textColor = (isOutgoing || isDark) ? .white : .darkGray
|
||||
replyTextLabel.textColor = isOutgoing
|
||||
? .white
|
||||
: UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? .white : .darkGray
|
||||
}
|
||||
} else {
|
||||
replyContainer.isHidden = true
|
||||
}
|
||||
@@ -786,6 +831,7 @@ final class NativeMessageCell: UICollectionViewCell {
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard let layout = currentLayout else { return }
|
||||
Self.ensureBubbleImages(for: traitCollection)
|
||||
|
||||
let cellW = contentView.bounds.width
|
||||
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
|
||||
|
||||
@@ -183,6 +183,15 @@ final class NativeMessageListController: UIViewController {
|
||||
self, selector: #selector(handleAvatarDidUpdate),
|
||||
name: Notification.Name("avatarDidUpdate"), object: nil
|
||||
)
|
||||
// Regenerate bubble images + full cell refresh on theme switch.
|
||||
registerForTraitChanges([UITraitUserInterfaceStyle.self]) { (self: NativeMessageListController, previousTraitCollection: UITraitCollection) in
|
||||
let oldStyle = previousTraitCollection.userInterfaceStyle
|
||||
let newStyle = self.traitCollection.userInterfaceStyle
|
||||
guard oldStyle != newStyle else { return }
|
||||
NativeMessageCell.regenerateBubbleImages(with: self.traitCollection)
|
||||
self.calculateLayouts()
|
||||
self.refreshAllMessageCells()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleAvatarDidUpdate() {
|
||||
@@ -483,6 +492,9 @@ final class NativeMessageListController: UIViewController {
|
||||
let imageView = UIImageView(image: Self.makeTelegramDownButtonImage())
|
||||
imageView.contentMode = .center
|
||||
imageView.frame = rect
|
||||
imageView.tintColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? .white : .black
|
||||
}
|
||||
button.addSubview(imageView)
|
||||
|
||||
let badgeView = UIView(frame: .zero)
|
||||
@@ -585,7 +597,7 @@ final class NativeMessageListController: UIViewController {
|
||||
gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center
|
||||
gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right
|
||||
gc.strokePath()
|
||||
}.withRenderingMode(.alwaysOriginal)
|
||||
}.withRenderingMode(.alwaysTemplate)
|
||||
}
|
||||
|
||||
private static func compactUnreadCountString(_ count: Int) -> String {
|
||||
@@ -614,7 +626,9 @@ final class NativeMessageListController: UIViewController {
|
||||
c.addSubview(glass)
|
||||
let l = UILabel()
|
||||
l.font = UIFont.systemFont(ofSize: 12, weight: .medium)
|
||||
l.textColor = .white
|
||||
l.textColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? .white : .black
|
||||
}
|
||||
l.textAlignment = .center
|
||||
c.addSubview(l)
|
||||
return (c, l)
|
||||
@@ -1071,6 +1085,18 @@ final class NativeMessageListController: UIViewController {
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
/// Reconfigure all message cells and force a layout pass.
|
||||
/// Used for deterministic theme refresh when trait style changes.
|
||||
private func refreshAllMessageCells() {
|
||||
var snapshot = dataSource.snapshot()
|
||||
let allIds = snapshot.itemIdentifiers
|
||||
guard !allIds.isEmpty else { return }
|
||||
snapshot.reconfigureItems(allIds)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.layoutIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Bubble Position
|
||||
|
||||
private func bubblePosition(for message: ChatMessage, at reversedIndex: Int) -> BubblePosition {
|
||||
|
||||
@@ -18,13 +18,25 @@ final class TelegramContextMenuCardView: UIView {
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
|
||||
|
||||
// MARK: - Colors (Telegram dark theme)
|
||||
// MARK: - Colors (adaptive light/dark)
|
||||
|
||||
private static let tintBg = UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78)
|
||||
private static let textColor = UIColor.white
|
||||
private static let tintBg = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78)
|
||||
: UIColor(red: 0xF5/255, green: 0xF5/255, blue: 0xF7/255, alpha: 0.78)
|
||||
}
|
||||
private static let textColor = UIColor.label
|
||||
private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1)
|
||||
private static let separatorColor = UIColor(white: 1, alpha: 0.15)
|
||||
private static let highlightColor = UIColor(white: 1, alpha: 0.15)
|
||||
private static let separatorColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(white: 1, alpha: 0.15)
|
||||
: UIColor(white: 0, alpha: 0.1)
|
||||
}
|
||||
private static let highlightColor = UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark
|
||||
? UIColor(white: 1, alpha: 0.15)
|
||||
: UIColor(white: 0, alpha: 0.08)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
@@ -33,7 +45,7 @@ final class TelegramContextMenuCardView: UIView {
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
|
||||
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||
private let tintView = UIView()
|
||||
private let items: [TelegramContextMenuItem]
|
||||
|
||||
|
||||
@@ -530,12 +530,13 @@ extension AppDelegate: PKPushRegistryDelegate {
|
||||
}
|
||||
let data = payload.dictionaryPayload
|
||||
Logger.voip.info("VoIP push received: \(data.description, privacy: .public)")
|
||||
// Server sends: { "type": "CALL", "from": "<pubkey>", "callId": "<uuid>" }
|
||||
// Server sends: { "type": "CALL", "from": "<pubkey>", "callId": "<uuid>", "joinToken": "<token>" }
|
||||
// Fallback to "dialog" for backward compat with older server versions.
|
||||
let callerKey = data["from"] as? String
|
||||
?? data["dialog"] as? String
|
||||
?? ""
|
||||
let callId = data["callId"] as? String
|
||||
let joinToken = data["joinToken"] as? String
|
||||
// Resolve caller display name from multiple sources.
|
||||
let callerName: String = {
|
||||
// 1. Push payload (if server sends title)
|
||||
@@ -554,7 +555,7 @@ extension AppDelegate: PKPushRegistryDelegate {
|
||||
}
|
||||
return "Rosetta"
|
||||
}()
|
||||
Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public)")
|
||||
Logger.voip.info("VoIP resolved: key=\(callerKey.prefix(16), privacy: .public) name=\(callerName, privacy: .public) callId=\(callId ?? "nil", privacy: .public) joinTokenPresent=\((joinToken?.isEmpty == false).description, privacy: .public)")
|
||||
|
||||
// Apple REQUIREMENT: reportNewIncomingCall MUST be called SYNCHRONOUSLY.
|
||||
// Using Task { @MainActor } would introduce an async hop that may be
|
||||
@@ -597,7 +598,9 @@ extension AppDelegate: PKPushRegistryDelegate {
|
||||
}
|
||||
CallManager.shared.setupIncomingCallFromPush(
|
||||
callerKey: callerKey,
|
||||
callerName: callerName
|
||||
callerName: callerName,
|
||||
callId: callId,
|
||||
joinToken: joinToken
|
||||
)
|
||||
}
|
||||
|
||||
@@ -671,7 +674,6 @@ struct RosettaApp: App {
|
||||
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
|
||||
@AppStorage("isLoggedIn") private var isLoggedIn = false
|
||||
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
|
||||
@AppStorage("rosetta_dark_mode") private var isDarkMode: Bool = true
|
||||
@State private var appState: AppState?
|
||||
@State private var transitionOverlay: Bool = false
|
||||
|
||||
@@ -695,7 +697,11 @@ struct RosettaApp: App {
|
||||
.animation(.easeInOut(duration: 0.035), value: transitionOverlay)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(isDarkMode ? .dark : .light)
|
||||
// NOTE: preferredColorScheme removed — DarkModeWrapper is the single
|
||||
// source of truth via window.overrideUserInterfaceStyle. Having both
|
||||
// caused snapshot races where the hosting controller's stale
|
||||
// preferredColorScheme(.dark) blocked the window's .light override,
|
||||
// making dark→light circular reveal animation invisible.
|
||||
.onAppear {
|
||||
if appState == nil {
|
||||
appState = initialState()
|
||||
|
||||
@@ -2,12 +2,23 @@ import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
final class CallPacketParityTests: XCTestCase {
|
||||
func testSignalPeerRoundTripForCallKeyExchangeAndCreateRoom() throws {
|
||||
func testSignalPeerRoundTripForLegacyCallAcceptEndCallAndKeyExchange() throws {
|
||||
let call = PacketSignalPeer(
|
||||
src: "02caller",
|
||||
dst: "02callee",
|
||||
sharedPublic: "",
|
||||
signalType: .call,
|
||||
callId: "call-123",
|
||||
joinToken: "join-123",
|
||||
roomId: ""
|
||||
)
|
||||
let accept = PacketSignalPeer(
|
||||
src: "02callee",
|
||||
dst: "02caller",
|
||||
sharedPublic: "",
|
||||
signalType: .accept,
|
||||
callId: "call-123",
|
||||
joinToken: "join-123",
|
||||
roomId: ""
|
||||
)
|
||||
let keyExchange = PacketSignalPeer(
|
||||
@@ -15,14 +26,18 @@ final class CallPacketParityTests: XCTestCase {
|
||||
dst: "02caller",
|
||||
sharedPublic: "abcdef012345",
|
||||
signalType: .keyExchange,
|
||||
callId: "",
|
||||
joinToken: "",
|
||||
roomId: ""
|
||||
)
|
||||
let createRoom = PacketSignalPeer(
|
||||
let endCall = PacketSignalPeer(
|
||||
src: "02caller",
|
||||
dst: "02callee",
|
||||
sharedPublic: "",
|
||||
signalType: .createRoom,
|
||||
roomId: "room-42"
|
||||
signalType: .endCall,
|
||||
callId: "call-123",
|
||||
joinToken: "join-123",
|
||||
roomId: ""
|
||||
)
|
||||
|
||||
let decodedCall = try decodeSignal(call)
|
||||
@@ -30,29 +45,67 @@ final class CallPacketParityTests: XCTestCase {
|
||||
XCTAssertEqual(decodedCall.src, "02caller")
|
||||
XCTAssertEqual(decodedCall.dst, "02callee")
|
||||
XCTAssertEqual(decodedCall.sharedPublic, "")
|
||||
XCTAssertEqual(decodedCall.callId, "call-123")
|
||||
XCTAssertEqual(decodedCall.joinToken, "join-123")
|
||||
XCTAssertEqual(decodedCall.roomId, "")
|
||||
|
||||
let decodedAccept = try decodeSignal(accept)
|
||||
XCTAssertEqual(decodedAccept.signalType, .accept)
|
||||
XCTAssertEqual(decodedAccept.callId, "call-123")
|
||||
XCTAssertEqual(decodedAccept.joinToken, "join-123")
|
||||
|
||||
let decodedKeyExchange = try decodeSignal(keyExchange)
|
||||
XCTAssertEqual(decodedKeyExchange.signalType, .keyExchange)
|
||||
XCTAssertEqual(decodedKeyExchange.src, "02callee")
|
||||
XCTAssertEqual(decodedKeyExchange.dst, "02caller")
|
||||
XCTAssertEqual(decodedKeyExchange.sharedPublic, "abcdef012345")
|
||||
XCTAssertEqual(decodedKeyExchange.callId, "")
|
||||
XCTAssertEqual(decodedKeyExchange.joinToken, "")
|
||||
XCTAssertEqual(decodedKeyExchange.roomId, "")
|
||||
|
||||
let decodedEndCall = try decodeSignal(endCall)
|
||||
XCTAssertEqual(decodedEndCall.signalType, .endCall)
|
||||
XCTAssertEqual(decodedEndCall.callId, "call-123")
|
||||
XCTAssertEqual(decodedEndCall.joinToken, "join-123")
|
||||
}
|
||||
|
||||
func testSignalCodeFourRoundTripForLegacyActiveAndCreateRoomFallback() throws {
|
||||
let legacyActive = PacketSignalPeer(
|
||||
src: "02caller",
|
||||
dst: "02callee",
|
||||
sharedPublic: "",
|
||||
signalType: .createRoom,
|
||||
callId: "",
|
||||
joinToken: "",
|
||||
roomId: ""
|
||||
)
|
||||
let createRoom = PacketSignalPeer(
|
||||
src: "02caller",
|
||||
dst: "02callee",
|
||||
sharedPublic: "",
|
||||
signalType: .createRoom,
|
||||
callId: "",
|
||||
joinToken: "",
|
||||
roomId: "room-42"
|
||||
)
|
||||
|
||||
let decodedLegacyActive = try decodeSignal(legacyActive)
|
||||
XCTAssertEqual(decodedLegacyActive.signalType, .createRoom)
|
||||
XCTAssertEqual(decodedLegacyActive.roomId, "")
|
||||
|
||||
let decodedCreateRoom = try decodeSignal(createRoom)
|
||||
XCTAssertEqual(decodedCreateRoom.signalType, .createRoom)
|
||||
XCTAssertEqual(decodedCreateRoom.src, "02caller")
|
||||
XCTAssertEqual(decodedCreateRoom.dst, "02callee")
|
||||
XCTAssertEqual(decodedCreateRoom.sharedPublic, "")
|
||||
XCTAssertEqual(decodedCreateRoom.roomId, "room-42")
|
||||
}
|
||||
|
||||
func testSignalPeerRoundTripForBusyAndPeerDisconnectedShortFormat() throws {
|
||||
func testSignalPeerRoundTripForBusyPeerDisconnectedAndRingingTimeoutShortFormat() throws {
|
||||
let busy = PacketSignalPeer(
|
||||
src: "02should-not-be-sent",
|
||||
dst: "02should-not-be-sent",
|
||||
sharedPublic: "ignored",
|
||||
signalType: .endCallBecauseBusy,
|
||||
callId: "ignored",
|
||||
joinToken: "ignored",
|
||||
roomId: "ignored-room"
|
||||
)
|
||||
let disconnected = PacketSignalPeer(
|
||||
@@ -60,6 +113,17 @@ final class CallPacketParityTests: XCTestCase {
|
||||
dst: "02should-not-be-sent",
|
||||
sharedPublic: "ignored",
|
||||
signalType: .endCallBecausePeerDisconnected,
|
||||
callId: "ignored",
|
||||
joinToken: "ignored",
|
||||
roomId: "ignored-room"
|
||||
)
|
||||
let ringingTimeout = PacketSignalPeer(
|
||||
src: "02should-not-be-sent",
|
||||
dst: "02should-not-be-sent",
|
||||
sharedPublic: "ignored",
|
||||
signalType: .ringingTimeout,
|
||||
callId: "ignored",
|
||||
joinToken: "ignored",
|
||||
roomId: "ignored-room"
|
||||
)
|
||||
|
||||
@@ -68,6 +132,8 @@ final class CallPacketParityTests: XCTestCase {
|
||||
XCTAssertEqual(decodedBusy.src, "")
|
||||
XCTAssertEqual(decodedBusy.dst, "")
|
||||
XCTAssertEqual(decodedBusy.sharedPublic, "")
|
||||
XCTAssertEqual(decodedBusy.callId, "")
|
||||
XCTAssertEqual(decodedBusy.joinToken, "")
|
||||
XCTAssertEqual(decodedBusy.roomId, "")
|
||||
|
||||
let decodedDisconnected = try decodeSignal(disconnected)
|
||||
@@ -75,7 +141,18 @@ final class CallPacketParityTests: XCTestCase {
|
||||
XCTAssertEqual(decodedDisconnected.src, "")
|
||||
XCTAssertEqual(decodedDisconnected.dst, "")
|
||||
XCTAssertEqual(decodedDisconnected.sharedPublic, "")
|
||||
XCTAssertEqual(decodedDisconnected.callId, "")
|
||||
XCTAssertEqual(decodedDisconnected.joinToken, "")
|
||||
XCTAssertEqual(decodedDisconnected.roomId, "")
|
||||
|
||||
let decodedTimeout = try decodeSignal(ringingTimeout)
|
||||
XCTAssertEqual(decodedTimeout.signalType, .ringingTimeout)
|
||||
XCTAssertEqual(decodedTimeout.src, "")
|
||||
XCTAssertEqual(decodedTimeout.dst, "")
|
||||
XCTAssertEqual(decodedTimeout.sharedPublic, "")
|
||||
XCTAssertEqual(decodedTimeout.callId, "")
|
||||
XCTAssertEqual(decodedTimeout.joinToken, "")
|
||||
XCTAssertEqual(decodedTimeout.roomId, "")
|
||||
}
|
||||
|
||||
func testWebRtcRoundTripForOfferAnswerAndIceCandidate() throws {
|
||||
|
||||
@@ -97,12 +97,14 @@ struct SignalPeerCallFlowTests {
|
||||
let caller = "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
|
||||
let callee = "03f0e1d2c3b4a5968778695a4b3c2d1e0f9e8d7c6b5a49382716051a2b3c4d5e6f"
|
||||
let packet = PacketSignalPeer(src: caller, dst: callee, sharedPublic: "",
|
||||
signalType: .call, roomId: "")
|
||||
signalType: .call, callId: "call-1", joinToken: "join-1", roomId: "")
|
||||
let decoded = try decode(packet)
|
||||
#expect(decoded.signalType == .call)
|
||||
#expect(decoded.src == caller)
|
||||
#expect(decoded.dst == callee)
|
||||
#expect(decoded.sharedPublic == "")
|
||||
#expect(decoded.callId == "call-1")
|
||||
#expect(decoded.joinToken == "join-1")
|
||||
#expect(decoded.roomId == "")
|
||||
}
|
||||
|
||||
@@ -153,6 +155,19 @@ struct SignalPeerCallFlowTests {
|
||||
#expect(decoded.signalType == .endCallBecausePeerDisconnected)
|
||||
}
|
||||
|
||||
@Test("RINGING_TIMEOUT short format — 3 bytes wire size")
|
||||
func ringingTimeoutShortFormat() throws {
|
||||
let packet = PacketSignalPeer(src: "ignored", dst: "ignored", sharedPublic: "ignored",
|
||||
signalType: .ringingTimeout, roomId: "ignored")
|
||||
let data = PacketRegistry.encode(packet)
|
||||
#expect(data.count == 3)
|
||||
|
||||
let decoded = try decode(packet)
|
||||
#expect(decoded.signalType == .ringingTimeout)
|
||||
#expect(decoded.src == "")
|
||||
#expect(decoded.dst == "")
|
||||
}
|
||||
|
||||
private func decode(_ packet: PacketSignalPeer) throws -> PacketSignalPeer {
|
||||
let data = PacketRegistry.encode(packet)
|
||||
guard let result = PacketRegistry.decode(from: data),
|
||||
@@ -171,7 +186,8 @@ struct CallPushEnumParityTests {
|
||||
arguments: [
|
||||
(SignalType.call, 0), (SignalType.keyExchange, 1), (SignalType.activeCall, 2),
|
||||
(SignalType.endCall, 3), (SignalType.createRoom, 4),
|
||||
(SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6)
|
||||
(SignalType.endCallBecausePeerDisconnected, 5), (SignalType.endCallBecauseBusy, 6),
|
||||
(SignalType.accept, 7), (SignalType.ringingTimeout, 8)
|
||||
])
|
||||
func signalTypeEnumValues(pair: (SignalType, Int)) {
|
||||
#expect(pair.0.rawValue == pair.1)
|
||||
@@ -222,12 +238,12 @@ struct CallPushWireFormatTests {
|
||||
#expect(data[14] == 0x00); #expect(data[15] == 0x42)
|
||||
}
|
||||
|
||||
@Test("SignalPeer call byte layout: signalType→src→dst")
|
||||
@Test("SignalPeer call byte layout: signalType→src→dst→callId→joinToken")
|
||||
func signalPeerCallByteLayout() {
|
||||
let packet = PacketSignalPeer(src: "S", dst: "D", sharedPublic: "",
|
||||
signalType: .call, roomId: "")
|
||||
let data = PacketRegistry.encode(packet)
|
||||
#expect(data.count == 15)
|
||||
#expect(data.count == 23)
|
||||
|
||||
// packetId = 0x001A
|
||||
#expect(data[0] == 0x00); #expect(data[1] == 0x1A)
|
||||
@@ -241,6 +257,12 @@ struct CallPushWireFormatTests {
|
||||
#expect(data[9] == 0x00); #expect(data[10] == 0x00)
|
||||
#expect(data[11] == 0x00); #expect(data[12] == 0x01)
|
||||
#expect(data[13] == 0x00); #expect(data[14] == 0x44)
|
||||
// callId "": length=0
|
||||
#expect(data[15] == 0x00); #expect(data[16] == 0x00)
|
||||
#expect(data[17] == 0x00); #expect(data[18] == 0x00)
|
||||
// joinToken "": length=0
|
||||
#expect(data[19] == 0x00); #expect(data[20] == 0x00)
|
||||
#expect(data[21] == 0x00); #expect(data[22] == 0x00)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CryptoKit
|
||||
import XCTest
|
||||
@testable import Rosetta
|
||||
|
||||
@@ -101,4 +102,127 @@ final class CallRoutingTests: XCTestCase {
|
||||
XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerA)
|
||||
XCTAssertEqual(CallManager.shared.uiState.statusText, "Calling...")
|
||||
}
|
||||
|
||||
func testLegacyOutgoingFlowCallAcceptKeyExchangeActive() {
|
||||
let start = CallManager.shared.startOutgoingCall(
|
||||
toPublicKey: peerA,
|
||||
title: "Peer A",
|
||||
username: "peer_a"
|
||||
)
|
||||
XCTAssertEqual(start, .started)
|
||||
|
||||
let accept = PacketSignalPeer(
|
||||
src: peerA,
|
||||
dst: ownKey,
|
||||
sharedPublic: "",
|
||||
signalType: .accept,
|
||||
callId: "call-legacy-1",
|
||||
joinToken: "join-legacy-1",
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(accept)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.signalingMode, .legacy)
|
||||
XCTAssertEqual(CallManager.shared.callId, "call-legacy-1")
|
||||
XCTAssertEqual(CallManager.shared.joinToken, "join-legacy-1")
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
|
||||
|
||||
let keyExchange = PacketSignalPeer(
|
||||
src: peerA,
|
||||
dst: ownKey,
|
||||
sharedPublic: makePeerPublicHex(),
|
||||
signalType: .keyExchange,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(keyExchange)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
|
||||
|
||||
// Legacy ACTIVE arrives as signal code 4 with empty roomId.
|
||||
let active = PacketSignalPeer(
|
||||
src: peerA,
|
||||
dst: ownKey,
|
||||
sharedPublic: "",
|
||||
signalType: .createRoom,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(active)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange)
|
||||
}
|
||||
|
||||
func testCreateRoomFallbackOutgoingFlowKeyExchangeBeforeAccept() {
|
||||
let start = CallManager.shared.startOutgoingCall(
|
||||
toPublicKey: peerA,
|
||||
title: "Peer A",
|
||||
username: "peer_a"
|
||||
)
|
||||
XCTAssertEqual(start, .started)
|
||||
|
||||
let keyExchange = PacketSignalPeer(
|
||||
src: peerA,
|
||||
dst: ownKey,
|
||||
sharedPublic: makePeerPublicHex(),
|
||||
signalType: .keyExchange,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(keyExchange)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.signalingMode, .createRoom)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .webRtcExchange)
|
||||
}
|
||||
|
||||
func testDeferredAcceptCompletesWhenMetadataArrivesLater() {
|
||||
CallManager.shared.setupIncomingCallFromPush(
|
||||
callerKey: peerA,
|
||||
callerName: "Peer A"
|
||||
)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .incoming)
|
||||
|
||||
let acceptResult = CallManager.shared.acceptIncomingCall()
|
||||
XCTAssertEqual(acceptResult, .started)
|
||||
XCTAssertTrue(CallManager.shared.pendingIncomingAccept)
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
|
||||
XCTAssertEqual(CallManager.shared.signalingMode, .undecided)
|
||||
|
||||
let delayedCall = PacketSignalPeer(
|
||||
src: peerA,
|
||||
dst: ownKey,
|
||||
sharedPublic: "",
|
||||
signalType: .call,
|
||||
callId: "call-delayed-1",
|
||||
joinToken: "join-delayed-1",
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(delayedCall)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.signalingMode, .legacy)
|
||||
XCTAssertFalse(CallManager.shared.pendingIncomingAccept)
|
||||
XCTAssertEqual(CallManager.shared.callId, "call-delayed-1")
|
||||
XCTAssertEqual(CallManager.shared.joinToken, "join-delayed-1")
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .keyExchange)
|
||||
}
|
||||
|
||||
func testRingingTimeoutSignalTearsDownCall() {
|
||||
let start = CallManager.shared.startOutgoingCall(
|
||||
toPublicKey: peerA,
|
||||
title: "Peer A",
|
||||
username: "peer_a"
|
||||
)
|
||||
XCTAssertEqual(start, .started)
|
||||
|
||||
let timeoutPacket = PacketSignalPeer(
|
||||
src: "",
|
||||
dst: "",
|
||||
sharedPublic: "",
|
||||
signalType: .ringingTimeout,
|
||||
roomId: ""
|
||||
)
|
||||
CallManager.shared.testHandleSignalPacket(timeoutPacket)
|
||||
|
||||
XCTAssertEqual(CallManager.shared.uiState.phase, .idle)
|
||||
XCTAssertEqual(CallManager.shared.uiState.statusText, "No answer")
|
||||
}
|
||||
|
||||
private func makePeerPublicHex() -> String {
|
||||
Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation.hexString
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user