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