diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 4b17e4b..4254828 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -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; diff --git a/Rosetta/Core/Crypto/BIP39WordList.swift b/Rosetta/Core/Crypto/BIP39WordList.swift index 306a800..343dcce 100644 --- a/Rosetta/Core/Crypto/BIP39WordList.swift +++ b/Rosetta/Core/Crypto/BIP39WordList.swift @@ -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 = Set(wordList) + nonisolated static let wordSet: Set = Set(wordList) - static func index(of word: String) -> Int? { + nonisolated static func index(of word: String) -> Int? { wordList.firstIndex(of: word) } } diff --git a/Rosetta/Core/Crypto/BiometricAuthManager.swift b/Rosetta/Core/Crypto/BiometricAuthManager.swift index 3c95733..9d82cc7 100644 --- a/Rosetta/Core/Crypto/BiometricAuthManager.swift +++ b/Rosetta/Core/Crypto/BiometricAuthManager.swift @@ -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) diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift index 332a7f6..dd6fc12 100644 --- a/Rosetta/Core/Crypto/CryptoManager.swift +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -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]() diff --git a/Rosetta/Core/Crypto/CryptoPrimitives.swift b/Rosetta/Core/Crypto/CryptoPrimitives.swift index 3c81afd..4384355 100644 --- a/Rosetta/Core/Crypto/CryptoPrimitives.swift +++ b/Rosetta/Core/Crypto/CryptoPrimitives.swift @@ -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 diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift index 34985a5..ae6a274 100644 --- a/Rosetta/Core/Crypto/MessageCrypto.swift +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -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:"` → 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:`. - 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)") } diff --git a/Rosetta/Core/Crypto/NativeCryptoBridge.h b/Rosetta/Core/Crypto/NativeCryptoBridge.h new file mode 100644 index 0000000..8df5f8e --- /dev/null +++ b/Rosetta/Core/Crypto/NativeCryptoBridge.h @@ -0,0 +1,18 @@ +#import + +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 diff --git a/Rosetta/Core/Crypto/NativeCryptoBridge.mm b/Rosetta/Core/Crypto/NativeCryptoBridge.mm new file mode 100644 index 0000000..dd4366a --- /dev/null +++ b/Rosetta/Core/Crypto/NativeCryptoBridge.mm @@ -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(plaintext.bytes); + const auto *keyBytes = static_cast(key.bytes); + const auto *nonceBytes = static_cast(nonce.bytes); + + std::vector encrypted; + const bool ok = rosetta::nativecrypto::xchacha20poly1305_encrypt( + plainBytes, + static_cast(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(ciphertextWithTag.bytes); + const auto *keyBytes = static_cast(key.bytes); + const auto *nonceBytes = static_cast(nonce.bytes); + + std::vector plaintext; + const bool ok = rosetta::nativecrypto::xchacha20poly1305_decrypt( + cipherBytes, + static_cast(ciphertextWithTag.length), + keyBytes, + nonceBytes, + plaintext + ); + if (!ok) { + return nil; + } + + if (plaintext.empty()) { + return [NSData data]; + } + return [NSData dataWithBytes:plaintext.data() length:plaintext.size()]; +} + +@end diff --git a/Rosetta/Core/Crypto/NativeXChaCha20.cpp b/Rosetta/Core/Crypto/NativeXChaCha20.cpp new file mode 100644 index 0000000..7d41145 --- /dev/null +++ b/Rosetta/Core/Crypto/NativeXChaCha20.cpp @@ -0,0 +1,413 @@ +#include "NativeXChaCha20.hpp" + +#include +#include + +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(src[0]) + | (static_cast(src[1]) << 8U) + | (static_cast(src[2]) << 16U) + | (static_cast(src[3]) << 24U); +} + +inline std::uint64_t load_le64(const std::uint8_t *src) { + return static_cast(src[0]) + | (static_cast(src[1]) << 8U) + | (static_cast(src[2]) << 16U) + | (static_cast(src[3]) << 24U) + | (static_cast(src[4]) << 32U) + | (static_cast(src[5]) << 40U) + | (static_cast(src[6]) << 48U) + | (static_cast(src[7]) << 56U); +} + +inline void store_le32(std::uint32_t value, std::uint8_t *dst) { + dst[0] = static_cast(value & 0xFFU); + dst[1] = static_cast((value >> 8U) & 0xFFU); + dst[2] = static_cast((value >> 16U) & 0xFFU); + dst[3] = static_cast((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((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 &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(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 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(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(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 &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 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 &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 diff --git a/Rosetta/Core/Crypto/NativeXChaCha20.hpp b/Rosetta/Core/Crypto/NativeXChaCha20.hpp new file mode 100644 index 0000000..17ff714 --- /dev/null +++ b/Rosetta/Core/Crypto/NativeXChaCha20.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +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 &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 &plaintext +); + +} // namespace rosetta::nativecrypto diff --git a/Rosetta/Core/Crypto/Poly1305Engine.swift b/Rosetta/Core/Crypto/Poly1305Engine.swift index 736b61b..8751859 100644 --- a/Rosetta/Core/Crypto/Poly1305Engine.swift +++ b/Rosetta/Core/Crypto/Poly1305Engine.swift @@ -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 diff --git a/Rosetta/Core/Crypto/XChaCha20Engine.swift b/Rosetta/Core/Crypto/XChaCha20Engine.swift index 94bf0ce..9a588e7 100644 --- a/Rosetta/Core/Crypto/XChaCha20Engine.swift +++ b/Rosetta/Core/Crypto/XChaCha20Engine.swift @@ -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.. = { + nonisolated(unsafe) private let imageCache: NSCache = { let cache = NSCache() 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) diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift index 7856a87..1978838 100644 --- a/Rosetta/Core/Layout/MessageCellLayout.swift +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -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)) } diff --git a/Rosetta/Core/Layout/MessageLayout.cpp b/Rosetta/Core/Layout/MessageLayout.cpp deleted file mode 100644 index 5b34219..0000000 --- a/Rosetta/Core/Layout/MessageLayout.cpp +++ /dev/null @@ -1,115 +0,0 @@ -#include "MessageLayout.hpp" -#include -#include - -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 diff --git a/Rosetta/Core/Layout/MessageLayout.hpp b/Rosetta/Core/Layout/MessageLayout.hpp deleted file mode 100644 index c95153f..0000000 --- a/Rosetta/Core/Layout/MessageLayout.hpp +++ /dev/null @@ -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 diff --git a/Rosetta/Core/Layout/MessageLayoutBridge.h b/Rosetta/Core/Layout/MessageLayoutBridge.h deleted file mode 100644 index d7fde9e..0000000 --- a/Rosetta/Core/Layout/MessageLayoutBridge.h +++ /dev/null @@ -1,38 +0,0 @@ -#import - -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 diff --git a/Rosetta/Core/Layout/MessageLayoutBridge.mm b/Rosetta/Core/Layout/MessageLayoutBridge.mm deleted file mode 100644 index 63fb314..0000000 --- a/Rosetta/Core/Layout/MessageLayoutBridge.mm +++ /dev/null @@ -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(maxWidth); - input.textHeight = static_cast(textH); - input.hasText = (text.length > 0); - input.isOutgoing = isOutgoing; - input.position = static_cast(position); - input.hasReplyQuote = hasReplyQuote; - input.isForward = false; - input.imageCount = 0; - input.fileCount = 0; - input.avatarCount = 0; - - auto result = rosetta::calculateLayout(input); - return static_cast(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(maxWidth); - input.textHeight = static_cast(captionH); - input.hasText = (caption && caption.length > 0); - input.isOutgoing = isOutgoing; - input.position = static_cast(position); - input.hasReplyQuote = false; - input.isForward = false; - input.imageCount = imageCount; - input.fileCount = fileCount; - input.avatarCount = avatarCount; - - auto result = rosetta::calculateLayout(input); - return static_cast(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(maxWidth); - input.textHeight = 0; - input.hasText = false; - input.isOutgoing = isOutgoing; - input.position = static_cast(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(captionH); - - auto result = rosetta::calculateLayout(input); - return static_cast(result.totalHeight); -} - -@end diff --git a/Rosetta/Core/Network/Protocol/Stream.swift b/Rosetta/Core/Network/Protocol/Stream.swift index e57760f..683fc55 100644 --- a/Rosetta/Core/Network/Protocol/Stream.swift +++ b/Rosetta/Core/Network/Protocol/Stream.swift @@ -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.. 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)) } } } diff --git a/Rosetta/Core/Services/AccountManager.swift b/Rosetta/Core/Services/AccountManager.swift index cbdae1e..0ad8bf0 100644 --- a/Rosetta/Core/Services/AccountManager.swift +++ b/Rosetta/Core/Services/AccountManager.swift @@ -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() diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 1293f1f..5ea9a72 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -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 = "" diff --git a/Rosetta/Core/Utils/BlurHash.swift b/Rosetta/Core/Utils/BlurHash.swift index 345becc..28b597c 100644 --- a/Rosetta/Core/Utils/BlurHash.swift +++ b/Rosetta/Core/Utils/BlurHash.swift @@ -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.. 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(_ value: T) -> Float { + private nonisolated static func sRGBToLinear(_ 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) } } diff --git a/Rosetta/Core/Utils/NativeBlurHash.cpp b/Rosetta/Core/Utils/NativeBlurHash.cpp new file mode 100644 index 0000000..2058d12 --- /dev/null +++ b/Rosetta/Core/Utils/NativeBlurHash.cpp @@ -0,0 +1,173 @@ +#include "NativeBlurHash.hpp" + +#include +#include +#include + +namespace { + +constexpr char kBase83Chars[] = + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"; +constexpr float kPi = 3.14159265358979323846f; + +constexpr std::array make_base83_lookup() { + std::array lookup = {}; + for (std::size_t i = 0; i < lookup.size(); i++) { + lookup[i] = -1; + } + for (int i = 0; i < 83; i++) { + lookup[static_cast(kBase83Chars[i])] = static_cast(i); + } + return lookup; +} + +constexpr auto kBase83Lookup = make_base83_lookup(); + +int base83_index(char c) { + const auto uc = static_cast(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(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(v * 12.92f * 255.0f + 0.5f); + } + return static_cast((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 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 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(quant_r) - 9.0f) / 9.0f, 2.0f) * maximum_value, + sign_pow((static_cast(quant_g) - 9.0f) / 9.0f, 2.0f) * maximum_value, + sign_pow((static_cast(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 &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(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(quantized_maximum + 1) / 166.0f; + + std::vector> colors; + colors.reserve(static_cast(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(4 + i * 2), 2), + maximum_value * punch + )); + } + } + + const std::size_t pixel_count = static_cast(width) * static_cast(height); + rgb.assign(pixel_count * 3, 0); + + std::vector cos_x; + std::vector cos_y; + cos_x.resize(static_cast(width) * static_cast(num_x)); + cos_y.resize(static_cast(height) * static_cast(num_y)); + + for (int x = 0; x < width; x++) { + for (int i = 0; i < num_x; i++) { + cos_x[static_cast(x * num_x + i)] = std::cos( + kPi * static_cast(x) * static_cast(i) + / static_cast(width) + ); + } + } + for (int y = 0; y < height; y++) { + for (int j = 0; j < num_y; j++) { + cos_y[static_cast(y * num_y + j)] = std::cos( + kPi * static_cast(y) * static_cast(j) + / static_cast(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(y * num_y + j)]; + for (int i = 0; i < num_x; i++) { + const float basis_x = cos_x[static_cast(x * num_x + i)]; + const float basis = basis_x * basis_y; + const auto &color = colors[static_cast(i + j * num_x)]; + r += color[0] * basis; + g += color[1] * basis; + b += color[2] * basis; + } + } + + const std::size_t offset = static_cast((y * width + x) * 3); + rgb[offset] = static_cast(std::clamp(linear_to_srgb(r), 0, 255)); + rgb[offset + 1] = static_cast(std::clamp(linear_to_srgb(g), 0, 255)); + rgb[offset + 2] = static_cast(std::clamp(linear_to_srgb(b), 0, 255)); + } + } + + return true; +} + +} // namespace rosetta::nativeimage diff --git a/Rosetta/Core/Utils/NativeBlurHash.hpp b/Rosetta/Core/Utils/NativeBlurHash.hpp new file mode 100644 index 0000000..aaefaa0 --- /dev/null +++ b/Rosetta/Core/Utils/NativeBlurHash.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include +#include + +namespace rosetta::nativeimage { + +bool decode_blurhash_to_rgb( + const std::string &blurhash, + int width, + int height, + float punch, + std::vector &rgb +); + +} // namespace rosetta::nativeimage diff --git a/Rosetta/Core/Utils/NativeBlurHashBridge.h b/Rosetta/Core/Utils/NativeBlurHashBridge.h new file mode 100644 index 0000000..7bec583 --- /dev/null +++ b/Rosetta/Core/Utils/NativeBlurHashBridge.h @@ -0,0 +1,15 @@ +#import + +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 diff --git a/Rosetta/Core/Utils/NativeBlurHashBridge.mm b/Rosetta/Core/Utils/NativeBlurHashBridge.mm new file mode 100644 index 0000000..a839879 --- /dev/null +++ b/Rosetta/Core/Utils/NativeBlurHashBridge.mm @@ -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 rgb; + const bool ok = rosetta::nativeimage::decode_blurhash_to_rgb( + std::string(blurHash.UTF8String ?: ""), + static_cast(width), + static_cast(height), + punch, + rgb + ); + if (!ok) { + return nil; + } + + if (rgb.empty()) { + return [NSData data]; + } + return [NSData dataWithBytes:rgb.data() length:rgb.size()]; +} + +@end diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index f27ec60..5cfcca4 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -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 } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 5ec8a0f..deab269 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -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( diff --git a/Rosetta/Rosetta-Bridging-Header.h b/Rosetta/Rosetta-Bridging-Header.h index 2b0a662..9f3241f 100644 --- a/Rosetta/Rosetta-Bridging-Header.h +++ b/Rosetta/Rosetta-Bridging-Header.h @@ -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"