Убраны actor-isolation warnings и выровненны версии extension

This commit is contained in:
2026-03-28 09:18:48 +05:00
parent 1978db0f38
commit 66369ec0b9
29 changed files with 938 additions and 443 deletions

View File

@@ -273,7 +273,7 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
@@ -283,7 +283,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.1.8;
MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -500,7 +500,7 @@
CLANG_ENABLE_OBJC_WEAK = NO;
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19;
CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
@@ -510,7 +510,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.1.8;
MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;

View File

@@ -2,7 +2,7 @@
// https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt
enum BIP39 {
static let wordList: [String] = [
nonisolated static let wordList: [String] = [
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
"absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
"acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
@@ -261,9 +261,9 @@ enum BIP39 {
"yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
]
static let wordSet: Set<String> = Set(wordList)
nonisolated static let wordSet: Set<String> = Set(wordList)
static func index(of word: String) -> Int? {
nonisolated static func index(of word: String) -> Int? {
wordList.firstIndex(of: word)
}
}

View File

@@ -221,14 +221,17 @@ final class BiometricAuthManager: @unchecked Sendable {
}
/// Whether a biometric-protected password exists for the given account.
/// Uses `kSecUseAuthenticationUIFail` to check existence WITHOUT triggering Face ID.
/// Uses an `LAContext` with `interactionNotAllowed = true` to check existence
/// without triggering biometric UI.
func hasStoredPassword(forAccount publicKey: String) -> Bool {
let key = passwordKey(for: publicKey)
let context = LAContext()
context.interactionNotAllowed = true
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService,
kSecAttrAccount as String: key,
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail,
kSecUseAuthenticationContext as String: context,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)

View File

@@ -36,23 +36,23 @@ enum CryptoError: LocalizedError {
/// Low-level primitives are in `CryptoPrimitives`.
final class CryptoManager: @unchecked Sendable {
static let shared = CryptoManager()
nonisolated static let shared = CryptoManager()
// MARK: - Android Parity: PBKDF2 Key Cache
/// Caches derived PBKDF2 keys to avoid repeated ~50-100ms derivations.
/// Android: `CryptoManager.pbkdf2KeyCache` (ConcurrentHashMap).
/// Key format: "algorithm::password", value: derived 32-byte key.
private let pbkdf2CacheLock = NSLock()
private var pbkdf2Cache: [String: Data] = [:]
nonisolated private let pbkdf2CacheLock = NSLock()
nonisolated(unsafe) private var pbkdf2Cache: [String: Data] = [:]
// MARK: - Android Parity: Decryption Cache
/// Caches decrypted results to avoid repeated AES + PBKDF2 for same input.
/// Android: `CryptoManager.decryptionCache` (ConcurrentHashMap, max 2000).
private static let decryptionCacheMaxSize = 2000
private let decryptionCacheLock = NSLock()
private var decryptionCache: [String: Data] = [:]
nonisolated private static let decryptionCacheMaxSize = 2000
nonisolated private let decryptionCacheLock = NSLock()
nonisolated(unsafe) private var decryptionCache: [String: Data] = [:]
private init() {}
@@ -236,7 +236,7 @@ final class CryptoManager: @unchecked Sendable {
}
private extension CryptoManager {
func decryptWithPassword(
nonisolated func decryptWithPassword(
ciphertext: Data,
iv: Data,
password: String,
@@ -257,7 +257,7 @@ private extension CryptoManager {
private extension CryptoManager {
func mnemonicFromEntropy(_ entropy: Data) throws -> [String] {
nonisolated func mnemonicFromEntropy(_ entropy: Data) throws -> [String] {
guard entropy.count == 16 else { throw CryptoError.invalidEntropy }
let hashBytes = Data(SHA256.hash(data: entropy))
@@ -283,7 +283,7 @@ private extension CryptoManager {
}
}
func entropyFromMnemonic(_ words: [String]) throws -> Data {
nonisolated func entropyFromMnemonic(_ words: [String]) throws -> Data {
guard words.count == 12 else { throw CryptoError.invalidMnemonic }
var bits = [Bool]()

View File

@@ -11,7 +11,7 @@ enum CryptoPrimitives {
// MARK: - AES-256-CBC
static func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
nonisolated static func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var ciphertext = Data(count: outputSize)
var numBytes = 0
@@ -41,7 +41,7 @@ enum CryptoPrimitives {
return ciphertext.prefix(numBytes)
}
static func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
nonisolated static func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var plaintext = Data(count: outputSize)
var numBytes = 0
@@ -73,7 +73,7 @@ enum CryptoPrimitives {
// MARK: - PBKDF2
static func pbkdf2(
nonisolated static func pbkdf2(
password: String,
salt: String,
iterations: Int,
@@ -108,7 +108,7 @@ enum CryptoPrimitives {
// MARK: - Random Bytes
static func randomBytes(count: Int) throws -> Data {
nonisolated static func randomBytes(count: Int) throws -> Data {
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { ptr -> OSStatus in
guard let base = ptr.baseAddress else { return errSecAllocate }
@@ -129,7 +129,7 @@ extension CryptoPrimitives {
///
/// Previously used Apple's `compression_encode_buffer(COMPRESSION_ZLIB)` (raw deflate)
/// with a manual zlib wrapper that output was incompatible with pako.inflate().
static func zlibDeflate(_ data: Data) throws -> Data {
nonisolated static func zlibDeflate(_ data: Data) throws -> Data {
let sourceLen = uLong(data.count)
var destLen = compressBound(sourceLen)
var dest = Data(count: Int(destLen))
@@ -148,7 +148,7 @@ extension CryptoPrimitives {
}
/// Raw deflate compression (no zlib header, compatible with Java Deflater(nowrap=true)).
static func rawDeflate(_ data: Data) throws -> Data {
nonisolated static func rawDeflate(_ data: Data) throws -> Data {
let sourceSize = data.count
let destinationSize = sourceSize + 512
var destination = Data(count: destinationSize)
@@ -172,7 +172,7 @@ extension CryptoPrimitives {
/// zlib-wrapped data MUST be stripped FIRST `tryRawInflate` can produce
/// garbage output from zlib header bytes (0x78 0x9C are valid but meaningless
/// raw deflate instructions), causing false-positive decompression.
static func rawInflate(_ data: Data) throws -> Data {
nonisolated static func rawInflate(_ data: Data) throws -> Data {
// 1. Strip zlib wrapper FIRST if present (iOS zlibDeflate / desktop pako.deflate).
// Must be tried before raw inflate to avoid false-positive decompression
// where raw inflate interprets the zlib header as deflate instructions.
@@ -189,7 +189,7 @@ extension CryptoPrimitives {
throw CryptoError.compressionFailed
}
private static func tryRawInflate(_ data: Data) -> Data? {
nonisolated private static func tryRawInflate(_ data: Data) -> Data? {
let sourceSize = data.count
for multiplier in [4, 8, 16, 32] {
let destinationSize = max(sourceSize * multiplier, 256)
@@ -218,7 +218,7 @@ extension Data {
}
/// Initialize from a hex string (case-insensitive).
init(hexString: String) {
nonisolated init(hexString: String) {
let hex = hexString.lowercased()
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex

View File

@@ -24,7 +24,7 @@ enum MessageCrypto {
/// Decrypts an incoming message and returns both plaintext and the working key+nonce.
/// The returned `keyAndNonce` is the candidate that successfully decrypted the message
/// critical for deriving the correct attachment password.
static func decryptIncomingFull(
nonisolated static func decryptIncomingFull(
ciphertext: String,
encryptedKey: String,
myPrivateKeyHex: String
@@ -50,7 +50,7 @@ enum MessageCrypto {
throw CryptoError.invalidData("Failed to decrypt message content with all key candidates")
}
static func decryptIncoming(
nonisolated static func decryptIncoming(
ciphertext: String,
encryptedKey: String,
myPrivateKeyHex: String
@@ -67,7 +67,7 @@ enum MessageCrypto {
/// - plaintext: The message text.
/// - recipientPublicKeyHex: Recipient's secp256k1 compressed public key (hex).
/// - Returns: Tuple of (content: hex ciphertext+tag, chachaKey: base64 encrypted key for recipient, plainKeyAndNonce: raw key+nonce bytes).
static func encryptOutgoing(
nonisolated static func encryptOutgoing(
plaintext: String,
recipientPublicKeyHex: String
) throws -> (content: String, chachaKey: String, plainKeyAndNonce: Data) {
@@ -96,7 +96,7 @@ enum MessageCrypto {
/// Decrypts an incoming message using already decrypted key+nonce bytes.
/// Mirrors Android `decryptIncomingWithPlainKey`.
static func decryptIncomingWithPlainKey(
nonisolated static func decryptIncomingWithPlainKey(
ciphertext: String,
plainKeyAndNonce: Data
) throws -> String {
@@ -109,7 +109,7 @@ enum MessageCrypto {
/// Extract raw decrypted key+nonce data from an encrypted key string (ECDH path).
/// Verifies each candidate by attempting XChaCha20 decryption to find the correct one.
/// Falls back to first candidate if ciphertext is unavailable.
static func extractDecryptedKeyData(
nonisolated static func extractDecryptedKeyData(
encryptedKey: String,
myPrivateKeyHex: String,
verifyCiphertext: String? = nil
@@ -133,7 +133,7 @@ enum MessageCrypto {
/// Emulates Android's `String(bytes, UTF_8).toByteArray(ISO_8859_1)` round-trip.
/// Uses BOTH WHATWG and Android UTF-8 decoders returns candidates for each.
/// WHATWG and Android decoders handle invalid UTF-8 differently different bytes.
static func androidUtf8BytesToLatin1Bytes(_ utf8Bytes: Data) -> Data {
nonisolated static func androidUtf8BytesToLatin1Bytes(_ utf8Bytes: Data) -> Data {
// Primary: WHATWG decoder (matches Java's Modified UTF-8 for most cases)
let decoded = String(decoding: utf8Bytes, as: UTF8.self)
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
@@ -143,7 +143,7 @@ enum MessageCrypto {
}
/// Alternative key recovery using Android UTF-8 decoder.
static func androidUtf8BytesToLatin1BytesAlt(_ utf8Bytes: Data) -> Data {
nonisolated static func androidUtf8BytesToLatin1BytesAlt(_ utf8Bytes: Data) -> Data {
let decoded = bytesToAndroidUtf8String(utf8Bytes)
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
return latin1
@@ -156,7 +156,7 @@ enum MessageCrypto {
/// Returns password candidates from a stored attachment password string.
/// New format: `"rawkey:<hex>"` derives Android (`bytesToJsUtf8String`) + WHATWG passwords.
/// Legacy format: plain string used as-is (backward compat with persisted messages).
static func attachmentPasswordCandidates(from stored: String) -> [String] {
nonisolated static func attachmentPasswordCandidates(from stored: String) -> [String] {
if stored.hasPrefix("rawkey:") {
let hex = String(stored.dropFirst("rawkey:".count))
let keyData = Data(hexString: hex)
@@ -193,7 +193,7 @@ enum MessageCrypto {
/// Uses feross/buffer npm polyfill UTF-8 decoding semantics.
/// Key difference from previous implementation: on invalid sequence, ALWAYS advance by 1 byte
/// and emit 1× U+FFFD (not variable bytes/U+FFFD count).
static func bytesToAndroidUtf8String(_ bytes: Data) -> String {
nonisolated static func bytesToAndroidUtf8String(_ bytes: Data) -> String {
var codePoints: [Int] = []
codePoints.reserveCapacity(bytes.count)
var index = 0
@@ -276,7 +276,7 @@ private extension MessageCrypto {
/// Decrypts and returns candidate XChaCha20 key+nonce buffers.
/// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex)
/// Supports Android sync shorthand `sync:<aesChachaKey>`.
static func decryptKeyFromSenderCandidates(encryptedKey: String, myPrivateKeyHex: String) throws -> [Data] {
nonisolated static func decryptKeyFromSenderCandidates(encryptedKey: String, myPrivateKeyHex: String) throws -> [Data] {
if encryptedKey.hasPrefix("sync:") {
let aesChachaKey = String(encryptedKey.dropFirst("sync:".count))
guard !aesChachaKey.isEmpty else {
@@ -365,7 +365,7 @@ private extension MessageCrypto {
}
/// Encrypts the XChaCha20 key+nonce for a recipient using ECDH.
static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String {
nonisolated static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String {
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey()
let recipientPubKey = try P256K.KeyAgreement.PublicKey(
@@ -399,7 +399,7 @@ private extension MessageCrypto {
}
/// Extracts the 32-byte x-coordinate from ECDH shared secret bytes.
static func extractXCoordinate(from sharedSecretData: Data) -> Data {
nonisolated static func extractXCoordinate(from sharedSecretData: Data) -> Data {
// Uncompressed point: 0x04 || X(32) || Y(32)
if sharedSecretData.count == 65, sharedSecretData.first == 0x04 {
return sharedSecretData[1..<33]
@@ -421,13 +421,13 @@ private extension MessageCrypto {
}
/// Legacy Android compatibility: x-coordinate serialized through BigInteger loses leading zeros.
static func legacySharedKey(fromExactX exactX: Data) -> Data {
nonisolated static func legacySharedKey(fromExactX exactX: Data) -> Data {
let trimmed = exactX.drop(while: { $0 == 0 })
return trimmed.isEmpty ? Data([0]) : Data(trimmed)
}
/// JS/Android compatibility: private key hex can arrive without leading zero bytes.
static func normalizePrivateKeyHex(_ rawHex: String) -> String {
nonisolated static func normalizePrivateKeyHex(_ rawHex: String) -> String {
var hex = rawHex
if hex.count % 2 != 0 {
hex = "0" + hex
@@ -441,7 +441,7 @@ private extension MessageCrypto {
return hex
}
static func decryptWithKeyAndNonce(ciphertext: String, keyAndNonce: Data) throws -> String {
nonisolated static func decryptWithKeyAndNonce(ciphertext: String, keyAndNonce: Data) throws -> String {
guard keyAndNonce.count >= 56 else {
throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)")
}

View File

@@ -0,0 +1,18 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Objective-C++ bridge exposing native C++ XChaCha20-Poly1305 routines to Swift.
@interface NativeCryptoBridge : NSObject
+ (nullable NSData *)xChaCha20Poly1305Encrypt:(NSData *)plaintext
key:(NSData *)key
nonce:(NSData *)nonce;
+ (nullable NSData *)xChaCha20Poly1305Decrypt:(NSData *)ciphertextWithTag
key:(NSData *)key
nonce:(NSData *)nonce;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,65 @@
#import "NativeCryptoBridge.h"
#include "NativeXChaCha20.hpp"
@implementation NativeCryptoBridge
+ (nullable NSData *)xChaCha20Poly1305Encrypt:(NSData *)plaintext
key:(NSData *)key
nonce:(NSData *)nonce {
if (key.length != 32 || nonce.length != 24) {
return nil;
}
const auto *plainBytes = static_cast<const std::uint8_t *>(plaintext.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> encrypted;
const bool ok = rosetta::nativecrypto::xchacha20poly1305_encrypt(
plainBytes,
static_cast<std::size_t>(plaintext.length),
keyBytes,
nonceBytes,
encrypted
);
if (!ok) {
return nil;
}
if (encrypted.empty()) {
return [NSData data];
}
return [NSData dataWithBytes:encrypted.data() length:encrypted.size()];
}
+ (nullable NSData *)xChaCha20Poly1305Decrypt:(NSData *)ciphertextWithTag
key:(NSData *)key
nonce:(NSData *)nonce {
if (key.length != 32 || nonce.length != 24 || ciphertextWithTag.length < 16) {
return nil;
}
const auto *cipherBytes = static_cast<const std::uint8_t *>(ciphertextWithTag.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> plaintext;
const bool ok = rosetta::nativecrypto::xchacha20poly1305_decrypt(
cipherBytes,
static_cast<std::size_t>(ciphertextWithTag.length),
keyBytes,
nonceBytes,
plaintext
);
if (!ok) {
return nil;
}
if (plaintext.empty()) {
return [NSData data];
}
return [NSData dataWithBytes:plaintext.data() length:plaintext.size()];
}
@end

View File

@@ -0,0 +1,413 @@
#include "NativeXChaCha20.hpp"
#include <array>
#include <cstring>
namespace {
constexpr std::size_t kXChaChaKeySize = 32;
constexpr std::size_t kXChaChaNonceSize = 24;
constexpr std::size_t kChaChaNonceSize = 12;
constexpr std::size_t kChaChaBlockSize = 64;
constexpr std::size_t kPoly1305TagSize = 16;
inline std::uint32_t load_le32(const std::uint8_t *src) {
return static_cast<std::uint32_t>(src[0])
| (static_cast<std::uint32_t>(src[1]) << 8U)
| (static_cast<std::uint32_t>(src[2]) << 16U)
| (static_cast<std::uint32_t>(src[3]) << 24U);
}
inline std::uint64_t load_le64(const std::uint8_t *src) {
return static_cast<std::uint64_t>(src[0])
| (static_cast<std::uint64_t>(src[1]) << 8U)
| (static_cast<std::uint64_t>(src[2]) << 16U)
| (static_cast<std::uint64_t>(src[3]) << 24U)
| (static_cast<std::uint64_t>(src[4]) << 32U)
| (static_cast<std::uint64_t>(src[5]) << 40U)
| (static_cast<std::uint64_t>(src[6]) << 48U)
| (static_cast<std::uint64_t>(src[7]) << 56U);
}
inline void store_le32(std::uint32_t value, std::uint8_t *dst) {
dst[0] = static_cast<std::uint8_t>(value & 0xFFU);
dst[1] = static_cast<std::uint8_t>((value >> 8U) & 0xFFU);
dst[2] = static_cast<std::uint8_t>((value >> 16U) & 0xFFU);
dst[3] = static_cast<std::uint8_t>((value >> 24U) & 0xFFU);
}
inline void store_le64(std::uint64_t value, std::uint8_t *dst) {
for (int i = 0; i < 8; i++) {
dst[i] = static_cast<std::uint8_t>((value >> (i * 8U)) & 0xFFU);
}
}
inline void quarter_round(std::uint32_t *state, int a, int b, int c, int d) {
state[a] += state[b];
state[d] ^= state[a];
state[d] = (state[d] << 16U) | (state[d] >> 16U);
state[c] += state[d];
state[b] ^= state[c];
state[b] = (state[b] << 12U) | (state[b] >> 20U);
state[a] += state[b];
state[d] ^= state[a];
state[d] = (state[d] << 8U) | (state[d] >> 24U);
state[c] += state[d];
state[b] ^= state[c];
state[b] = (state[b] << 7U) | (state[b] >> 25U);
}
void chacha20_block(
const std::uint8_t key[kXChaChaKeySize],
const std::uint8_t nonce[kChaChaNonceSize],
std::uint32_t counter,
std::uint8_t out[kChaChaBlockSize]
) {
std::uint32_t state[16] = {};
state[0] = 0x61707865U;
state[1] = 0x3320646eU;
state[2] = 0x79622d32U;
state[3] = 0x6b206574U;
for (int i = 0; i < 8; i++) {
state[4 + i] = load_le32(key + (i * 4));
}
state[12] = counter;
for (int i = 0; i < 3; i++) {
state[13 + i] = load_le32(nonce + (i * 4));
}
std::uint32_t working[16];
std::memcpy(working, state, sizeof(state));
for (int i = 0; i < 10; i++) {
quarter_round(working, 0, 4, 8, 12);
quarter_round(working, 1, 5, 9, 13);
quarter_round(working, 2, 6, 10, 14);
quarter_round(working, 3, 7, 11, 15);
quarter_round(working, 0, 5, 10, 15);
quarter_round(working, 1, 6, 11, 12);
quarter_round(working, 2, 7, 8, 13);
quarter_round(working, 3, 4, 9, 14);
}
for (int i = 0; i < 16; i++) {
working[i] += state[i];
store_le32(working[i], out + (i * 4));
}
}
void hchacha20(
const std::uint8_t key[kXChaChaKeySize],
const std::uint8_t nonce[16],
std::uint8_t out[kXChaChaKeySize]
) {
std::uint32_t state[16] = {};
state[0] = 0x61707865U;
state[1] = 0x3320646eU;
state[2] = 0x79622d32U;
state[3] = 0x6b206574U;
for (int i = 0; i < 8; i++) {
state[4 + i] = load_le32(key + (i * 4));
}
for (int i = 0; i < 4; i++) {
state[12 + i] = load_le32(nonce + (i * 4));
}
for (int i = 0; i < 10; i++) {
quarter_round(state, 0, 4, 8, 12);
quarter_round(state, 1, 5, 9, 13);
quarter_round(state, 2, 6, 10, 14);
quarter_round(state, 3, 7, 11, 15);
quarter_round(state, 0, 5, 10, 15);
quarter_round(state, 1, 6, 11, 12);
quarter_round(state, 2, 7, 8, 13);
quarter_round(state, 3, 4, 9, 14);
}
for (int i = 0; i < 4; i++) {
store_le32(state[i], out + (i * 4));
}
for (int i = 0; i < 4; i++) {
store_le32(state[12 + i], out + (16 + i * 4));
}
}
void chacha20_xor(
const std::uint8_t *input,
std::size_t input_length,
const std::uint8_t key[kXChaChaKeySize],
const std::uint8_t nonce[kChaChaNonceSize],
std::uint32_t initial_counter,
std::vector<std::uint8_t> &output
) {
output.resize(input_length);
std::uint32_t counter = initial_counter;
std::uint8_t block[kChaChaBlockSize];
for (std::size_t offset = 0; offset < input_length; offset += kChaChaBlockSize) {
chacha20_block(key, nonce, counter, block);
const std::size_t block_size = std::min(kChaChaBlockSize, input_length - offset);
for (std::size_t i = 0; i < block_size; i++) {
output[offset + i] = static_cast<std::uint8_t>(input[offset + i] ^ block[i]);
}
counter += 1;
}
}
void poly1305_to_limbs26(const std::uint8_t block16[16], std::uint64_t limbs[5]) {
const std::uint64_t lo = load_le64(block16);
const std::uint64_t hi = load_le64(block16 + 8);
limbs[0] = lo & 0x3FFFFFFULL;
limbs[1] = (lo >> 26U) & 0x3FFFFFFULL;
limbs[2] = ((lo >> 52U) | (hi << 12U)) & 0x3FFFFFFULL;
limbs[3] = (hi >> 14U) & 0x3FFFFFFULL;
limbs[4] = (hi >> 40U) & 0x3FFFFFFULL;
}
void poly1305_multiply_reduce(std::uint64_t h[5], const std::uint64_t r[5]) {
const std::uint64_t r0 = r[0];
const std::uint64_t r1 = r[1];
const std::uint64_t r2 = r[2];
const std::uint64_t r3 = r[3];
const std::uint64_t r4 = r[4];
const std::uint64_t s1 = r1 * 5ULL;
const std::uint64_t s2 = r2 * 5ULL;
const std::uint64_t s3 = r3 * 5ULL;
const std::uint64_t s4 = r4 * 5ULL;
const std::uint64_t h0_in = h[0];
const std::uint64_t h1_in = h[1];
const std::uint64_t h2_in = h[2];
const std::uint64_t h3_in = h[3];
const std::uint64_t h4_in = h[4];
std::uint64_t h0 = h0_in * r0 + h1_in * s4 + h2_in * s3 + h3_in * s2 + h4_in * s1;
std::uint64_t h1 = h0_in * r1 + h1_in * r0 + h2_in * s4 + h3_in * s3 + h4_in * s2;
std::uint64_t h2 = h0_in * r2 + h1_in * r1 + h2_in * r0 + h3_in * s4 + h4_in * s3;
std::uint64_t h3 = h0_in * r3 + h1_in * r2 + h2_in * r1 + h3_in * r0 + h4_in * s4;
std::uint64_t h4 = h0_in * r4 + h1_in * r3 + h2_in * r2 + h3_in * r1 + h4_in * r0;
std::uint64_t c = h0 >> 26U;
h1 += c;
h0 &= 0x3FFFFFFULL;
c = h1 >> 26U;
h2 += c;
h1 &= 0x3FFFFFFULL;
c = h2 >> 26U;
h3 += c;
h2 &= 0x3FFFFFFULL;
c = h3 >> 26U;
h4 += c;
h3 &= 0x3FFFFFFULL;
c = h4 >> 26U;
h0 += c * 5ULL;
h4 &= 0x3FFFFFFULL;
c = h0 >> 26U;
h1 += c;
h0 &= 0x3FFFFFFULL;
h[0] = h0;
h[1] = h1;
h[2] = h2;
h[3] = h3;
h[4] = h4;
}
void poly1305_mac(
const std::uint8_t *data,
std::size_t data_length,
const std::uint8_t key32[32],
std::uint8_t out_tag16[16]
) {
std::uint8_t r_bytes[16];
std::memcpy(r_bytes, key32, 16);
r_bytes[3] &= 15U;
r_bytes[7] &= 15U;
r_bytes[11] &= 15U;
r_bytes[15] &= 15U;
r_bytes[4] &= 252U;
r_bytes[8] &= 252U;
r_bytes[12] &= 252U;
std::uint8_t s_bytes[16];
std::memcpy(s_bytes, key32 + 16, 16);
std::uint64_t r[5];
poly1305_to_limbs26(r_bytes, r);
std::uint64_t h[5] = {0, 0, 0, 0, 0};
std::vector<std::uint8_t> mac_input;
const std::size_t pad = (16 - (data_length % 16)) % 16;
mac_input.reserve(data_length + pad + 16);
mac_input.insert(mac_input.end(), data, data + data_length);
mac_input.insert(mac_input.end(), pad, 0);
mac_input.insert(mac_input.end(), 8, 0);
std::uint8_t ct_len_le[8];
store_le64(static_cast<std::uint64_t>(data_length), ct_len_le);
mac_input.insert(mac_input.end(), ct_len_le, ct_len_le + 8);
for (std::size_t offset = 0; offset < mac_input.size(); offset += 16) {
std::uint8_t block[16];
std::memcpy(block, mac_input.data() + offset, 16);
std::uint64_t n[5];
poly1305_to_limbs26(block, n);
h[0] += n[0];
h[1] += n[1];
h[2] += n[2];
h[3] += n[3];
h[4] += n[4] + (1ULL << 24U);
poly1305_multiply_reduce(h, r);
}
std::uint64_t h0 = h[0];
std::uint64_t h1 = h[1];
std::uint64_t h2 = h[2];
std::uint64_t h3 = h[3];
std::uint64_t h4 = h[4];
std::uint64_t c = h0 >> 26U;
h1 += c;
h0 &= 0x3FFFFFFULL;
c = h1 >> 26U;
h2 += c;
h1 &= 0x3FFFFFFULL;
c = h2 >> 26U;
h3 += c;
h2 &= 0x3FFFFFFULL;
c = h3 >> 26U;
h4 += c;
h3 &= 0x3FFFFFFULL;
c = h4 >> 26U;
h0 += c * 5ULL;
h4 &= 0x3FFFFFFULL;
c = h0 >> 26U;
h1 += c;
h0 &= 0x3FFFFFFULL;
std::uint64_t g0 = h0 + 5ULL;
c = g0 >> 26U;
g0 &= 0x3FFFFFFULL;
std::uint64_t g1 = h1 + c;
c = g1 >> 26U;
g1 &= 0x3FFFFFFULL;
std::uint64_t g2 = h2 + c;
c = g2 >> 26U;
g2 &= 0x3FFFFFFULL;
std::uint64_t g3 = h3 + c;
c = g3 >> 26U;
g3 &= 0x3FFFFFFULL;
const std::uint64_t g4 = h4 + c - (1ULL << 26U);
const std::uint64_t mask = (g4 >> 63U) - 1ULL;
const std::uint64_t nmask = ~mask;
h0 = (h0 & nmask) | (g0 & mask);
h1 = (h1 & nmask) | (g1 & mask);
h2 = (h2 & nmask) | (g2 & mask);
h3 = (h3 & nmask) | (g3 & mask);
h4 = (h4 & nmask) | (g4 & mask);
const std::uint64_t lo = h0 | (h1 << 26U) | (h2 << 52U);
const std::uint64_t hi = (h2 >> 12U) | (h3 << 14U) | (h4 << 40U);
const std::uint64_t s_lo = load_le64(s_bytes);
const std::uint64_t s_hi = load_le64(s_bytes + 8);
const std::uint64_t result_lo = lo + s_lo;
const std::uint64_t carry = (result_lo < lo) ? 1ULL : 0ULL;
const std::uint64_t result_hi = hi + s_hi + carry;
store_le64(result_lo, out_tag16);
store_le64(result_hi, out_tag16 + 8);
}
bool constant_time_equal(const std::uint8_t *a, const std::uint8_t *b, std::size_t length) {
std::uint8_t diff = 0;
for (std::size_t i = 0; i < length; i++) {
diff |= static_cast<std::uint8_t>(a[i] ^ b[i]);
}
return diff == 0;
}
void derive_subkey_and_nonce(
const std::uint8_t key32[kXChaChaKeySize],
const std::uint8_t nonce24[kXChaChaNonceSize],
std::uint8_t out_subkey32[kXChaChaKeySize],
std::uint8_t out_chacha_nonce12[kChaChaNonceSize]
) {
hchacha20(key32, nonce24, out_subkey32);
std::memset(out_chacha_nonce12, 0, kChaChaNonceSize);
std::memcpy(out_chacha_nonce12 + 4, nonce24 + 16, 8);
}
} // namespace
namespace rosetta::nativecrypto {
bool xchacha20poly1305_encrypt(
const std::uint8_t *plaintext,
std::size_t plaintext_length,
const std::uint8_t *key32,
const std::uint8_t *nonce24,
std::vector<std::uint8_t> &ciphertext_with_tag
) {
if (key32 == nullptr || nonce24 == nullptr || (plaintext == nullptr && plaintext_length > 0)) {
return false;
}
std::uint8_t subkey[kXChaChaKeySize];
std::uint8_t chacha_nonce[kChaChaNonceSize];
derive_subkey_and_nonce(key32, nonce24, subkey, chacha_nonce);
std::vector<std::uint8_t> ciphertext;
chacha20_xor(plaintext, plaintext_length, subkey, chacha_nonce, 1, ciphertext);
std::uint8_t block[kChaChaBlockSize];
chacha20_block(subkey, chacha_nonce, 0, block);
std::uint8_t tag[kPoly1305TagSize];
poly1305_mac(ciphertext.data(), ciphertext.size(), block, tag);
ciphertext_with_tag = ciphertext;
ciphertext_with_tag.insert(ciphertext_with_tag.end(), tag, tag + kPoly1305TagSize);
return true;
}
bool xchacha20poly1305_decrypt(
const std::uint8_t *ciphertext_with_tag,
std::size_t ciphertext_with_tag_length,
const std::uint8_t *key32,
const std::uint8_t *nonce24,
std::vector<std::uint8_t> &plaintext
) {
if (key32 == nullptr || nonce24 == nullptr || ciphertext_with_tag == nullptr) {
return false;
}
if (ciphertext_with_tag_length < kPoly1305TagSize) {
return false;
}
const std::size_t ciphertext_length = ciphertext_with_tag_length - kPoly1305TagSize;
const std::uint8_t *ciphertext = ciphertext_with_tag;
const std::uint8_t *tag = ciphertext_with_tag + ciphertext_length;
std::uint8_t subkey[kXChaChaKeySize];
std::uint8_t chacha_nonce[kChaChaNonceSize];
derive_subkey_and_nonce(key32, nonce24, subkey, chacha_nonce);
std::uint8_t block[kChaChaBlockSize];
chacha20_block(subkey, chacha_nonce, 0, block);
std::uint8_t computed_tag[kPoly1305TagSize];
poly1305_mac(ciphertext, ciphertext_length, block, computed_tag);
if (!constant_time_equal(tag, computed_tag, kPoly1305TagSize)) {
return false;
}
chacha20_xor(ciphertext, ciphertext_length, subkey, chacha_nonce, 1, plaintext);
return true;
}
} // namespace rosetta::nativecrypto

View File

@@ -0,0 +1,25 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
namespace rosetta::nativecrypto {
bool xchacha20poly1305_encrypt(
const uint8_t *plaintext,
std::size_t plaintext_length,
const uint8_t *key32,
const uint8_t *nonce24,
std::vector<uint8_t> &ciphertext_with_tag
);
bool xchacha20poly1305_decrypt(
const uint8_t *ciphertext_with_tag,
std::size_t ciphertext_with_tag_length,
const uint8_t *key32,
const uint8_t *nonce24,
std::vector<uint8_t> &plaintext
);
} // namespace rosetta::nativecrypto

View File

@@ -7,7 +7,7 @@ import Foundation
enum Poly1305Engine {
/// Computes a Poly1305 MAC matching the AEAD construction.
static func mac(data: Data, key: Data) -> Data {
nonisolated static func mac(data: Data, key: Data) -> Data {
// Clamp r (first 16 bytes of key)
var r = [UInt8](key[0..<16])
r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15
@@ -61,7 +61,7 @@ enum Poly1305Engine {
private extension Poly1305Engine {
/// Convert 16 bytes to 5 limbs of 26 bits each (little-endian).
static func toLimbs26(_ bytes: [UInt8]) -> [UInt64] {
nonisolated static func toLimbs26(_ bytes: [UInt8]) -> [UInt64] {
let b = bytes.count >= 16 ? bytes : bytes + [UInt8](repeating: 0, count: 16 - bytes.count)
var full = [UInt8](repeating: 0, count: 17)
@@ -82,7 +82,7 @@ private extension Poly1305Engine {
}
/// Multiply two numbers in 26-bit limb form, reduce mod 2^130 - 5.
static func multiply(_ a: [UInt64], _ r: [UInt64]) -> [UInt64] {
nonisolated static func multiply(_ a: [UInt64], _ r: [UInt64]) -> [UInt64] {
let r0 = r[0], r1 = r[1], r2 = r[2], r3 = r[3], r4 = r[4]
let s1 = r1 * 5, s2 = r2 * 5, s3 = r3 * 5, s4 = r4 * 5
let a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4]
@@ -105,7 +105,7 @@ private extension Poly1305Engine {
}
/// Final reduction mod 2^130-5 and add s.
static func freeze(_ h: [UInt64], s: [UInt8]) -> [UInt8] {
nonisolated static func freeze(_ h: [UInt64], s: [UInt8]) -> [UInt8] {
var h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4]
var c: UInt64

View File

@@ -6,12 +6,12 @@ import Foundation
/// Matches the Android `MessageCrypto` XChaCha20 implementation for cross-platform compatibility.
enum XChaCha20Engine {
static let poly1305TagSize = 16
nonisolated static let poly1305TagSize = 16
// MARK: - XChaCha20-Poly1305 Decrypt
/// Decrypts ciphertext+tag using XChaCha20-Poly1305.
static func decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data {
nonisolated static func decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data {
guard ciphertextWithTag.count >= poly1305TagSize else {
throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag")
}
@@ -19,6 +19,14 @@ enum XChaCha20Engine {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
if let native = NativeCryptoBridge.xChaCha20Poly1305Decrypt(
ciphertextWithTag,
key: key,
nonce: nonce
) {
return native
}
let ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)]
let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...]
@@ -48,11 +56,19 @@ enum XChaCha20Engine {
// MARK: - XChaCha20-Poly1305 Encrypt
/// Encrypts plaintext using XChaCha20-Poly1305.
static func encrypt(plaintext: Data, key: Data, nonce: Data) throws -> Data {
nonisolated static func encrypt(plaintext: Data, key: Data, nonce: Data) throws -> Data {
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
if let native = NativeCryptoBridge.xChaCha20Poly1305Encrypt(
plaintext,
key: key,
nonce: nonce
) {
return native
}
// Step 1: HChaCha20 derive subkey
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
@@ -80,7 +96,7 @@ enum XChaCha20Engine {
extension XChaCha20Engine {
/// ChaCha20 quarter round.
static func quarterRound(_ state: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
nonisolated static func quarterRound(_ state: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 16) | (state[d] >> 16)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 12) | (state[b] >> 20)
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 8) | (state[d] >> 24)
@@ -88,7 +104,7 @@ extension XChaCha20Engine {
}
/// Generates a 64-byte ChaCha20 block.
static func chacha20Block(key: Data, nonce: Data, counter: UInt32) -> Data {
nonisolated static func chacha20Block(key: Data, nonce: Data, counter: UInt32) -> Data {
var state = [UInt32](repeating: 0, count: 16)
// Constants: "expand 32-byte k"
@@ -133,7 +149,7 @@ extension XChaCha20Engine {
}
/// ChaCha20 stream cipher encryption/decryption.
static func chacha20Encrypt(data: Data, key: Data, nonce: Data, initialCounter: UInt32) -> Data {
nonisolated static func chacha20Encrypt(data: Data, key: Data, nonce: Data, initialCounter: UInt32) -> Data {
var result = Data(count: data.count)
var counter = initialCounter
@@ -150,7 +166,7 @@ extension XChaCha20Engine {
}
/// HChaCha20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
static func hchacha20(key: Data, nonce: Data) -> Data {
nonisolated static func hchacha20(key: Data, nonce: Data) -> Data {
var state = [UInt32](repeating: 0, count: 16)
state[0] = 0x61707865; state[1] = 0x3320646e
@@ -190,7 +206,7 @@ extension XChaCha20Engine {
}
/// Constant-time comparison of two Data objects.
static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool {
nonisolated static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {

View File

@@ -44,16 +44,16 @@ actor ImageLoadLimiter {
/// Key format: attachment ID (8-char random string).
final class AttachmentCache: @unchecked Sendable {
static let shared = AttachmentCache()
nonisolated static let shared = AttachmentCache()
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AttachmentCache")
nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AttachmentCache")
private let cacheDir: URL
nonisolated private let cacheDir: URL
/// In-memory image cache eliminates disk I/O + crypto on scroll-back.
/// Android parity: LruCache in AttachmentFileManager.kt.
/// Same pattern as AvatarRepository.cache (NSCache is thread-safe).
private let imageCache: NSCache<NSString, UIImage> = {
nonisolated(unsafe) private let imageCache: NSCache<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
cache.countLimit = 100
cache.totalCostLimit = 80 * 1024 * 1024 // 80 MB auto-evicts under memory pressure
@@ -62,9 +62,9 @@ final class AttachmentCache: @unchecked Sendable {
/// Private key for encrypting files at rest (Android parity).
/// Set from SessionManager.startSession() after unlocking account.
private let keyLock = NSLock()
private var _privateKey: String?
var privateKey: String? {
nonisolated private let keyLock = NSLock()
nonisolated(unsafe) private var _privateKey: String?
nonisolated var privateKey: String? {
get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey }
set {
keyLock.lock()
@@ -85,7 +85,7 @@ final class AttachmentCache: @unchecked Sendable {
/// Android parity: `BitmapFactory.Options.inSampleSize` with max 4096px.
/// Uses `CGImageSource` for memory-efficient downsampled decoding + EXIF orientation.
/// `kCGImageSourceShouldCacheImmediately` forces decode now (not lazily on first draw).
static func downsampledImage(from data: Data, maxPixelSize: Int = 4096) -> UIImage? {
nonisolated static func downsampledImage(from data: Data, maxPixelSize: Int = 4096) -> UIImage? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
return UIImage(data: data)
}
@@ -104,7 +104,7 @@ final class AttachmentCache: @unchecked Sendable {
// MARK: - Images
/// Saves a decoded image to cache, encrypted with private key (Android parity).
func saveImage(_ image: UIImage, forAttachmentId id: String) {
nonisolated func saveImage(_ image: UIImage, forAttachmentId id: String) {
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
// Warm in-memory cache immediately next loadImage() returns in O(1)
@@ -124,7 +124,7 @@ final class AttachmentCache: @unchecked Sendable {
}
/// Loads a cached image for an attachment ID, or `nil` if not cached.
func loadImage(forAttachmentId id: String) -> UIImage? {
nonisolated func loadImage(forAttachmentId id: String) -> UIImage? {
// Fast path: in-memory cache hit no disk I/O, no crypto
if let cached = imageCache.object(forKey: id as NSString) {
return cached
@@ -161,7 +161,7 @@ final class AttachmentCache: @unchecked Sendable {
/// Saves raw file data to cache (encrypted), returns the file URL.
@discardableResult
func saveFile(_ data: Data, forAttachmentId id: String, fileName: String) -> URL {
nonisolated func saveFile(_ data: Data, forAttachmentId id: String, fileName: String) -> URL {
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
let url = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
@@ -178,7 +178,7 @@ final class AttachmentCache: @unchecked Sendable {
}
/// Returns cached file URL, or `nil` if not cached.
func fileURL(forAttachmentId id: String, fileName: String) -> URL? {
nonisolated func fileURL(forAttachmentId id: String, fileName: String) -> URL? {
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
// Check encrypted
let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
@@ -190,7 +190,7 @@ final class AttachmentCache: @unchecked Sendable {
}
/// Load file data, decrypting if needed.
func loadFileData(forAttachmentId id: String, fileName: String) -> Data? {
nonisolated func loadFileData(forAttachmentId id: String, fileName: String) -> Data? {
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
// Try encrypted
let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
@@ -211,7 +211,7 @@ final class AttachmentCache: @unchecked Sendable {
// MARK: - Cleanup
func clearAll() {
nonisolated func clearAll() {
imageCache.removeAllObjects()
try? FileManager.default.removeItem(at: cacheDir)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)

View File

@@ -399,7 +399,7 @@ extension MessageCellLayout {
// MARK: - Collage Height (Thread-Safe)
/// Photo collage height same formulas as C++ MessageLayout & PhotoCollageView.swift.
/// Photo collage height same formulas as PhotoCollageView.swift.
private static func collageHeight(count: Int, width: CGFloat) -> CGFloat {
guard count > 0 else { return 0 }
if count == 1 { return max(180, min(width * 0.93, 340)) }

View File

@@ -1,115 +0,0 @@
#include "MessageLayout.hpp"
#include <algorithm>
#include <cmath>
namespace rosetta {
// Constants matching MessageCellView.swift / PhotoCollageView.swift exactly
static constexpr float kReplyQuoteHeight = 41.0f;
static constexpr float kReplyQuoteTopPadding = 5.0f;
static constexpr float kFileAttachmentHeight = 56.0f;
static constexpr float kAvatarAttachmentHeight = 60.0f;
static constexpr float kTailProtrusion = 6.0f;
static constexpr float kTextPaddingVertical = 5.0f;
static constexpr float kForwardHeaderHeight = 40.0f;
static constexpr float kBorderWidth = 2.0f;
static constexpr float kCollageSpacing = 2.0f;
static constexpr float kCaptionTopPadding = 6.0f;
static constexpr float kCaptionBottomPadding = 5.0f;
float calculateCollageHeight(int count, float width) {
if (count <= 0) return 0.0f;
if (count == 1) {
// Single image: aspect ratio preserved, max 320pt
return std::min(width * 0.75f, 320.0f);
}
if (count == 2) {
// Side by side
float cellW = (width - kCollageSpacing) / 2.0f;
return std::min(cellW * 1.2f, 320.0f);
}
if (count == 3) {
// 1 large left + 2 stacked right
float leftW = width * 0.66f;
return std::min(leftW * 1.1f, 320.0f);
}
if (count == 4) {
// 2×2 grid
float cellW = (width - kCollageSpacing) / 2.0f;
float cellH = std::min(cellW * 0.85f, 160.0f);
return cellH * 2.0f + kCollageSpacing;
}
// 5+ images: 2 top + 3 bottom
float topH = std::min(width / 2.0f * 0.85f, 176.0f);
float botH = std::min(width / 3.0f * 0.85f, 144.0f);
return topH + kCollageSpacing + botH;
}
MessageLayoutResult calculateLayout(const MessageLayoutInput& input) {
MessageLayoutResult result{};
float height = 0.0f;
// Top padding: 6pt for single/top, 2pt for mid/bottom
bool isTopOrSingle = (input.position == BubblePosition::Single ||
input.position == BubblePosition::Top);
height += isTopOrSingle ? 6.0f : 2.0f;
bool hasVisibleAttachments = (input.imageCount + input.fileCount + input.avatarCount) > 0;
if (input.isForward) {
// ── Forwarded message ──
height += kForwardHeaderHeight;
if (input.forwardImageCount > 0) {
result.photoCollageHeight = calculateCollageHeight(
input.forwardImageCount, input.containerWidth - 20.0f);
height += result.photoCollageHeight;
}
height += input.forwardFileCount * kFileAttachmentHeight;
if (input.forwardHasCaption) {
height += input.forwardCaptionHeight + kCaptionTopPadding + kCaptionBottomPadding;
} else if (input.forwardImageCount == 0 && input.forwardFileCount == 0) {
height += 20.0f; // fallback text ("Photo"/"File"/"Message")
} else {
height += 5.0f; // bottom spacer
}
} else if (hasVisibleAttachments) {
// ── Attachment bubble (images, files, avatars) ──
if (input.imageCount > 0) {
result.photoCollageHeight = calculateCollageHeight(
input.imageCount, input.containerWidth - kBorderWidth * 2.0f);
height += result.photoCollageHeight;
}
height += input.fileCount * kFileAttachmentHeight;
height += input.avatarCount * kAvatarAttachmentHeight;
if (input.hasText) {
height += input.textHeight + kCaptionTopPadding + kCaptionBottomPadding;
}
} else {
// ── Text-only bubble ──
if (input.hasReplyQuote) {
height += kReplyQuoteHeight + kReplyQuoteTopPadding;
}
height += input.textHeight + kTextPaddingVertical * 2.0f;
}
// Tail protrusion: single/bottom positions have a tail (+6pt)
bool hasTail = (input.position == BubblePosition::Single ||
input.position == BubblePosition::Bottom);
if (hasTail) {
height += kTailProtrusion;
}
result.totalHeight = std::ceil(height);
result.bubbleHeight = result.totalHeight - (isTopOrSingle ? 6.0f : 2.0f);
return result;
}
} // namespace rosetta

View File

@@ -1,55 +0,0 @@
#pragma once
/// Pure C++ message cell height calculator.
/// No UIKit, no CoreText, no ObjC runtime — just math.
/// Text height is measured externally (CoreText via ObjC++ bridge)
/// and passed in as `textHeight`.
namespace rosetta {
enum class BubblePosition : int {
Single = 0,
Top = 1,
Mid = 2,
Bottom = 3
};
enum class AttachmentType : int {
Image = 0,
Messages = 1,
File = 2,
Avatar = 3
};
struct MessageLayoutInput {
float containerWidth;
float textHeight; // Measured by CoreText externally
bool hasText;
bool isOutgoing;
BubblePosition position;
bool hasReplyQuote;
bool isForward;
int imageCount;
int fileCount;
int avatarCount;
int forwardImageCount;
int forwardFileCount;
bool forwardHasCaption;
float forwardCaptionHeight;
};
struct MessageLayoutResult {
float totalHeight;
float bubbleHeight;
float photoCollageHeight;
};
/// Calculate total cell height from message properties.
/// All constants match MessageCellView.swift exactly.
MessageLayoutResult calculateLayout(const MessageLayoutInput& input);
/// Calculate photo collage height for N images at given width.
/// Matches PhotoCollageView.swift grid formulas.
float calculateCollageHeight(int imageCount, float containerWidth);
} // namespace rosetta

View File

@@ -1,38 +0,0 @@
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
/// Objective-C++ bridge exposing C++ MessageLayout engine to Swift.
/// Uses CoreText for text measurement, C++ for layout math.
@interface MessageLayoutBridge : NSObject
/// Calculate cell height for a text-only message.
+ (CGFloat)textCellHeight:(NSString *)text
maxWidth:(CGFloat)maxWidth
isOutgoing:(BOOL)isOutgoing
position:(int)position
hasReplyQuote:(BOOL)hasReplyQuote
font:(UIFont *)font;
/// Calculate cell height for a message with direct attachments.
+ (CGFloat)attachmentCellHeightWithImages:(int)imageCount
files:(int)fileCount
avatars:(int)avatarCount
caption:(nullable NSString *)caption
maxWidth:(CGFloat)maxWidth
isOutgoing:(BOOL)isOutgoing
position:(int)position
font:(UIFont *)font;
/// Calculate cell height for a forwarded message.
+ (CGFloat)forwardCellHeightWithImages:(int)imageCount
files:(int)fileCount
caption:(nullable NSString *)caption
maxWidth:(CGFloat)maxWidth
isOutgoing:(BOOL)isOutgoing
position:(int)position
font:(UIFont *)font;
@end
NS_ASSUME_NONNULL_END

View File

@@ -1,112 +0,0 @@
#import "MessageLayoutBridge.h"
#include "MessageLayout.hpp"
@implementation MessageLayoutBridge
/// Measure text height using CoreText (10-20x faster than SwiftUI Text measurement).
+ (CGFloat)measureTextHeight:(NSString *)text
maxWidth:(CGFloat)maxWidth
isOutgoing:(BOOL)isOutgoing
font:(UIFont *)font {
if (!text || text.length == 0) return 0.0;
// Inner padding: 11pt leading, 64pt (outgoing) or 48pt (incoming) trailing
CGFloat trailingPad = isOutgoing ? 64.0 : 48.0;
CGFloat textMaxW = maxWidth - 11.0 - trailingPad;
if (textMaxW <= 0) return 0.0;
NSDictionary *attrs = @{NSFontAttributeName: font};
NSAttributedString *attrStr = [[NSAttributedString alloc] initWithString:text
attributes:attrs];
CGRect rect = [attrStr boundingRectWithSize:CGSizeMake(textMaxW, CGFLOAT_MAX)
options:NSStringDrawingUsesLineFragmentOrigin
context:nil];
return ceil(rect.size.height);
}
+ (CGFloat)textCellHeight:(NSString *)text
maxWidth:(CGFloat)maxWidth
isOutgoing:(BOOL)isOutgoing
position:(int)position
hasReplyQuote:(BOOL)hasReplyQuote
font:(UIFont *)font {
CGFloat textH = [self measureTextHeight:text maxWidth:maxWidth isOutgoing:isOutgoing font:font];
rosetta::MessageLayoutInput input{};
input.containerWidth = static_cast<float>(maxWidth);
input.textHeight = static_cast<float>(textH);
input.hasText = (text.length > 0);
input.isOutgoing = isOutgoing;
input.position = static_cast<rosetta::BubblePosition>(position);
input.hasReplyQuote = hasReplyQuote;
input.isForward = false;
input.imageCount = 0;
input.fileCount = 0;
input.avatarCount = 0;
auto result = rosetta::calculateLayout(input);
return static_cast<CGFloat>(result.totalHeight);
}
+ (CGFloat)attachmentCellHeightWithImages:(int)imageCount
files:(int)fileCount
avatars:(int)avatarCount
caption:(nullable NSString *)caption
maxWidth:(CGFloat)maxWidth
isOutgoing:(BOOL)isOutgoing
position:(int)position
font:(UIFont *)font {
CGFloat captionH = 0;
if (caption && caption.length > 0) {
captionH = [self measureTextHeight:caption maxWidth:maxWidth isOutgoing:isOutgoing font:font];
}
rosetta::MessageLayoutInput input{};
input.containerWidth = static_cast<float>(maxWidth);
input.textHeight = static_cast<float>(captionH);
input.hasText = (caption && caption.length > 0);
input.isOutgoing = isOutgoing;
input.position = static_cast<rosetta::BubblePosition>(position);
input.hasReplyQuote = false;
input.isForward = false;
input.imageCount = imageCount;
input.fileCount = fileCount;
input.avatarCount = avatarCount;
auto result = rosetta::calculateLayout(input);
return static_cast<CGFloat>(result.totalHeight);
}
+ (CGFloat)forwardCellHeightWithImages:(int)imageCount
files:(int)fileCount
caption:(nullable NSString *)caption
maxWidth:(CGFloat)maxWidth
isOutgoing:(BOOL)isOutgoing
position:(int)position
font:(UIFont *)font {
CGFloat captionH = 0;
if (caption && caption.length > 0) {
captionH = [self measureTextHeight:caption maxWidth:maxWidth isOutgoing:isOutgoing font:font];
}
rosetta::MessageLayoutInput input{};
input.containerWidth = static_cast<float>(maxWidth);
input.textHeight = 0;
input.hasText = false;
input.isOutgoing = isOutgoing;
input.position = static_cast<rosetta::BubblePosition>(position);
input.hasReplyQuote = false;
input.isForward = true;
input.imageCount = 0;
input.fileCount = 0;
input.avatarCount = 0;
input.forwardImageCount = imageCount;
input.forwardFileCount = fileCount;
input.forwardHasCaption = (caption && caption.length > 0);
input.forwardCaptionHeight = static_cast<float>(captionH);
auto result = rosetta::calculateLayout(input);
return static_cast<CGFloat>(result.totalHeight);
}
@end

View File

@@ -4,7 +4,7 @@ import Foundation
/// Matches the React Native / Android implementation exactly.
final class Stream: @unchecked Sendable {
private var bytes: [Int]
private var bytes: [UInt8]
private var readPointer: Int = 0
private var writePointer: Int = 0
@@ -12,33 +12,36 @@ final class Stream: @unchecked Sendable {
init() {
bytes = []
bytes.reserveCapacity(256)
}
init(data: Data) {
bytes = data.map { Int($0) & 0xFF }
bytes = Array(data)
}
// MARK: - Output
func toData() -> Data {
Data(bytes.map { UInt8($0 & 0xFF) })
Data(bytes)
}
// MARK: - Bit-Level I/O
func writeBit(_ value: Int) {
let bit = value & 1
let bit = UInt8(value & 1)
ensureCapacityForUpcomingBits(1)
let byteIndex = writePointer >> 3
ensureCapacity(byteIndex)
bytes[byteIndex] = bytes[byteIndex] | (bit << (7 - (writePointer & 7)))
let shift = 7 - (writePointer & 7)
bytes[byteIndex] = bytes[byteIndex] | (bit << shift)
writePointer += 1
}
func readBit() -> Int {
let byteIndex = readPointer >> 3
let bit = (bytes[byteIndex] >> (7 - (readPointer & 7))) & 1
let shift = 7 - (readPointer & 7)
let bit = (bytes[byteIndex] >> shift) & 1
readPointer += 1
return bit
return Int(bit)
}
// MARK: - Bool
@@ -54,30 +57,33 @@ final class Stream: @unchecked Sendable {
// MARK: - Int8 (9 bits: 1 sign + 8 data)
func writeInt8(_ value: Int) {
let negationBit = value < 0 ? 1 : 0
let int8Value = abs(value) & 0xFF
let negationBit: UInt8 = value < 0 ? 1 : 0
let int8Value = UInt8(abs(value) & 0xFF)
ensureCapacityForUpcomingBits(9)
let byteIndex = writePointer >> 3
ensureCapacity(byteIndex)
bytes[byteIndex] = bytes[byteIndex] | (negationBit << (7 - (writePointer & 7)))
let signShift = 7 - (writePointer & 7)
bytes[byteIndex] = bytes[byteIndex] | (negationBit << signShift)
writePointer += 1
for i in 0..<8 {
let bit = (int8Value >> (7 - i)) & 1
let idx = writePointer >> 3
ensureCapacity(idx)
bytes[idx] = bytes[idx] | (bit << (7 - (writePointer & 7)))
let shift = 7 - (writePointer & 7)
bytes[idx] = bytes[idx] | (bit << shift)
writePointer += 1
}
}
func readInt8() -> Int {
var value = 0
let negationBit = (bytes[readPointer >> 3] >> (7 - (readPointer & 7))) & 1
let signShift = 7 - (readPointer & 7)
let negationBit = Int((bytes[readPointer >> 3] >> signShift) & 1)
readPointer += 1
for i in 0..<8 {
let bit = (bytes[readPointer >> 3] >> (7 - (readPointer & 7))) & 1
let shift = 7 - (readPointer & 7)
let bit = Int((bytes[readPointer >> 3] >> shift) & 1)
value = value | (bit << (7 - i))
readPointer += 1
}
@@ -128,6 +134,8 @@ final class Stream: @unchecked Sendable {
func writeString(_ value: String) {
let utf16Units = Array(value.utf16)
let requiredBits = 36 + utf16Units.count * 18
ensureCapacityForUpcomingBits(requiredBits)
writeInt32(utf16Units.count)
for codeUnit in utf16Units {
writeInt16(Int(codeUnit))
@@ -153,6 +161,8 @@ final class Stream: @unchecked Sendable {
// MARK: - Bytes (Int32 length + raw Int8s)
func writeBytes(_ value: Data) {
let requiredBits = 36 + value.count * 9
ensureCapacityForUpcomingBits(requiredBits)
writeInt32(value.count)
for byte in value {
writeInt8(Int(byte))
@@ -163,16 +173,22 @@ final class Stream: @unchecked Sendable {
let length = readInt32()
var result = Data(capacity: length)
for _ in 0..<length {
result.append(UInt8(readInt8() & 0xFF))
result.append(UInt8(truncatingIfNeeded: readInt8()))
}
return result
}
// MARK: - Private
private func ensureCapacityForUpcomingBits(_ bitCount: Int) {
guard bitCount > 0 else { return }
let lastBitIndex = writePointer + bitCount - 1
ensureCapacity(lastBitIndex >> 3)
}
private func ensureCapacity(_ index: Int) {
while bytes.count <= index {
bytes.append(0)
if bytes.count <= index {
bytes.append(contentsOf: repeatElement(0, count: index - bytes.count + 1))
}
}
}

View File

@@ -26,7 +26,7 @@ final class AccountManager {
private let crypto = CryptoManager.shared
private let keychain = KeychainManager.shared
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager")
nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager")
private init() {
migrateFromSingleAccount()

View File

@@ -12,7 +12,7 @@ final class SessionManager {
static let shared = SessionManager()
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session")
nonisolated private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session")
private(set) var isAuthenticated = false
private(set) var currentPublicKey: String = ""

View File

@@ -15,7 +15,7 @@ enum BlurHashEncoder {
/// - image: Source image (will be downscaled internally for performance).
/// - numberOfComponents: AC components (x, y). Android parity: `BlurHash.encode(bitmap, 4, 3)`.
/// - Returns: BlurHash string, or `nil` if encoding fails.
static func encode(image: UIImage, numberOfComponents components: (Int, Int) = (4, 3)) -> String? {
nonisolated static func encode(image: UIImage, numberOfComponents components: (Int, Int) = (4, 3)) -> String? {
let (componentX, componentY) = components
guard componentX >= 1, componentX <= 9, componentY >= 1, componentY <= 9 else { return nil }
@@ -89,7 +89,7 @@ enum BlurHashEncoder {
// MARK: - Basis Function
private static func multiplyBasisFunction(
private nonisolated static func multiplyBasisFunction(
pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int,
componentX: Int, componentY: Int
) -> (Float, Float, Float) {
@@ -116,7 +116,7 @@ enum BlurHashEncoder {
// MARK: - sRGB <-> Linear
static func sRGBToLinear(_ value: UInt8) -> Float {
nonisolated static func sRGBToLinear(_ value: UInt8) -> Float {
let v = Float(value) / 255
if v <= 0.04045 {
return v / 12.92
@@ -125,7 +125,7 @@ enum BlurHashEncoder {
}
}
static func linearToSRGB(_ value: Float) -> Int {
nonisolated static func linearToSRGB(_ value: Float) -> Int {
let v = max(0, min(1, value))
if v <= 0.0031308 {
return Int(v * 12.92 * 255 + 0.5)
@@ -136,31 +136,31 @@ enum BlurHashEncoder {
// MARK: - DC / AC Encoding
private static func encodeDC(_ value: (Float, Float, Float)) -> Int {
private nonisolated static func encodeDC(_ value: (Float, Float, Float)) -> Int {
let r = linearToSRGB(value.0)
let g = linearToSRGB(value.1)
let b = linearToSRGB(value.2)
return (r << 16) + (g << 8) + b
}
private static func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
private nonisolated static func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
let r = max(0, min(18, Int(floor(signPow(value.0 / maximumValue) * 9 + 9.5))))
let g = max(0, min(18, Int(floor(signPow(value.1 / maximumValue) * 9 + 9.5))))
let b = max(0, min(18, Int(floor(signPow(value.2 / maximumValue) * 9 + 9.5))))
return r * 19 * 19 + g * 19 + b
}
private static func signPow(_ value: Float) -> Float {
private nonisolated static func signPow(_ value: Float) -> Float {
return copysign(pow(abs(value), 0.5), value)
}
// MARK: - Base83 Encoding
private static let base83Characters: [Character] = Array(
private nonisolated static let base83Characters: [Character] = Array(
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
)
private static func encode83(value: Int, length: Int) -> String {
private nonisolated static func encode83(value: Int, length: Int) -> String {
var result = ""
for i in 1...length {
let digit = (value / pow83(length - i)) % 83
@@ -169,7 +169,7 @@ enum BlurHashEncoder {
return result
}
private static func pow83(_ exponent: Int) -> Int {
private nonisolated static func pow83(_ exponent: Int) -> Int {
var result = 1
for _ in 0..<exponent {
result *= 83
@@ -198,9 +198,19 @@ enum BlurHashDecoder {
/// - height: Output image height in pixels (default 32).
/// - punch: Color intensity multiplier (default 1). Android parity.
/// - Returns: A UIImage placeholder, or `nil` if decoding fails.
static func decode(blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? {
nonisolated static func decode(blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? {
guard blurHash.count >= 6 else { return nil }
if let nativeRGB = NativeBlurHashBridge.decodeBlurHash(
blurHash,
width: width,
height: height,
punch: punch
),
let nativeImage = makeImageFromRGBData(nativeRGB, width: width, height: height) {
return nativeImage
}
let sizeFlag = decodeBase83(blurHash, from: 0, length: 1)
let numY = (sizeFlag / 9) + 1
let numX = (sizeFlag % 9) + 1
@@ -250,30 +260,19 @@ enum BlurHashDecoder {
}
}
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data) else { return nil }
guard let cgImage = CGImage(
width: width, height: height,
bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil, shouldInterpolate: true, intent: .defaultIntent
) else { return nil }
return UIImage(cgImage: cgImage)
return makeImageFromRGBData(data as Data, width: width, height: height)
}
// MARK: - DC / AC Decoding
private static func decodeDC(_ value: Int) -> (Float, Float, Float) {
private nonisolated static func decodeDC(_ value: Int) -> (Float, Float, Float) {
let r = value >> 16
let g = (value >> 8) & 255
let b = value & 255
return (sRGBToLinear(r), sRGBToLinear(g), sRGBToLinear(b))
}
private static func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
private nonisolated static func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) {
let quantR = value / (19 * 19)
let quantG = (value / 19) % 19
let quantB = value % 19
@@ -284,11 +283,11 @@ enum BlurHashDecoder {
)
}
private static func signPow(_ value: Float, _ exp: Float) -> Float {
private nonisolated static func signPow(_ value: Float, _ exp: Float) -> Float {
copysign(pow(abs(value), exp), value)
}
private static func sRGBToLinear<T: BinaryInteger>(_ value: T) -> Float {
private nonisolated static func sRGBToLinear<T: BinaryInteger>(_ value: T) -> Float {
let v = Float(Int64(value)) / 255
if v <= 0.04045 { return v / 12.92 }
else { return pow((v + 0.055) / 1.055, 2.4) }
@@ -296,7 +295,7 @@ enum BlurHashDecoder {
// MARK: - Base83 Decoding (string-index based, matching canonical)
private static let base83Lookup: [Character: Int] = {
private nonisolated static let base83Lookup: [Character: Int] = {
let chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
var lookup = [Character: Int]()
for (i, ch) in chars.enumerated() {
@@ -305,7 +304,7 @@ enum BlurHashDecoder {
return lookup
}()
private static func decodeBase83(_ string: String, from start: Int, length: Int) -> Int {
private nonisolated static func decodeBase83(_ string: String, from start: Int, length: Int) -> Int {
let startIdx = string.index(string.startIndex, offsetBy: start)
let endIdx = string.index(startIdx, offsetBy: length)
var value = 0
@@ -314,6 +313,26 @@ enum BlurHashDecoder {
}
return value
}
private nonisolated static func makeImageFromRGBData(_ data: Data, width: Int, height: Int) -> UIImage? {
let bytesPerRow = width * 3
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let provider = CGDataProvider(data: data as CFData) else { return nil }
guard let cgImage = CGImage(
width: width,
height: height,
bitsPerComponent: 8,
bitsPerPixel: 24,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: bitmapInfo,
provider: provider,
decode: nil,
shouldInterpolate: true,
intent: .defaultIntent
) else { return nil }
return UIImage(cgImage: cgImage)
}
}
// MARK: - UIImage Extension
@@ -327,7 +346,7 @@ extension UIImage {
/// Creates a UIImage from a BlurHash string.
/// Canonical woltapp/blurhash decoder with punch parameter (Android parity).
static func fromBlurHash(_ blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? {
nonisolated static func fromBlurHash(_ blurHash: String, width: Int = 32, height: Int = 32, punch: Float = 1) -> UIImage? {
return BlurHashDecoder.decode(blurHash: blurHash, width: width, height: height, punch: punch)
}
}

View File

@@ -0,0 +1,173 @@
#include "NativeBlurHash.hpp"
#include <algorithm>
#include <array>
#include <cmath>
namespace {
constexpr char kBase83Chars[] =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~";
constexpr float kPi = 3.14159265358979323846f;
constexpr std::array<std::int8_t, 128> make_base83_lookup() {
std::array<std::int8_t, 128> lookup = {};
for (std::size_t i = 0; i < lookup.size(); i++) {
lookup[i] = -1;
}
for (int i = 0; i < 83; i++) {
lookup[static_cast<unsigned char>(kBase83Chars[i])] = static_cast<std::int8_t>(i);
}
return lookup;
}
constexpr auto kBase83Lookup = make_base83_lookup();
int base83_index(char c) {
const auto uc = static_cast<unsigned char>(c);
if (uc >= kBase83Lookup.size()) {
return 0;
}
const int index = kBase83Lookup[uc];
return index >= 0 ? index : 0;
}
int decode_base83(const std::string &str, std::size_t start, std::size_t length) {
int value = 0;
for (std::size_t i = 0; i < length; i++) {
value = value * 83 + base83_index(str[start + i]);
}
return value;
}
float srgb_to_linear(int value) {
const float v = static_cast<float>(value) / 255.0f;
if (v <= 0.04045f) {
return v / 12.92f;
}
return std::pow((v + 0.055f) / 1.055f, 2.4f);
}
int linear_to_srgb(float value) {
const float v = std::clamp(value, 0.0f, 1.0f);
if (v <= 0.0031308f) {
return static_cast<int>(v * 12.92f * 255.0f + 0.5f);
}
return static_cast<int>((1.055f * std::pow(v, 1.0f / 2.4f) - 0.055f) * 255.0f + 0.5f);
}
float sign_pow(float value, float exp) {
return std::copysign(std::pow(std::abs(value), exp), value);
}
std::array<float, 3> decode_dc(int value) {
const int r = value >> 16;
const int g = (value >> 8) & 255;
const int b = value & 255;
return {srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b)};
}
std::array<float, 3> decode_ac(int value, float maximum_value) {
const int quant_r = value / (19 * 19);
const int quant_g = (value / 19) % 19;
const int quant_b = value % 19;
return {
sign_pow((static_cast<float>(quant_r) - 9.0f) / 9.0f, 2.0f) * maximum_value,
sign_pow((static_cast<float>(quant_g) - 9.0f) / 9.0f, 2.0f) * maximum_value,
sign_pow((static_cast<float>(quant_b) - 9.0f) / 9.0f, 2.0f) * maximum_value
};
}
} // namespace
namespace rosetta::nativeimage {
bool decode_blurhash_to_rgb(
const std::string &blurhash,
int width,
int height,
float punch,
std::vector<std::uint8_t> &rgb
) {
if (blurhash.size() < 6 || width <= 0 || height <= 0) {
return false;
}
const int size_flag = decode_base83(blurhash, 0, 1);
const int num_y = (size_flag / 9) + 1;
const int num_x = (size_flag % 9) + 1;
const std::size_t expected_length = static_cast<std::size_t>(4 + 2 * num_x * num_y);
if (blurhash.size() != expected_length) {
return false;
}
const int quantized_maximum = decode_base83(blurhash, 1, 1);
const float maximum_value = static_cast<float>(quantized_maximum + 1) / 166.0f;
std::vector<std::array<float, 3>> colors;
colors.reserve(static_cast<std::size_t>(num_x * num_y));
for (int i = 0; i < num_x * num_y; i++) {
if (i == 0) {
colors.push_back(decode_dc(decode_base83(blurhash, 2, 4)));
} else {
colors.push_back(decode_ac(
decode_base83(blurhash, static_cast<std::size_t>(4 + i * 2), 2),
maximum_value * punch
));
}
}
const std::size_t pixel_count = static_cast<std::size_t>(width) * static_cast<std::size_t>(height);
rgb.assign(pixel_count * 3, 0);
std::vector<float> cos_x;
std::vector<float> cos_y;
cos_x.resize(static_cast<std::size_t>(width) * static_cast<std::size_t>(num_x));
cos_y.resize(static_cast<std::size_t>(height) * static_cast<std::size_t>(num_y));
for (int x = 0; x < width; x++) {
for (int i = 0; i < num_x; i++) {
cos_x[static_cast<std::size_t>(x * num_x + i)] = std::cos(
kPi * static_cast<float>(x) * static_cast<float>(i)
/ static_cast<float>(width)
);
}
}
for (int y = 0; y < height; y++) {
for (int j = 0; j < num_y; j++) {
cos_y[static_cast<std::size_t>(y * num_y + j)] = std::cos(
kPi * static_cast<float>(y) * static_cast<float>(j)
/ static_cast<float>(height)
);
}
}
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
float r = 0;
float g = 0;
float b = 0;
for (int j = 0; j < num_y; j++) {
const float basis_y = cos_y[static_cast<std::size_t>(y * num_y + j)];
for (int i = 0; i < num_x; i++) {
const float basis_x = cos_x[static_cast<std::size_t>(x * num_x + i)];
const float basis = basis_x * basis_y;
const auto &color = colors[static_cast<std::size_t>(i + j * num_x)];
r += color[0] * basis;
g += color[1] * basis;
b += color[2] * basis;
}
}
const std::size_t offset = static_cast<std::size_t>((y * width + x) * 3);
rgb[offset] = static_cast<std::uint8_t>(std::clamp(linear_to_srgb(r), 0, 255));
rgb[offset + 1] = static_cast<std::uint8_t>(std::clamp(linear_to_srgb(g), 0, 255));
rgb[offset + 2] = static_cast<std::uint8_t>(std::clamp(linear_to_srgb(b), 0, 255));
}
}
return true;
}
} // namespace rosetta::nativeimage

View File

@@ -0,0 +1,18 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace rosetta::nativeimage {
bool decode_blurhash_to_rgb(
const std::string &blurhash,
int width,
int height,
float punch,
std::vector<std::uint8_t> &rgb
);
} // namespace rosetta::nativeimage

View File

@@ -0,0 +1,15 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// Objective-C++ bridge exposing native C++ BlurHash decoder to Swift.
@interface NativeBlurHashBridge : NSObject
+ (nullable NSData *)decodeBlurHash:(NSString *)blurHash
width:(NSInteger)width
height:(NSInteger)height
punch:(float)punch;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,33 @@
#import "NativeBlurHashBridge.h"
#include "NativeBlurHash.hpp"
@implementation NativeBlurHashBridge
+ (nullable NSData *)decodeBlurHash:(NSString *)blurHash
width:(NSInteger)width
height:(NSInteger)height
punch:(float)punch {
if (blurHash.length < 6 || width <= 0 || height <= 0) {
return nil;
}
std::vector<std::uint8_t> rgb;
const bool ok = rosetta::nativeimage::decode_blurhash_to_rgb(
std::string(blurHash.UTF8String ?: ""),
static_cast<int>(width),
static_cast<int>(height),
punch,
rgb
);
if (!ok) {
return nil;
}
if (rgb.empty()) {
return [NSData data];
}
return [NSData dataWithBytes:rgb.data() length:rgb.size()];
}
@end

View File

@@ -1038,7 +1038,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
photoLoadTasks[attachmentId] = Task { [weak self] in
await ImageLoadLimiter.shared.acquire()
let loaded = await Task.detached(priority: .userInitiated) {
await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
}.value
await ImageLoadLimiter.shared.release()
guard !Task.isCancelled else { return }

View File

@@ -727,7 +727,7 @@ extension NativeMessageListController: ComposerViewDelegate {
/// When `preCalculatedHeight` is set, `preferredLayoutAttributesFitting` returns it
/// immediately skipping the expensive SwiftUI self-sizing layout pass.
final class PreSizedCell: UICollectionViewCell {
/// Height from C++ MessageLayout engine. Set in cell registration closure.
/// Precomputed layout height for the message cell. Set in cell registration closure.
var preCalculatedHeight: CGFloat?
override func preferredLayoutAttributesFitting(

View File

@@ -6,4 +6,5 @@
// Exposes C++ layout engine via Objective-C++ wrappers.
//
#import "Core/Layout/MessageLayoutBridge.h"
#import "Core/Crypto/NativeCryptoBridge.h"
#import "Core/Utils/NativeBlurHashBridge.h"