iOS звонки в foreground с full E2EE и паритетом call-attachment
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
D0BD72A9646880B604F1AC3C /* RosettaNotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = A182B0EDE5C68E7C6F1FB6D1 /* RosettaNotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
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 */; };
|
D60B2E657D691F256B5B7FD4 /* SchemaParityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F4769EEC8ABADB3AA98D3A5 /* SchemaParityTests.swift */; };
|
||||||
DA91A59FDC04C2EBE77550F4 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F43A41D5496A62870E307FC /* NotificationService.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 */; };
|
EC5DFA298C697AE235323240 /* MigrationHarnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAA4AD95B61886B5A22EF0D /* MigrationHarnessTests.swift */; };
|
||||||
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
|
F1A000012F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */; };
|
||||||
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
|
F1A000032F6F00010092AD05 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = F1A000042F6F00010092AD05 /* FirebaseMessaging */; };
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
E20000032F8D11110092AD05 /* WebRTC in Frameworks */,
|
||||||
85E887F72F6DC9460032774C /* GRDB in Frameworks */,
|
85E887F72F6DC9460032774C /* GRDB in Frameworks */,
|
||||||
853F29992F4B63D20092AD05 /* Lottie in Frameworks */,
|
853F29992F4B63D20092AD05 /* Lottie in Frameworks */,
|
||||||
853F29A02F4B63D20092AD05 /* P256K in Frameworks */,
|
853F29A02F4B63D20092AD05 /* P256K in Frameworks */,
|
||||||
@@ -217,6 +219,7 @@
|
|||||||
F1A000042F6F00010092AD05 /* FirebaseMessaging */,
|
F1A000042F6F00010092AD05 /* FirebaseMessaging */,
|
||||||
F1A000072F6F00010092AD05 /* FirebaseCrashlytics */,
|
F1A000072F6F00010092AD05 /* FirebaseCrashlytics */,
|
||||||
D1DB00022F8C00010092AD05 /* GRDB */,
|
D1DB00022F8C00010092AD05 /* GRDB */,
|
||||||
|
E20000022F8D11110092AD05 /* WebRTC */,
|
||||||
);
|
);
|
||||||
productName = Rosetta;
|
productName = Rosetta;
|
||||||
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
|
productReference = 853F29622F4B50410092AD05 /* Rosetta.app */;
|
||||||
@@ -271,6 +274,7 @@
|
|||||||
853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */,
|
853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */,
|
||||||
F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
|
F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
|
||||||
D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */,
|
D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */,
|
||||||
|
E20000012F8D11110092AD05 /* XCRemoteSwiftPackageReference "WebRTC" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 853F29632F4B50410092AD05 /* Products */;
|
productRefGroup = 853F29632F4B50410092AD05 /* Products */;
|
||||||
@@ -712,6 +716,14 @@
|
|||||||
minimumVersion = 7.0.0;
|
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" */ = {
|
F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
|
repositoryURL = "https://github.com/firebase/firebase-ios-sdk";
|
||||||
@@ -738,6 +750,11 @@
|
|||||||
package = D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */;
|
package = D1DB00012F8C00010092AD05 /* XCRemoteSwiftPackageReference "GRDB.swift" */;
|
||||||
productName = GRDB;
|
productName = GRDB;
|
||||||
};
|
};
|
||||||
|
E20000022F8D11110092AD05 /* WebRTC */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = E20000012F8D11110092AD05 /* XCRemoteSwiftPackageReference "WebRTC" */;
|
||||||
|
productName = WebRTC;
|
||||||
|
};
|
||||||
F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */ = {
|
F1A000022F6F00010092AD05 /* FirebaseAnalyticsWithoutAdIdSupport */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
package = F1A000052F6F00010092AD05 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */;
|
||||||
|
|||||||
76
Rosetta/Core/Crypto/CallMediaCrypto.swift
Normal file
76
Rosetta/Core/Crypto/CallMediaCrypto.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ NS_ASSUME_NONNULL_BEGIN
|
|||||||
key:(NSData *)key
|
key:(NSData *)key
|
||||||
nonce:(NSData *)nonce;
|
nonce:(NSData *)nonce;
|
||||||
|
|
||||||
|
+ (nullable NSData *)xChaCha20Xor:(NSData *)input
|
||||||
|
key:(NSData *)key
|
||||||
|
nonce:(NSData *)nonce;
|
||||||
|
|
||||||
|
+ (nullable NSData *)naclSharedKeyFromRawDH:(NSData *)rawDH;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|||||||
@@ -62,4 +62,51 @@
|
|||||||
return [NSData dataWithBytes:plaintext.data() length:plaintext.size()];
|
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
|
@end
|
||||||
|
|||||||
@@ -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) {
|
inline void quarter_round(std::uint32_t *state, int a, int b, int c, int d) {
|
||||||
state[a] += state[b];
|
state[a] += state[b];
|
||||||
state[d] ^= state[a];
|
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]) {
|
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 lo = load_le64(block16);
|
||||||
const std::uint64_t hi = load_le64(block16 + 8);
|
const std::uint64_t hi = load_le64(block16 + 8);
|
||||||
@@ -410,4 +486,43 @@ bool xchacha20poly1305_decrypt(
|
|||||||
return true;
|
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
|
} // namespace rosetta::nativecrypto
|
||||||
|
|||||||
@@ -22,4 +22,17 @@ bool xchacha20poly1305_decrypt(
|
|||||||
std::vector<uint8_t> &plaintext
|
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
|
} // namespace rosetta::nativecrypto
|
||||||
|
|||||||
18
Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.h
Normal file
18
Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.h
Normal 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
|
||||||
484
Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.mm
Normal file
484
Rosetta/Core/Crypto/WebRTCFrameCryptorBridge.mm
Normal 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
|
||||||
@@ -29,7 +29,7 @@ struct BubbleMetrics: Sendable {
|
|||||||
mainRadius: 16,
|
mainRadius: 16,
|
||||||
auxiliaryRadius: 8,
|
auxiliaryRadius: 8,
|
||||||
tailProtrusion: 6,
|
tailProtrusion: 6,
|
||||||
defaultSpacing: screenPixel,
|
defaultSpacing: 2 + screenPixel,
|
||||||
mergedSpacing: 0,
|
mergedSpacing: 0,
|
||||||
textInsets: UIEdgeInsets(top: 6 + screenPixel, left: 11, bottom: 6 - screenPixel, right: 11),
|
textInsets: UIEdgeInsets(top: 6 + screenPixel, left: 11, bottom: 6 - screenPixel, right: 11),
|
||||||
mediaStatusInsets: UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)
|
mediaStatusInsets: UIEdgeInsets(top: 2, left: 7, bottom: 2, right: 7)
|
||||||
|
|||||||
@@ -94,8 +94,10 @@ extension MessageCellLayout {
|
|||||||
let replyName: String?
|
let replyName: String?
|
||||||
let replyText: String?
|
let replyText: String?
|
||||||
let imageCount: Int
|
let imageCount: Int
|
||||||
|
let imageDimensions: CGSize?
|
||||||
let fileCount: Int
|
let fileCount: Int
|
||||||
let avatarCount: Int
|
let avatarCount: Int
|
||||||
|
let callCount: Int
|
||||||
let isForward: Bool
|
let isForward: Bool
|
||||||
let forwardImageCount: Int
|
let forwardImageCount: Int
|
||||||
let forwardFileCount: Int
|
let forwardFileCount: Int
|
||||||
@@ -157,7 +159,7 @@ extension MessageCellLayout {
|
|||||||
messageType = .photoWithCaption
|
messageType = .photoWithCaption
|
||||||
} else if config.imageCount > 0 {
|
} else if config.imageCount > 0 {
|
||||||
messageType = .photo
|
messageType = .photo
|
||||||
} else if config.fileCount > 0 || config.avatarCount > 0 {
|
} else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 {
|
||||||
messageType = .file
|
messageType = .file
|
||||||
} else if config.hasReplyQuote {
|
} else if config.hasReplyQuote {
|
||||||
messageType = .textWithReply
|
messageType = .textWithReply
|
||||||
@@ -279,16 +281,8 @@ extension MessageCellLayout {
|
|||||||
let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width)
|
let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width)
|
||||||
let replyH: CGFloat = config.hasReplyQuote ? 46 : 0
|
let replyH: CGFloat = config.hasReplyQuote ? 46 : 0
|
||||||
var photoH: CGFloat = 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 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.
|
// Tiny floor just to prevent zero-width collapse.
|
||||||
// Telegram does NOT force a large minW — short messages get tight bubbles.
|
// Telegram does NOT force a large minW — short messages get tight bubbles.
|
||||||
@@ -300,15 +294,44 @@ extension MessageCellLayout {
|
|||||||
var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
|
var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
|
||||||
|
|
||||||
if config.imageCount > 0 {
|
if config.imageCount > 0 {
|
||||||
// Media bubbles should not stretch edge-to-edge; keep Telegram-like cap.
|
// Telegram-exact photo sizing (ChatMessageInteractiveMediaNode.swift):
|
||||||
bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth)
|
// 1. unboundSize = pixelDims * 0.5 (or 200×100 default)
|
||||||
photoH = Self.collageHeight(
|
// 2. fitted = unbound.aspectFitted(maxDimensions)
|
||||||
count: config.imageCount,
|
// 3. width = min(unbound.width, fitted.width, maxWidth) ← KEY: cap to natural size
|
||||||
width: bubbleW - 8,
|
// 4. height = fit to width preserving aspect ratio, then clamp
|
||||||
maxHeight: mediaDimensions.maxHeight,
|
// 5. Photo inset: 2pt on ALL four sides
|
||||||
minHeight: mediaDimensions.minHeight
|
let photoInset: CGFloat = 2
|
||||||
)
|
if config.imageCount == 1, let dims = config.imageDimensions {
|
||||||
bubbleH += photoH
|
// 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 {
|
if !config.text.isEmpty {
|
||||||
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
bubbleH += topPad + textMeasurement.size.height + bottomPad
|
||||||
if photoH > 0 { bubbleH += 6 }
|
if photoH > 0 { bubbleH += 6 }
|
||||||
@@ -367,7 +390,9 @@ extension MessageCellLayout {
|
|||||||
// checkFrame.minX = bubbleW - inset - checkW
|
// checkFrame.minX = bubbleW - inset - checkW
|
||||||
let metadataRightInset: CGFloat
|
let metadataRightInset: CGFloat
|
||||||
if isMediaMessage {
|
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 {
|
} else if isTextMessage {
|
||||||
// Outgoing: 5pt (checkmarks fill the gap to rightPad)
|
// Outgoing: 5pt (checkmarks fill the gap to rightPad)
|
||||||
// Incoming: rightPad (11pt, same as text — no checkmarks to fill the gap)
|
// Incoming: rightPad (11pt, same as text — no checkmarks to fill the gap)
|
||||||
@@ -377,7 +402,8 @@ extension MessageCellLayout {
|
|||||||
} else {
|
} else {
|
||||||
metadataRightInset = rightPad
|
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 statusEndX = bubbleW - metadataRightInset
|
||||||
let statusEndY = bubbleH - metadataBottomInset
|
let statusEndY = bubbleH - metadataBottomInset
|
||||||
let statusVerticalOffset: CGFloat = isTextMessage
|
let statusVerticalOffset: CGFloat = isTextMessage
|
||||||
@@ -435,8 +461,9 @@ extension MessageCellLayout {
|
|||||||
if config.hasReplyQuote { textY = replyH + topPad }
|
if config.hasReplyQuote { textY = replyH + topPad }
|
||||||
if forwardHeaderH > 0 { textY = forwardHeaderH + topPad }
|
if forwardHeaderH > 0 { textY = forwardHeaderH + topPad }
|
||||||
if photoH > 0 {
|
if photoH > 0 {
|
||||||
textY = photoH + 6 + topPad
|
// Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap
|
||||||
if config.hasReplyQuote { textY = replyH + photoH + 6 + topPad }
|
textY = photoH + 4 + 6 + topPad
|
||||||
|
if config.hasReplyQuote { textY = replyH + photoH + 4 + 6 + topPad }
|
||||||
}
|
}
|
||||||
if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + 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 replyNameFrame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17)
|
||||||
let replyTextFrame = CGRect(x: 9, y: 20, 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 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)
|
let fwdHeaderFrame = CGRect(x: 10, y: 6, width: bubbleW - 20, height: 14)
|
||||||
@@ -495,7 +524,7 @@ extension MessageCellLayout {
|
|||||||
hasPhoto: config.imageCount > 0,
|
hasPhoto: config.imageCount > 0,
|
||||||
photoFrame: photoFrame,
|
photoFrame: photoFrame,
|
||||||
photoCollageHeight: photoH,
|
photoCollageHeight: photoH,
|
||||||
hasFile: config.fileCount > 0 || config.avatarCount > 0,
|
hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0,
|
||||||
fileFrame: fileFrame,
|
fileFrame: fileFrame,
|
||||||
isForward: config.isForward,
|
isForward: config.isForward,
|
||||||
forwardHeaderFrame: fwdHeaderFrame,
|
forwardHeaderFrame: fwdHeaderFrame,
|
||||||
@@ -626,7 +655,7 @@ extension MessageCellLayout {
|
|||||||
if hasImage {
|
if hasImage {
|
||||||
return .media
|
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 {
|
if hasFileLike {
|
||||||
return .file
|
return .file
|
||||||
}
|
}
|
||||||
@@ -751,9 +780,15 @@ extension MessageCellLayout {
|
|||||||
let images = message.attachments.filter { $0.type == .image }
|
let images = message.attachments.filter { $0.type == .image }
|
||||||
let files = message.attachments.filter { $0.type == .file }
|
let files = message.attachments.filter { $0.type == .file }
|
||||||
let avatars = message.attachments.filter { $0.type == .avatar }
|
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 hasReply = message.attachments.contains { $0.type == .messages }
|
||||||
let isForward = hasReply && displayText.isEmpty
|
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(
|
let config = Config(
|
||||||
maxBubbleWidth: maxBubbleWidth,
|
maxBubbleWidth: maxBubbleWidth,
|
||||||
isOutgoing: isOutgoing,
|
isOutgoing: isOutgoing,
|
||||||
@@ -765,8 +800,10 @@ extension MessageCellLayout {
|
|||||||
replyName: nil,
|
replyName: nil,
|
||||||
replyText: nil,
|
replyText: nil,
|
||||||
imageCount: images.count,
|
imageCount: images.count,
|
||||||
|
imageDimensions: imageDims,
|
||||||
fileCount: files.count,
|
fileCount: files.count,
|
||||||
avatarCount: avatars.count,
|
avatarCount: avatars.count,
|
||||||
|
callCount: calls.count,
|
||||||
isForward: isForward,
|
isForward: isForward,
|
||||||
forwardImageCount: isForward ? images.count : 0,
|
forwardImageCount: isForward ? images.count : 0,
|
||||||
forwardFileCount: isForward ? files.count : 0,
|
forwardFileCount: isForward ? files.count : 0,
|
||||||
@@ -781,3 +818,14 @@ extension MessageCellLayout {
|
|||||||
return (result, textResult)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ enum PacketRegistry {
|
|||||||
0x17: { PacketDeviceList() },
|
0x17: { PacketDeviceList() },
|
||||||
0x18: { PacketDeviceResolve() },
|
0x18: { PacketDeviceResolve() },
|
||||||
0x19: { PacketSync() },
|
0x19: { PacketSync() },
|
||||||
|
0x1A: { PacketSignalPeer() },
|
||||||
|
0x1B: { PacketWebRTC() },
|
||||||
|
0x1C: { PacketIceServers() },
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Deserializes a packet from raw binary data.
|
/// Deserializes a packet from raw binary data.
|
||||||
|
|||||||
45
Rosetta/Core/Network/Protocol/Packets/PacketIceServers.swift
Normal file
45
Rosetta/Core/Network/Protocol/Packets/PacketIceServers.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Rosetta/Core/Network/Protocol/Packets/PacketSignalPeer.swift
Normal file
55
Rosetta/Core/Network/Protocol/Packets/PacketSignalPeer.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Rosetta/Core/Network/Protocol/Packets/PacketWebRTC.swift
Normal file
26
Rosetta/Core/Network/Protocol/Packets/PacketWebRTC.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,9 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
var onGroupBanReceived: ((PacketGroupBan) -> Void)?
|
var onGroupBanReceived: ((PacketGroupBan) -> Void)?
|
||||||
var onSyncReceived: ((PacketSync) -> Void)?
|
var onSyncReceived: ((PacketSync) -> Void)?
|
||||||
var onDeviceNewReceived: ((PacketDeviceNew) -> Void)?
|
var onDeviceNewReceived: ((PacketDeviceNew) -> Void)?
|
||||||
|
var onSignalPeerReceived: ((PacketSignalPeer) -> Void)?
|
||||||
|
var onWebRTCReceived: ((PacketWebRTC) -> Void)?
|
||||||
|
var onIceServersReceived: ((PacketIceServers) -> Void)?
|
||||||
var onHandshakeCompleted: ((PacketHandshake) -> Void)?
|
var onHandshakeCompleted: ((PacketHandshake) -> Void)?
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
@@ -82,9 +85,15 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
private let resultHandlersLock = NSLock()
|
private let resultHandlersLock = NSLock()
|
||||||
|
private let signalPeerHandlersLock = NSLock()
|
||||||
|
private let webRTCHandlersLock = NSLock()
|
||||||
|
private let iceServersHandlersLock = NSLock()
|
||||||
private let packetQueueLock = NSLock()
|
private let packetQueueLock = NSLock()
|
||||||
private let searchRouter = SearchPacketRouter()
|
private let searchRouter = SearchPacketRouter()
|
||||||
private var resultHandlers: [UUID: (PacketResult) -> Void] = [:]
|
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
|
// Saved credentials for auto-reconnect
|
||||||
private var savedPublicKey: String?
|
private var savedPublicKey: String?
|
||||||
@@ -279,6 +288,33 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
sendPacket(packet)
|
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) {
|
func sendPacket(_ packet: any Packet) {
|
||||||
PerformanceLogger.shared.track("protocol.sendPacket")
|
PerformanceLogger.shared.track("protocol.sendPacket")
|
||||||
let id = String(type(of: packet).packetId, radix: 16)
|
let id = String(type(of: packet).packetId, radix: 16)
|
||||||
@@ -307,6 +343,53 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
searchRouter.removeHandler(id)
|
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))
|
// MARK: - Result Handlers (Android parity: waitPacket(0x02))
|
||||||
|
|
||||||
/// Register a one-shot handler for PacketResult (0x02).
|
/// Register a one-shot handler for PacketResult (0x02).
|
||||||
@@ -563,6 +646,21 @@ final class ProtocolManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
onSyncReceived?(p)
|
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:
|
default:
|
||||||
break
|
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) {
|
private func handleHandshakeResponse(_ packet: PacketHandshake) {
|
||||||
handshakeTimeoutTask?.cancel()
|
handshakeTimeoutTask?.cancel()
|
||||||
handshakeTimeoutTask = nil
|
handshakeTimeoutTask = nil
|
||||||
|
|||||||
425
Rosetta/Core/Services/CallManager+Runtime.swift
Normal file
425
Rosetta/Core/Services/CallManager+Runtime.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
328
Rosetta/Core/Services/CallManager.swift
Normal file
328
Rosetta/Core/Services/CallManager.swift
Normal 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
|
||||||
47
Rosetta/Core/Services/CallModels.swift
Normal file
47
Rosetta/Core/Services/CallModels.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,6 +171,7 @@ final class SessionManager {
|
|||||||
currentPublicKey = account.publicKey
|
currentPublicKey = account.publicKey
|
||||||
displayName = account.displayName ?? ""
|
displayName = account.displayName ?? ""
|
||||||
username = account.username ?? ""
|
username = account.username ?? ""
|
||||||
|
CallManager.shared.bindAccount(publicKey: account.publicKey)
|
||||||
|
|
||||||
// Migrate legacy JSON → SQLite on first launch (before repositories read from DB).
|
// Migrate legacy JSON → SQLite on first launch (before repositories read from DB).
|
||||||
let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded(
|
let migrated = await DatabaseMigrationFromJSON.migrateIfNeeded(
|
||||||
@@ -536,7 +537,19 @@ final class SessionManager {
|
|||||||
switch attachment.type {
|
switch attachment.type {
|
||||||
case .image:
|
case .image:
|
||||||
let blurhash = attachment.thumbnail?.blurHash(numberOfComponents: (4, 3)) ?? ""
|
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:
|
case .file:
|
||||||
previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
|
previewSuffix = "\(attachment.fileSize ?? 0)::\(attachment.fileName ?? "file")"
|
||||||
default:
|
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")
|
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).
|
/// Sends typing indicator with throttling (desktop parity: max once per 3s per dialog).
|
||||||
func sendTypingIndicator(toPublicKey: String) {
|
func sendTypingIndicator(toPublicKey: String) {
|
||||||
let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
let base = toPublicKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -1089,6 +1183,7 @@ final class SessionManager {
|
|||||||
AttachmentCache.shared.privateKey = nil
|
AttachmentCache.shared.privateKey = nil
|
||||||
RecentSearchesRepository.shared.clearSession()
|
RecentSearchesRepository.shared.clearSession()
|
||||||
DraftManager.shared.reset()
|
DraftManager.shared.reset()
|
||||||
|
CallManager.shared.resetForSessionEnd()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Protocol Callbacks
|
// MARK: - Protocol Callbacks
|
||||||
@@ -1274,6 +1369,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
// Send push token to server for push notifications (Android parity).
|
// Send push token to server for push notifications (Android parity).
|
||||||
self.sendPushTokenToServer()
|
self.sendPushTokenToServer()
|
||||||
|
CallManager.shared.onAuthenticated()
|
||||||
|
|
||||||
// Desktop parity: user info refresh is deferred until sync completes.
|
// Desktop parity: user info refresh is deferred until sync completes.
|
||||||
// Desktop fetches lazily per-component (useUserInformation); we do it
|
// Desktop fetches lazily per-component (useUserInformation); we do it
|
||||||
|
|||||||
@@ -41,7 +41,26 @@ enum AttachmentPreviewCodec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func blurHash(from preview: String) -> String {
|
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(
|
static func parseFilePreview(
|
||||||
@@ -88,6 +107,37 @@ enum AttachmentPreviewCodec {
|
|||||||
return "\(tag)::\(normalizedPayload)"
|
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 {
|
private static func normalizePayload(_ payload: String) -> String {
|
||||||
var value = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
var value = payload.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
while value.hasPrefix("::") {
|
while value.hasPrefix("::") {
|
||||||
|
|||||||
141
Rosetta/Features/Calls/ActiveCallOverlayView.swift
Normal file
141
Rosetta/Features/Calls/ActiveCallOverlayView.swift
Normal 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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ struct ChatDetailView: View {
|
|||||||
@State private var showNoAvatarAlert = false
|
@State private var showNoAvatarAlert = false
|
||||||
@State private var pendingAttachments: [PendingAttachment] = []
|
@State private var pendingAttachments: [PendingAttachment] = []
|
||||||
@State private var showOpponentProfile = false
|
@State private var showOpponentProfile = false
|
||||||
|
@State private var callErrorMessage: String?
|
||||||
@State private var replyingToMessage: ChatMessage?
|
@State private var replyingToMessage: ChatMessage?
|
||||||
@State private var showForwardPicker = false
|
@State private var showForwardPicker = false
|
||||||
@State private var forwardingMessage: ChatMessage?
|
@State private var forwardingMessage: ChatMessage?
|
||||||
@@ -307,6 +308,20 @@ struct ChatDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Set a profile photo in Settings to share it with contacts.")
|
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) {
|
.sheet(isPresented: $showAttachmentPanel) {
|
||||||
AttachmentPanelView(
|
AttachmentPanelView(
|
||||||
onSend: { attachments, caption in
|
onSend: { attachments, caption in
|
||||||
@@ -476,11 +491,28 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button { openProfile() } label: {
|
HStack(spacing: 8) {
|
||||||
ChatDetailToolbarAvatar(route: route, size: 35)
|
if canStartCall {
|
||||||
.frame(width: 36, height: 36)
|
Button { startVoiceCall() } label: {
|
||||||
.contentShape(Circle())
|
Image(systemName: "phone.fill")
|
||||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
.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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -507,11 +539,28 @@ private extension ChatDetailView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button { openProfile() } label: {
|
HStack(spacing: 8) {
|
||||||
ChatDetailToolbarAvatar(route: route, size: 38)
|
if canStartCall {
|
||||||
.frame(width: 44, height: 44)
|
Button { startVoiceCall() } label: {
|
||||||
.contentShape(Circle())
|
Image(systemName: "phone.fill")
|
||||||
.background { glass(shape: .circle, strokeOpacity: 0.22, strokeColor: .white) }
|
.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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -541,6 +590,35 @@ private extension ChatDetailView {
|
|||||||
return RosettaColors.initials(name: titleText, publicKey: route.publicKey)
|
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 {
|
var avatarColorIndex: Int {
|
||||||
RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
|
RosettaColors.avatarColorIndex(for: titleText, publicKey: route.publicKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ enum MediaBubbleCornerMaskFactory {
|
|||||||
private static let mergedRadius: CGFloat = 8
|
private static let mergedRadius: CGFloat = 8
|
||||||
private static let inset: CGFloat = 2
|
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(
|
static func containerMask(
|
||||||
bounds: CGRect,
|
bounds: CGRect,
|
||||||
mergeType: BubbleMergeType,
|
mergeType: BubbleMergeType,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
|
|
||||||
// Timestamp + delivery
|
// Timestamp + delivery
|
||||||
private let statusBackgroundView = UIView()
|
private let statusBackgroundView = UIView()
|
||||||
|
private let statusGradientLayer = CAGradientLayer()
|
||||||
private let timestampLabel = UILabel()
|
private let timestampLabel = UILabel()
|
||||||
private let checkSentView = UIImageView()
|
private let checkSentView = UIImageView()
|
||||||
private let checkReadView = UIImageView()
|
private let checkReadView = UIImageView()
|
||||||
@@ -93,6 +94,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
// File
|
// File
|
||||||
private let fileContainer = UIView()
|
private let fileContainer = UIView()
|
||||||
private let fileIconView = UIView()
|
private let fileIconView = UIView()
|
||||||
|
private let fileIconSymbolView = UIImageView()
|
||||||
private let fileNameLabel = UILabel()
|
private let fileNameLabel = UILabel()
|
||||||
private let fileSizeLabel = UILabel()
|
private let fileSizeLabel = UILabel()
|
||||||
|
|
||||||
@@ -158,10 +160,18 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
bubbleView.addSubview(textLabel)
|
bubbleView.addSubview(textLabel)
|
||||||
|
|
||||||
// Timestamp
|
// Timestamp
|
||||||
statusBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.3)
|
statusBackgroundView.backgroundColor = .clear
|
||||||
statusBackgroundView.layer.cornerRadius = 7
|
statusBackgroundView.layer.cornerRadius = 7
|
||||||
statusBackgroundView.layer.cornerCurve = .continuous
|
statusBackgroundView.layer.cornerCurve = .continuous
|
||||||
|
statusBackgroundView.clipsToBounds = true
|
||||||
statusBackgroundView.isHidden = 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)
|
bubbleView.addSubview(statusBackgroundView)
|
||||||
|
|
||||||
timestampLabel.font = Self.timestampFont
|
timestampLabel.font = Self.timestampFont
|
||||||
@@ -254,6 +264,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
// File
|
// File
|
||||||
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
||||||
fileIconView.layer.cornerRadius = 20
|
fileIconView.layer.cornerRadius = 20
|
||||||
|
fileIconSymbolView.tintColor = .white
|
||||||
|
fileIconSymbolView.contentMode = .scaleAspectFit
|
||||||
|
fileIconView.addSubview(fileIconSymbolView)
|
||||||
fileContainer.addSubview(fileIconView)
|
fileContainer.addSubview(fileIconView)
|
||||||
fileNameLabel.font = Self.fileNameFont
|
fileNameLabel.font = Self.fileNameFont
|
||||||
fileNameLabel.textColor = .white
|
fileNameLabel.textColor = .white
|
||||||
@@ -407,13 +420,44 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
// File
|
// File
|
||||||
if let layout = currentLayout, layout.hasFile {
|
if let layout = currentLayout, layout.hasFile {
|
||||||
fileContainer.isHidden = false
|
fileContainer.isHidden = false
|
||||||
let fileAtt = message.attachments.first { $0.type == .file }
|
if let callAtt = message.attachments.first(where: { $0.type == .call }) {
|
||||||
if let fileAtt {
|
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
|
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 {
|
} else {
|
||||||
|
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
|
||||||
|
fileIconSymbolView.image = UIImage(systemName: "doc.fill")
|
||||||
fileNameLabel.text = "File"
|
fileNameLabel.text = "File"
|
||||||
|
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
|
||||||
}
|
}
|
||||||
fileSizeLabel.text = ""
|
|
||||||
} else {
|
} else {
|
||||||
fileContainer.isHidden = true
|
fileContainer.isHidden = true
|
||||||
}
|
}
|
||||||
@@ -462,7 +506,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
width: layout.bubbleSize.width + tailProtrusion,
|
width: layout.bubbleSize.width + tailProtrusion,
|
||||||
height: layout.bubbleSize.height)
|
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(
|
bubbleImageView.image = Self.bubbleImages.image(
|
||||||
outgoing: layout.isOutgoing, mergeType: layout.mergeType
|
outgoing: layout.isOutgoing, mergeType: layout.mergeType
|
||||||
)
|
)
|
||||||
@@ -514,6 +561,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Telegram-style date/status pill on media-only bubbles.
|
// Telegram-style date/status pill on media-only bubbles.
|
||||||
|
updateStatusBackgroundVisibility()
|
||||||
updateStatusBackgroundFrame()
|
updateStatusBackgroundFrame()
|
||||||
|
|
||||||
// Reply
|
// Reply
|
||||||
@@ -538,6 +586,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
if layout.hasFile {
|
if layout.hasFile {
|
||||||
fileContainer.frame = layout.fileFrame
|
fileContainer.frame = layout.fileFrame
|
||||||
fileIconView.frame = CGRect(x: 10, y: 8, width: 40, height: 40)
|
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)
|
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)
|
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)
|
// MARK: - Self-sizing (from pre-calculated layout)
|
||||||
|
|
||||||
override func preferredLayoutAttributesFitting(
|
override func preferredLayoutAttributesFitting(
|
||||||
@@ -1306,6 +1362,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
width: contentRect.width + insets.left + insets.right,
|
width: contentRect.width + insets.left + insets.right,
|
||||||
height: contentRect.height + insets.top + insets.bottom
|
height: contentRect.height + insets.top + insets.bottom
|
||||||
)
|
)
|
||||||
|
CATransaction.begin()
|
||||||
|
CATransaction.setDisableActions(true)
|
||||||
|
statusGradientLayer.frame = statusBackgroundView.bounds
|
||||||
|
CATransaction.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func bringStatusOverlayToFront() {
|
private func bringStatusOverlayToFront() {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ final class NativeMessageListController: UIViewController {
|
|||||||
listConfig.backgroundColor = .clear
|
listConfig.backgroundColor = .clear
|
||||||
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
|
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
|
||||||
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
|
let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment)
|
||||||
|
section.interGroupSpacing = 0
|
||||||
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
|
section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10)
|
||||||
return section
|
return section
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct MainTabView: View {
|
|||||||
@State private var isChatListDetailPresented = false
|
@State private var isChatListDetailPresented = false
|
||||||
@State private var isSettingsEditPresented = false
|
@State private var isSettingsEditPresented = false
|
||||||
@State private var isSettingsDetailPresented = false
|
@State private var isSettingsDetailPresented = false
|
||||||
|
@StateObject private var callManager = CallManager.shared
|
||||||
|
|
||||||
// Add Account — presented as fullScreenCover so Settings stays alive.
|
// Add Account — presented as fullScreenCover so Settings stays alive.
|
||||||
// Using optional AuthScreen as the item ensures the correct screen is
|
// 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
|
// Reads DialogRepository in its own scope — MainTabView.body
|
||||||
// never observes the dialogs dictionary directly.
|
// never observes the dialogs dictionary directly.
|
||||||
UnreadCountObserver(count: $cachedUnreadCount)
|
UnreadCountObserver(count: $cachedUnreadCount)
|
||||||
|
|
||||||
|
if callManager.uiState.isVisible {
|
||||||
|
ActiveCallOverlayView(callManager: callManager)
|
||||||
|
.zIndex(10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Switch to Chats tab when user taps a push notification.
|
// Switch to Chats tab when user taps a push notification.
|
||||||
// Without this, the navigation happens in the Chats NavigationStack
|
// Without this, the navigation happens in the Chats NavigationStack
|
||||||
|
|||||||
@@ -7,4 +7,5 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
#import "Core/Crypto/NativeCryptoBridge.h"
|
#import "Core/Crypto/NativeCryptoBridge.h"
|
||||||
|
#import "Core/Crypto/WebRTCFrameCryptorBridge.h"
|
||||||
#import "Core/Utils/NativeBlurHashBridge.h"
|
#import "Core/Utils/NativeBlurHashBridge.h"
|
||||||
|
|||||||
88
RosettaTests/CallAttachmentParityTests.swift
Normal file
88
RosettaTests/CallAttachmentParityTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
RosettaTests/CallPacketParityTests.swift
Normal file
153
RosettaTests/CallPacketParityTests.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
104
RosettaTests/CallRoutingTests.swift
Normal file
104
RosettaTests/CallRoutingTests.swift
Normal 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...")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user