iOS звонки в foreground с full E2EE и паритетом call-attachment

This commit is contained in:
2026-03-28 23:40:39 +05:00
parent e49d224e6a
commit 16191ef197
30 changed files with 2719 additions and 44 deletions

View File

@@ -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" */;

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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<const std::uint8_t *>(input.bytes);
const auto *keyBytes = static_cast<const std::uint8_t *>(key.bytes);
const auto *nonceBytes = static_cast<const std::uint8_t *>(nonce.bytes);
std::vector<std::uint8_t> output;
const bool ok = rosetta::nativecrypto::xchacha20_xor(
inputBytes,
static_cast<std::size_t>(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<const std::uint8_t *>(rawDH.bytes);
std::vector<std::uint8_t> 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

View File

@@ -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<std::uint8_t> &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<std::uint8_t> &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

View File

@@ -22,4 +22,17 @@ bool xchacha20poly1305_decrypt(
std::vector<uint8_t> &plaintext
);
bool xchacha20_xor(
const uint8_t *input,
std::size_t input_length,
const uint8_t *key32,
const uint8_t *nonce24,
std::vector<uint8_t> &output
);
bool hsalsa20_derive(
const uint8_t *raw_dh32,
std::vector<uint8_t> &shared_key32
);
} // namespace rosetta::nativecrypto

View File

@@ -0,0 +1,18 @@
#import <Foundation/Foundation.h>
@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

View File

@@ -0,0 +1,484 @@
#import "WebRTCFrameCryptorBridge.h"
#import <WebRTC/RTCRtpReceiver.h>
#import <WebRTC/RTCRtpSender.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <string>
#include <unordered_map>
#include <vector>
#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 <typename T>
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 <typename T>
inline scoped_refptr<T> make_ref_counted(T *ptr) {
return scoped_refptr<T>(ptr);
}
template <typename T>
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<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> frame,
rtc::ArrayView<uint8_t> 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<uint32_t> &csrcs,
rtc::ArrayView<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> encrypted_frame,
rtc::ArrayView<uint8_t> 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<webrtc::FrameEncryptorInterface>)encryptor;
@end
@interface RTCRtpReceiver (RosettaFrameCryptorPrivate)
- (void)setFrameDecryptor:(rtc::scoped_refptr<webrtc::FrameDecryptorInterface>)decryptor;
@end
namespace {
constexpr std::size_t kSharedKeyLength = 32;
constexpr std::size_t kNonceLength = 24;
std::mutex gCryptorLock;
std::unordered_map<std::string, rtc::scoped_refptr<webrtc::FrameEncryptorInterface>> gSenderEncryptors;
std::unordered_map<std::string, rtc::scoped_refptr<webrtc::FrameDecryptorInterface>> 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<std::uintptr_t>(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<std::uintptr_t>(receiver));
}
void fillNonceFromAdditionalData(std::array<std::uint8_t, kNonceLength> &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<std::size_t>(8, additionalDataLength);
std::memcpy(nonce.data(), additionalData, copyCount);
}
bool xorFramePayload(const std::uint8_t *input,
std::size_t inputLength,
const std::array<std::uint8_t, kSharedKeyLength> &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<std::uint8_t, kNonceLength> nonce;
fillNonceFromAdditionalData(nonce, additionalData, additionalDataLength);
std::vector<std::uint8_t> 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<std::uint8_t, kSharedKeyLength> &key) : key_(key) {}
int Encrypt(cricket::MediaType media_type,
uint32_t ssrc,
rtc::ArrayView<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> frame,
rtc::ArrayView<uint8_t> 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<int> refCount_{0};
std::array<std::uint8_t, kSharedKeyLength> key_;
};
class RosettaFrameDecryptor final : public webrtc::FrameDecryptorInterface {
public:
explicit RosettaFrameDecryptor(const std::array<std::uint8_t, kSharedKeyLength> &key) : key_(key) {}
Result Decrypt(cricket::MediaType media_type,
const std::vector<uint32_t> &csrcs,
rtc::ArrayView<const uint8_t> additional_data,
rtc::ArrayView<const uint8_t> encrypted_frame,
rtc::ArrayView<uint8_t> 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<int> refCount_{0};
std::array<std::uint8_t, kSharedKeyLength> key_;
};
std::array<std::uint8_t, kSharedKeyLength> normalizeSharedKey(NSData *sharedKey) {
std::array<std::uint8_t, kSharedKeyLength> key{};
if (sharedKey.length == 0 || sharedKey.bytes == nullptr) {
return key;
}
const std::size_t copyLength = std::min<std::size_t>(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<std::mutex> 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<webrtc::FrameEncryptorInterface> 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<std::mutex> 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<webrtc::FrameDecryptorInterface> decryptor(
new RosettaFrameDecryptor(normalizedKey)
);
[receiver setFrameDecryptor:decryptor];
gReceiverDecryptors[key] = decryptor;
return YES;
}
+ (void)detachSender:(RTCRtpSender *)sender {
if (sender == nil) {
return;
}
std::lock_guard<std::mutex> lock(gCryptorLock);
const std::string key = senderMapKey(sender);
if ([sender respondsToSelector:@selector(setFrameEncryptor:)]) {
[sender setFrameEncryptor:rtc::scoped_refptr<webrtc::FrameEncryptorInterface>()];
}
gSenderEncryptors.erase(key);
}
+ (void)detachReceiver:(RTCRtpReceiver *)receiver {
if (receiver == nil) {
return;
}
std::lock_guard<std::mutex> lock(gCryptorLock);
const std::string key = receiverMapKey(receiver);
if ([receiver respondsToSelector:@selector(setFrameDecryptor:)]) {
[receiver setFrameDecryptor:rtc::scoped_refptr<webrtc::FrameDecryptorInterface>()];
}
gReceiverDecryptors.erase(key);
}
@end

View File

@@ -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)

View File

@@ -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))
}
}

View File

@@ -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.

View File

@@ -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..<count {
parsed.append(
CallIceServer(
url: stream.readString(),
username: stream.readString(),
credential: stream.readString(),
transport: stream.readString()
)
)
}
iceServers = parsed
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
/// Call signaling packet (0x1A / 26).
/// Wire format mirrors desktop/android:
/// `signalType` always first, then short-form for busy/disconnected,
/// otherwise `src`, `dst`, optional `sharedPublic`, optional `roomId`.
enum SignalType: Int, Sendable {
case call = 0
case keyExchange = 1
case activeCall = 2
case endCall = 3
case createRoom = 4
case endCallBecausePeerDisconnected = 5
case endCallBecauseBusy = 6
}
struct PacketSignalPeer: Packet {
static let packetId = 0x1A
var src: String = ""
var dst: String = ""
var sharedPublic: String = ""
var signalType: SignalType = .call
var roomId: String = ""
func write(to stream: Stream) {
stream.writeInt8(signalType.rawValue)
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected {
return
}
stream.writeString(src)
stream.writeString(dst)
if signalType == .keyExchange {
stream.writeString(sharedPublic)
}
if signalType == .createRoom {
stream.writeString(roomId)
}
}
mutating func read(from stream: Stream) {
signalType = SignalType(rawValue: stream.readInt8()) ?? .call
if signalType == .endCallBecauseBusy || signalType == .endCallBecausePeerDisconnected {
return
}
src = stream.readString()
dst = stream.readString()
if signalType == .keyExchange {
sharedPublic = stream.readString()
}
if signalType == .createRoom {
roomId = stream.readString()
}
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
/// WebRTC signaling packet (0x1B / 27).
/// Carries SDP offer/answer and ICE candidate payloads.
enum WebRTCSignalType: Int, Sendable {
case offer = 0
case answer = 1
case iceCandidate = 2
}
struct PacketWebRTC: Packet {
static let packetId = 0x1B
var signalType: WebRTCSignalType = .offer
var sdpOrCandidate: String = ""
func write(to stream: Stream) {
stream.writeInt8(signalType.rawValue)
stream.writeString(sdpOrCandidate)
}
mutating func read(from stream: Stream) {
signalType = WebRTCSignalType(rawValue: stream.readInt8()) ?? .offer
sdpOrCandidate = stream.readString()
}
}

View File

@@ -54,6 +54,9 @@ final class ProtocolManager: @unchecked Sendable {
var onGroupBanReceived: ((PacketGroupBan) -> 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

View File

@@ -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<Void, Error>) 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<Void, Error>) 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)
}
}
}

View File

@@ -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<String> = []
var durationTask: Task<Void, Never>?
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

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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..<pipeIdx])
}
return raw
}
/// Extract pixel dimensions encoded as `|WxH` suffix in image preview.
/// Format: "tag::blurhash|WxH" "|" separator avoids breaking desktop's `::` parsing.
/// Returns nil if no dimensions found (legacy messages).
static func imageDimensions(from preview: String) -> 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..<xIdx]),
let h = Int(dimStr[dimStr.index(after: xIdx)...]),
w >= 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..<normalized.endIndex, in: normalized)
guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
match.numberOfRanges > 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("::") {

View File

@@ -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 ""
}
}
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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() {

View File

@@ -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
}

View File

@@ -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

View File

@@ -7,4 +7,5 @@
//
#import "Core/Crypto/NativeCryptoBridge.h"
#import "Core/Crypto/WebRTCFrameCryptorBridge.h"
#import "Core/Utils/NativeBlurHashBridge.h"

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

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