diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 9632052..3b1f35f 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; }; DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.swift */; }; + E20000032F8D11110092AD05 /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = E20000022F8D11110092AD05 /* WebRTC */; }; EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */; }; F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; }; F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; }; @@ -92,6 +93,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E20000032F8D11110092AD05 /* WebRTC in Frameworks */, 85E887F72F6DC9460032774C /* GRDB in Frameworks */, 853F29992F4B63D20092AD05 /* Lottie in Frameworks */, 853F29A02F4B63D20092AD05 /* P256K in Frameworks */, @@ -217,6 +219,7 @@ F1A000042F6F00010092AD05 /* FirebaseMessaging */, F1A000072F6F00010092AD05 /* FirebaseCrashlytics */, D1DB00022F8C00010092AD05 /* GRDB */, + E20000022F8D11110092AD05 /* WebRTC */, ); productName = Rosetta; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; @@ -271,6 +274,7 @@ 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */, + E20000012F8D11110092AD05 /* XCRemoteSwiftPackageReference "WebRTC" */, ); preferredProjectObjectVersion = 77; productRefGroup = 853F29632F4B50410092AD05 /* Products */; @@ -712,6 +716,14 @@ minimumVersion = 7.0.0; }; }; + E20000012F8D11110092AD05 /* XCRemoteSwiftPackageReference "WebRTC" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/stasel/WebRTC.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 146.0.0; + }; + }; F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; @@ -738,6 +750,11 @@ package = D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */; productName = GRDB; }; + E20000022F8D11110092AD05 /* WebRTC */ = { + isa = XCSwiftPackageProductDependency; + package = E20000012F8D11110092AD05 /* XCRemoteSwiftPackageReference "WebRTC" */; + productName = WebRTC; + }; F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */ = { isa = XCSwiftPackageProductDependency; package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; diff --git a/Rosetta/Core/Crypto/CallMediaCrypto.swift b/Rosetta/Core/Crypto/CallMediaCrypto.swift new file mode 100644 index 0000000..4c3a7dd --- /dev/null +++ b/Rosetta/Core/Crypto/CallMediaCrypto.swift @@ -0,0 +1,76 @@ +import CryptoKit +import Foundation + +/// Call media E2EE helpers. +/// +/// - Shared key parity with desktop/android: `X25519 -> HSalsa20(zeros16, rawDh32)`. +/// - Frame crypto parity: `XChaCha20 xor(frame, nonce, key)` where nonce is +/// derived from frame additional-data timestamp. +enum CallMediaCrypto { + + static let keyLength = 32 + static let nonceLength = 24 + + static func deriveSharedKey( + localPrivateKey: Curve25519.KeyAgreement.PrivateKey, + peerPublicHex: String + ) -> Data? { + let peerRaw = Data(hexString: peerPublicHex) + guard peerRaw.count == keyLength else { return nil } + guard let peerPublicKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: peerRaw) else { + return nil + } + + guard let rawDh = try? localPrivateKey.sharedSecretFromKeyAgreement(with: peerPublicKey) else { + return nil + } + + let rawDhBytes = rawDh.withUnsafeBytes { Data($0) } + guard rawDhBytes.count == keyLength else { return nil } + + guard let hsalsa = NativeCryptoBridge.naclSharedKey(fromRawDH: rawDhBytes) else { + return nil + } + guard hsalsa.count == keyLength else { return nil } + return hsalsa + } + + static func xorFrame( + _ frame: Data, + key: Data, + additionalData: Data? + ) -> Data? { + guard key.count >= keyLength else { return nil } + let nonce = nonceFromAdditionalData(additionalData) + let key32 = Data(key.prefix(keyLength)) + return NativeCryptoBridge.xChaCha20Xor(frame, key: key32, nonce: nonce) + } + + /// Desktop/audio parity: + /// - preferred path: 8-byte big-endian timestamp payload. + /// - fallback path: RTP header (timestamp bytes 4...7). + private static func nonceFromAdditionalData(_ additionalData: Data?) -> Data { + var nonce = Data(repeating: 0, count: nonceLength) + guard let additionalData, !additionalData.isEmpty else { return nonce } + + if additionalData.count == 8 { + nonce.replaceSubrange(0..<8, with: additionalData) + return nonce + } + + if additionalData.count >= 12 { + let version = (additionalData[additionalData.startIndex] >> 6) & 0x03 + if version == 2 { + let timestamp = additionalData[(additionalData.startIndex + 4)..<(additionalData.startIndex + 8)] + nonce.replaceSubrange(4..<8, with: timestamp) + return nonce + } + } + + if additionalData.count >= 8 { + nonce.replaceSubrange(0..<8, with: additionalData.prefix(8)) + } + + return nonce + } +} diff --git a/Rosetta/Core/Crypto/NativeCryptoBridge.h b/Rosetta/Core/Crypto/NativeCryptoBridge.h index 8df5f8e..317d271 100644 --- a/Rosetta/Core/Crypto/NativeCryptoBridge.h +++ b/Rosetta/Core/Crypto/NativeCryptoBridge.h @@ -13,6 +13,12 @@ NS_ASSUME_NONNULL_BEGIN key:(NSData *)key nonce:(NSData *)nonce; ++ (nullable NSData *)xChaCha20Xor:(NSData *)input + key:(NSData *)key + nonce:(NSData *)nonce; + ++ (nullable NSData *)naclSharedKeyFromRawDH:(NSData *)rawDH; + @end NS_ASSUME_NONNULL_END diff --git a/Rosetta/Core/Crypto/NativeCryptoBridge.mm b/Rosetta/Core/Crypto/NativeCryptoBridge.mm index dd4366a..c88f70a 100644 --- a/Rosetta/Core/Crypto/NativeCryptoBridge.mm +++ b/Rosetta/Core/Crypto/NativeCryptoBridge.mm @@ -62,4 +62,51 @@ return [NSData dataWithBytes:plaintext.data() length:plaintext.size()]; } ++ (nullable NSData *)xChaCha20Xor:(NSData *)input + key:(NSData *)key + nonce:(NSData *)nonce { + if (key.length != 32 || nonce.length != 24) { + return nil; + } + + const auto *inputBytes = static_cast(input.bytes); + const auto *keyBytes = static_cast(key.bytes); + const auto *nonceBytes = static_cast(nonce.bytes); + + std::vector output; + const bool ok = rosetta::nativecrypto::xchacha20_xor( + inputBytes, + static_cast(input.length), + keyBytes, + nonceBytes, + output + ); + if (!ok) { + return nil; + } + + if (output.empty()) { + return [NSData data]; + } + return [NSData dataWithBytes:output.data() length:output.size()]; +} + ++ (nullable NSData *)naclSharedKeyFromRawDH:(NSData *)rawDH { + if (rawDH.length != 32) { + return nil; + } + + const auto *rawDHBytes = static_cast(rawDH.bytes); + std::vector sharedKey; + const bool ok = rosetta::nativecrypto::hsalsa20_derive(rawDHBytes, sharedKey); + if (!ok) { + return nil; + } + + if (sharedKey.empty()) { + return [NSData data]; + } + return [NSData dataWithBytes:sharedKey.data() length:sharedKey.size()]; +} + @end diff --git a/Rosetta/Core/Crypto/NativeXChaCha20.cpp b/Rosetta/Core/Crypto/NativeXChaCha20.cpp index 7d41145..8a0df0a 100644 --- a/Rosetta/Core/Crypto/NativeXChaCha20.cpp +++ b/Rosetta/Core/Crypto/NativeXChaCha20.cpp @@ -42,6 +42,10 @@ inline void store_le64(std::uint64_t value, std::uint8_t *dst) { } } +inline std::uint32_t rotl32(std::uint32_t value, int shift) { + return (value << shift) | (value >> (32 - shift)); +} + inline void quarter_round(std::uint32_t *state, int a, int b, int c, int d) { state[a] += state[b]; state[d] ^= state[a]; @@ -159,6 +163,78 @@ void chacha20_xor( } } +void hsalsa20_core( + const std::uint8_t key[kXChaChaKeySize], + const std::uint8_t nonce16[16], + std::uint8_t out[kXChaChaKeySize] +) { + std::uint32_t x[16] = {}; + x[0] = 0x61707865U; + x[1] = load_le32(key + 0); + x[2] = load_le32(key + 4); + x[3] = load_le32(key + 8); + x[4] = load_le32(key + 12); + x[5] = 0x3320646eU; + x[6] = load_le32(nonce16 + 0); + x[7] = load_le32(nonce16 + 4); + x[8] = load_le32(nonce16 + 8); + x[9] = load_le32(nonce16 + 12); + x[10] = 0x79622d32U; + x[11] = load_le32(key + 16); + x[12] = load_le32(key + 20); + x[13] = load_le32(key + 24); + x[14] = load_le32(key + 28); + x[15] = 0x6b206574U; + + for (int i = 0; i < 20; i += 2) { + // Salsa20 column round + x[4] ^= rotl32(x[0] + x[12], 7); + x[8] ^= rotl32(x[4] + x[0], 9); + x[12] ^= rotl32(x[8] + x[4], 13); + x[0] ^= rotl32(x[12] + x[8], 18); + x[9] ^= rotl32(x[5] + x[1], 7); + x[13] ^= rotl32(x[9] + x[5], 9); + x[1] ^= rotl32(x[13] + x[9], 13); + x[5] ^= rotl32(x[1] + x[13], 18); + x[14] ^= rotl32(x[10] + x[6], 7); + x[2] ^= rotl32(x[14] + x[10], 9); + x[6] ^= rotl32(x[2] + x[14], 13); + x[10] ^= rotl32(x[6] + x[2], 18); + x[3] ^= rotl32(x[15] + x[11], 7); + x[7] ^= rotl32(x[3] + x[15], 9); + x[11] ^= rotl32(x[7] + x[3], 13); + x[15] ^= rotl32(x[11] + x[7], 18); + + // Salsa20 row round + x[1] ^= rotl32(x[0] + x[3], 7); + x[2] ^= rotl32(x[1] + x[0], 9); + x[3] ^= rotl32(x[2] + x[1], 13); + x[0] ^= rotl32(x[3] + x[2], 18); + x[6] ^= rotl32(x[5] + x[4], 7); + x[7] ^= rotl32(x[6] + x[5], 9); + x[4] ^= rotl32(x[7] + x[6], 13); + x[5] ^= rotl32(x[4] + x[7], 18); + x[11] ^= rotl32(x[10] + x[9], 7); + x[8] ^= rotl32(x[11] + x[10], 9); + x[9] ^= rotl32(x[8] + x[11], 13); + x[10] ^= rotl32(x[9] + x[8], 18); + x[12] ^= rotl32(x[15] + x[14], 7); + x[13] ^= rotl32(x[12] + x[15], 9); + x[14] ^= rotl32(x[13] + x[12], 13); + x[15] ^= rotl32(x[14] + x[13], 18); + } + + // NaCl box.before() output order: 0,5,10,15,6,7,8,9 + store_le32(x[0], out + 0); + store_le32(x[5], out + 4); + store_le32(x[10], out + 8); + store_le32(x[15], out + 12); + store_le32(x[6], out + 16); + store_le32(x[7], out + 20); + store_le32(x[8], out + 24); + store_le32(x[9], out + 28); +} + void poly1305_to_limbs26(const std::uint8_t block16[16], std::uint64_t limbs[5]) { const std::uint64_t lo = load_le64(block16); const std::uint64_t hi = load_le64(block16 + 8); @@ -410,4 +486,43 @@ bool xchacha20poly1305_decrypt( return true; } +bool xchacha20_xor( + const std::uint8_t *input, + std::size_t input_length, + const std::uint8_t *key32, + const std::uint8_t *nonce24, + std::vector &output +) { + if (key32 == nullptr || nonce24 == nullptr || (input == nullptr && input_length > 0)) { + return false; + } + + std::uint8_t subkey[kXChaChaKeySize]; + std::uint8_t chacha_nonce[kChaChaNonceSize]; + derive_subkey_and_nonce(key32, nonce24, subkey, chacha_nonce); + + // Stream-cipher mode parity with desktop/libsodium crypto_stream_xchacha20_xor. + chacha20_xor(input, input_length, subkey, chacha_nonce, 0, output); + + std::memset(subkey, 0, sizeof(subkey)); + std::memset(chacha_nonce, 0, sizeof(chacha_nonce)); + return true; +} + +bool hsalsa20_derive( + const std::uint8_t *raw_dh32, + std::vector &shared_key32 +) { + if (raw_dh32 == nullptr) { + return false; + } + + const std::uint8_t zero_nonce[16] = {0}; + std::uint8_t out[kXChaChaKeySize]; + hsalsa20_core(raw_dh32, zero_nonce, out); + shared_key32.assign(out, out + kXChaChaKeySize); + std::memset(out, 0, sizeof(out)); + return true; +} + } // namespace rosetta::nativecrypto diff --git a/Rosetta/Core/Crypto/NativeXChaCha20.hpp b/Rosetta/Core/Crypto/NativeXChaCha20.hpp index 17ff714..3b2fb54 100644 --- a/Rosetta/Core/Crypto/NativeXChaCha20.hpp +++ b/Rosetta/Core/Crypto/NativeXChaCha20.hpp @@ -22,4 +22,17 @@ bool xchacha20poly1305_decrypt( std::vector &plaintext ); +bool xchacha20_xor( + const uint8_t *input, + std::size_t input_length, + const uint8_t *key32, + const uint8_t *nonce24, + std::vector &output +); + +bool hsalsa20_derive( + const uint8_t *raw_dh32, + std::vector &shared_key32 +); + } // namespace rosetta::nativecrypto diff --git a/Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.h b/Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.h new file mode 100644 index 0000000..1de5111 --- /dev/null +++ b/Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.h @@ -0,0 +1,18 @@ +#import + +@class RTCRtpReceiver; +@class RTCRtpSender; + +NS_ASSUME_NONNULL_BEGIN + +/// Objective-C bridge for attaching native WebRTC frame encryptor/decryptor. +@interface WebRTCFrameCryptorBridge : NSObject + ++ (BOOL)attachSender:(RTCRtpSender *)sender sharedKey:(NSData *)sharedKey; ++ (BOOL)attachReceiver:(RTCRtpReceiver *)receiver sharedKey:(NSData *)sharedKey; ++ (void)detachSender:(RTCRtpSender *)sender; ++ (void)detachReceiver:(RTCRtpReceiver *)receiver; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.mm b/Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.mm new file mode 100644 index 0000000..27cba7b --- /dev/null +++ b/Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.mm @@ -0,0 +1,484 @@ +#import "WebRTCFrameCryptorBridge.h" + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "NativeXChaCha20.hpp" + +namespace rtc { + +enum class RefCountReleaseStatus { + kDroppedLastRef, + kOtherRefsRemained +}; + +class RefCountInterface { +public: + virtual void AddRef() const = 0; + virtual RefCountReleaseStatus Release() const = 0; + +protected: + virtual ~RefCountInterface() = default; +}; + +template +class scoped_refptr { +public: + scoped_refptr() : ptr_(nullptr) {} + + explicit scoped_refptr(T *ptr) : ptr_(ptr) { + if (ptr_ != nullptr) { + ptr_->AddRef(); + } + } + + scoped_refptr(const scoped_refptr &other) : ptr_(other.ptr_) { + if (ptr_ != nullptr) { + ptr_->AddRef(); + } + } + + scoped_refptr(scoped_refptr &&other) noexcept : ptr_(other.ptr_) { + other.ptr_ = nullptr; + } + + ~scoped_refptr() { + if (ptr_ != nullptr) { + ptr_->Release(); + ptr_ = nullptr; + } + } + + scoped_refptr &operator=(const scoped_refptr &other) { + if (this == &other) { + return *this; + } + T *next = other.ptr_; + if (next != nullptr) { + next->AddRef(); + } + if (ptr_ != nullptr) { + ptr_->Release(); + } + ptr_ = next; + return *this; + } + + scoped_refptr &operator=(scoped_refptr &&other) noexcept { + if (this == &other) { + return *this; + } + if (ptr_ != nullptr) { + ptr_->Release(); + } + ptr_ = other.ptr_; + other.ptr_ = nullptr; + return *this; + } + + scoped_refptr &operator=(T *ptr) { + if (ptr == ptr_) { + return *this; + } + if (ptr != nullptr) { + ptr->AddRef(); + } + if (ptr_ != nullptr) { + ptr_->Release(); + } + ptr_ = ptr; + return *this; + } + + T *get() const { return ptr_; } + T *operator->() const { return ptr_; } + explicit operator bool() const { return ptr_ != nullptr; } + +private: + T *ptr_; +}; + +template +inline scoped_refptr make_ref_counted(T *ptr) { + return scoped_refptr(ptr); +} + +template +class ArrayView { +public: + ArrayView() : data_(nullptr), size_(0) {} + ArrayView(T *data, std::size_t size) : data_(data), size_(size) {} + + T *data() const { return data_; } + std::size_t size() const { return size_; } + +private: + T *data_; + std::size_t size_; +}; + +} // namespace rtc + +namespace cricket { + +enum MediaType { + MEDIA_TYPE_AUDIO, + MEDIA_TYPE_VIDEO, + MEDIA_TYPE_DATA, + MEDIA_TYPE_UNSUPPORTED, + MEDIA_TYPE_ANY +}; + +} // namespace cricket + +namespace webrtc { + +class FrameEncryptorInterface : public rtc::RefCountInterface { +public: + virtual int Encrypt(cricket::MediaType media_type, + uint32_t ssrc, + rtc::ArrayView additional_data, + rtc::ArrayView frame, + rtc::ArrayView encrypted_frame, + std::size_t *bytes_written) = 0; + virtual std::size_t GetMaxCiphertextByteSize(cricket::MediaType media_type, + std::size_t frame_size) = 0; + +protected: + ~FrameEncryptorInterface() override = default; +}; + +class FrameDecryptorInterface : public rtc::RefCountInterface { +public: + struct Result { + enum class Status { + kOk = 0, + kRecoverable = 1, + kFailedToDecrypt = 2 + }; + + Result(Status s, std::size_t bw) : status(s), bytes_written(bw) {} + bool IsOk() const { return status == Status::kOk; } + + Status status; + std::size_t bytes_written; + }; + + virtual Result Decrypt(cricket::MediaType media_type, + const std::vector &csrcs, + rtc::ArrayView additional_data, + rtc::ArrayView encrypted_frame, + rtc::ArrayView frame) = 0; + virtual std::size_t GetMaxPlaintextByteSize(cricket::MediaType media_type, + std::size_t encrypted_frame_size) = 0; + +protected: + ~FrameDecryptorInterface() override = default; +}; + +} // namespace webrtc + +@interface RTCRtpSender (RosettaFrameCryptorPrivate) +- (void)setFrameEncryptor:(rtc::scoped_refptr)encryptor; +@end + +@interface RTCRtpReceiver (RosettaFrameCryptorPrivate) +- (void)setFrameDecryptor:(rtc::scoped_refptr)decryptor; +@end + +namespace { + +constexpr std::size_t kSharedKeyLength = 32; +constexpr std::size_t kNonceLength = 24; + +std::mutex gCryptorLock; +std::unordered_map> gSenderEncryptors; +std::unordered_map> gReceiverDecryptors; + +std::string senderMapKey(RTCRtpSender *sender) { + NSString *senderId = sender.senderId; + if (senderId.length > 0) { + return std::string(senderId.UTF8String); + } + return "sender@" + std::to_string(reinterpret_cast(sender)); +} + +std::string receiverMapKey(RTCRtpReceiver *receiver) { + NSString *receiverId = receiver.receiverId; + if (receiverId.length > 0) { + return std::string(receiverId.UTF8String); + } + return "receiver@" + std::to_string(reinterpret_cast(receiver)); +} + +void fillNonceFromAdditionalData(std::array &nonce, + const std::uint8_t *additionalData, + std::size_t additionalDataLength) { + nonce.fill(0); + + if (additionalData == nullptr || additionalDataLength == 0) { + return; + } + + // Desktop path: additionalData is 8-byte big-endian timestamp. + if (additionalDataLength == 8) { + std::memcpy(nonce.data(), additionalData, 8); + return; + } + + // RTP fallback path: use RTP timestamp (bytes 4...7) when header version is valid. + if (additionalDataLength >= 12) { + const std::uint8_t version = (additionalData[0] >> 6U) & 0x03U; + if (version == 2) { + std::memcpy(nonce.data() + 4, additionalData + 4, 4); + return; + } + } + + // Last-resort path: copy first 8 bytes to nonce prefix. + const std::size_t copyCount = std::min(8, additionalDataLength); + std::memcpy(nonce.data(), additionalData, copyCount); +} + +bool xorFramePayload(const std::uint8_t *input, + std::size_t inputLength, + const std::array &key, + const std::uint8_t *additionalData, + std::size_t additionalDataLength, + std::uint8_t *output, + std::size_t outputCapacity, + std::size_t *bytesWritten) { + if (input == nullptr || output == nullptr || bytesWritten == nullptr) { + return false; + } + if (outputCapacity < inputLength) { + return false; + } + + std::array nonce; + fillNonceFromAdditionalData(nonce, additionalData, additionalDataLength); + + std::vector encrypted; + const bool ok = rosetta::nativecrypto::xchacha20_xor( + input, + inputLength, + key.data(), + nonce.data(), + encrypted + ); + + if (!ok || encrypted.size() != inputLength) { + return false; + } + + if (inputLength > 0) { + std::memcpy(output, encrypted.data(), inputLength); + } + *bytesWritten = inputLength; + return true; +} + +class RosettaFrameEncryptor final : public webrtc::FrameEncryptorInterface { +public: + explicit RosettaFrameEncryptor(const std::array &key) : key_(key) {} + + int Encrypt(cricket::MediaType media_type, + uint32_t ssrc, + rtc::ArrayView additional_data, + rtc::ArrayView frame, + rtc::ArrayView encrypted_frame, + std::size_t *bytes_written) override { + (void)media_type; + (void)ssrc; + if (xorFramePayload( + frame.data(), + frame.size(), + key_, + additional_data.data(), + additional_data.size(), + encrypted_frame.data(), + encrypted_frame.size(), + bytes_written)) { + return 0; + } + return 1; + } + + std::size_t GetMaxCiphertextByteSize(cricket::MediaType media_type, std::size_t frame_size) override { + (void)media_type; + return frame_size; + } + + void AddRef() const override { + refCount_.fetch_add(1, std::memory_order_relaxed); + } + + rtc::RefCountReleaseStatus Release() const override { + const int previous = refCount_.fetch_sub(1, std::memory_order_acq_rel); + if (previous == 1) { + delete this; + return rtc::RefCountReleaseStatus::kDroppedLastRef; + } + return rtc::RefCountReleaseStatus::kOtherRefsRemained; + } + +private: + mutable std::atomic refCount_{0}; + std::array key_; +}; + +class RosettaFrameDecryptor final : public webrtc::FrameDecryptorInterface { +public: + explicit RosettaFrameDecryptor(const std::array &key) : key_(key) {} + + Result Decrypt(cricket::MediaType media_type, + const std::vector &csrcs, + rtc::ArrayView additional_data, + rtc::ArrayView encrypted_frame, + rtc::ArrayView frame) override { + (void)media_type; + (void)csrcs; + + std::size_t bytesWritten = 0; + if (xorFramePayload( + encrypted_frame.data(), + encrypted_frame.size(), + key_, + additional_data.data(), + additional_data.size(), + frame.data(), + frame.size(), + &bytesWritten)) { + return Result(Result::Status::kOk, bytesWritten); + } + return Result(Result::Status::kFailedToDecrypt, 0); + } + + std::size_t GetMaxPlaintextByteSize(cricket::MediaType media_type, + std::size_t encrypted_frame_size) override { + (void)media_type; + return encrypted_frame_size; + } + + void AddRef() const override { + refCount_.fetch_add(1, std::memory_order_relaxed); + } + + rtc::RefCountReleaseStatus Release() const override { + const int previous = refCount_.fetch_sub(1, std::memory_order_acq_rel); + if (previous == 1) { + delete this; + return rtc::RefCountReleaseStatus::kDroppedLastRef; + } + return rtc::RefCountReleaseStatus::kOtherRefsRemained; + } + +private: + mutable std::atomic refCount_{0}; + std::array key_; +}; + +std::array normalizeSharedKey(NSData *sharedKey) { + std::array key{}; + if (sharedKey.length == 0 || sharedKey.bytes == nullptr) { + return key; + } + const std::size_t copyLength = std::min(kSharedKeyLength, sharedKey.length); + std::memcpy(key.data(), sharedKey.bytes, copyLength); + return key; +} + +} // namespace + +@implementation WebRTCFrameCryptorBridge + ++ (BOOL)attachSender:(RTCRtpSender *)sender sharedKey:(NSData *)sharedKey { + if (sender == nil || sharedKey == nil || sharedKey.length < kSharedKeyLength) { + return NO; + } + if (![sender respondsToSelector:@selector(setFrameEncryptor:)]) { + return NO; + } + + const std::string key = senderMapKey(sender); + std::lock_guard lock(gCryptorLock); + const auto existing = gSenderEncryptors.find(key); + if (existing != gSenderEncryptors.end()) { + [sender setFrameEncryptor:existing->second]; + return YES; + } + + const auto normalizedKey = normalizeSharedKey(sharedKey); + rtc::scoped_refptr encryptor( + new RosettaFrameEncryptor(normalizedKey) + ); + + [sender setFrameEncryptor:encryptor]; + gSenderEncryptors[key] = encryptor; + return YES; +} + ++ (BOOL)attachReceiver:(RTCRtpReceiver *)receiver sharedKey:(NSData *)sharedKey { + if (receiver == nil || sharedKey == nil || sharedKey.length < kSharedKeyLength) { + return NO; + } + if (![receiver respondsToSelector:@selector(setFrameDecryptor:)]) { + return NO; + } + + const std::string key = receiverMapKey(receiver); + std::lock_guard lock(gCryptorLock); + const auto existing = gReceiverDecryptors.find(key); + if (existing != gReceiverDecryptors.end()) { + [receiver setFrameDecryptor:existing->second]; + return YES; + } + + const auto normalizedKey = normalizeSharedKey(sharedKey); + rtc::scoped_refptr decryptor( + new RosettaFrameDecryptor(normalizedKey) + ); + + [receiver setFrameDecryptor:decryptor]; + gReceiverDecryptors[key] = decryptor; + return YES; +} + ++ (void)detachSender:(RTCRtpSender *)sender { + if (sender == nil) { + return; + } + + std::lock_guard lock(gCryptorLock); + const std::string key = senderMapKey(sender); + if ([sender respondsToSelector:@selector(setFrameEncryptor:)]) { + [sender setFrameEncryptor:rtc::scoped_refptr()]; + } + gSenderEncryptors.erase(key); +} + ++ (void)detachReceiver:(RTCRtpReceiver *)receiver { + if (receiver == nil) { + return; + } + + std::lock_guard lock(gCryptorLock); + const std::string key = receiverMapKey(receiver); + if ([receiver respondsToSelector:@selector(setFrameDecryptor:)]) { + [receiver setFrameDecryptor:rtc::scoped_refptr()]; + } + gReceiverDecryptors.erase(key); +} + +@end diff --git a/Rosetta/Core/Layout/BubbleGeometryEngine.swift b/Rosetta/Core/Layout/BubbleGeometryEngine.swift index 60d656a..2134575 100644 --- a/Rosetta/Core/Layout/BubbleGeometryEngine.swift +++ b/Rosetta/Core/Layout/BubbleGeometryEngine.swift @@ -29,7 +29,7 @@ struct BubbleMetrics: Sendable { mainRadius: 16, auxiliaryRadius: 8, tailProtrusion: 6, - defaultSpacing: screenPixel, + defaultSpacing: 2 + screenPixel, mergedSpacing: 0, textInsets: UIEdgeInsets(top: 6 + screenPixel, left: 11, bottom: 6 - screenPixel, right: 11), mediaStatusInsets: UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7) diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 0ac64f5..6ae46b7 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -94,8 +94,10 @@ extension MessageCellLayout { let replyName: String? let replyText: String? let imageCount: Int + let imageDimensions: CGSize? let fileCount: Int let avatarCount: Int + let callCount: Int let isForward: Bool let forwardImageCount: Int let forwardFileCount: Int @@ -157,7 +159,7 @@ extension MessageCellLayout { messageType = .photoWithCaption } else if config.imageCount > 0 { messageType = .photo - } else if config.fileCount > 0 || config.avatarCount > 0 { + } else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 { messageType = .file } else if config.hasReplyQuote { messageType = .textWithReply @@ -279,16 +281,8 @@ extension MessageCellLayout { let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width) let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 var photoH: CGFloat = 0 - if config.imageCount > 0 { - photoH = Self.collageHeight( - count: config.imageCount, - width: effectiveMaxBubbleWidth - 8, - maxHeight: mediaDimensions.maxHeight, - minHeight: mediaDimensions.minHeight - ) - } let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 - let fileH: CGFloat = CGFloat(config.fileCount) * 56 + let fileH: CGFloat = CGFloat(config.fileCount + config.callCount) * 56 // Tiny floor just to prevent zero-width collapse. // Telegram does NOT force a large minW — short messages get tight bubbles. @@ -300,15 +294,44 @@ extension MessageCellLayout { var bubbleH: CGFloat = replyH + forwardHeaderH + fileH if config.imageCount > 0 { - // Media bubbles should not stretch edge-to-edge; keep Telegram-like cap. - bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth) - photoH = Self.collageHeight( - count: config.imageCount, - width: bubbleW - 8, - maxHeight: mediaDimensions.maxHeight, - minHeight: mediaDimensions.minHeight - ) - bubbleH += photoH + // Telegram-exact photo sizing (ChatMessageInteractiveMediaNode.swift): + // 1. unboundSize = pixelDims * 0.5 (or 200×100 default) + // 2. fitted = unbound.aspectFitted(maxDimensions) + // 3. width = min(unbound.width, fitted.width, maxWidth) ← KEY: cap to natural size + // 4. height = fit to width preserving aspect ratio, then clamp + // 5. Photo inset: 2pt on ALL four sides + let photoInset: CGFloat = 2 + if config.imageCount == 1, let dims = config.imageDimensions { + // Telegram-exact: unboundSize = pixelDims * 0.5, then aspectFitted + let unbound = CGSize( + width: floor(dims.width * 0.5), + height: floor(dims.height * 0.5) + ) + let maxConstraint = CGSize(width: mediaBubbleMaxWidth, height: mediaDimensions.maxHeight) + let fitted = unbound.aspectFitted(maxConstraint) + // Telegram: resultWidth = min(nativeSize.width, fitted.width, maxWidth) + // This prevents scaling UP — bubble can't exceed natural photo size + bubbleW = max(mediaBubbleMinWidth, + min(ceil(unbound.width), min(ceil(fitted.width), mediaBubbleMaxWidth))) + // Fit height to the chosen width, preserving aspect ratio + let aspectH = ceil(bubbleW * unbound.height / unbound.width) + photoH = max(mediaDimensions.minHeight, min(aspectH, mediaDimensions.maxHeight)) + } else if config.imageCount == 1 { + // No dimensions (legacy messages) — use full max width with 0.75 aspect + bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth) + photoH = max(mediaDimensions.minHeight, + min(ceil(bubbleW * 0.75), mediaDimensions.maxHeight)) + } else { + bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth) + photoH = Self.collageHeight( + count: config.imageCount, + width: bubbleW - 4, + maxHeight: mediaDimensions.maxHeight, + minHeight: mediaDimensions.minHeight + ) + } + // Telegram: 2pt inset on all 4 sides → bubble is photoH + 4pt taller + bubbleH += photoH + photoInset * 2 if !config.text.isEmpty { bubbleH += topPad + textMeasurement.size.height + bottomPad if photoH > 0 { bubbleH += 6 } @@ -367,7 +390,9 @@ extension MessageCellLayout { // checkFrame.minX = bubbleW - inset - checkW let metadataRightInset: CGFloat if isMediaMessage { - metadataRightInset = 6 + // Telegram: statusInsets are 6pt from MEDIA edge, not bubble edge. + // Photo has 2pt inset from bubble → 6 + 2 = 8pt from bubble edge. + metadataRightInset = 8 } else if isTextMessage { // Outgoing: 5pt (checkmarks fill the gap to rightPad) // Incoming: rightPad (11pt, same as text — no checkmarks to fill the gap) @@ -377,7 +402,8 @@ extension MessageCellLayout { } else { metadataRightInset = rightPad } - let metadataBottomInset: CGFloat = isMediaMessage ? 6 : bottomPad + // Telegram: statusInsets bottom 6pt from MEDIA edge → 6 + 2 = 8pt from bubble edge + let metadataBottomInset: CGFloat = isMediaMessage ? 8 : bottomPad let statusEndX = bubbleW - metadataRightInset let statusEndY = bubbleH - metadataBottomInset let statusVerticalOffset: CGFloat = isTextMessage @@ -435,8 +461,9 @@ extension MessageCellLayout { if config.hasReplyQuote { textY = replyH + topPad } if forwardHeaderH > 0 { textY = forwardHeaderH + topPad } if photoH > 0 { - textY = photoH + 6 + topPad - if config.hasReplyQuote { textY = replyH + photoH + 6 + topPad } + // Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap + textY = photoH + 4 + 6 + topPad + if config.hasReplyQuote { textY = replyH + photoH + 4 + 6 + topPad } } if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad } @@ -461,7 +488,9 @@ extension MessageCellLayout { let replyNameFrame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17) let replyTextFrame = CGRect(x: 9, y: 20, width: bubbleW - 24, height: 17) - let photoFrame = CGRect(x: 2, y: config.hasReplyQuote ? replyH : 0, width: bubbleW - 4, height: photoH) + // Telegram: 2pt inset on all four sides between photo and bubble edge + let photoY: CGFloat = (config.hasReplyQuote ? replyH : 0) + 2 + let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH) let fileFrame = CGRect(x: 0, y: config.hasReplyQuote ? replyH : 0, width: bubbleW, height: fileH) let fwdHeaderFrame = CGRect(x: 10, y: 6, width: bubbleW - 20, height: 14) @@ -495,7 +524,7 @@ extension MessageCellLayout { hasPhoto: config.imageCount > 0, photoFrame: photoFrame, photoCollageHeight: photoH, - hasFile: config.fileCount > 0 || config.avatarCount > 0, + hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0, fileFrame: fileFrame, isForward: config.isForward, forwardHeaderFrame: fwdHeaderFrame, @@ -626,7 +655,7 @@ extension MessageCellLayout { if hasImage { return .media } - let hasFileLike = message.attachments.contains { $0.type == .file || $0.type == .avatar } + let hasFileLike = message.attachments.contains { $0.type == .file || $0.type == .avatar || $0.type == .call } if hasFileLike { return .file } @@ -751,9 +780,15 @@ extension MessageCellLayout { let images = message.attachments.filter { $0.type == .image } let files = message.attachments.filter { $0.type == .file } let avatars = message.attachments.filter { $0.type == .avatar } + let calls = message.attachments.filter { $0.type == .call } let hasReply = message.attachments.contains { $0.type == .messages } let isForward = hasReply && displayText.isEmpty + // Parse image dimensions from preview field (format: "tag::blurhash::WxH") + let imageDims: CGSize? = images.first.flatMap { + AttachmentPreviewCodec.imageDimensions(from: $0.preview) + } + let config = Config( maxBubbleWidth: maxBubbleWidth, isOutgoing: isOutgoing, @@ -765,8 +800,10 @@ extension MessageCellLayout { replyName: nil, replyText: nil, imageCount: images.count, + imageDimensions: imageDims, fileCount: files.count, avatarCount: avatars.count, + callCount: calls.count, isForward: isForward, forwardImageCount: isForward ? images.count : 0, forwardFileCount: isForward ? files.count : 0, @@ -781,3 +818,14 @@ extension MessageCellLayout { return (result, textResult) } } + +// MARK: - Geometry Helpers + +private extension CGSize { + /// Scale to fit inside `boundingSize` preserving aspect ratio (Telegram-exact sizing). + func aspectFitted(_ boundingSize: CGSize) -> CGSize { + guard width > 0, height > 0 else { return boundingSize } + let scale = min(boundingSize.width / width, boundingSize.height / height) + return CGSize(width: floor(width * scale), height: floor(height * scale)) + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift index 24633d9..9aa5607 100644 --- a/Rosetta/Core/Network/Protocol/Packets/Packet.swift +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -36,6 +36,9 @@ enum PacketRegistry { 0x17: { PacketDeviceList() }, 0x18: { PacketDeviceResolve() }, 0x19: { PacketSync() }, + 0x1A: { PacketSignalPeer() }, + 0x1B: { PacketWebRTC() }, + 0x1C: { PacketIceServers() }, ] /// Deserializes a packet from raw binary data. diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketIceServers.swift b/Rosetta/Core/Network/Protocol/Packets/PacketIceServers.swift new file mode 100644 index 0000000..16223d9 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketIceServers.swift @@ -0,0 +1,45 @@ +import Foundation + +struct CallIceServer: Equatable, Sendable { + var url: String = "" + var username: String = "" + var credential: String = "" + var transport: String = "" +} + +/// ICE servers packet (0x1C / 28). +/// Server returns TURN/STUN configuration for call setup. +struct PacketIceServers: Packet { + static let packetId = 0x1C + + var iceServers: [CallIceServer] = [] + + func write(to stream: Stream) { + stream.writeInt16(iceServers.count) + for server in iceServers { + stream.writeString(server.url) + stream.writeString(server.username) + stream.writeString(server.credential) + stream.writeString(server.transport) + } + } + + mutating func read(from stream: Stream) { + let count = max(stream.readInt16(), 0) + var parsed: [CallIceServer] = [] + parsed.reserveCapacity(count) + + for _ in 0.. Void)? var onSyncReceived: ((PacketSync) -> Void)? var onDeviceNewReceived: ((PacketDeviceNew) -> Void)? + var onSignalPeerReceived: ((PacketSignalPeer) -> Void)? + var onWebRTCReceived: ((PacketWebRTC) -> Void)? + var onIceServersReceived: ((PacketIceServers) -> Void)? var onHandshakeCompleted: ((PacketHandshake) -> Void)? // MARK: - Private @@ -82,9 +85,15 @@ final class ProtocolManager: @unchecked Sendable { return val } private let resultHandlersLock = NSLock() + private let signalPeerHandlersLock = NSLock() + private let webRTCHandlersLock = NSLock() + private let iceServersHandlersLock = NSLock() private let packetQueueLock = NSLock() private let searchRouter = SearchPacketRouter() private var resultHandlers: [UUID: (PacketResult) -> Void] = [:] + private var signalPeerHandlers: [UUID: (PacketSignalPeer) -> Void] = [:] + private var webRTCHandlers: [UUID: (PacketWebRTC) -> Void] = [:] + private var iceServersHandlers: [UUID: (PacketIceServers) -> Void] = [:] // Saved credentials for auto-reconnect private var savedPublicKey: String? @@ -279,6 +288,33 @@ final class ProtocolManager: @unchecked Sendable { sendPacket(packet) } + func sendCallSignal( + signalType: SignalType, + src: String = "", + dst: String = "", + sharedPublic: String = "", + roomId: String = "" + ) { + var packet = PacketSignalPeer() + packet.signalType = signalType + packet.src = src + packet.dst = dst + packet.sharedPublic = sharedPublic + packet.roomId = roomId + sendPacket(packet) + } + + func sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) { + var packet = PacketWebRTC() + packet.signalType = signalType + packet.sdpOrCandidate = sdpOrCandidate + sendPacket(packet) + } + + func requestIceServers() { + sendPacket(PacketIceServers()) + } + func sendPacket(_ packet: any Packet) { PerformanceLogger.shared.track("protocol.sendPacket") let id = String(type(of: packet).packetId, radix: 16) @@ -307,6 +343,53 @@ final class ProtocolManager: @unchecked Sendable { searchRouter.removeHandler(id) } + // MARK: - Call Packet Handlers (Android-like wait/unwait) + + @discardableResult + func addSignalPeerHandler(_ handler: @escaping (PacketSignalPeer) -> Void) -> UUID { + let id = UUID() + signalPeerHandlersLock.lock() + signalPeerHandlers[id] = handler + signalPeerHandlersLock.unlock() + return id + } + + func removeSignalPeerHandler(_ id: UUID) { + signalPeerHandlersLock.lock() + signalPeerHandlers.removeValue(forKey: id) + signalPeerHandlersLock.unlock() + } + + @discardableResult + func addWebRtcHandler(_ handler: @escaping (PacketWebRTC) -> Void) -> UUID { + let id = UUID() + webRTCHandlersLock.lock() + webRTCHandlers[id] = handler + webRTCHandlersLock.unlock() + return id + } + + func removeWebRtcHandler(_ id: UUID) { + webRTCHandlersLock.lock() + webRTCHandlers.removeValue(forKey: id) + webRTCHandlersLock.unlock() + } + + @discardableResult + func addIceServersHandler(_ handler: @escaping (PacketIceServers) -> Void) -> UUID { + let id = UUID() + iceServersHandlersLock.lock() + iceServersHandlers[id] = handler + iceServersHandlersLock.unlock() + return id + } + + func removeIceServersHandler(_ id: UUID) { + iceServersHandlersLock.lock() + iceServersHandlers.removeValue(forKey: id) + iceServersHandlersLock.unlock() + } + // MARK: - Result Handlers (Android parity: waitPacket(0x02)) /// Register a one-shot handler for PacketResult (0x02). @@ -563,6 +646,21 @@ final class ProtocolManager: @unchecked Sendable { } onSyncReceived?(p) } + case 0x1A: + if let p = packet as? PacketSignalPeer { + onSignalPeerReceived?(p) + notifySignalPeerHandlers(p) + } + case 0x1B: + if let p = packet as? PacketWebRTC { + onWebRTCReceived?(p) + notifyWebRtcHandlers(p) + } + case 0x1C: + if let p = packet as? PacketIceServers { + onIceServersReceived?(p) + notifyIceServersHandlers(p) + } default: break } @@ -584,6 +682,36 @@ final class ProtocolManager: @unchecked Sendable { } } + private func notifySignalPeerHandlers(_ packet: PacketSignalPeer) { + signalPeerHandlersLock.lock() + let handlers = signalPeerHandlers.values + signalPeerHandlersLock.unlock() + + for handler in handlers { + handler(packet) + } + } + + private func notifyWebRtcHandlers(_ packet: PacketWebRTC) { + webRTCHandlersLock.lock() + let handlers = webRTCHandlers.values + webRTCHandlersLock.unlock() + + for handler in handlers { + handler(packet) + } + } + + private func notifyIceServersHandlers(_ packet: PacketIceServers) { + iceServersHandlersLock.lock() + let handlers = iceServersHandlers.values + iceServersHandlersLock.unlock() + + for handler in handlers { + handler(packet) + } + } + private func handleHandshakeResponse(_ packet: PacketHandshake) { handshakeTimeoutTask?.cancel() handshakeTimeoutTask = nil diff --git a/Rosetta/Core/Services/CallManager+Runtime.swift b/Rosetta/Core/Services/CallManager+Runtime.swift new file mode 100644 index 0000000..f8c9f83 --- /dev/null +++ b/Rosetta/Core/Services/CallManager+Runtime.swift @@ -0,0 +1,425 @@ +import AVFAudio +import CryptoKit +import Foundation +import WebRTC + +@MainActor +extension CallManager { + + func handleWebRtcPacket(_ packet: PacketWebRTC) async { + guard uiState.phase == .webRtcExchange || uiState.phase == .active else { return } + guard let peerConnection = self.peerConnection else { return } + + switch packet.signalType { + case .answer: + guard let answer = parseSessionDescription(from: packet.sdpOrCandidate), + answer.type == .answer else { + return + } + do { + try await setRemoteDescription(answer, on: peerConnection) + remoteDescriptionSet = true + await flushBufferedRemoteCandidates() + } catch { + finishCall(reason: "Failed to apply answer", notifyPeer: false) + } + case .offer: + guard let offer = parseSessionDescription(from: packet.sdpOrCandidate), + offer.type == .offer else { + return + } + do { + try await setRemoteDescription(offer, on: peerConnection) + remoteDescriptionSet = true + await flushBufferedRemoteCandidates() + + let answer = try await createAnswer(on: peerConnection) + try await setLocalDescription(answer, on: peerConnection) + ProtocolManager.shared.sendWebRtcSignal( + signalType: .answer, + sdpOrCandidate: serializeSessionDescription(answer) + ) + } catch { + finishCall(reason: "Failed to handle offer", notifyPeer: false) + } + case .iceCandidate: + guard let candidate = parseIceCandidate(from: packet.sdpOrCandidate) else { return } + if !remoteDescriptionSet { + bufferedRemoteCandidates.append(candidate) + return + } + try? await peerConnection.add(candidate) + } + } + + func ensurePeerConnectionAndOffer() async { + do { + try configureAudioSession() + let peerConnection = try ensurePeerConnection() + applySenderCryptorIfPossible() + + let offer = try await createOffer(on: peerConnection) + try await setLocalDescription(offer, on: peerConnection) + ProtocolManager.shared.sendWebRtcSignal( + signalType: .offer, + sdpOrCandidate: serializeSessionDescription(offer) + ) + offerSent = true + } catch { + finishCall(reason: "Failed to establish call", notifyPeer: false) + } + } + + func beginCallSession(peerPublicKey: String, title: String, username: String) { + finishCall(reason: nil, notifyPeer: false) + uiState = CallUiState( + phase: .idle, + peerPublicKey: peerPublicKey, + peerTitle: title, + peerUsername: username + ) + } + + func finishCall(reason: String?, notifyPeer: Bool) { + let snapshot = uiState + if notifyPeer, + ownPublicKey.isEmpty == false, + snapshot.peerPublicKey.isEmpty == false, + snapshot.phase != .idle { + ProtocolManager.shared.sendCallSignal( + signalType: .endCall, + src: ownPublicKey, + dst: snapshot.peerPublicKey + ) + } + + if role == .caller, + snapshot.peerPublicKey.isEmpty == false { + let duration = max(snapshot.durationSec, 0) + Task { @MainActor in + try? await SessionManager.shared.sendCallAttachment( + toPublicKey: snapshot.peerPublicKey, + durationSec: duration, + opponentTitle: snapshot.peerTitle, + opponentUsername: snapshot.peerUsername + ) + } + } + + durationTask?.cancel() + durationTask = nil + + if let localAudioSender { + WebRTCFrameCryptorBridge.detach(localAudioSender) + } + if let currentPeerConnection = self.peerConnection { + for receiver in currentPeerConnection.receivers { + WebRTCFrameCryptorBridge.detach(receiver) + } + currentPeerConnection.close() + } + + localAudioTrack = nil + localAudioSource = nil + localAudioSender = nil + self.peerConnection = nil + bufferedRemoteCandidates.removeAll() + attachedReceiverIds.removeAll() + + role = nil + roomId = "" + localPrivateKey = nil + localPublicKeyHex = "" + sharedKey = nil + offerSent = false + remoteDescriptionSet = false + lastPeerSharedPublicHex = "" + + uiState = CallUiState() + if let reason, !reason.isEmpty { + uiState.statusText = reason + } + + deactivateAudioSession() + } + + func ensureLocalSessionKeys() { + guard localPrivateKey == nil else { return } + let key = Curve25519.KeyAgreement.PrivateKey() + localPrivateKey = key + localPublicKeyHex = key.publicKey.rawRepresentation.hexString + } + + func hydratePeerIdentity(for publicKey: String) { + if let dialog = DialogRepository.shared.dialogs[publicKey] { + if uiState.peerTitle.isEmpty { + uiState.peerTitle = dialog.opponentTitle + } + if uiState.peerUsername.isEmpty { + uiState.peerUsername = dialog.opponentUsername + } + } + } + + func applySenderCryptorIfPossible() { + guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return } + guard let localAudioSender else { return } + _ = WebRTCFrameCryptorBridge.attach(localAudioSender, sharedKey: sharedKey) + } + + func startDurationTimerIfNeeded() { + durationTask?.cancel() + durationTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let self else { return } + guard self.uiState.phase == .active else { return } + self.uiState.durationSec += 1 + } + } + } + } + + // MARK: - WebRTC setup + + func ensurePeerConnection() throws -> RTCPeerConnection { + if let currentPeerConnection = self.peerConnection { return currentPeerConnection } + + if peerConnectionFactory == nil { + RTCPeerConnectionFactory.initialize() + peerConnectionFactory = RTCPeerConnectionFactory() + } + let factory = peerConnectionFactory ?? RTCPeerConnectionFactory() + + let configuration = RTCConfiguration() + if iceServers.isEmpty { + configuration.iceServers = [RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])] + } else { + configuration.iceServers = iceServers + } + configuration.sdpSemantics = .unifiedPlan + + let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) + guard let connection = factory.peerConnection( + with: configuration, + constraints: constraints, + delegate: self + ) else { + throw NSError(domain: "CallManager", code: -3) + } + + let audioSource = factory.audioSource(with: constraints) + let audioTrack = factory.audioTrack(with: audioSource, trackId: "rosetta_audio_track") + audioTrack.isEnabled = !uiState.isMuted + localAudioSender = connection.add(audioTrack, streamIds: ["rosetta_audio_stream"]) + + self.localAudioSource = audioSource + self.localAudioTrack = audioTrack + self.peerConnection = connection + return connection + } + + func configureAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try session.setCategory( + .playAndRecord, + mode: .voiceChat, + options: [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker] + ) + try session.setActive(true) + applyAudioOutputRouting() + } + + func deactivateAudioSession() { + try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation]) + } + + func applyAudioOutputRouting() { + let session = AVAudioSession.sharedInstance() + if uiState.isSpeakerOn { + try? session.overrideOutputAudioPort(.speaker) + } else { + try? session.overrideOutputAudioPort(.none) + } + } + + // MARK: - SDP / ICE + + private func createOffer(on peerConnection: RTCPeerConnection) async throws -> RTCSessionDescription { + try await withCheckedThrowingContinuation { continuation in + let constraints = RTCMediaConstraints( + mandatoryConstraints: ["OfferToReceiveAudio": "true"], + optionalConstraints: nil + ) + peerConnection.offer(for: constraints) { sdp, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let sdp else { + continuation.resume(throwing: NSError(domain: "CallManager", code: -1)) + return + } + continuation.resume(returning: sdp) + } + } + } + + private func createAnswer(on peerConnection: RTCPeerConnection) async throws -> RTCSessionDescription { + try await withCheckedThrowingContinuation { continuation in + let constraints = RTCMediaConstraints( + mandatoryConstraints: ["OfferToReceiveAudio": "true"], + optionalConstraints: nil + ) + peerConnection.answer(for: constraints) { sdp, error in + if let error { + continuation.resume(throwing: error) + return + } + guard let sdp else { + continuation.resume(throwing: NSError(domain: "CallManager", code: -2)) + return + } + continuation.resume(returning: sdp) + } + } + } + + private func setLocalDescription(_ sdp: RTCSessionDescription, on peerConnection: RTCPeerConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + peerConnection.setLocalDescription(sdp) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } + + private func setRemoteDescription(_ sdp: RTCSessionDescription, on peerConnection: RTCPeerConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + peerConnection.setRemoteDescription(sdp) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + } + + private func flushBufferedRemoteCandidates() async { + guard let currentPeerConnection = self.peerConnection else { return } + guard !bufferedRemoteCandidates.isEmpty else { return } + for candidate in bufferedRemoteCandidates { + try? await currentPeerConnection.add(candidate) + } + bufferedRemoteCandidates.removeAll() + } + + private func serializeSessionDescription(_ sdp: RTCSessionDescription) -> String { + let payload: [String: Any] = [ + "type": serializeSdpType(sdp.type), + "sdp": sdp.sdp, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let raw = String(data: data, encoding: .utf8) else { + return "" + } + return raw + } + + private func parseSessionDescription(from raw: String) -> RTCSessionDescription? { + guard let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let typeRaw = json["type"] as? String, + let sdp = json["sdp"] as? String else { + return nil + } + + let type: RTCSdpType + switch typeRaw.lowercased() { + case "offer": type = .offer + case "answer": type = .answer + case "pranswer": type = .prAnswer + case "rollback": type = .rollback + default: return nil + } + + return RTCSessionDescription(type: type, sdp: sdp) + } + + private func serializeSdpType(_ type: RTCSdpType) -> String { + switch type { + case .offer: + return "offer" + case .answer: + return "answer" + case .prAnswer: + return "pranswer" + case .rollback: + return "rollback" + @unknown default: + return "offer" + } + } + + private func parseIceCandidate(from raw: String) -> RTCIceCandidate? { + guard let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let candidate = json["candidate"] as? String else { + return nil + } + let sdpMid = json["sdpMid"] as? String + let lineIndex = (json["sdpMLineIndex"] as? NSNumber)?.int32Value ?? 0 + return RTCIceCandidate(sdp: candidate, sdpMLineIndex: lineIndex, sdpMid: sdpMid) + } +} + +// MARK: - RTCPeerConnectionDelegate + +extension CallManager: RTCPeerConnectionDelegate { + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { + Task { @MainActor in + for audioTrack in stream.audioTracks { + audioTrack.isEnabled = !self.uiState.isSpeakerOn + } + } + } + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {} + nonisolated func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { + Task { @MainActor in + self.handleIceConnectionStateChanged(newState) + } + } + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + Task { @MainActor in + self.handleGeneratedCandidate(candidate) + } + } + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) {} + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} + + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { + if newState == .connected { + Task { @MainActor in self.setCallActiveIfNeeded() } + return + } + if newState == .failed || newState == .closed || newState == .disconnected { + Task { @MainActor in self.finishCall(reason: "Connection lost", notifyPeer: false) } + } + } + + nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didStartReceivingOn transceiver: RTCRtpTransceiver) { + guard let receiver = transceiver.receiver as RTCRtpReceiver? else { return } + Task { @MainActor in + self.attachReceiverCryptor(receiver) + } + } +} diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift new file mode 100644 index 0000000..92cbfe0 --- /dev/null +++ b/Rosetta/Core/Services/CallManager.swift @@ -0,0 +1,328 @@ +import AVFAudio +import Combine +import CryptoKit +import Foundation +import WebRTC + +@MainActor +final class CallManager: NSObject, ObservableObject { + + static let shared = CallManager() + + @Published var uiState = CallUiState() + + var ownPublicKey: String = "" + var role: CallRole? + var roomId: String = "" + var localPrivateKey: Curve25519.KeyAgreement.PrivateKey? + var localPublicKeyHex: String = "" + var sharedKey: Data? + var offerSent = false + var remoteDescriptionSet = false + var lastPeerSharedPublicHex = "" + + var iceServers: [RTCIceServer] = [] + + private var signalToken: UUID? + private var webRtcToken: UUID? + private var iceToken: UUID? + + var peerConnectionFactory: RTCPeerConnectionFactory? + var peerConnection: RTCPeerConnection? + var localAudioSource: RTCAudioSource? + var localAudioTrack: RTCAudioTrack? + var localAudioSender: RTCRtpSender? + var bufferedRemoteCandidates: [RTCIceCandidate] = [] + var attachedReceiverIds: Set = [] + + var durationTask: Task? + + private override init() { + super.init() + wireProtocolHandlers() + } + + deinit { + if let signalToken { ProtocolManager.shared.removeSignalPeerHandler(signalToken) } + if let webRtcToken { ProtocolManager.shared.removeWebRtcHandler(webRtcToken) } + if let iceToken { ProtocolManager.shared.removeIceServersHandler(iceToken) } + } + + // MARK: - Public API + + func bindAccount(publicKey: String) { + ownPublicKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func onAuthenticated() { + ProtocolManager.shared.requestIceServers() + } + + func resetForSessionEnd() { + finishCall(reason: nil, notifyPeer: false) + } + + func startOutgoingCall(toPublicKey: String, title: String, username: String) -> CallActionResult { + let target = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard !target.isEmpty, !DatabaseManager.isGroupDialogKey(target) else { return .invalidTarget } + guard ownPublicKey.isEmpty == false else { return .accountNotBound } + guard uiState.phase == .idle else { return .alreadyInCall } + + beginCallSession(peerPublicKey: target, title: title, username: username) + role = .caller + ensureLocalSessionKeys() + uiState.phase = .outgoing + uiState.statusText = "Calling..." + + ProtocolManager.shared.sendCallSignal( + signalType: .call, + src: ownPublicKey, + dst: target + ) + return .started + } + + func acceptIncomingCall() -> CallActionResult { + guard uiState.phase == .incoming else { return .notIncoming } + guard ownPublicKey.isEmpty == false else { return .accountNotBound } + guard uiState.peerPublicKey.isEmpty == false else { return .invalidTarget } + + role = .callee + ensureLocalSessionKeys() + guard localPublicKeyHex.isEmpty == false else { return .invalidTarget } + + ProtocolManager.shared.sendCallSignal( + signalType: .keyExchange, + src: ownPublicKey, + dst: uiState.peerPublicKey, + sharedPublic: localPublicKeyHex + ) + + uiState.phase = .keyExchange + uiState.statusText = "Exchanging keys..." + return .started + } + + func declineIncomingCall() { + guard uiState.phase == .incoming else { return } + if ownPublicKey.isEmpty == false, uiState.peerPublicKey.isEmpty == false { + ProtocolManager.shared.sendCallSignal( + signalType: .endCall, + src: ownPublicKey, + dst: uiState.peerPublicKey + ) + } + finishCall(reason: nil, notifyPeer: false) + } + + func endCall() { + finishCall(reason: nil, notifyPeer: true) + } + + func toggleMute() { + let nextMuted = !uiState.isMuted + uiState.isMuted = nextMuted + localAudioTrack?.isEnabled = !nextMuted + } + + func toggleSpeaker() { + let nextSpeaker = !uiState.isSpeakerOn + uiState.isSpeakerOn = nextSpeaker + applyAudioOutputRouting() + } + + // MARK: - Protocol handlers + + private func wireProtocolHandlers() { + signalToken = ProtocolManager.shared.addSignalPeerHandler { [weak self] packet in + Task { @MainActor [weak self] in + self?.handleSignalPacket(packet) + } + } + webRtcToken = ProtocolManager.shared.addWebRtcHandler { [weak self] packet in + Task { @MainActor [weak self] in + await self?.handleWebRtcPacket(packet) + } + } + iceToken = ProtocolManager.shared.addIceServersHandler { [weak self] packet in + Task { @MainActor [weak self] in + self?.handleIceServersPacket(packet) + } + } + } + + private func handleSignalPacket(_ packet: PacketSignalPeer) { + switch packet.signalType { + case .endCallBecauseBusy: + finishCall(reason: "User is busy", notifyPeer: false) + return + case .endCallBecausePeerDisconnected: + finishCall(reason: "Peer disconnected", notifyPeer: false) + return + case .endCall: + finishCall(reason: "Call ended", notifyPeer: false) + return + default: + break + } + + if uiState.peerPublicKey.isEmpty == false, packet.src.isEmpty == false { + if packet.src != uiState.peerPublicKey && packet.src != ownPublicKey { + return + } + } + + switch packet.signalType { + case .call: + let incomingPeer = packet.src.trimmingCharacters(in: .whitespacesAndNewlines) + guard incomingPeer.isEmpty == false else { return } + guard uiState.phase == .idle else { + ProtocolManager.shared.sendCallSignal( + signalType: .endCallBecauseBusy, + src: ownPublicKey, + dst: incomingPeer + ) + return + } + beginCallSession(peerPublicKey: incomingPeer, title: "", username: "") + role = .callee + uiState.phase = .incoming + uiState.statusText = "Incoming call..." + hydratePeerIdentity(for: incomingPeer) + case .keyExchange: + handleKeyExchange(packet) + case .createRoom: + let incomingRoomId = packet.roomId.trimmingCharacters(in: .whitespacesAndNewlines) + guard incomingRoomId.isEmpty == false else { return } + roomId = incomingRoomId + uiState.phase = .webRtcExchange + uiState.statusText = "Connecting..." + Task { [weak self] in + await self?.ensurePeerConnectionAndOffer() + } + case .activeCall: + break + case .endCall, .endCallBecausePeerDisconnected, .endCallBecauseBusy: + break + } + } + + private func handleKeyExchange(_ packet: PacketSignalPeer) { + let peerPublicHex = packet.sharedPublic.trimmingCharacters(in: .whitespacesAndNewlines) + guard peerPublicHex.isEmpty == false else { return } + if sharedKey != nil, + peerPublicHex.caseInsensitiveCompare(lastPeerSharedPublicHex) == .orderedSame { + return + } + lastPeerSharedPublicHex = peerPublicHex + + ensureLocalSessionKeys() + guard let localPrivateKey else { return } + guard let derivedSharedKey = CallMediaCrypto.deriveSharedKey( + localPrivateKey: localPrivateKey, + peerPublicHex: peerPublicHex + ) else { + return + } + + sharedKey = derivedSharedKey + uiState.keyCast = derivedSharedKey.hexString + applySenderCryptorIfPossible() + + switch role { + case .caller: + ProtocolManager.shared.sendCallSignal( + signalType: .keyExchange, + src: ownPublicKey, + dst: uiState.peerPublicKey, + sharedPublic: localPublicKeyHex + ) + ProtocolManager.shared.sendCallSignal( + signalType: .createRoom, + src: ownPublicKey, + dst: uiState.peerPublicKey + ) + uiState.phase = .webRtcExchange + uiState.statusText = "Creating room..." + case .callee: + uiState.phase = .keyExchange + uiState.statusText = "Waiting for room..." + case .none: + break + } + } + + private func handleIceServersPacket(_ packet: PacketIceServers) { + let mapped = packet.iceServers.compactMap { server -> RTCIceServer? in + let url = server.url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !url.isEmpty else { return nil } + + if url.hasPrefix("stun:") || url.hasPrefix("turn:") { + return RTCIceServer(urlStrings: [url], username: server.username, credential: server.credential) + } + + let transport = server.transport.trimmingCharacters(in: .whitespacesAndNewlines) + if transport.isEmpty { + return RTCIceServer(urlStrings: ["turn:\(url)"], username: server.username, credential: server.credential) + } + return RTCIceServer(urlStrings: ["turn:\(url)?transport=\(transport)"], username: server.username, credential: server.credential) + } + iceServers = mapped + } + + // MARK: - Internal helpers used by delegate extension + + func setCallActiveIfNeeded() { + guard uiState.phase != .active else { return } + uiState.phase = .active + uiState.statusText = "Call active" + startDurationTimerIfNeeded() + } + + func attachReceiverCryptor(_ receiver: RTCRtpReceiver) { + guard let sharedKey, sharedKey.count >= CallMediaCrypto.keyLength else { return } + guard attachedReceiverIds.contains(receiver.receiverId) == false else { return } + if WebRTCFrameCryptorBridge.attach(receiver, sharedKey: sharedKey) { + attachedReceiverIds.insert(receiver.receiverId) + } + } + + func handleGeneratedCandidate(_ candidate: RTCIceCandidate) { + let payload: [String: Any] = [ + "candidate": candidate.sdp, + "sdpMid": candidate.sdpMid as Any, + "sdpMLineIndex": Int(candidate.sdpMLineIndex), + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload), + let raw = String(data: data, encoding: .utf8) else { + return + } + ProtocolManager.shared.sendWebRtcSignal(signalType: .iceCandidate, sdpOrCandidate: raw) + } + + func handleIceConnectionStateChanged(_ state: RTCIceConnectionState) { + switch state { + case .connected, .completed: + setCallActiveIfNeeded() + case .failed, .closed, .disconnected: + finishCall(reason: "Connection lost", notifyPeer: false) + default: + break + } + } +} + +#if DEBUG +@MainActor +extension CallManager { + /// Test-only hook to drive signal routing without a live transport. + func testHandleSignalPacket(_ packet: PacketSignalPeer) { + handleSignalPacket(packet) + } + + /// Test-only helper for deterministic state setup in routing tests. + func testSetUiState(_ state: CallUiState) { + uiState = state + } +} +#endif diff --git a/Rosetta/Core/Services/CallModels.swift b/Rosetta/Core/Services/CallModels.swift new file mode 100644 index 0000000..54bdb7d --- /dev/null +++ b/Rosetta/Core/Services/CallModels.swift @@ -0,0 +1,47 @@ +import Foundation + +enum CallPhase: String, Sendable { + case idle + case incoming + case outgoing + case keyExchange + case webRtcExchange + case active + case ended +} + +enum CallRole: Sendable { + case caller + case callee +} + +enum CallActionResult: Sendable { + case started + case alreadyInCall + case accountNotBound + case invalidTarget + case notIncoming +} + +struct CallUiState: Equatable, Sendable { + var phase: CallPhase = .idle + var peerPublicKey: String = "" + var peerTitle: String = "" + var peerUsername: String = "" + var statusText: String = "" + var durationSec: Int = 0 + var isMuted: Bool = false + var isSpeakerOn: Bool = false + var keyCast: String = "" + + var isVisible: Bool { + phase != .idle + } + + var displayName: String { + if !peerTitle.isEmpty { return peerTitle } + if !peerUsername.isEmpty { return "@\(peerUsername)" } + if !peerPublicKey.isEmpty { return String(peerPublicKey.prefix(12)) } + return "Unknown" + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 777a077..74137fb 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -171,6 +171,7 @@ final class SessionManager { currentPublicKey = account.publicKey displayName = account.displayName ?? "" username = account.username ?? "" + CallManager.shared.bindAccount(publicKey: account.publicKey) // Migrate legacy JSON → SQLite on first launch (before repositories read from DB). let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded( @@ -536,7 +537,19 @@ final class SessionManager { switch attachment.type { case .image: let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? "" - previewSuffix = blurhash + // Encode image dimensions for Telegram-style aspect-ratio sizing. + // Format: "blurhash::WxH" — backward compatible (old parsers ignore suffix). + if let img = UIImage(data: attachment.data) { + // Encode pixel dimensions after blurhash using "|" separator. + // "|" is NOT in blurhash Base83 alphabet and NOT in "::" protocol separator, + // so desktop's getPreview() passes "blurhash|WxH" to blurhashToBase64Image() + // which silently ignores the suffix (blurhash decoder stops at unknown chars). + let w = Int(img.size.width * img.scale) + let h = Int(img.size.height * img.scale) + previewSuffix = "\(blurhash)|\(w)x\(h)" + } else { + previewSuffix = blurhash + } case .file: previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")" default: @@ -982,6 +995,87 @@ final class SessionManager { Self.logger.info("📤 Reply message sent to \(toPublicKey.prefix(12))… with \(replyMessages.count) quoted message(s), \(forwardedImages.count) re-uploaded photos") } + /// Sends a call event message (AttachmentType.call, type=4) to dialog history. + /// Desktop/Android parity: caller emits one attachment with `preview = durationSec`. + func sendCallAttachment( + toPublicKey: String, + durationSec: Int, + opponentTitle: String = "", + opponentUsername: String = "" + ) async throws { + guard let privKey = privateKeyHex, let hash = privateKeyHash else { + Self.logger.error("📤 Cannot send call attachment — missing keys") + throw CryptoError.decryptionFailed + } + + let messageId = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let timestamp = Int64(Date().timeIntervalSince1970 * 1000) + + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: "", + recipientPublicKeyHex: toPublicKey + ) + + guard let latin1ForSync = String(data: encrypted.plainKeyAndNonce, encoding: .isoLatin1) else { + throw CryptoError.encryptionFailed + } + let aesChachaPayload = Data(latin1ForSync.utf8) + let aesChachaKey = try CryptoManager.shared.encryptWithPasswordDesktopCompat( + aesChachaPayload, + password: privKey + ) + + var packet = PacketMessage() + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = toPublicKey + packet.content = encrypted.content + packet.chachaKey = encrypted.chachaKey + packet.timestamp = timestamp + packet.privateKey = hash + packet.messageId = messageId + packet.aesChachaKey = aesChachaKey + packet.attachments = [ + MessageAttachment( + id: String(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16)), + preview: String(max(durationSec, 0)), + blob: "", + type: .call + ), + ] + + let existingDialog = DialogRepository.shared.dialogs[toPublicKey] + let title = !opponentTitle.isEmpty ? opponentTitle : (existingDialog?.opponentTitle ?? "") + let username = !opponentUsername.isEmpty ? opponentUsername : (existingDialog?.opponentUsername ?? "") + DialogRepository.shared.ensureDialog( + opponentKey: toPublicKey, + title: title, + username: username, + myPublicKey: currentPublicKey + ) + + MessageRepository.shared.upsertFromMessagePacket( + packet, + myPublicKey: currentPublicKey, + decryptedText: "", + fromSync: false + ) + DialogRepository.shared.updateDialogFromMessages(opponentKey: toPublicKey) + MessageRepository.shared.persistNow() + + if toPublicKey == currentPublicKey { + MessageRepository.shared.updateDeliveryStatus(messageId: messageId, status: .delivered) + DialogRepository.shared.updateDeliveryStatus( + messageId: messageId, + opponentKey: toPublicKey, + status: .delivered + ) + return + } + + packetFlowSender.sendPacket(packet) + registerOutgoingRetry(for: packet) + } + /// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog). func sendTypingIndicator(toPublicKey: String) { let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1089,6 +1183,7 @@ final class SessionManager { AttachmentCache.shared.privateKey = nil RecentSearchesRepository.shared.clearSession() DraftManager.shared.reset() + CallManager.shared.resetForSessionEnd() } // MARK: - Protocol Callbacks @@ -1274,6 +1369,7 @@ final class SessionManager { // Send push token to server for push notifications (Android parity). self.sendPushTokenToServer() + CallManager.shared.onAuthenticated() // Desktop parity: user info refresh is deferred until sync completes. // Desktop fetches lazily per-component (useUserInformation); we do it diff --git a/Rosetta/Core/Utils/AttachmentPreviewCodec.swift b/Rosetta/Core/Utils/AttachmentPreviewCodec.swift index 227adc6..99c2d30 100644 --- a/Rosetta/Core/Utils/AttachmentPreviewCodec.swift +++ b/Rosetta/Core/Utils/AttachmentPreviewCodec.swift @@ -41,7 +41,26 @@ enum AttachmentPreviewCodec { } static func blurHash(from preview: String) -> String { - payload(from: preview) + let raw = payload(from: preview) + // Strip trailing "|WxH" dimension suffix if present. + if let pipeIdx = raw.lastIndex(of: "|") { + return String(raw[raw.startIndex.. CGSize? { + let raw = payload(from: preview) + guard let pipeIdx = raw.lastIndex(of: "|") else { return nil } + let dimStr = raw[raw.index(after: pipeIdx)...] + guard let xIdx = dimStr.firstIndex(of: "x"), + let w = Int(dimStr[dimStr.startIndex..= 10, h >= 10 else { return nil } + return CGSize(width: CGFloat(w), height: CGFloat(h)) } static func parseFilePreview( @@ -88,6 +107,37 @@ enum AttachmentPreviewCodec { return "\(tag)::\(normalizedPayload)" } + static func parseCallDurationSeconds(_ preview: String) -> Int { + let normalized = payload(from: preview) + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let direct = Int(normalized) { + return max(direct, 0) + } + + let patterns = [ + #"duration(?:Sec|Seconds)?\s*[:=]\s*(\d+)"#, + #"duration\s+(\d+)"#, + ] + + for pattern in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { + continue + } + let nsRange = NSRange(normalized.startIndex.. 1, + let valueRange = Range(match.range(at: 1), in: normalized), + let value = Int(normalized[valueRange]) + else { + continue + } + return max(value, 0) + } + + return 0 + } + private static func normalizePayload(_ payload: String) -> String { var value = payload.trimmingCharacters(in: .whitespacesAndNewlines) while value.hasPrefix("::") { diff --git a/Rosetta/Features/Calls/ActiveCallOverlayView.swift b/Rosetta/Features/Calls/ActiveCallOverlayView.swift new file mode 100644 index 0000000..feb634b --- /dev/null +++ b/Rosetta/Features/Calls/ActiveCallOverlayView.swift @@ -0,0 +1,141 @@ +import SwiftUI + +struct ActiveCallOverlayView: View { + @ObservedObject var callManager: CallManager + + private var state: CallUiState { + callManager.uiState + } + + private var durationText: String { + let duration = max(state.durationSec, 0) + let minutes = duration / 60 + let seconds = duration % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + var body: some View { + ZStack { + Color.black.opacity(0.7) + .ignoresSafeArea() + + VStack(spacing: 20) { + Image(systemName: "phone.fill") + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(.white) + .padding(20) + .background(Circle().fill(Color.white.opacity(0.14))) + + Text(state.displayName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .lineLimit(2) + + if state.phase == .active { + Text(durationText) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.85)) + } else { + Text(statusText(for: state.phase)) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.85)) + } + + controls + } + .padding(28) + .frame(maxWidth: 360) + .background( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(Color.black.opacity(0.62)) + ) + .overlay( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke(Color.white.opacity(0.15), lineWidth: 1) + ) + .padding(.horizontal, 24) + } + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } + + @ViewBuilder + private var controls: some View { + if state.phase == .incoming { + HStack(spacing: 16) { + callActionButton( + title: "Decline", + icon: "phone.down.fill", + color: RosettaColors.error + ) { + callManager.declineIncomingCall() + } + callActionButton( + title: "Accept", + icon: "phone.fill", + color: RosettaColors.success + ) { + _ = callManager.acceptIncomingCall() + } + } + } else { + HStack(spacing: 16) { + callActionButton( + title: state.isMuted ? "Unmute" : "Mute", + icon: state.isMuted ? "mic.slash.fill" : "mic.fill", + color: Color.white.opacity(0.18) + ) { + callManager.toggleMute() + } + callActionButton( + title: state.isSpeakerOn ? "Earpiece" : "Speaker", + icon: state.isSpeakerOn ? "speaker.slash.fill" : "speaker.wave.2.fill", + color: Color.white.opacity(0.18) + ) { + callManager.toggleSpeaker() + } + callActionButton( + title: "End", + icon: "phone.down.fill", + color: RosettaColors.error + ) { + callManager.endCall() + } + } + } + } + + @ViewBuilder + private func callActionButton( + title: String, + icon: String, + color: Color, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 52, height: 52) + .background(Circle().fill(color)) + Text(title) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.92)) + } + } + .buttonStyle(.plain) + } + + private func statusText(for phase: CallPhase) -> String { + switch phase { + case .incoming: return "Incoming call" + case .outgoing: return "Calling..." + case .keyExchange: return "Exchanging keys..." + case .webRtcExchange: return "Connecting..." + case .active: return "Active" + case .ended: return "Ended" + case .idle: return "" + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 595bfb2..e594acd 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -40,6 +40,7 @@ struct ChatDetailView: View { @State private var showNoAvatarAlert = false @State private var pendingAttachments: [PendingAttachment] = [] @State private var showOpponentProfile = false + @State private var callErrorMessage: String? @State private var replyingToMessage: ChatMessage? @State private var showForwardPicker = false @State private var forwardingMessage: ChatMessage? @@ -307,6 +308,20 @@ struct ChatDetailView: View { } message: { Text("Set a profile photo in Settings to share it with contacts.") } + .alert("Call Error", isPresented: Binding( + get: { callErrorMessage != nil }, + set: { isPresented in + if !isPresented { + callErrorMessage = nil + } + } + )) { + Button("OK", role: .cancel) { + callErrorMessage = nil + } + } message: { + Text(callErrorMessage ?? "Failed to start call.") + } .sheet(isPresented: $showAttachmentPanel) { AttachmentPanelView( onSend: { attachments, caption in @@ -476,11 +491,28 @@ private extension ChatDetailView { } ToolbarItem(placement: .navigationBarTrailing) { - Button { openProfile() } label: { - ChatDetailToolbarAvatar(route: route, size: 35) - .frame(width: 36, height: 36) - .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + HStack(spacing: 8) { + if canStartCall { + Button { startVoiceCall() } label: { + Image(systemName: "phone.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .background { + glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Start Call") + } + + Button { openProfile() } label: { + ChatDetailToolbarAvatar(route: route, size: 35) + .frame(width: 36, height: 36) + .contentShape(Circle()) + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + } } .buttonStyle(.plain) } @@ -507,11 +539,28 @@ private extension ChatDetailView { } ToolbarItem(placement: .navigationBarTrailing) { - Button { openProfile() } label: { - ChatDetailToolbarAvatar(route: route, size: 38) - .frame(width: 44, height: 44) - .contentShape(Circle()) - .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + HStack(spacing: 8) { + if canStartCall { + Button { startVoiceCall() } label: { + Image(systemName: "phone.fill") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + .contentShape(Circle()) + .background { + glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Start Call") + } + + Button { openProfile() } label: { + ChatDetailToolbarAvatar(route: route, size: 38) + .frame(width: 44, height: 44) + .contentShape(Circle()) + .background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) } + } } .buttonStyle(.plain) } @@ -541,6 +590,35 @@ private extension ChatDetailView { return RosettaColors.initials(name: titleText, publicKey: route.publicKey) } + private var canStartCall: Bool { + !route.isSavedMessages + && !route.isSystemAccount + && !DatabaseManager.isGroupDialogKey(route.publicKey) + } + + private func startVoiceCall() { + let peerTitle = dialog?.opponentTitle ?? route.title + let peerUsername = dialog?.opponentUsername ?? route.username + let result = CallManager.shared.startOutgoingCall( + toPublicKey: route.publicKey, + title: peerTitle, + username: peerUsername + ) + + switch result { + case .started: + break + case .alreadyInCall: + callErrorMessage = "You are already in another call." + case .accountNotBound: + callErrorMessage = "Account is not ready for calls yet." + case .invalidTarget: + callErrorMessage = "Unable to start call for this chat." + case .notIncoming: + callErrorMessage = "Call state is invalid." + } + } + var avatarColorIndex: Int { RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey) } diff --git a/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift b/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift index f5332be..c949f1c 100644 --- a/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift +++ b/Rosetta/Features/Chats/ChatDetail/MediaBubbleCornerMaskFactory.swift @@ -5,6 +5,22 @@ enum MediaBubbleCornerMaskFactory { private static let mergedRadius: CGFloat = 8 private static let inset: CGFloat = 2 + /// Full bubble mask INCLUDING tail shape — used for photo-only messages + /// where the photo fills the entire bubble area. + static func fullBubbleMask( + bounds: CGRect, + mergeType: BubbleMergeType, + outgoing: Bool + ) -> CAShapeLayer { + let path = BubbleGeometryEngine.makeCGPath( + in: bounds, mergeType: mergeType, outgoing: outgoing + ) + let mask = CAShapeLayer() + mask.frame = bounds + mask.path = path + return mask + } + static func containerMask( bounds: CGRect, mergeType: BubbleMergeType, diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index f54edbe..58d8862 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -66,6 +66,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // Timestamp + delivery private let statusBackgroundView = UIView() + private let statusGradientLayer = CAGradientLayer() private let timestampLabel = UILabel() private let checkSentView = UIImageView() private let checkReadView = UIImageView() @@ -93,6 +94,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // File private let fileContainer = UIView() private let fileIconView = UIView() + private let fileIconSymbolView = UIImageView() private let fileNameLabel = UILabel() private let fileSizeLabel = UILabel() @@ -158,10 +160,18 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel bubbleView.addSubview(textLabel) // Timestamp - statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.3) + statusBackgroundView.backgroundColor = .clear statusBackgroundView.layer.cornerRadius = 7 statusBackgroundView.layer.cornerCurve = .continuous + statusBackgroundView.clipsToBounds = true statusBackgroundView.isHidden = true + statusGradientLayer.colors = [ + UIColor.black.withAlphaComponent(0.0).cgColor, + UIColor.black.withAlphaComponent(0.5).cgColor + ] + statusGradientLayer.startPoint = CGPoint(x: 0, y: 0) + statusGradientLayer.endPoint = CGPoint(x: 1, y: 1) + statusBackgroundView.layer.insertSublayer(statusGradientLayer, at: 0) bubbleView.addSubview(statusBackgroundView) timestampLabel.font = Self.timestampFont @@ -254,6 +264,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // File fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) fileIconView.layer.cornerRadius = 20 + fileIconSymbolView.tintColor = .white + fileIconSymbolView.contentMode = .scaleAspectFit + fileIconView.addSubview(fileIconSymbolView) fileContainer.addSubview(fileIconView) fileNameLabel.font = Self.fileNameFont fileNameLabel.textColor = .white @@ -407,13 +420,44 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel // File if let layout = currentLayout, layout.hasFile { fileContainer.isHidden = false - let fileAtt = message.attachments.first { $0.type == .file } - if let fileAtt { + if let callAtt = message.attachments.first(where: { $0.type == .call }) { + let durationSec = AttachmentPreviewCodec.parseCallDurationSeconds(callAtt.preview) + let isOutgoing = currentLayout?.isOutgoing ?? false + let isError = durationSec == 0 + + if isError { + fileIconView.backgroundColor = UIColor.systemRed.withAlphaComponent(0.85) + fileIconSymbolView.image = UIImage(systemName: "xmark") + fileNameLabel.text = isOutgoing ? "Rejected call" : "Missed call" + fileSizeLabel.text = "Call was not answered or was rejected" + fileSizeLabel.textColor = UIColor.systemRed.withAlphaComponent(0.95) + } else { + fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) + fileIconSymbolView.image = UIImage( + systemName: isOutgoing ? "phone.arrow.up.right.fill" : "phone.arrow.down.left.fill" + ) + fileNameLabel.text = isOutgoing ? "Outgoing call" : "Incoming call" + fileSizeLabel.text = Self.formattedDuration(seconds: durationSec) + fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + } + } else if let fileAtt = message.attachments.first(where: { $0.type == .file }) { + fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) + fileIconSymbolView.image = UIImage(systemName: "doc.fill") fileNameLabel.text = AttachmentPreviewCodec.parseFilePreview(fileAtt.preview).fileName + fileSizeLabel.text = "" + fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) + } else if message.attachments.first(where: { $0.type == .avatar }) != nil { + fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) + fileIconSymbolView.image = UIImage(systemName: "person.crop.circle.fill") + fileNameLabel.text = "Avatar" + fileSizeLabel.text = "" + fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) } else { + fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2) + fileIconSymbolView.image = UIImage(systemName: "doc.fill") fileNameLabel.text = "File" + fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6) } - fileSizeLabel.text = "" } else { fileContainer.isHidden = true } @@ -462,7 +506,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel width: layout.bubbleSize.width + tailProtrusion, height: layout.bubbleSize.height) } - bubbleImageView.frame = imageFrame + // Telegram extends bubble image by 1pt on each side (ChatMessageBackground.swift line 115: + // `let imageFrame = CGRect(...).insetBy(dx: -1.0, dy: -1.0)`). + // This makes adjacent bubbles overlap by 2pt vertically, reducing perceived gap. + bubbleImageView.frame = imageFrame.insetBy(dx: -1, dy: -1) bubbleImageView.image = Self.bubbleImages.image( outgoing: layout.isOutgoing, mergeType: layout.mergeType ) @@ -514,6 +561,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel #endif // Telegram-style date/status pill on media-only bubbles. + updateStatusBackgroundVisibility() updateStatusBackgroundFrame() // Reply @@ -538,6 +586,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel if layout.hasFile { fileContainer.frame = layout.fileFrame fileIconView.frame = CGRect(x: 10, y: 8, width: 40, height: 40) + fileIconSymbolView.frame = CGRect(x: 9, y: 9, width: 22, height: 22) fileNameLabel.frame = CGRect(x: 60, y: 10, width: layout.fileFrame.width - 70, height: 17) fileSizeLabel.frame = CGRect(x: 60, y: 30, width: layout.fileFrame.width - 70, height: 15) } @@ -596,6 +645,13 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel ) } + private static func formattedDuration(seconds: Int) -> String { + let safe = max(seconds, 0) + let minutes = safe / 60 + let secs = safe % 60 + return String(format: "%d:%02d", minutes, secs) + } + // MARK: - Self-sizing (from pre-calculated layout) override func preferredLayoutAttributesFitting( @@ -1306,6 +1362,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel width: contentRect.width + insets.left + insets.right, height: contentRect.height + insets.top + insets.bottom ) + CATransaction.begin() + CATransaction.setDisableActions(true) + statusGradientLayer.frame = statusBackgroundView.bounds + CATransaction.commit() } private func bringStatusOverlayToFront() { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index bce40f1..ef5b8da 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -171,6 +171,7 @@ final class NativeMessageListController: UIViewController { listConfig.backgroundColor = .clear let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment) + section.interGroupSpacing = 0 section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) return section } diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 770f4a3..96b34c5 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -11,6 +11,7 @@ struct MainTabView: View { @State private var isChatListDetailPresented = false @State private var isSettingsEditPresented = false @State private var isSettingsDetailPresented = false + @StateObject private var callManager = CallManager.shared // Add Account — presented as fullScreenCover so Settings stays alive. // Using optional AuthScreen as the item ensures the correct screen is @@ -63,6 +64,11 @@ struct MainTabView: View { // Reads DialogRepository in its own scope — MainTabView.body // never observes the dialogs dictionary directly. UnreadCountObserver(count: $cachedUnreadCount) + + if callManager.uiState.isVisible { + ActiveCallOverlayView(callManager: callManager) + .zIndex(10) + } } // Switch to Chats tab when user taps a push notification. // Without this, the navigation happens in the Chats NavigationStack diff --git a/Rosetta/Rosetta-Bridging-Header.h b/Rosetta/Rosetta-Bridging-Header.h index 9f3241f..5d8dbac 100644 --- a/Rosetta/Rosetta-Bridging-Header.h +++ b/Rosetta/Rosetta-Bridging-Header.h @@ -7,4 +7,5 @@ // #import "Core/Crypto/NativeCryptoBridge.h" +#import "Core/Crypto/WebRTCFrameCryptorBridge.h" #import "Core/Utils/NativeBlurHashBridge.h" diff --git a/RosettaTests/CallAttachmentParityTests.swift b/RosettaTests/CallAttachmentParityTests.swift new file mode 100644 index 0000000..f987a6b --- /dev/null +++ b/RosettaTests/CallAttachmentParityTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import Rosetta + +@MainActor +final class CallAttachmentParityTests: XCTestCase { + private var ctx: DBTestContext! + private var packetSenderMock: CallAttachmentPacketSenderMock! + + private var ownPrivateKeyHex: String = "" + private var ownPublicKey: String = "" + private var peerPublicKey: String = "" + + override func setUpWithError() throws { + let ownPair = try Self.makeKeyPair() + let peerPair = try Self.makeKeyPair() + + ownPrivateKeyHex = ownPair.privateKeyHex + ownPublicKey = ownPair.publicKeyHex + peerPublicKey = peerPair.publicKeyHex + + ctx = DBTestContext(account: ownPublicKey) + packetSenderMock = CallAttachmentPacketSenderMock() + + SessionManager.shared.testConfigureSessionForParityFlows( + currentPublicKey: ownPublicKey, + privateKeyHex: ownPrivateKeyHex + ) + SessionManager.shared.packetFlowSender = packetSenderMock + AttachmentCache.shared.privateKey = ownPrivateKeyHex + } + + override func tearDownWithError() throws { + ctx?.teardown() + ctx = nil + packetSenderMock = nil + AttachmentCache.shared.privateKey = nil + SessionManager.shared.testResetParityFlowDependencies() + } + + func testCallDurationPreviewParserMatrix() { + let tag = "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb" + + XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds("65"), 65) + XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds("\(tag)::125"), 125) + XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds("durationSec=42"), 42) + XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds("duration 33"), 33) + XCTAssertEqual(AttachmentPreviewCodec.parseCallDurationSeconds("invalid"), 0) + } + + func testSendCallAttachmentProducesType4WithDurationPreview() async throws { + try await ctx.bootstrap() + + try await SessionManager.shared.sendCallAttachment( + toPublicKey: peerPublicKey, + durationSec: 87, + opponentTitle: "Peer", + opponentUsername: "peer" + ) + + XCTAssertEqual(packetSenderMock.sentMessages.count, 1) + guard let packet = packetSenderMock.sentMessages.first else { + XCTFail("Expected one outgoing call attachment packet") + return + } + + XCTAssertEqual(packet.attachments.count, 1) + let attachment = packet.attachments[0] + XCTAssertEqual(attachment.type, .call) + XCTAssertEqual(attachment.preview, "87") + XCTAssertEqual(attachment.blob, "") + } + + private static func makeKeyPair() throws -> (privateKeyHex: String, publicKeyHex: String) { + let mnemonic = try CryptoManager.shared.generateMnemonic() + let pair = try CryptoManager.shared.deriveKeyPair(from: mnemonic) + return (pair.privateKey.hexString, pair.publicKey.hexString) + } +} + +private final class CallAttachmentPacketSenderMock: PacketFlowSending { + private(set) var sentMessages: [PacketMessage] = [] + + func sendPacket(_ packet: any Packet) { + if let message = packet as? PacketMessage { + sentMessages.append(message) + } + } +} diff --git a/RosettaTests/CallPacketParityTests.swift b/RosettaTests/CallPacketParityTests.swift new file mode 100644 index 0000000..21a4171 --- /dev/null +++ b/RosettaTests/CallPacketParityTests.swift @@ -0,0 +1,153 @@ +import XCTest +@testable import Rosetta + +final class CallPacketParityTests: XCTestCase { + func testSignalPeerRoundTripForCallKeyExchangeAndCreateRoom() throws { + let call = PacketSignalPeer( + src: "02caller", + dst: "02callee", + sharedPublic: "", + signalType: .call, + roomId: "" + ) + let keyExchange = PacketSignalPeer( + src: "02callee", + dst: "02caller", + sharedPublic: "abcdef012345", + signalType: .keyExchange, + roomId: "" + ) + let createRoom = PacketSignalPeer( + src: "02caller", + dst: "02callee", + sharedPublic: "", + signalType: .createRoom, + roomId: "room-42" + ) + + let decodedCall = try decodeSignal(call) + XCTAssertEqual(decodedCall.signalType, .call) + XCTAssertEqual(decodedCall.src, "02caller") + XCTAssertEqual(decodedCall.dst, "02callee") + XCTAssertEqual(decodedCall.sharedPublic, "") + XCTAssertEqual(decodedCall.roomId, "") + + let decodedKeyExchange = try decodeSignal(keyExchange) + XCTAssertEqual(decodedKeyExchange.signalType, .keyExchange) + XCTAssertEqual(decodedKeyExchange.src, "02callee") + XCTAssertEqual(decodedKeyExchange.dst, "02caller") + XCTAssertEqual(decodedKeyExchange.sharedPublic, "abcdef012345") + XCTAssertEqual(decodedKeyExchange.roomId, "") + + let decodedCreateRoom = try decodeSignal(createRoom) + XCTAssertEqual(decodedCreateRoom.signalType, .createRoom) + XCTAssertEqual(decodedCreateRoom.src, "02caller") + XCTAssertEqual(decodedCreateRoom.dst, "02callee") + XCTAssertEqual(decodedCreateRoom.sharedPublic, "") + XCTAssertEqual(decodedCreateRoom.roomId, "room-42") + } + + func testSignalPeerRoundTripForBusyAndPeerDisconnectedShortFormat() throws { + let busy = PacketSignalPeer( + src: "02should-not-be-sent", + dst: "02should-not-be-sent", + sharedPublic: "ignored", + signalType: .endCallBecauseBusy, + roomId: "ignored-room" + ) + let disconnected = PacketSignalPeer( + src: "02should-not-be-sent", + dst: "02should-not-be-sent", + sharedPublic: "ignored", + signalType: .endCallBecausePeerDisconnected, + roomId: "ignored-room" + ) + + let decodedBusy = try decodeSignal(busy) + XCTAssertEqual(decodedBusy.signalType, .endCallBecauseBusy) + XCTAssertEqual(decodedBusy.src, "") + XCTAssertEqual(decodedBusy.dst, "") + XCTAssertEqual(decodedBusy.sharedPublic, "") + XCTAssertEqual(decodedBusy.roomId, "") + + let decodedDisconnected = try decodeSignal(disconnected) + XCTAssertEqual(decodedDisconnected.signalType, .endCallBecausePeerDisconnected) + XCTAssertEqual(decodedDisconnected.src, "") + XCTAssertEqual(decodedDisconnected.dst, "") + XCTAssertEqual(decodedDisconnected.sharedPublic, "") + XCTAssertEqual(decodedDisconnected.roomId, "") + } + + func testWebRtcRoundTripForOfferAnswerAndIceCandidate() throws { + let offer = PacketWebRTC(signalType: .offer, sdpOrCandidate: #"{"type":"offer","sdp":"v=0"}"#) + let answer = PacketWebRTC(signalType: .answer, sdpOrCandidate: #"{"type":"answer","sdp":"v=0"}"#) + let candidate = PacketWebRTC( + signalType: .iceCandidate, + sdpOrCandidate: #"{"candidate":"candidate:1 1 udp 2130706431 10.0.0.1 7777 typ host"}"# + ) + + let decodedOffer = try decodeWebRtc(offer) + XCTAssertEqual(decodedOffer.signalType, .offer) + XCTAssertEqual(decodedOffer.sdpOrCandidate, offer.sdpOrCandidate) + + let decodedAnswer = try decodeWebRtc(answer) + XCTAssertEqual(decodedAnswer.signalType, .answer) + XCTAssertEqual(decodedAnswer.sdpOrCandidate, answer.sdpOrCandidate) + + let decodedCandidate = try decodeWebRtc(candidate) + XCTAssertEqual(decodedCandidate.signalType, .iceCandidate) + XCTAssertEqual(decodedCandidate.sdpOrCandidate, candidate.sdpOrCandidate) + } + + func testIceServersRoundTrip() throws { + let packet = PacketIceServers( + iceServers: [ + CallIceServer( + url: "turn:turn.rosetta.im?transport=udp", + username: "u1", + credential: "p1", + transport: "udp" + ), + CallIceServer( + url: "stun:stun.l.google.com:19302", + username: "", + credential: "", + transport: "" + ), + ] + ) + + let encoded = PacketRegistry.encode(packet) + guard let decoded = PacketRegistry.decode(from: encoded), + let decodedPacket = decoded.packet as? PacketIceServers + else { + XCTFail("Failed to decode PacketIceServers") + return + } + + XCTAssertEqual(decoded.packetId, 0x1C) + XCTAssertEqual(decodedPacket.iceServers, packet.iceServers) + } + + private func decodeSignal(_ packet: PacketSignalPeer) throws -> PacketSignalPeer { + let encoded = PacketRegistry.encode(packet) + guard let decoded = PacketRegistry.decode(from: encoded), + let decodedPacket = decoded.packet as? PacketSignalPeer + else { + throw NSError(domain: "CallPacketParityTests", code: 1) + } + XCTAssertEqual(decoded.packetId, 0x1A) + return decodedPacket + } + + private func decodeWebRtc(_ packet: PacketWebRTC) throws -> PacketWebRTC { + let encoded = PacketRegistry.encode(packet) + guard let decoded = PacketRegistry.decode(from: encoded), + let decodedPacket = decoded.packet as? PacketWebRTC + else { + throw NSError(domain: "CallPacketParityTests", code: 2) + } + XCTAssertEqual(decoded.packetId, 0x1B) + return decodedPacket + } +} diff --git a/RosettaTests/CallRoutingTests.swift b/RosettaTests/CallRoutingTests.swift new file mode 100644 index 0000000..28f85f1 --- /dev/null +++ b/RosettaTests/CallRoutingTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import Rosetta + +@MainActor +final class CallRoutingTests: XCTestCase { + private let ownKey = "02-own" + private let peerA = "02-peer-a" + private let peerB = "02-peer-b" + + override func setUp() { + super.setUp() + CallManager.shared.resetForSessionEnd() + CallManager.shared.bindAccount(publicKey: ownKey) + } + + override func tearDown() { + CallManager.shared.resetForSessionEnd() + super.tearDown() + } + + func testIncomingCallMovesToIncomingPhase() { + let packet = PacketSignalPeer( + src: peerA, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + + CallManager.shared.testHandleSignalPacket(packet) + + XCTAssertEqual(CallManager.shared.uiState.phase, .incoming) + XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerA) + } + + func testBusySignalEndsCurrentCallStateWithoutCrosstalk() { + CallManager.shared.testSetUiState( + CallUiState( + phase: .outgoing, + peerPublicKey: peerA, + statusText: "Calling..." + ) + ) + + let packet = PacketSignalPeer( + src: "", + dst: "", + sharedPublic: "", + signalType: .endCallBecauseBusy, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(packet) + + XCTAssertEqual(CallManager.shared.uiState.phase, .idle) + XCTAssertEqual(CallManager.shared.uiState.statusText, "User is busy") + XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, "") + } + + func testPeerDisconnectedSignalEndsCurrentCallState() { + CallManager.shared.testSetUiState( + CallUiState( + phase: .active, + peerPublicKey: peerA, + statusText: "Call active" + ) + ) + + let packet = PacketSignalPeer( + src: "", + dst: "", + sharedPublic: "", + signalType: .endCallBecausePeerDisconnected, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(packet) + + XCTAssertEqual(CallManager.shared.uiState.phase, .idle) + XCTAssertEqual(CallManager.shared.uiState.statusText, "Peer disconnected") + XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, "") + } + + func testInterleavingForeignSignalDoesNotOverrideActivePeer() { + CallManager.shared.testSetUiState( + CallUiState( + phase: .outgoing, + peerPublicKey: peerA, + statusText: "Calling..." + ) + ) + + let foreignPacket = PacketSignalPeer( + src: peerB, + dst: ownKey, + sharedPublic: "", + signalType: .call, + roomId: "" + ) + CallManager.shared.testHandleSignalPacket(foreignPacket) + + XCTAssertEqual(CallManager.shared.uiState.phase, .outgoing) + XCTAssertEqual(CallManager.shared.uiState.peerPublicKey, peerA) + XCTAssertEqual(CallManager.shared.uiState.statusText, "Calling...") + } +}