Убраны actor-isolation warnings и выровненны версии extension
This commit is contained in:
@@ -273,7 +273,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
|
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 27;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
|
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
|
||||||
@@ -283,7 +283,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.8;
|
MARKETING_VERSION = 1.2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -500,7 +500,7 @@
|
|||||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
|
CODE_SIGN_ENTITLEMENTS = RosettaNotificationService/RosettaNotificationService.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 19;
|
CURRENT_PROJECT_VERSION = 27;
|
||||||
DEVELOPMENT_TEAM = QN8Z263QGX;
|
DEVELOPMENT_TEAM = QN8Z263QGX;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
|
INFOPLIST_FILE = RosettaNotificationService/Info.plist;
|
||||||
@@ -510,7 +510,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1.8;
|
MARKETING_VERSION = 1.2.6;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
|
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.NotificationService;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt
|
// https://github.com/bitcoin/bips/blob/master/bip-0039/english.txt
|
||||||
|
|
||||||
enum BIP39 {
|
enum BIP39 {
|
||||||
static let wordList: [String] = [
|
nonisolated static let wordList: [String] = [
|
||||||
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
|
"abandon", "ability", "able", "about", "above", "absent", "absorb", "abstract",
|
||||||
"absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
|
"absurd", "abuse", "access", "accident", "account", "accuse", "achieve", "acid",
|
||||||
"acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
|
"acoustic", "acquire", "across", "act", "action", "actor", "actress", "actual",
|
||||||
@@ -261,9 +261,9 @@ enum BIP39 {
|
|||||||
"yellow", "you", "young", "youth", "zebra", "zero", "zone", "zoo",
|
"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)
|
wordList.firstIndex(of: word)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,14 +221,17 @@ final class BiometricAuthManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a biometric-protected password exists for the given account.
|
/// 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 {
|
func hasStoredPassword(forAccount publicKey: String) -> Bool {
|
||||||
let key = passwordKey(for: publicKey)
|
let key = passwordKey(for: publicKey)
|
||||||
|
let context = LAContext()
|
||||||
|
context.interactionNotAllowed = true
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: keychainService,
|
kSecAttrService as String: keychainService,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key,
|
||||||
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail,
|
kSecUseAuthenticationContext as String: context,
|
||||||
]
|
]
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
|||||||
@@ -36,23 +36,23 @@ enum CryptoError: LocalizedError {
|
|||||||
/// Low-level primitives are in `CryptoPrimitives`.
|
/// Low-level primitives are in `CryptoPrimitives`.
|
||||||
final class CryptoManager: @unchecked Sendable {
|
final class CryptoManager: @unchecked Sendable {
|
||||||
|
|
||||||
static let shared = CryptoManager()
|
nonisolated static let shared = CryptoManager()
|
||||||
|
|
||||||
// MARK: - Android Parity: PBKDF2 Key Cache
|
// MARK: - Android Parity: PBKDF2 Key Cache
|
||||||
|
|
||||||
/// Caches derived PBKDF2 keys to avoid repeated ~50-100ms derivations.
|
/// Caches derived PBKDF2 keys to avoid repeated ~50-100ms derivations.
|
||||||
/// Android: `CryptoManager.pbkdf2KeyCache` (ConcurrentHashMap).
|
/// Android: `CryptoManager.pbkdf2KeyCache` (ConcurrentHashMap).
|
||||||
/// Key format: "algorithm::password", value: derived 32-byte key.
|
/// Key format: "algorithm::password", value: derived 32-byte key.
|
||||||
private let pbkdf2CacheLock = NSLock()
|
nonisolated private let pbkdf2CacheLock = NSLock()
|
||||||
private var pbkdf2Cache: [String: Data] = [:]
|
nonisolated(unsafe) private var pbkdf2Cache: [String: Data] = [:]
|
||||||
|
|
||||||
// MARK: - Android Parity: Decryption Cache
|
// MARK: - Android Parity: Decryption Cache
|
||||||
|
|
||||||
/// Caches decrypted results to avoid repeated AES + PBKDF2 for same input.
|
/// Caches decrypted results to avoid repeated AES + PBKDF2 for same input.
|
||||||
/// Android: `CryptoManager.decryptionCache` (ConcurrentHashMap, max 2000).
|
/// Android: `CryptoManager.decryptionCache` (ConcurrentHashMap, max 2000).
|
||||||
private static let decryptionCacheMaxSize = 2000
|
nonisolated private static let decryptionCacheMaxSize = 2000
|
||||||
private let decryptionCacheLock = NSLock()
|
nonisolated private let decryptionCacheLock = NSLock()
|
||||||
private var decryptionCache: [String: Data] = [:]
|
nonisolated(unsafe) private var decryptionCache: [String: Data] = [:]
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ final class CryptoManager: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension CryptoManager {
|
private extension CryptoManager {
|
||||||
func decryptWithPassword(
|
nonisolated func decryptWithPassword(
|
||||||
ciphertext: Data,
|
ciphertext: Data,
|
||||||
iv: Data,
|
iv: Data,
|
||||||
password: String,
|
password: String,
|
||||||
@@ -257,7 +257,7 @@ private extension CryptoManager {
|
|||||||
|
|
||||||
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 }
|
guard entropy.count == 16 else { throw CryptoError.invalidEntropy }
|
||||||
|
|
||||||
let hashBytes = Data(SHA256.hash(data: entropy))
|
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 }
|
guard words.count == 12 else { throw CryptoError.invalidMnemonic }
|
||||||
|
|
||||||
var bits = [Bool]()
|
var bits = [Bool]()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ enum CryptoPrimitives {
|
|||||||
|
|
||||||
// MARK: - AES-256-CBC
|
// 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
|
let outputSize = data.count + kCCBlockSizeAES128
|
||||||
var ciphertext = Data(count: outputSize)
|
var ciphertext = Data(count: outputSize)
|
||||||
var numBytes = 0
|
var numBytes = 0
|
||||||
@@ -41,7 +41,7 @@ enum CryptoPrimitives {
|
|||||||
return ciphertext.prefix(numBytes)
|
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
|
let outputSize = data.count + kCCBlockSizeAES128
|
||||||
var plaintext = Data(count: outputSize)
|
var plaintext = Data(count: outputSize)
|
||||||
var numBytes = 0
|
var numBytes = 0
|
||||||
@@ -73,7 +73,7 @@ enum CryptoPrimitives {
|
|||||||
|
|
||||||
// MARK: - PBKDF2
|
// MARK: - PBKDF2
|
||||||
|
|
||||||
static func pbkdf2(
|
nonisolated static func pbkdf2(
|
||||||
password: String,
|
password: String,
|
||||||
salt: String,
|
salt: String,
|
||||||
iterations: Int,
|
iterations: Int,
|
||||||
@@ -108,7 +108,7 @@ enum CryptoPrimitives {
|
|||||||
|
|
||||||
// MARK: - Random Bytes
|
// MARK: - Random Bytes
|
||||||
|
|
||||||
static func randomBytes(count: Int) throws -> Data {
|
nonisolated static func randomBytes(count: Int) throws -> Data {
|
||||||
var data = Data(count: count)
|
var data = Data(count: count)
|
||||||
let status = data.withUnsafeMutableBytes { ptr -> OSStatus in
|
let status = data.withUnsafeMutableBytes { ptr -> OSStatus in
|
||||||
guard let base = ptr.baseAddress else { return errSecAllocate }
|
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)
|
/// Previously used Apple's `compression_encode_buffer(COMPRESSION_ZLIB)` (raw deflate)
|
||||||
/// with a manual zlib wrapper — that output was incompatible with pako.inflate().
|
/// 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)
|
let sourceLen = uLong(data.count)
|
||||||
var destLen = compressBound(sourceLen)
|
var destLen = compressBound(sourceLen)
|
||||||
var dest = Data(count: Int(destLen))
|
var dest = Data(count: Int(destLen))
|
||||||
@@ -148,7 +148,7 @@ extension CryptoPrimitives {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Raw deflate compression (no zlib header, compatible with Java Deflater(nowrap=true)).
|
/// 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 sourceSize = data.count
|
||||||
let destinationSize = sourceSize + 512
|
let destinationSize = sourceSize + 512
|
||||||
var destination = Data(count: destinationSize)
|
var destination = Data(count: destinationSize)
|
||||||
@@ -172,7 +172,7 @@ extension CryptoPrimitives {
|
|||||||
/// ⚠️ zlib-wrapped data MUST be stripped FIRST — `tryRawInflate` can produce
|
/// ⚠️ zlib-wrapped data MUST be stripped FIRST — `tryRawInflate` can produce
|
||||||
/// garbage output from zlib header bytes (0x78 0x9C are valid but meaningless
|
/// garbage output from zlib header bytes (0x78 0x9C are valid but meaningless
|
||||||
/// raw deflate instructions), causing false-positive decompression.
|
/// 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).
|
// 1. Strip zlib wrapper FIRST if present (iOS zlibDeflate / desktop pako.deflate).
|
||||||
// Must be tried before raw inflate to avoid false-positive decompression
|
// Must be tried before raw inflate to avoid false-positive decompression
|
||||||
// where raw inflate interprets the zlib header as deflate instructions.
|
// where raw inflate interprets the zlib header as deflate instructions.
|
||||||
@@ -189,7 +189,7 @@ extension CryptoPrimitives {
|
|||||||
throw CryptoError.compressionFailed
|
throw CryptoError.compressionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func tryRawInflate(_ data: Data) -> Data? {
|
nonisolated private static func tryRawInflate(_ data: Data) -> Data? {
|
||||||
let sourceSize = data.count
|
let sourceSize = data.count
|
||||||
for multiplier in [4, 8, 16, 32] {
|
for multiplier in [4, 8, 16, 32] {
|
||||||
let destinationSize = max(sourceSize * multiplier, 256)
|
let destinationSize = max(sourceSize * multiplier, 256)
|
||||||
@@ -218,7 +218,7 @@ extension Data {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize from a hex string (case-insensitive).
|
/// Initialize from a hex string (case-insensitive).
|
||||||
init(hexString: String) {
|
nonisolated init(hexString: String) {
|
||||||
let hex = hexString.lowercased()
|
let hex = hexString.lowercased()
|
||||||
var data = Data(capacity: hex.count / 2)
|
var data = Data(capacity: hex.count / 2)
|
||||||
var index = hex.startIndex
|
var index = hex.startIndex
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ enum MessageCrypto {
|
|||||||
/// Decrypts an incoming message and returns both plaintext and the working key+nonce.
|
/// Decrypts an incoming message and returns both plaintext and the working key+nonce.
|
||||||
/// The returned `keyAndNonce` is the candidate that successfully decrypted the message —
|
/// The returned `keyAndNonce` is the candidate that successfully decrypted the message —
|
||||||
/// critical for deriving the correct attachment password.
|
/// critical for deriving the correct attachment password.
|
||||||
static func decryptIncomingFull(
|
nonisolated static func decryptIncomingFull(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
encryptedKey: String,
|
encryptedKey: String,
|
||||||
myPrivateKeyHex: String
|
myPrivateKeyHex: String
|
||||||
@@ -50,7 +50,7 @@ enum MessageCrypto {
|
|||||||
throw CryptoError.invalidData("Failed to decrypt message content with all key candidates")
|
throw CryptoError.invalidData("Failed to decrypt message content with all key candidates")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decryptIncoming(
|
nonisolated static func decryptIncoming(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
encryptedKey: String,
|
encryptedKey: String,
|
||||||
myPrivateKeyHex: String
|
myPrivateKeyHex: String
|
||||||
@@ -67,7 +67,7 @@ enum MessageCrypto {
|
|||||||
/// - plaintext: The message text.
|
/// - plaintext: The message text.
|
||||||
/// - recipientPublicKeyHex: Recipient's secp256k1 compressed public key (hex).
|
/// - 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).
|
/// - 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,
|
plaintext: String,
|
||||||
recipientPublicKeyHex: String
|
recipientPublicKeyHex: String
|
||||||
) throws -> (content: String, chachaKey: String, plainKeyAndNonce: Data) {
|
) throws -> (content: String, chachaKey: String, plainKeyAndNonce: Data) {
|
||||||
@@ -96,7 +96,7 @@ enum MessageCrypto {
|
|||||||
|
|
||||||
/// Decrypts an incoming message using already decrypted key+nonce bytes.
|
/// Decrypts an incoming message using already decrypted key+nonce bytes.
|
||||||
/// Mirrors Android `decryptIncomingWithPlainKey`.
|
/// Mirrors Android `decryptIncomingWithPlainKey`.
|
||||||
static func decryptIncomingWithPlainKey(
|
nonisolated static func decryptIncomingWithPlainKey(
|
||||||
ciphertext: String,
|
ciphertext: String,
|
||||||
plainKeyAndNonce: Data
|
plainKeyAndNonce: Data
|
||||||
) throws -> String {
|
) throws -> String {
|
||||||
@@ -109,7 +109,7 @@ enum MessageCrypto {
|
|||||||
/// Extract raw decrypted key+nonce data from an encrypted key string (ECDH path).
|
/// 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.
|
/// Verifies each candidate by attempting XChaCha20 decryption to find the correct one.
|
||||||
/// Falls back to first candidate if ciphertext is unavailable.
|
/// Falls back to first candidate if ciphertext is unavailable.
|
||||||
static func extractDecryptedKeyData(
|
nonisolated static func extractDecryptedKeyData(
|
||||||
encryptedKey: String,
|
encryptedKey: String,
|
||||||
myPrivateKeyHex: String,
|
myPrivateKeyHex: String,
|
||||||
verifyCiphertext: String? = nil
|
verifyCiphertext: String? = nil
|
||||||
@@ -133,7 +133,7 @@ enum MessageCrypto {
|
|||||||
/// Emulates Android's `String(bytes, UTF_8).toByteArray(ISO_8859_1)` round-trip.
|
/// 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.
|
/// Uses BOTH WHATWG and Android UTF-8 decoders — returns candidates for each.
|
||||||
/// WHATWG and Android decoders handle invalid UTF-8 differently → different bytes.
|
/// 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)
|
// Primary: WHATWG decoder (matches Java's Modified UTF-8 for most cases)
|
||||||
let decoded = String(decoding: utf8Bytes, as: UTF8.self)
|
let decoded = String(decoding: utf8Bytes, as: UTF8.self)
|
||||||
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
||||||
@@ -143,7 +143,7 @@ enum MessageCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Alternative key recovery using Android UTF-8 decoder.
|
/// 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)
|
let decoded = bytesToAndroidUtf8String(utf8Bytes)
|
||||||
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
if let latin1 = decoded.data(using: .isoLatin1, allowLossyConversion: true) {
|
||||||
return latin1
|
return latin1
|
||||||
@@ -156,7 +156,7 @@ enum MessageCrypto {
|
|||||||
/// Returns password candidates from a stored attachment password string.
|
/// Returns password candidates from a stored attachment password string.
|
||||||
/// New format: `"rawkey:<hex>"` → derives Android (`bytesToJsUtf8String`) + WHATWG passwords.
|
/// New format: `"rawkey:<hex>"` → derives Android (`bytesToJsUtf8String`) + WHATWG passwords.
|
||||||
/// Legacy format: plain string → used as-is (backward compat with persisted messages).
|
/// 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:") {
|
if stored.hasPrefix("rawkey:") {
|
||||||
let hex = String(stored.dropFirst("rawkey:".count))
|
let hex = String(stored.dropFirst("rawkey:".count))
|
||||||
let keyData = Data(hexString: hex)
|
let keyData = Data(hexString: hex)
|
||||||
@@ -193,7 +193,7 @@ enum MessageCrypto {
|
|||||||
/// Uses feross/buffer npm polyfill UTF-8 decoding semantics.
|
/// Uses feross/buffer npm polyfill UTF-8 decoding semantics.
|
||||||
/// Key difference from previous implementation: on invalid sequence, ALWAYS advance by 1 byte
|
/// Key difference from previous implementation: on invalid sequence, ALWAYS advance by 1 byte
|
||||||
/// and emit 1× U+FFFD (not variable bytes/U+FFFD count).
|
/// 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] = []
|
var codePoints: [Int] = []
|
||||||
codePoints.reserveCapacity(bytes.count)
|
codePoints.reserveCapacity(bytes.count)
|
||||||
var index = 0
|
var index = 0
|
||||||
@@ -276,7 +276,7 @@ private extension MessageCrypto {
|
|||||||
/// Decrypts and returns candidate XChaCha20 key+nonce buffers.
|
/// Decrypts and returns candidate XChaCha20 key+nonce buffers.
|
||||||
/// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex)
|
/// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex)
|
||||||
/// Supports Android sync shorthand `sync:<aesChachaKey>`.
|
/// 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:") {
|
if encryptedKey.hasPrefix("sync:") {
|
||||||
let aesChachaKey = String(encryptedKey.dropFirst("sync:".count))
|
let aesChachaKey = String(encryptedKey.dropFirst("sync:".count))
|
||||||
guard !aesChachaKey.isEmpty else {
|
guard !aesChachaKey.isEmpty else {
|
||||||
@@ -365,7 +365,7 @@ private extension MessageCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encrypts the XChaCha20 key+nonce for a recipient using ECDH.
|
/// 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 ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey()
|
||||||
|
|
||||||
let recipientPubKey = try P256K.KeyAgreement.PublicKey(
|
let recipientPubKey = try P256K.KeyAgreement.PublicKey(
|
||||||
@@ -399,7 +399,7 @@ private extension MessageCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Extracts the 32-byte x-coordinate from ECDH shared secret bytes.
|
/// 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)
|
// Uncompressed point: 0x04 || X(32) || Y(32)
|
||||||
if sharedSecretData.count == 65, sharedSecretData.first == 0x04 {
|
if sharedSecretData.count == 65, sharedSecretData.first == 0x04 {
|
||||||
return sharedSecretData[1..<33]
|
return sharedSecretData[1..<33]
|
||||||
@@ -421,13 +421,13 @@ private extension MessageCrypto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy Android compatibility: x-coordinate serialized through BigInteger loses leading zeros.
|
/// 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 })
|
let trimmed = exactX.drop(while: { $0 == 0 })
|
||||||
return trimmed.isEmpty ? Data([0]) : Data(trimmed)
|
return trimmed.isEmpty ? Data([0]) : Data(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// JS/Android compatibility: private key hex can arrive without leading zero bytes.
|
/// 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
|
var hex = rawHex
|
||||||
if hex.count % 2 != 0 {
|
if hex.count % 2 != 0 {
|
||||||
hex = "0" + hex
|
hex = "0" + hex
|
||||||
@@ -441,7 +441,7 @@ private extension MessageCrypto {
|
|||||||
return hex
|
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 {
|
guard keyAndNonce.count >= 56 else {
|
||||||
throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)")
|
throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)")
|
||||||
}
|
}
|
||||||
|
|||||||
18
Rosetta/Core/Crypto/NativeCryptoBridge.h
Normal file
18
Rosetta/Core/Crypto/NativeCryptoBridge.h
Normal 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
|
||||||
65
Rosetta/Core/Crypto/NativeCryptoBridge.mm
Normal file
65
Rosetta/Core/Crypto/NativeCryptoBridge.mm
Normal 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
|
||||||
413
Rosetta/Core/Crypto/NativeXChaCha20.cpp
Normal file
413
Rosetta/Core/Crypto/NativeXChaCha20.cpp
Normal 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
|
||||||
25
Rosetta/Core/Crypto/NativeXChaCha20.hpp
Normal file
25
Rosetta/Core/Crypto/NativeXChaCha20.hpp
Normal 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
|
||||||
@@ -7,7 +7,7 @@ import Foundation
|
|||||||
enum Poly1305Engine {
|
enum Poly1305Engine {
|
||||||
|
|
||||||
/// Computes a Poly1305 MAC matching the AEAD construction.
|
/// 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)
|
// Clamp r (first 16 bytes of key)
|
||||||
var r = [UInt8](key[0..<16])
|
var r = [UInt8](key[0..<16])
|
||||||
r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15
|
r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15
|
||||||
@@ -61,7 +61,7 @@ enum Poly1305Engine {
|
|||||||
private extension Poly1305Engine {
|
private extension Poly1305Engine {
|
||||||
|
|
||||||
/// Convert 16 bytes to 5 limbs of 26 bits each (little-endian).
|
/// 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)
|
let b = bytes.count >= 16 ? bytes : bytes + [UInt8](repeating: 0, count: 16 - bytes.count)
|
||||||
|
|
||||||
var full = [UInt8](repeating: 0, count: 17)
|
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.
|
/// 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 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 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]
|
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.
|
/// 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 h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4]
|
||||||
|
|
||||||
var c: UInt64
|
var c: UInt64
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import Foundation
|
|||||||
/// Matches the Android `MessageCrypto` XChaCha20 implementation for cross-platform compatibility.
|
/// Matches the Android `MessageCrypto` XChaCha20 implementation for cross-platform compatibility.
|
||||||
enum XChaCha20Engine {
|
enum XChaCha20Engine {
|
||||||
|
|
||||||
static let poly1305TagSize = 16
|
nonisolated static let poly1305TagSize = 16
|
||||||
|
|
||||||
// MARK: - XChaCha20-Poly1305 Decrypt
|
// MARK: - XChaCha20-Poly1305 Decrypt
|
||||||
|
|
||||||
/// Decrypts ciphertext+tag using XChaCha20-Poly1305.
|
/// 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 {
|
guard ciphertextWithTag.count >= poly1305TagSize else {
|
||||||
throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag")
|
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")
|
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 ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)]
|
||||||
let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...]
|
let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...]
|
||||||
|
|
||||||
@@ -48,11 +56,19 @@ enum XChaCha20Engine {
|
|||||||
// MARK: - XChaCha20-Poly1305 Encrypt
|
// MARK: - XChaCha20-Poly1305 Encrypt
|
||||||
|
|
||||||
/// Encrypts plaintext using XChaCha20-Poly1305.
|
/// 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 {
|
guard key.count == 32, nonce.count == 24 else {
|
||||||
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
|
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
|
// Step 1: HChaCha20 — derive subkey
|
||||||
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
|
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
|
||||||
|
|
||||||
@@ -80,7 +96,7 @@ enum XChaCha20Engine {
|
|||||||
extension XChaCha20Engine {
|
extension XChaCha20Engine {
|
||||||
|
|
||||||
/// ChaCha20 quarter round.
|
/// 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[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[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)
|
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.
|
/// 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)
|
var state = [UInt32](repeating: 0, count: 16)
|
||||||
|
|
||||||
// Constants: "expand 32-byte k"
|
// Constants: "expand 32-byte k"
|
||||||
@@ -133,7 +149,7 @@ extension XChaCha20Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// ChaCha20 stream cipher encryption/decryption.
|
/// 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 result = Data(count: data.count)
|
||||||
var counter = initialCounter
|
var counter = initialCounter
|
||||||
|
|
||||||
@@ -150,7 +166,7 @@ extension XChaCha20Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// HChaCha20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
|
/// 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)
|
var state = [UInt32](repeating: 0, count: 16)
|
||||||
|
|
||||||
state[0] = 0x61707865; state[1] = 0x3320646e
|
state[0] = 0x61707865; state[1] = 0x3320646e
|
||||||
@@ -190,7 +206,7 @@ extension XChaCha20Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Constant-time comparison of two Data objects.
|
/// 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 }
|
guard a.count == b.count else { return false }
|
||||||
var result: UInt8 = 0
|
var result: UInt8 = 0
|
||||||
for i in 0..<a.count {
|
for i in 0..<a.count {
|
||||||
|
|||||||
@@ -44,16 +44,16 @@ actor ImageLoadLimiter {
|
|||||||
/// Key format: attachment ID (8-char random string).
|
/// Key format: attachment ID (8-char random string).
|
||||||
final class AttachmentCache: @unchecked Sendable {
|
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.
|
/// In-memory image cache — eliminates disk I/O + crypto on scroll-back.
|
||||||
/// Android parity: LruCache in AttachmentFileManager.kt.
|
/// Android parity: LruCache in AttachmentFileManager.kt.
|
||||||
/// Same pattern as AvatarRepository.cache (NSCache is thread-safe).
|
/// 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>()
|
let cache = NSCache<NSString, UIImage>()
|
||||||
cache.countLimit = 100
|
cache.countLimit = 100
|
||||||
cache.totalCostLimit = 80 * 1024 * 1024 // 80 MB — auto-evicts under memory pressure
|
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).
|
/// Private key for encrypting files at rest (Android parity).
|
||||||
/// Set from SessionManager.startSession() after unlocking account.
|
/// Set from SessionManager.startSession() after unlocking account.
|
||||||
private let keyLock = NSLock()
|
nonisolated private let keyLock = NSLock()
|
||||||
private var _privateKey: String?
|
nonisolated(unsafe) private var _privateKey: String?
|
||||||
var privateKey: String? {
|
nonisolated var privateKey: String? {
|
||||||
get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey }
|
get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey }
|
||||||
set {
|
set {
|
||||||
keyLock.lock()
|
keyLock.lock()
|
||||||
@@ -85,7 +85,7 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
/// Android parity: `BitmapFactory.Options.inSampleSize` with max 4096px.
|
/// Android parity: `BitmapFactory.Options.inSampleSize` with max 4096px.
|
||||||
/// Uses `CGImageSource` for memory-efficient downsampled decoding + EXIF orientation.
|
/// Uses `CGImageSource` for memory-efficient downsampled decoding + EXIF orientation.
|
||||||
/// `kCGImageSourceShouldCacheImmediately` forces decode now (not lazily on first draw).
|
/// `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 {
|
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||||
return UIImage(data: data)
|
return UIImage(data: data)
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
// MARK: - Images
|
// MARK: - Images
|
||||||
|
|
||||||
/// Saves a decoded image to cache, encrypted with private key (Android parity).
|
/// 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 }
|
guard let data = image.jpegData(compressionQuality: 0.95) else { return }
|
||||||
|
|
||||||
// Warm in-memory cache immediately — next loadImage() returns in O(1)
|
// 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.
|
/// 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
|
// Fast path: in-memory cache hit — no disk I/O, no crypto
|
||||||
if let cached = imageCache.object(forKey: id as NSString) {
|
if let cached = imageCache.object(forKey: id as NSString) {
|
||||||
return cached
|
return cached
|
||||||
@@ -161,7 +161,7 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
|
|
||||||
/// Saves raw file data to cache (encrypted), returns the file URL.
|
/// Saves raw file data to cache (encrypted), returns the file URL.
|
||||||
@discardableResult
|
@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 safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
|
||||||
let url = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
|
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.
|
/// 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: "_")
|
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
|
||||||
// Check encrypted
|
// Check encrypted
|
||||||
let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
|
let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
|
||||||
@@ -190,7 +190,7 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load file data, decrypting if needed.
|
/// 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: "_")
|
let safeFileName = fileName.replacingOccurrences(of: "/", with: "_")
|
||||||
// Try encrypted
|
// Try encrypted
|
||||||
let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
|
let encUrl = cacheDir.appendingPathComponent("file_\(id)_\(safeFileName).enc")
|
||||||
@@ -211,7 +211,7 @@ final class AttachmentCache: @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
|
|
||||||
func clearAll() {
|
nonisolated func clearAll() {
|
||||||
imageCache.removeAllObjects()
|
imageCache.removeAllObjects()
|
||||||
try? FileManager.default.removeItem(at: cacheDir)
|
try? FileManager.default.removeItem(at: cacheDir)
|
||||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||||
|
|||||||
@@ -399,7 +399,7 @@ extension MessageCellLayout {
|
|||||||
|
|
||||||
// MARK: - Collage Height (Thread-Safe)
|
// 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 {
|
private static func collageHeight(count: Int, width: CGFloat) -> CGFloat {
|
||||||
guard count > 0 else { return 0 }
|
guard count > 0 else { return 0 }
|
||||||
if count == 1 { return max(180, min(width * 0.93, 340)) }
|
if count == 1 { return max(180, min(width * 0.93, 340)) }
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -4,7 +4,7 @@ import Foundation
|
|||||||
/// Matches the React Native / Android implementation exactly.
|
/// Matches the React Native / Android implementation exactly.
|
||||||
final class Stream: @unchecked Sendable {
|
final class Stream: @unchecked Sendable {
|
||||||
|
|
||||||
private var bytes: [Int]
|
private var bytes: [UInt8]
|
||||||
private var readPointer: Int = 0
|
private var readPointer: Int = 0
|
||||||
private var writePointer: Int = 0
|
private var writePointer: Int = 0
|
||||||
|
|
||||||
@@ -12,33 +12,36 @@ final class Stream: @unchecked Sendable {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
bytes = []
|
bytes = []
|
||||||
|
bytes.reserveCapacity(256)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: Data) {
|
init(data: Data) {
|
||||||
bytes = data.map { Int($0) & 0xFF }
|
bytes = Array(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Output
|
// MARK: - Output
|
||||||
|
|
||||||
func toData() -> Data {
|
func toData() -> Data {
|
||||||
Data(bytes.map { UInt8($0 & 0xFF) })
|
Data(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bit-Level I/O
|
// MARK: - Bit-Level I/O
|
||||||
|
|
||||||
func writeBit(_ value: Int) {
|
func writeBit(_ value: Int) {
|
||||||
let bit = value & 1
|
let bit = UInt8(value & 1)
|
||||||
|
ensureCapacityForUpcomingBits(1)
|
||||||
let byteIndex = writePointer >> 3
|
let byteIndex = writePointer >> 3
|
||||||
ensureCapacity(byteIndex)
|
let shift = 7 - (writePointer & 7)
|
||||||
bytes[byteIndex] = bytes[byteIndex] | (bit << (7 - (writePointer & 7)))
|
bytes[byteIndex] = bytes[byteIndex] | (bit << shift)
|
||||||
writePointer += 1
|
writePointer += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func readBit() -> Int {
|
func readBit() -> Int {
|
||||||
let byteIndex = readPointer >> 3
|
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
|
readPointer += 1
|
||||||
return bit
|
return Int(bit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bool
|
// MARK: - Bool
|
||||||
@@ -54,30 +57,33 @@ final class Stream: @unchecked Sendable {
|
|||||||
// MARK: - Int8 (9 bits: 1 sign + 8 data)
|
// MARK: - Int8 (9 bits: 1 sign + 8 data)
|
||||||
|
|
||||||
func writeInt8(_ value: Int) {
|
func writeInt8(_ value: Int) {
|
||||||
let negationBit = value < 0 ? 1 : 0
|
let negationBit: UInt8 = value < 0 ? 1 : 0
|
||||||
let int8Value = abs(value) & 0xFF
|
let int8Value = UInt8(abs(value) & 0xFF)
|
||||||
|
ensureCapacityForUpcomingBits(9)
|
||||||
|
|
||||||
let byteIndex = writePointer >> 3
|
let byteIndex = writePointer >> 3
|
||||||
ensureCapacity(byteIndex)
|
let signShift = 7 - (writePointer & 7)
|
||||||
bytes[byteIndex] = bytes[byteIndex] | (negationBit << (7 - (writePointer & 7)))
|
bytes[byteIndex] = bytes[byteIndex] | (negationBit << signShift)
|
||||||
writePointer += 1
|
writePointer += 1
|
||||||
|
|
||||||
for i in 0..<8 {
|
for i in 0..<8 {
|
||||||
let bit = (int8Value >> (7 - i)) & 1
|
let bit = (int8Value >> (7 - i)) & 1
|
||||||
let idx = writePointer >> 3
|
let idx = writePointer >> 3
|
||||||
ensureCapacity(idx)
|
let shift = 7 - (writePointer & 7)
|
||||||
bytes[idx] = bytes[idx] | (bit << (7 - (writePointer & 7)))
|
bytes[idx] = bytes[idx] | (bit << shift)
|
||||||
writePointer += 1
|
writePointer += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func readInt8() -> Int {
|
func readInt8() -> Int {
|
||||||
var value = 0
|
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
|
readPointer += 1
|
||||||
|
|
||||||
for i in 0..<8 {
|
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))
|
value = value | (bit << (7 - i))
|
||||||
readPointer += 1
|
readPointer += 1
|
||||||
}
|
}
|
||||||
@@ -128,6 +134,8 @@ final class Stream: @unchecked Sendable {
|
|||||||
|
|
||||||
func writeString(_ value: String) {
|
func writeString(_ value: String) {
|
||||||
let utf16Units = Array(value.utf16)
|
let utf16Units = Array(value.utf16)
|
||||||
|
let requiredBits = 36 + utf16Units.count * 18
|
||||||
|
ensureCapacityForUpcomingBits(requiredBits)
|
||||||
writeInt32(utf16Units.count)
|
writeInt32(utf16Units.count)
|
||||||
for codeUnit in utf16Units {
|
for codeUnit in utf16Units {
|
||||||
writeInt16(Int(codeUnit))
|
writeInt16(Int(codeUnit))
|
||||||
@@ -153,6 +161,8 @@ final class Stream: @unchecked Sendable {
|
|||||||
// MARK: - Bytes (Int32 length + raw Int8s)
|
// MARK: - Bytes (Int32 length + raw Int8s)
|
||||||
|
|
||||||
func writeBytes(_ value: Data) {
|
func writeBytes(_ value: Data) {
|
||||||
|
let requiredBits = 36 + value.count * 9
|
||||||
|
ensureCapacityForUpcomingBits(requiredBits)
|
||||||
writeInt32(value.count)
|
writeInt32(value.count)
|
||||||
for byte in value {
|
for byte in value {
|
||||||
writeInt8(Int(byte))
|
writeInt8(Int(byte))
|
||||||
@@ -163,16 +173,22 @@ final class Stream: @unchecked Sendable {
|
|||||||
let length = readInt32()
|
let length = readInt32()
|
||||||
var result = Data(capacity: length)
|
var result = Data(capacity: length)
|
||||||
for _ in 0..<length {
|
for _ in 0..<length {
|
||||||
result.append(UInt8(readInt8() & 0xFF))
|
result.append(UInt8(truncatingIfNeeded: readInt8()))
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// 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) {
|
private func ensureCapacity(_ index: Int) {
|
||||||
while bytes.count <= index {
|
if bytes.count <= index {
|
||||||
bytes.append(0)
|
bytes.append(contentsOf: repeatElement(0, count: index - bytes.count + 1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ final class AccountManager {
|
|||||||
|
|
||||||
private let crypto = CryptoManager.shared
|
private let crypto = CryptoManager.shared
|
||||||
private let keychain = KeychainManager.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() {
|
private init() {
|
||||||
migrateFromSingleAccount()
|
migrateFromSingleAccount()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ final class SessionManager {
|
|||||||
|
|
||||||
static let shared = 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 isAuthenticated = false
|
||||||
private(set) var currentPublicKey: String = ""
|
private(set) var currentPublicKey: String = ""
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ enum BlurHashEncoder {
|
|||||||
/// - image: Source image (will be downscaled internally for performance).
|
/// - image: Source image (will be downscaled internally for performance).
|
||||||
/// - numberOfComponents: AC components (x, y). Android parity: `BlurHash.encode(bitmap, 4, 3)`.
|
/// - numberOfComponents: AC components (x, y). Android parity: `BlurHash.encode(bitmap, 4, 3)`.
|
||||||
/// - Returns: BlurHash string, or `nil` if encoding fails.
|
/// - 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
|
let (componentX, componentY) = components
|
||||||
guard componentX >= 1, componentX <= 9, componentY >= 1, componentY <= 9 else { return nil }
|
guard componentX >= 1, componentX <= 9, componentY >= 1, componentY <= 9 else { return nil }
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ enum BlurHashEncoder {
|
|||||||
|
|
||||||
// MARK: - Basis Function
|
// MARK: - Basis Function
|
||||||
|
|
||||||
private static func multiplyBasisFunction(
|
private nonisolated static func multiplyBasisFunction(
|
||||||
pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int,
|
pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int,
|
||||||
componentX: Int, componentY: Int
|
componentX: Int, componentY: Int
|
||||||
) -> (Float, Float, Float) {
|
) -> (Float, Float, Float) {
|
||||||
@@ -116,7 +116,7 @@ enum BlurHashEncoder {
|
|||||||
|
|
||||||
// MARK: - sRGB <-> Linear
|
// MARK: - sRGB <-> Linear
|
||||||
|
|
||||||
static func sRGBToLinear(_ value: UInt8) -> Float {
|
nonisolated static func sRGBToLinear(_ value: UInt8) -> Float {
|
||||||
let v = Float(value) / 255
|
let v = Float(value) / 255
|
||||||
if v <= 0.04045 {
|
if v <= 0.04045 {
|
||||||
return v / 12.92
|
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))
|
let v = max(0, min(1, value))
|
||||||
if v <= 0.0031308 {
|
if v <= 0.0031308 {
|
||||||
return Int(v * 12.92 * 255 + 0.5)
|
return Int(v * 12.92 * 255 + 0.5)
|
||||||
@@ -136,31 +136,31 @@ enum BlurHashEncoder {
|
|||||||
|
|
||||||
// MARK: - DC / AC Encoding
|
// 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 r = linearToSRGB(value.0)
|
||||||
let g = linearToSRGB(value.1)
|
let g = linearToSRGB(value.1)
|
||||||
let b = linearToSRGB(value.2)
|
let b = linearToSRGB(value.2)
|
||||||
return (r << 16) + (g << 8) + b
|
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 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 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))))
|
let b = max(0, min(18, Int(floor(signPow(value.2 / maximumValue) * 9 + 9.5))))
|
||||||
return r * 19 * 19 + g * 19 + b
|
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)
|
return copysign(pow(abs(value), 0.5), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Base83 Encoding
|
// MARK: - Base83 Encoding
|
||||||
|
|
||||||
private static let base83Characters: [Character] = Array(
|
private nonisolated static let base83Characters: [Character] = Array(
|
||||||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||||
)
|
)
|
||||||
|
|
||||||
private static func encode83(value: Int, length: Int) -> String {
|
private nonisolated static func encode83(value: Int, length: Int) -> String {
|
||||||
var result = ""
|
var result = ""
|
||||||
for i in 1...length {
|
for i in 1...length {
|
||||||
let digit = (value / pow83(length - i)) % 83
|
let digit = (value / pow83(length - i)) % 83
|
||||||
@@ -169,7 +169,7 @@ enum BlurHashEncoder {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func pow83(_ exponent: Int) -> Int {
|
private nonisolated static func pow83(_ exponent: Int) -> Int {
|
||||||
var result = 1
|
var result = 1
|
||||||
for _ in 0..<exponent {
|
for _ in 0..<exponent {
|
||||||
result *= 83
|
result *= 83
|
||||||
@@ -198,9 +198,19 @@ enum BlurHashDecoder {
|
|||||||
/// - height: Output image height in pixels (default 32).
|
/// - height: Output image height in pixels (default 32).
|
||||||
/// - punch: Color intensity multiplier (default 1). Android parity.
|
/// - punch: Color intensity multiplier (default 1). Android parity.
|
||||||
/// - Returns: A UIImage placeholder, or `nil` if decoding fails.
|
/// - 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 }
|
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 sizeFlag = decodeBase83(blurHash, from: 0, length: 1)
|
||||||
let numY = (sizeFlag / 9) + 1
|
let numY = (sizeFlag / 9) + 1
|
||||||
let numX = (sizeFlag % 9) + 1
|
let numX = (sizeFlag % 9) + 1
|
||||||
@@ -250,30 +260,19 @@ enum BlurHashDecoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
|
return makeImageFromRGBData(data as Data, width: width, height: height)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - DC / AC Decoding
|
// 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 r = value >> 16
|
||||||
let g = (value >> 8) & 255
|
let g = (value >> 8) & 255
|
||||||
let b = value & 255
|
let b = value & 255
|
||||||
return (sRGBToLinear(r), sRGBToLinear(g), sRGBToLinear(b))
|
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 quantR = value / (19 * 19)
|
||||||
let quantG = (value / 19) % 19
|
let quantG = (value / 19) % 19
|
||||||
let quantB = value % 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)
|
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
|
let v = Float(Int64(value)) / 255
|
||||||
if v <= 0.04045 { return v / 12.92 }
|
if v <= 0.04045 { return v / 12.92 }
|
||||||
else { return pow((v + 0.055) / 1.055, 2.4) }
|
else { return pow((v + 0.055) / 1.055, 2.4) }
|
||||||
@@ -296,7 +295,7 @@ enum BlurHashDecoder {
|
|||||||
|
|
||||||
// MARK: - Base83 Decoding (string-index based, matching canonical)
|
// MARK: - Base83 Decoding (string-index based, matching canonical)
|
||||||
|
|
||||||
private static let base83Lookup: [Character: Int] = {
|
private nonisolated static let base83Lookup: [Character: Int] = {
|
||||||
let chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
let chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~"
|
||||||
var lookup = [Character: Int]()
|
var lookup = [Character: Int]()
|
||||||
for (i, ch) in chars.enumerated() {
|
for (i, ch) in chars.enumerated() {
|
||||||
@@ -305,7 +304,7 @@ enum BlurHashDecoder {
|
|||||||
return lookup
|
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 startIdx = string.index(string.startIndex, offsetBy: start)
|
||||||
let endIdx = string.index(startIdx, offsetBy: length)
|
let endIdx = string.index(startIdx, offsetBy: length)
|
||||||
var value = 0
|
var value = 0
|
||||||
@@ -314,6 +313,26 @@ enum BlurHashDecoder {
|
|||||||
}
|
}
|
||||||
return value
|
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
|
// MARK: - UIImage Extension
|
||||||
@@ -327,7 +346,7 @@ extension UIImage {
|
|||||||
|
|
||||||
/// Creates a UIImage from a BlurHash string.
|
/// Creates a UIImage from a BlurHash string.
|
||||||
/// Canonical woltapp/blurhash decoder with punch parameter (Android parity).
|
/// 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)
|
return BlurHashDecoder.decode(blurHash: blurHash, width: width, height: height, punch: punch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
Rosetta/Core/Utils/NativeBlurHash.cpp
Normal file
173
Rosetta/Core/Utils/NativeBlurHash.cpp
Normal 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
|
||||||
18
Rosetta/Core/Utils/NativeBlurHash.hpp
Normal file
18
Rosetta/Core/Utils/NativeBlurHash.hpp
Normal 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
|
||||||
15
Rosetta/Core/Utils/NativeBlurHashBridge.h
Normal file
15
Rosetta/Core/Utils/NativeBlurHashBridge.h
Normal 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
|
||||||
33
Rosetta/Core/Utils/NativeBlurHashBridge.mm
Normal file
33
Rosetta/Core/Utils/NativeBlurHashBridge.mm
Normal 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
|
||||||
@@ -1038,7 +1038,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
|||||||
photoLoadTasks[attachmentId] = Task { [weak self] in
|
photoLoadTasks[attachmentId] = Task { [weak self] in
|
||||||
await ImageLoadLimiter.shared.acquire()
|
await ImageLoadLimiter.shared.acquire()
|
||||||
let loaded = await Task.detached(priority: .userInitiated) {
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
await AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
AttachmentCache.shared.loadImage(forAttachmentId: attachmentId)
|
||||||
}.value
|
}.value
|
||||||
await ImageLoadLimiter.shared.release()
|
await ImageLoadLimiter.shared.release()
|
||||||
guard !Task.isCancelled else { return }
|
guard !Task.isCancelled else { return }
|
||||||
|
|||||||
@@ -727,7 +727,7 @@ extension NativeMessageListController: ComposerViewDelegate {
|
|||||||
/// When `preCalculatedHeight` is set, `preferredLayoutAttributesFitting` returns it
|
/// When `preCalculatedHeight` is set, `preferredLayoutAttributesFitting` returns it
|
||||||
/// immediately — skipping the expensive SwiftUI self-sizing layout pass.
|
/// immediately — skipping the expensive SwiftUI self-sizing layout pass.
|
||||||
final class PreSizedCell: UICollectionViewCell {
|
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?
|
var preCalculatedHeight: CGFloat?
|
||||||
|
|
||||||
override func preferredLayoutAttributesFitting(
|
override func preferredLayoutAttributesFitting(
|
||||||
|
|||||||
@@ -6,4 +6,5 @@
|
|||||||
// Exposes C++ layout engine via Objective-C++ wrappers.
|
// Exposes C++ layout engine via Objective-C++ wrappers.
|
||||||
//
|
//
|
||||||
|
|
||||||
#import "Core/Layout/MessageLayoutBridge.h"
|
#import "Core/Crypto/NativeCryptoBridge.h"
|
||||||
|
#import "Core/Utils/NativeBlurHashBridge.h"
|
||||||
|
|||||||
Reference in New Issue
Block a user