feat: Introduce system accounts and verification badges

- Added SystemAccounts enum to manage system account keys and titles.
- Refactored Dialog model to replace isVerified with verified level.
- Implemented effective verification logic for UI display in Dialog.
- Updated DialogRepository to handle user verification levels.
- Enhanced ProtocolManager and SessionManager to log user info with verification.
- Modified AuthCoordinator to support back navigation to unlock screen.
- Improved UnlockView and WelcomeView with new account creation flow.
- Added VerifiedBadge component to visually represent account verification levels.
- Updated ChatListView and SearchView to display verification badges for users.
- Cleaned up debug print statements across various components.
This commit is contained in:
2026-02-26 01:57:15 +05:00
parent 99a35302fa
commit 5f163af1d8
33 changed files with 1903 additions and 1466 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,6 @@
# Exclude from repo # Exclude from repo
.gitignore
.claude/
rosetta-android/ rosetta-android/
sprints/ sprints/
CLAUDE.md CLAUDE.md

View File

@@ -1,7 +1,6 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import CommonCrypto import CommonCrypto
import Compression
import P256K import P256K
// MARK: - Error Types // MARK: - Error Types
@@ -34,6 +33,7 @@ enum CryptoError: LocalizedError {
// MARK: - CryptoManager // MARK: - CryptoManager
/// All methods are `nonisolated` safe to call from any actor/thread. /// All methods are `nonisolated` safe to call from any actor/thread.
/// Low-level primitives are in `CryptoPrimitives`.
final class CryptoManager: @unchecked Sendable { final class CryptoManager: @unchecked Sendable {
static let shared = CryptoManager() static let shared = CryptoManager()
@@ -41,19 +41,13 @@ final class CryptoManager: @unchecked Sendable {
// MARK: - BIP39: Mnemonic Generation // MARK: - BIP39: Mnemonic Generation
/// Generates a cryptographically secure 12-word BIP39 mnemonic.
nonisolated func generateMnemonic() throws -> [String] { nonisolated func generateMnemonic() throws -> [String] {
var entropy = Data(count: 16) // 128 bits let entropy = try CryptoPrimitives.randomBytes(count: 16)
let status = entropy.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
return try mnemonicFromEntropy(entropy) return try mnemonicFromEntropy(entropy)
} }
// MARK: - BIP39: Mnemonic Validation // MARK: - BIP39: Mnemonic Validation
/// Returns `true` if all 12 words are in the BIP39 word list and the checksum is valid.
nonisolated func validateMnemonic(_ words: [String]) -> Bool { nonisolated func validateMnemonic(_ words: [String]) -> Bool {
guard words.count == 12 else { return false } guard words.count == 12 else { return false }
let normalized = words.map { $0.lowercased().trimmingCharacters(in: .whitespaces) } let normalized = words.map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
@@ -63,44 +57,37 @@ final class CryptoManager: @unchecked Sendable {
// MARK: - BIP39: Mnemonic Seed (PBKDF2-SHA512) // MARK: - BIP39: Mnemonic Seed (PBKDF2-SHA512)
/// Derives the 64-byte seed from a mnemonic using PBKDF2-SHA512 with 2048 iterations.
/// Compatible with BIP39 specification (no passphrase).
nonisolated func mnemonicToSeed(_ words: [String]) -> Data { nonisolated func mnemonicToSeed(_ words: [String]) -> Data {
let phrase = words.joined(separator: " ") let phrase = words.joined(separator: " ")
return pbkdf2(password: phrase, salt: "mnemonic", iterations: 2048, keyLength: 64, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512)) return CryptoPrimitives.pbkdf2(
password: phrase, salt: "mnemonic", iterations: 2048,
keyLength: 64, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512)
)
} }
// MARK: - Key Pair Derivation (secp256k1) // MARK: - Key Pair Derivation (secp256k1)
/// Derives a secp256k1 key pair from a mnemonic phrase.
/// Returns (privateKey: 32 bytes, publicKey: 33 bytes compressed).
nonisolated func deriveKeyPair(from mnemonic: [String]) throws -> (privateKey: Data, publicKey: Data) { nonisolated func deriveKeyPair(from mnemonic: [String]) throws -> (privateKey: Data, publicKey: Data) {
let seed = mnemonicToSeed(mnemonic) let seed = mnemonicToSeed(mnemonic)
let seedHex = seed.hexString let seedHex = seed.hexString
// SHA256 of the UTF-8 bytes of the hex-encoded seed string
let privateKey = Data(SHA256.hash(data: Data(seedHex.utf8))) let privateKey = Data(SHA256.hash(data: Data(seedHex.utf8)))
let publicKey = try deriveCompressedPublicKey(from: privateKey) let publicKey = try deriveCompressedPublicKey(from: privateKey)
return (privateKey, publicKey) return (privateKey, publicKey)
} }
// MARK: - Account Encryption (PBKDF2-SHA1 + zlib + AES-256-CBC) // MARK: - Account Encryption (PBKDF2 + zlib + AES-256-CBC)
/// Encrypts `data` with a password using PBKDF2-HMAC-SHA1 + zlib deflate + AES-256-CBC.
/// Compatible with Android (crypto-js uses SHA1 by default) and JS (pako.deflate).
/// Output format: `Base64(IV):Base64(ciphertext)`.
nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String { nonisolated func encryptWithPassword(_ data: Data, password: String) throws -> String {
let compressed = try rawDeflate(data) let compressed = try CryptoPrimitives.rawDeflate(data)
let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)) let key = CryptoPrimitives.pbkdf2(
let iv = try randomBytes(count: 16) password: password, salt: "rosetta", iterations: 1000,
let ciphertext = try aesCBCEncrypt(compressed, key: key, iv: iv) keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
)
let iv = try CryptoPrimitives.randomBytes(count: 16)
let ciphertext = try CryptoPrimitives.aesCBCEncrypt(compressed, key: key, iv: iv)
return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())" return "\(iv.base64EncodedString()):\(ciphertext.base64EncodedString())"
} }
/// Decrypts data encrypted with `encryptWithPassword(_:password:)`.
/// Tries PBKDF2-HMAC-SHA1 + zlib (Android-compatible) first, then falls back to
/// legacy PBKDF2-HMAC-SHA256 without compression (old iOS format) for migration.
nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data { nonisolated func decryptWithPassword(_ encrypted: String, password: String) throws -> Data {
let parts = encrypted.components(separatedBy: ":") let parts = encrypted.components(separatedBy: ":")
guard parts.count == 2, guard parts.count == 2,
@@ -111,16 +98,22 @@ final class CryptoManager: @unchecked Sendable {
// Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate // Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate
if let result = try? { if let result = try? {
let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)) let key = CryptoPrimitives.pbkdf2(
let decrypted = try aesCBCDecrypt(ciphertext, key: key, iv: iv) password: password, salt: "rosetta", iterations: 1000,
return try rawInflate(decrypted) keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)
)
let decrypted = try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: key, iv: iv)
return try CryptoPrimitives.rawInflate(decrypted)
}() { }() {
return result return result
} }
// Fallback: legacy iOS format (PBKDF2-SHA256, no compression) // Fallback: legacy iOS format (PBKDF2-SHA256, no compression)
let legacyKey = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)) let legacyKey = CryptoPrimitives.pbkdf2(
return try aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv) password: password, salt: "rosetta", iterations: 1000,
keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)
)
return try CryptoPrimitives.aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv)
} }
// MARK: - Utilities // MARK: - Utilities
@@ -129,8 +122,6 @@ final class CryptoManager: @unchecked Sendable {
Data(SHA256.hash(data: data)) Data(SHA256.hash(data: data))
} }
/// Generates the private key hash used for server handshake authentication.
/// Formula: SHA256(privateKeyHex + "rosetta") lowercase hex string.
nonisolated func generatePrivateKeyHash(privateKeyHex: String) -> String { nonisolated func generatePrivateKeyHash(privateKeyHex: String) -> String {
let combined = Data((privateKeyHex + "rosetta").utf8) let combined = Data((privateKeyHex + "rosetta").utf8)
return sha256(combined).hexString return sha256(combined).hexString
@@ -147,7 +138,6 @@ private extension CryptoManager {
let hashBytes = Data(SHA256.hash(data: entropy)) let hashBytes = Data(SHA256.hash(data: entropy))
let checksumByte = hashBytes[0] let checksumByte = hashBytes[0]
// Build bit array: 128 entropy bits + 4 checksum bits = 132 bits
var bits = [Bool]() var bits = [Bool]()
bits.reserveCapacity(132) bits.reserveCapacity(132)
for byte in entropy { for byte in entropy {
@@ -155,12 +145,10 @@ private extension CryptoManager {
bits.append((byte >> shift) & 1 == 1) bits.append((byte >> shift) & 1 == 1)
} }
} }
// Top 4 bits of SHA256 hash are the checksum
for shift in stride(from: 7, through: 4, by: -1) { for shift in stride(from: 7, through: 4, by: -1) {
bits.append((checksumByte >> shift) & 1 == 1) bits.append((checksumByte >> shift) & 1 == 1)
} }
// Split into 12 × 11-bit groups, map to words
return try (0..<12).map { chunk in return try (0..<12).map { chunk in
let index = (0..<11).reduce(0) { acc, bit in let index = (0..<11).reduce(0) { acc, bit in
acc * 2 + (bits[chunk * 11 + bit] ? 1 : 0) acc * 2 + (bits[chunk * 11 + bit] ? 1 : 0)
@@ -173,7 +161,6 @@ private extension CryptoManager {
func entropyFromMnemonic(_ words: [String]) throws -> Data { func entropyFromMnemonic(_ words: [String]) throws -> Data {
guard words.count == 12 else { throw CryptoError.invalidMnemonic } guard words.count == 12 else { throw CryptoError.invalidMnemonic }
// Convert 12 × 11-bit word indices into a 132-bit array
var bits = [Bool]() var bits = [Bool]()
bits.reserveCapacity(132) bits.reserveCapacity(132)
for word in words { for word in words {
@@ -183,7 +170,6 @@ private extension CryptoManager {
} }
} }
// First 128 bits = entropy, last 4 bits = checksum
var entropy = Data(count: 16) var entropy = Data(count: 16)
for byteIdx in 0..<16 { for byteIdx in 0..<16 {
let value: UInt8 = (0..<8).reduce(0) { acc, bit in let value: UInt8 = (0..<8).reduce(0) { acc, bit in
@@ -192,7 +178,6 @@ private extension CryptoManager {
entropy[byteIdx] = value entropy[byteIdx] = value
} }
// Verify checksum
let hashBytes = Data(SHA256.hash(data: entropy)) let hashBytes = Data(SHA256.hash(data: entropy))
let expectedTopNibble = hashBytes[0] >> 4 let expectedTopNibble = hashBytes[0] >> 4
let actualTopNibble: UInt8 = (0..<4).reduce(0) { acc, bit in let actualTopNibble: UInt8 = (0..<4).reduce(0) { acc, bit in
@@ -208,172 +193,9 @@ private extension CryptoManager {
extension CryptoManager { extension CryptoManager {
/// Computes the 33-byte compressed secp256k1 public key from a 32-byte private key.
nonisolated func deriveCompressedPublicKey(from privateKey: Data) throws -> Data { nonisolated func deriveCompressedPublicKey(from privateKey: Data) throws -> Data {
guard privateKey.count == 32 else { throw CryptoError.invalidPrivateKey } guard privateKey.count == 32 else { throw CryptoError.invalidPrivateKey }
// P256K v0.21+: init is `dataRepresentation:format:`, public key via `dataRepresentation`
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: privateKey, format: .compressed) let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: privateKey, format: .compressed)
// .compressed format dataRepresentation returns 33-byte compressed public key
return signingKey.publicKey.dataRepresentation return signingKey.publicKey.dataRepresentation
} }
} }
// MARK: - Crypto Primitives
private extension CryptoManager {
func pbkdf2(
password: String,
salt: String,
iterations: Int,
keyLength: Int,
prf: CCPseudoRandomAlgorithm
) -> Data {
var derivedKey = Data(repeating: 0, count: keyLength)
derivedKey.withUnsafeMutableBytes { keyPtr in
password.withCString { passPtr in
salt.withCString { saltPtr in
_ = CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passPtr, strlen(passPtr),
saltPtr, strlen(saltPtr),
prf,
UInt32(iterations),
keyPtr.bindMemory(to: UInt8.self).baseAddress!,
keyLength
)
}
}
}
return derivedKey
}
func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var ciphertext = Data(count: outputSize)
var numBytes = 0
let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCEncrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
ciphertextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.encryptionFailed }
return ciphertext.prefix(numBytes)
}
func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var plaintext = Data(count: outputSize)
var numBytes = 0
let status = plaintext.withUnsafeMutableBytes { plaintextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
plaintextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.decryptionFailed }
return plaintext.prefix(numBytes)
}
func randomBytes(count: Int) throws -> Data {
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
return data
}
// MARK: - zlib Raw Deflate / Inflate
/// Raw deflate compression (no zlib header, compatible with pako.deflate / Java Deflater(nowrap=true)).
func rawDeflate(_ data: Data) throws -> Data {
// Compression framework uses COMPRESSION_ZLIB which is raw deflate
let sourceSize = data.count
// Worst case: input size + 512 bytes overhead
let destinationSize = sourceSize + 512
var destination = Data(count: destinationSize)
let compressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
compression_encode_buffer(
destPtr.bindMemory(to: UInt8.self).baseAddress!,
destinationSize,
srcPtr.bindMemory(to: UInt8.self).baseAddress!,
sourceSize,
nil,
COMPRESSION_ZLIB
)
}
}
guard compressedSize > 0 else { throw CryptoError.compressionFailed }
return destination.prefix(compressedSize)
}
/// Raw inflate decompression (no zlib header, compatible with pako.inflate / Java Inflater(nowrap=true)).
func rawInflate(_ data: Data) throws -> Data {
let sourceSize = data.count
// Decompressed data can be much larger; start with 4x, retry if needed
var destinationSize = sourceSize * 4
if destinationSize < 256 { destinationSize = 256 }
for multiplier in [4, 8, 16, 32] {
destinationSize = sourceSize * multiplier
if destinationSize < 256 { destinationSize = 256 }
var destination = Data(count: destinationSize)
let decompressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
compression_decode_buffer(
destPtr.bindMemory(to: UInt8.self).baseAddress!,
destinationSize,
srcPtr.bindMemory(to: UInt8.self).baseAddress!,
sourceSize,
nil,
COMPRESSION_ZLIB
)
}
}
if decompressedSize > 0 && decompressedSize < destinationSize {
return destination.prefix(decompressedSize)
}
}
throw CryptoError.compressionFailed
}
}
// MARK: - Data Extension
extension Data {
nonisolated var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
}

View File

@@ -0,0 +1,185 @@
import Foundation
import CommonCrypto
import Compression
// MARK: - CryptoPrimitives
/// Shared low-level cryptographic primitives used by both `CryptoManager` and `MessageCrypto`.
/// Centralizes AES-CBC, PBKDF2, random bytes, and zlib to avoid duplication.
enum CryptoPrimitives {
// MARK: - AES-256-CBC
static func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var ciphertext = Data(count: outputSize)
var numBytes = 0
let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
guard let cBase = ciphertextPtr.baseAddress,
let dBase = dataPtr.baseAddress,
let kBase = keyPtr.baseAddress,
let iBase = ivPtr.baseAddress else { return CCCryptorStatus(kCCParamError) }
return CCCrypt(
CCOperation(kCCEncrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
kBase, key.count,
iBase,
dBase, data.count,
cBase, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.encryptionFailed }
return ciphertext.prefix(numBytes)
}
static func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var plaintext = Data(count: outputSize)
var numBytes = 0
let status = plaintext.withUnsafeMutableBytes { plaintextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
guard let pBase = plaintextPtr.baseAddress,
let dBase = dataPtr.baseAddress,
let kBase = keyPtr.baseAddress,
let iBase = ivPtr.baseAddress else { return CCCryptorStatus(kCCParamError) }
return CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
kBase, key.count,
iBase,
dBase, data.count,
pBase, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.decryptionFailed }
return plaintext.prefix(numBytes)
}
// MARK: - PBKDF2
static func pbkdf2(
password: String,
salt: String,
iterations: Int,
keyLength: Int,
prf: CCPseudoRandomAlgorithm
) -> Data {
var derivedKey = Data(repeating: 0, count: keyLength)
derivedKey.withUnsafeMutableBytes { keyPtr in
guard let keyBase = keyPtr.bindMemory(to: UInt8.self).baseAddress else { return }
password.withCString { passPtr in
salt.withCString { saltPtr in
_ = CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
passPtr, strlen(passPtr),
saltPtr, strlen(saltPtr),
prf,
UInt32(iterations),
keyBase,
keyLength
)
}
}
}
return derivedKey
}
// MARK: - Random Bytes
static func randomBytes(count: Int) throws -> Data {
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { ptr -> OSStatus in
guard let base = ptr.baseAddress else { return errSecAllocate }
return SecRandomCopyBytes(kSecRandomDefault, count, base)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
return data
}
}
// MARK: - zlib Raw Deflate / Inflate
extension CryptoPrimitives {
/// Raw deflate compression (no zlib header, compatible with pako.deflate / Java Deflater(nowrap=true)).
static func rawDeflate(_ data: Data) throws -> Data {
let sourceSize = data.count
let destinationSize = sourceSize + 512
var destination = Data(count: destinationSize)
let compressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
guard let dBase = destPtr.bindMemory(to: UInt8.self).baseAddress,
let sBase = srcPtr.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_encode_buffer(dBase, destinationSize, sBase, sourceSize, nil, COMPRESSION_ZLIB)
}
}
guard compressedSize > 0 else { throw CryptoError.compressionFailed }
return destination.prefix(compressedSize)
}
/// Raw inflate decompression (no zlib header, compatible with pako.inflate / Java Inflater(nowrap=true)).
static func rawInflate(_ data: Data) throws -> Data {
let sourceSize = data.count
for multiplier in [4, 8, 16, 32] {
var destinationSize = max(sourceSize * multiplier, 256)
var destination = Data(count: destinationSize)
let decompressedSize = destination.withUnsafeMutableBytes { destPtr in
data.withUnsafeBytes { srcPtr in
guard let dBase = destPtr.bindMemory(to: UInt8.self).baseAddress,
let sBase = srcPtr.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_decode_buffer(dBase, destinationSize, sBase, sourceSize, nil, COMPRESSION_ZLIB)
}
}
if decompressedSize > 0 && decompressedSize < destinationSize {
return destination.prefix(decompressedSize)
}
}
throw CryptoError.compressionFailed
}
}
// MARK: - Data Extensions
extension Data {
/// Lowercase hex string representation.
nonisolated var hexString: String {
map { String(format: "%02x", $0) }.joined()
}
/// Initialize from a hex string (case-insensitive).
init(hexString: String) {
let hex = hexString.lowercased()
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
if nextIndex == hex.endIndex && hex.distance(from: index, to: nextIndex) < 2 {
let byte = UInt8(hex[index...index], radix: 16) ?? 0
data.append(byte)
} else {
let byte = UInt8(hex[index..<nextIndex], radix: 16) ?? 0
data.append(byte)
}
index = nextIndex
}
self = data
}
}

View File

@@ -6,6 +6,11 @@ import P256K
/// Handles message-level encryption/decryption using XChaCha20-Poly1305 + ECDH. /// Handles message-level encryption/decryption using XChaCha20-Poly1305 + ECDH.
/// Matches the Android `MessageCrypto` implementation for cross-platform compatibility. /// Matches the Android `MessageCrypto` implementation for cross-platform compatibility.
///
/// Crypto engines are split into dedicated files:
/// - `XChaCha20Engine` ChaCha20 stream cipher + XChaCha20-Poly1305 AEAD
/// - `Poly1305Engine` Poly1305 MAC
/// - `CryptoPrimitives` Shared AES-CBC, PBKDF2, zlib, hex helpers
enum MessageCrypto { enum MessageCrypto {
// MARK: - Public API // MARK: - Public API
@@ -21,28 +26,23 @@ enum MessageCrypto {
encryptedKey: String, encryptedKey: String,
myPrivateKeyHex: String myPrivateKeyHex: String
) throws -> String { ) throws -> String {
// Step 1: ECDH decrypt the XChaCha20 key+nonce
let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex) let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex)
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)")
} }
let key = keyAndNonce[0..<32] // 32-byte XChaCha20 key let key = keyAndNonce[0..<32]
let nonce = keyAndNonce[32..<56] // 24-byte XChaCha20 nonce let nonce = keyAndNonce[32..<56]
// Step 2: XChaCha20-Poly1305 decrypt
let ciphertextData = Data(hexString: ciphertext) let ciphertextData = Data(hexString: ciphertext)
let plaintext = try xchacha20Poly1305Decrypt( let plaintext = try XChaCha20Engine.decrypt(
ciphertextWithTag: ciphertextData, ciphertextWithTag: ciphertextData, key: Data(key), nonce: Data(nonce)
key: Data(key),
nonce: Data(nonce)
) )
guard let text = String(data: plaintext, encoding: .utf8) else { guard let text = String(data: plaintext, encoding: .utf8) else {
throw CryptoError.invalidData("Decrypted data is not valid UTF-8") throw CryptoError.invalidData("Decrypted data is not valid UTF-8")
} }
return text return text
} }
@@ -61,32 +61,25 @@ enum MessageCrypto {
throw CryptoError.invalidData("Cannot encode plaintext as UTF-8") throw CryptoError.invalidData("Cannot encode plaintext as UTF-8")
} }
// Generate random 32-byte key + 24-byte nonce let key = try CryptoPrimitives.randomBytes(count: 32)
let key = try randomBytes(count: 32) let nonce = try CryptoPrimitives.randomBytes(count: 24)
let nonce = try randomBytes(count: 24)
let keyAndNonce = key + nonce let keyAndNonce = key + nonce
// XChaCha20-Poly1305 encrypt let ciphertextWithTag = try XChaCha20Engine.encrypt(
let ciphertextWithTag = try xchacha20Poly1305Encrypt(
plaintext: plaintextData, key: key, nonce: nonce plaintext: plaintextData, key: key, nonce: nonce
) )
// Encrypt key+nonce for recipient via ECDH
let chachaKey = try encryptKeyForRecipient( let chachaKey = try encryptKeyForRecipient(
keyAndNonce: keyAndNonce, keyAndNonce: keyAndNonce, recipientPublicKeyHex: recipientPublicKeyHex
recipientPublicKeyHex: recipientPublicKeyHex
) )
// Encrypt key+nonce for sender (self) via ECDH with sender's own public key
let senderPrivKey = try P256K.Signing.PrivateKey( let senderPrivKey = try P256K.Signing.PrivateKey(
dataRepresentation: Data(hexString: senderPrivateKeyHex), dataRepresentation: Data(hexString: senderPrivateKeyHex), format: .compressed
format: .compressed
) )
let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString
let aesChachaKey = try encryptKeyForRecipient( let aesChachaKey = try encryptKeyForRecipient(
keyAndNonce: keyAndNonce, keyAndNonce: keyAndNonce, recipientPublicKeyHex: senderPublicKeyHex
recipientPublicKeyHex: senderPublicKeyHex
) )
return ( return (
@@ -126,68 +119,43 @@ private extension MessageCrypto {
let iv = Data(hexString: ivHex) let iv = Data(hexString: ivHex)
let encryptedKeyData = Data(hexString: encryptedKeyHex) let encryptedKeyData = Data(hexString: encryptedKeyHex)
// ECDH: compute shared secret = myPublicKey × ephemeralPrivateKey
// Using P256K: create ephemeral private key, derive my public key, compute shared secret
let ephemeralPrivKeyData = Data(hexString: ephemeralPrivateKeyHex)
let myPrivKeyData = Data(hexString: myPrivateKeyHex)
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey( let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: ephemeralPrivKeyData, format: .compressed dataRepresentation: Data(hexString: ephemeralPrivateKeyHex), format: .compressed
) )
let myPrivKey = try P256K.KeyAgreement.PrivateKey( let myPrivKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: myPrivKeyData, format: .compressed dataRepresentation: Data(hexString: myPrivateKeyHex), format: .compressed
) )
let myPublicKey = myPrivKey.publicKey
// ECDH: ephemeralPrivateKey × myPublicKey shared point // ECDH: ephemeralPrivateKey × myPublicKey shared point
// P256K returns compressed format (1 + 32 bytes), we need just x-coordinate (bytes 1...32) let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: myPublicKey, format: .compressed) with: myPrivKey.publicKey, format: .compressed
)
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
let sharedKey = extractXCoordinate(from: sharedSecretData)
// Extract x-coordinate (skip the 1-byte prefix)
let sharedKey: Data
if sharedSecretData.count == 33 {
sharedKey = sharedSecretData[1..<33]
} else if sharedSecretData.count == 32 {
sharedKey = sharedSecretData
} else {
throw CryptoError.invalidData("Unexpected shared secret length: \(sharedSecretData.count)")
}
// AES-256-CBC decrypt // AES-256-CBC decrypt
let decryptedBytes = try aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv) let decryptedBytes = try CryptoPrimitives.aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv)
// UTF-8 Latin1 conversion (reverse of JS crypto-js compatibility) // UTF-8 Latin1 conversion (reverse of JS crypto-js compatibility)
// The Android code does: String(bytes, UTF-8) toByteArray(ISO_8859_1)
guard let utf8String = String(data: decryptedBytes, encoding: .utf8) else { guard let utf8String = String(data: decryptedBytes, encoding: .utf8) else {
throw CryptoError.invalidData("Decrypted key is not valid UTF-8") throw CryptoError.invalidData("Decrypted key is not valid UTF-8")
} }
let originalBytes = Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) }) return Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) })
return originalBytes
} }
/// 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 { static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String {
// Generate ephemeral key pair
let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey() let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey()
// Parse recipient public key
let recipientPubKey = try P256K.KeyAgreement.PublicKey( let recipientPubKey = try P256K.KeyAgreement.PublicKey(
dataRepresentation: Data(hexString: recipientPublicKeyHex), format: .compressed dataRepresentation: Data(hexString: recipientPublicKeyHex), format: .compressed
) )
// ECDH: ephemeralPrivKey × recipientPubKey shared secret let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(
let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: recipientPubKey, format: .compressed) with: recipientPubKey, format: .compressed
)
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
let sharedKey = extractXCoordinate(from: sharedSecretData)
// Extract x-coordinate
let sharedKey: Data
if sharedSecretData.count == 33 {
sharedKey = sharedSecretData[1..<33]
} else {
sharedKey = sharedSecretData
}
// Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility) // Convert keyAndNonce bytes to UTF-8 string representation (JS Buffer compatibility)
let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) }) let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) })
@@ -195,531 +163,22 @@ private extension MessageCrypto {
throw CryptoError.encryptionFailed throw CryptoError.encryptionFailed
} }
// AES-256-CBC encrypt let iv = try CryptoPrimitives.randomBytes(count: 16)
let iv = try randomBytes(count: 16) let ciphertext = try CryptoPrimitives.aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv)
let ciphertext = try aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv)
// Get ephemeral private key hex
let ephemeralPrivKeyHex = ephemeralPrivKey.rawRepresentation.hexString let ephemeralPrivKeyHex = ephemeralPrivKey.rawRepresentation.hexString
// Format: Base64(ivHex:ciphertextHex:ephemeralPrivateKeyHex)
let combined = "\(iv.hexString):\(ciphertext.hexString):\(ephemeralPrivKeyHex)" let combined = "\(iv.hexString):\(ciphertext.hexString):\(ephemeralPrivKeyHex)"
guard let base64 = combined.data(using: .utf8)?.base64EncodedString() else { guard let base64 = combined.data(using: .utf8)?.base64EncodedString() else {
throw CryptoError.encryptionFailed throw CryptoError.encryptionFailed
} }
return base64 return base64
} }
/// Extracts the 32-byte x-coordinate from a compressed ECDH shared secret.
static func extractXCoordinate(from sharedSecretData: Data) -> Data {
if sharedSecretData.count == 33 {
return sharedSecretData[1..<33]
} }
return sharedSecretData.prefix(32)
// MARK: - XChaCha20-Poly1305
private extension MessageCrypto {
static let poly1305TagSize = 16
/// XChaCha20-Poly1305 decryption matching Android implementation.
static func xchacha20Poly1305Decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data {
guard ciphertextWithTag.count > poly1305TagSize else {
throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag")
}
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
let ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)]
let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...]
// Step 1: HChaCha20 derive subkey from key + first 16 bytes of nonce
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce: [0,0,0,0] + nonce[16..<24]
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key from first 64 bytes of keystream (counter=0)
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Verify Poly1305 tag
let computedTag = poly1305MAC(
data: Data(ciphertext),
key: Data(poly1305Key)
)
guard constantTimeEqual(Data(tag), computedTag) else {
throw CryptoError.decryptionFailed
}
// Step 5: Decrypt with ChaCha20 (counter starts at 1)
let plaintext = chacha20Encrypt(
data: Data(ciphertext),
key: subkey,
nonce: chacha20Nonce,
initialCounter: 1
)
return plaintext
}
/// XChaCha20-Poly1305 encryption.
static func xchacha20Poly1305Encrypt(plaintext: Data, key: Data, nonce: Data) throws -> Data {
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
// Step 1: HChaCha20 derive subkey
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Encrypt with ChaCha20 (counter starts at 1)
let ciphertext = chacha20Encrypt(
data: plaintext,
key: subkey,
nonce: chacha20Nonce,
initialCounter: 1
)
// Step 5: Compute Poly1305 tag
let tag = poly1305MAC(data: ciphertext, key: Data(poly1305Key))
return ciphertext + tag
}
}
// MARK: - ChaCha20 Core
private extension MessageCrypto {
/// ChaCha20 quarter round.
static func quarterRound(_ state: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 16) | (state[d] >> 16)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 12) | (state[b] >> 20)
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 8) | (state[d] >> 24)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 7) | (state[b] >> 25)
}
/// Generates a 64-byte ChaCha20 block.
static func chacha20Block(key: Data, nonce: Data, counter: UInt32) -> Data {
var state = [UInt32](repeating: 0, count: 16)
// Constants: "expand 32-byte k"
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
// Key
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
// Counter
state[12] = counter
// Nonce
for i in 0..<3 {
state[13 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
var working = state
// 20 rounds (10 double rounds)
for _ in 0..<10 {
quarterRound(&working, 0, 4, 8, 12)
quarterRound(&working, 1, 5, 9, 13)
quarterRound(&working, 2, 6, 10, 14)
quarterRound(&working, 3, 7, 11, 15)
quarterRound(&working, 0, 5, 10, 15)
quarterRound(&working, 1, 6, 11, 12)
quarterRound(&working, 2, 7, 8, 13)
quarterRound(&working, 3, 4, 9, 14)
}
// Add initial state
for i in 0..<16 {
working[i] = working[i] &+ state[i]
}
// Serialize to bytes (little-endian)
var result = Data(count: 64)
for i in 0..<16 {
let val = working[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
/// ChaCha20 stream cipher encryption/decryption.
static func chacha20Encrypt(data: Data, key: Data, nonce: Data, initialCounter: UInt32) -> Data {
var result = Data(count: data.count)
var counter = initialCounter
for offset in stride(from: 0, to: data.count, by: 64) {
let block = chacha20Block(key: key, nonce: nonce, counter: counter)
let blockSize = min(64, data.count - offset)
for i in 0..<blockSize {
result[offset + i] = data[data.startIndex + offset + i] ^ block[i]
}
counter &+= 1
}
return result
}
/// HChaCha20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
static func hchacha20(key: Data, nonce: Data) -> Data {
var state = [UInt32](repeating: 0, count: 16)
// Constants
state[0] = 0x61707865
state[1] = 0x3320646e
state[2] = 0x79622d32
state[3] = 0x6b206574
// Key
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
// Nonce (16 bytes 4 uint32s)
for i in 0..<4 {
state[12 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
// 20 rounds
for _ in 0..<10 {
quarterRound(&state, 0, 4, 8, 12)
quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14)
quarterRound(&state, 3, 7, 11, 15)
quarterRound(&state, 0, 5, 10, 15)
quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13)
quarterRound(&state, 3, 4, 9, 14)
}
// Output: first 4 words + last 4 words
var result = Data(count: 32)
for i in 0..<4 {
let val = state[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
for i in 0..<4 {
let val = state[12 + i].littleEndian
result[16 + i * 4] = UInt8(truncatingIfNeeded: val)
result[16 + i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[16 + i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[16 + i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
}
// MARK: - Poly1305
private extension MessageCrypto {
/// Poly1305 MAC computation matching the AEAD construction.
static func poly1305MAC(data: Data, key: Data) -> Data {
// Clamp r (first 16 bytes of key)
var r = [UInt8](key[0..<16])
r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15
r[4] &= 252; r[8] &= 252; r[12] &= 252
// s = last 16 bytes of key
let s = [UInt8](key[16..<32])
// Convert r and s to big integers using UInt128-like arithmetic
var rVal: (UInt64, UInt64) = (0, 0) // (low, high)
for i in stride(from: 15, through: 8, by: -1) {
rVal.1 = rVal.1 << 8 | UInt64(r[i])
}
for i in stride(from: 7, through: 0, by: -1) {
rVal.0 = rVal.0 << 8 | UInt64(r[i])
}
// Use arrays for big number arithmetic (limbs approach)
// For simplicity and correctness, use a big-number representation
var accumulator = [UInt64](repeating: 0, count: 5) // 130-bit number in 26-bit limbs
let rLimbs = toLimbs26(r)
let p: UInt64 = (1 << 26) // 2^26
// Build padded data: data + padding to 16-byte boundary + lengths
var macInput = Data(data)
let padding = (16 - (data.count % 16)) % 16
if padding > 0 {
macInput.append(Data(repeating: 0, count: padding))
}
// AAD length (0 for our case no associated data)
macInput.append(Data(repeating: 0, count: 8))
// Ciphertext length (little-endian 64-bit)
var ctLen = UInt64(data.count).littleEndian
macInput.append(Data(bytes: &ctLen, count: 8))
// Process in 16-byte blocks
for offset in stride(from: 0, to: macInput.count, by: 16) {
let blockEnd = min(offset + 16, macInput.count)
var block = [UInt8](macInput[offset..<blockEnd])
while block.count < 16 { block.append(0) }
// Add block to accumulator (with hibit for message blocks, not for length blocks)
let hibit: UInt64 = offset < (macInput.count - 16) ? (1 << 24) : (offset < data.count + padding ? (1 << 24) : 0)
// For AEAD: hibit is 1 for actual data blocks, 0 for length fields
let isDataBlock = offset < data.count + padding
let bit: UInt64 = isDataBlock ? (1 << 24) : 0
let n = toLimbs26(block)
accumulator[0] = accumulator[0] &+ n[0]
accumulator[1] = accumulator[1] &+ n[1]
accumulator[2] = accumulator[2] &+ n[2]
accumulator[3] = accumulator[3] &+ n[3]
accumulator[4] = accumulator[4] &+ n[4] &+ bit
// Multiply accumulator by r
accumulator = poly1305Multiply(accumulator, rLimbs)
}
// Freeze: reduce mod 2^130 - 5, then add s
let result = poly1305Freeze(accumulator, s: s)
return Data(result)
}
/// Convert 16 bytes to 5 limbs of 26 bits each.
static func toLimbs26(_ bytes: [UInt8]) -> [UInt64] {
let b = bytes.count >= 16 ? bytes : bytes + [UInt8](repeating: 0, count: 16 - bytes.count)
var val: UInt64 = 0
var limbs = [UInt64](repeating: 0, count: 5)
// Read as little-endian 128-bit number
for i in stride(from: 15, through: 0, by: -1) {
val = val << 8 | UInt64(b[i])
if i == 0 {
limbs[0] = val & 0x3FFFFFF
limbs[1] = (val >> 26) & 0x3FFFFFF
limbs[2] = (val >> 52) & 0x3FFFFFF
}
}
// Re-read properly
var full = [UInt8](repeating: 0, count: 17)
for i in 0..<min(16, b.count) { full[i] = b[i] }
let lo = UInt64(full[0]) | UInt64(full[1]) << 8 | UInt64(full[2]) << 16 | UInt64(full[3]) << 24 |
UInt64(full[4]) << 32 | UInt64(full[5]) << 40 | UInt64(full[6]) << 48 | UInt64(full[7]) << 56
let hi = UInt64(full[8]) | UInt64(full[9]) << 8 | UInt64(full[10]) << 16 | UInt64(full[11]) << 24 |
UInt64(full[12]) << 32 | UInt64(full[13]) << 40 | UInt64(full[14]) << 48 | UInt64(full[15]) << 56
limbs[0] = lo & 0x3FFFFFF
limbs[1] = (lo >> 26) & 0x3FFFFFF
limbs[2] = ((lo >> 52) | (hi << 12)) & 0x3FFFFFF
limbs[3] = (hi >> 14) & 0x3FFFFFF
limbs[4] = (hi >> 40) & 0x3FFFFFF
return limbs
}
/// Multiply two numbers in 26-bit limb form, reduce mod 2^130 - 5.
static func poly1305Multiply(_ a: [UInt64], _ r: [UInt64]) -> [UInt64] {
// Full multiply into 10 limbs, then reduce
let r0 = r[0], r1 = r[1], r2 = r[2], r3 = r[3], r4 = r[4]
let s1 = r1 * 5, s2 = r2 * 5, s3 = r3 * 5, s4 = r4 * 5
let a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4]
var h0 = a0 * r0 + a1 * s4 + a2 * s3 + a3 * s2 + a4 * s1
var h1 = a0 * r1 + a1 * r0 + a2 * s4 + a3 * s3 + a4 * s2
var h2 = a0 * r2 + a1 * r1 + a2 * r0 + a3 * s4 + a4 * s3
var h3 = a0 * r3 + a1 * r2 + a2 * r1 + a3 * r0 + a4 * s4
var h4 = a0 * r4 + a1 * r3 + a2 * r2 + a3 * r1 + a4 * r0
// Carry propagation
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
return [h0, h1, h2, h3, h4]
}
/// Final reduction and add s.
static func poly1305Freeze(_ h: [UInt64], s: [UInt8]) -> [UInt8] {
var h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4]
// Full carry
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
// Compute h + -(2^130 - 5) = h - p
var g0 = h0 &+ 5; c = g0 >> 26; g0 &= 0x3FFFFFF
var g1 = h1 &+ c; c = g1 >> 26; g1 &= 0x3FFFFFF
var g2 = h2 &+ c; c = g2 >> 26; g2 &= 0x3FFFFFF
var g3 = h3 &+ c; c = g3 >> 26; g3 &= 0x3FFFFFF
let g4 = h4 &+ c &- (1 << 26)
// If g4 didn't underflow (bit 63 not set), use g (h >= p)
let mask = (g4 >> 63) &- 1 // 0 if g4 underflowed, 0xFFF...F otherwise
let 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)
// Reassemble into 128-bit number
let f0 = h0 | (h1 << 26)
let f1 = (h1 >> 38) | (h2 << 12) | (h3 << 38)
let f2 = (h3 >> 26) | (h4 << 0) // unused high bits
// Convert to two 64-bit values
let lo = h0 | (h1 << 26) | (h2 << 52)
let hi = (h2 >> 12) | (h3 << 14) | (h4 << 40)
// Add s (little-endian)
var sLo: UInt64 = 0
var sHi: UInt64 = 0
for i in stride(from: 7, through: 0, by: -1) {
sLo = sLo << 8 | UInt64(s[i])
}
for i in stride(from: 15, through: 8, by: -1) {
sHi = sHi << 8 | UInt64(s[i])
}
var resultLo = lo &+ sLo
var carry: UInt64 = resultLo < lo ? 1 : 0
var resultHi = hi &+ sHi &+ carry
// Output 16 bytes little-endian
var output = [UInt8](repeating: 0, count: 16)
for i in 0..<8 {
output[i] = UInt8(truncatingIfNeeded: resultLo >> (i * 8))
}
for i in 0..<8 {
output[8 + i] = UInt8(truncatingIfNeeded: resultHi >> (i * 8))
}
return output
}
static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= a[a.startIndex + i] ^ b[b.startIndex + i]
}
return result == 0
}
}
// MARK: - AES-CBC Helpers
private extension MessageCrypto {
static func aesCBCEncrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var ciphertext = Data(count: outputSize)
var numBytes = 0
let status = ciphertext.withUnsafeMutableBytes { ciphertextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCEncrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
ciphertextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.encryptionFailed }
return ciphertext.prefix(numBytes)
}
static func aesCBCDecrypt(_ data: Data, key: Data, iv: Data) throws -> Data {
let outputSize = data.count + kCCBlockSizeAES128
var plaintext = Data(count: outputSize)
var numBytes = 0
let status = plaintext.withUnsafeMutableBytes { plaintextPtr in
data.withUnsafeBytes { dataPtr in
key.withUnsafeBytes { keyPtr in
iv.withUnsafeBytes { ivPtr in
CCCrypt(
CCOperation(kCCDecrypt),
CCAlgorithm(kCCAlgorithmAES),
CCOptions(kCCOptionPKCS7Padding),
keyPtr.baseAddress!, key.count,
ivPtr.baseAddress!,
dataPtr.baseAddress!, data.count,
plaintextPtr.baseAddress!, outputSize,
&numBytes
)
}
}
}
}
guard status == kCCSuccess else { throw CryptoError.decryptionFailed }
return plaintext.prefix(numBytes)
}
static func randomBytes(count: Int) throws -> Data {
var data = Data(count: count)
let status = data.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
}
guard status == errSecSuccess else { throw CryptoError.invalidEntropy }
return data
}
}
// MARK: - Data Hex Extension
extension Data {
/// Initialize from a hex string.
init(hexString: String) {
let hex = hexString.lowercased()
var data = Data(capacity: hex.count / 2)
var index = hex.startIndex
while index < hex.endIndex {
let nextIndex = hex.index(index, offsetBy: 2, limitedBy: hex.endIndex) ?? hex.endIndex
if nextIndex == hex.endIndex && hex.distance(from: index, to: nextIndex) < 2 {
// Odd hex character at end
let byte = UInt8(hex[index...index], radix: 16) ?? 0
data.append(byte)
} else {
let byte = UInt8(hex[index..<nextIndex], radix: 16) ?? 0
data.append(byte)
}
index = nextIndex
}
self = data
} }
} }

View File

@@ -0,0 +1,153 @@
import Foundation
// MARK: - Poly1305Engine
/// Poly1305 message authentication code implementation.
/// Used by XChaCha20-Poly1305 AEAD construction.
enum Poly1305Engine {
/// Computes a Poly1305 MAC matching the AEAD construction.
static func mac(data: Data, key: Data) -> Data {
// Clamp r (first 16 bytes of key)
var r = [UInt8](key[0..<16])
r[3] &= 15; r[7] &= 15; r[11] &= 15; r[15] &= 15
r[4] &= 252; r[8] &= 252; r[12] &= 252
// s = last 16 bytes of key
let s = [UInt8](key[16..<32])
var accumulator = [UInt64](repeating: 0, count: 5)
let rLimbs = toLimbs26(r)
// Build padded data: data + padding to 16-byte boundary + lengths
var macInput = Data(data)
let padding = (16 - (data.count % 16)) % 16
if padding > 0 {
macInput.append(Data(repeating: 0, count: padding))
}
// AAD length (0 no associated data)
macInput.append(Data(repeating: 0, count: 8))
// Ciphertext length (little-endian 64-bit)
var ctLen = UInt64(data.count).littleEndian
macInput.append(Data(bytes: &ctLen, count: 8))
// Process in 16-byte blocks
for offset in stride(from: 0, to: macInput.count, by: 16) {
let blockEnd = min(offset + 16, macInput.count)
var block = [UInt8](macInput[offset..<blockEnd])
while block.count < 16 { block.append(0) }
// hibit is 1 for data blocks, 0 for length fields
let isDataBlock = offset < data.count + padding
let bit: UInt64 = isDataBlock ? (1 << 24) : 0
let n = toLimbs26(block)
accumulator[0] = accumulator[0] &+ n[0]
accumulator[1] = accumulator[1] &+ n[1]
accumulator[2] = accumulator[2] &+ n[2]
accumulator[3] = accumulator[3] &+ n[3]
accumulator[4] = accumulator[4] &+ n[4] &+ bit
accumulator = multiply(accumulator, rLimbs)
}
return Data(freeze(accumulator, s: s))
}
}
// MARK: - Limb Arithmetic
private extension Poly1305Engine {
/// Convert 16 bytes to 5 limbs of 26 bits each (little-endian).
static func toLimbs26(_ bytes: [UInt8]) -> [UInt64] {
let b = bytes.count >= 16 ? bytes : bytes + [UInt8](repeating: 0, count: 16 - bytes.count)
var full = [UInt8](repeating: 0, count: 17)
for i in 0..<min(16, b.count) { full[i] = b[i] }
let lo = UInt64(full[0]) | UInt64(full[1]) << 8 | UInt64(full[2]) << 16 | UInt64(full[3]) << 24 |
UInt64(full[4]) << 32 | UInt64(full[5]) << 40 | UInt64(full[6]) << 48 | UInt64(full[7]) << 56
let hi = UInt64(full[8]) | UInt64(full[9]) << 8 | UInt64(full[10]) << 16 | UInt64(full[11]) << 24 |
UInt64(full[12]) << 32 | UInt64(full[13]) << 40 | UInt64(full[14]) << 48 | UInt64(full[15]) << 56
return [
lo & 0x3FFFFFF,
(lo >> 26) & 0x3FFFFFF,
((lo >> 52) | (hi << 12)) & 0x3FFFFFF,
(hi >> 14) & 0x3FFFFFF,
(hi >> 40) & 0x3FFFFFF
]
}
/// Multiply two numbers in 26-bit limb form, reduce mod 2^130 - 5.
static func multiply(_ a: [UInt64], _ r: [UInt64]) -> [UInt64] {
let r0 = r[0], r1 = r[1], r2 = r[2], r3 = r[3], r4 = r[4]
let s1 = r1 * 5, s2 = r2 * 5, s3 = r3 * 5, s4 = r4 * 5
let a0 = a[0], a1 = a[1], a2 = a[2], a3 = a[3], a4 = a[4]
var h0 = a0 * r0 + a1 * s4 + a2 * s3 + a3 * s2 + a4 * s1
var h1 = a0 * r1 + a1 * r0 + a2 * s4 + a3 * s3 + a4 * s2
var h2 = a0 * r2 + a1 * r1 + a2 * r0 + a3 * s4 + a4 * s3
var h3 = a0 * r3 + a1 * r2 + a2 * r1 + a3 * r0 + a4 * s4
var h4 = a0 * r4 + a1 * r3 + a2 * r2 + a3 * r1 + a4 * r0
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
return [h0, h1, h2, h3, h4]
}
/// Final reduction mod 2^130-5 and add s.
static func freeze(_ h: [UInt64], s: [UInt8]) -> [UInt8] {
var h0 = h[0], h1 = h[1], h2 = h[2], h3 = h[3], h4 = h[4]
var c: UInt64
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
c = h1 >> 26; h2 += c; h1 &= 0x3FFFFFF
c = h2 >> 26; h3 += c; h2 &= 0x3FFFFFF
c = h3 >> 26; h4 += c; h3 &= 0x3FFFFFF
c = h4 >> 26; h0 += c * 5; h4 &= 0x3FFFFFF
c = h0 >> 26; h1 += c; h0 &= 0x3FFFFFF
// Compute h + -(2^130 - 5) = h - p
var g0 = h0 &+ 5; c = g0 >> 26; g0 &= 0x3FFFFFF
var g1 = h1 &+ c; c = g1 >> 26; g1 &= 0x3FFFFFF
var g2 = h2 &+ c; c = g2 >> 26; g2 &= 0x3FFFFFF
var g3 = h3 &+ c; c = g3 >> 26; g3 &= 0x3FFFFFF
let g4 = h4 &+ c &- (1 << 26)
// If g4 didn't underflow, use g (h >= p)
let mask = (g4 >> 63) &- 1
let 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)
// Convert to two 64-bit values
let lo = h0 | (h1 << 26) | (h2 << 52)
let hi = (h2 >> 12) | (h3 << 14) | (h4 << 40)
// Add s (little-endian)
var sLo: UInt64 = 0
var sHi: UInt64 = 0
for i in stride(from: 7, through: 0, by: -1) { sLo = sLo << 8 | UInt64(s[i]) }
for i in stride(from: 15, through: 8, by: -1) { sHi = sHi << 8 | UInt64(s[i]) }
let resultLo = lo &+ sLo
let carry: UInt64 = resultLo < lo ? 1 : 0
let resultHi = hi &+ sHi &+ carry
var output = [UInt8](repeating: 0, count: 16)
for i in 0..<8 { output[i] = UInt8(truncatingIfNeeded: resultLo >> (i * 8)) }
for i in 0..<8 { output[8 + i] = UInt8(truncatingIfNeeded: resultHi >> (i * 8)) }
return output
}
}

View File

@@ -0,0 +1,201 @@
import Foundation
// MARK: - XChaCha20Engine
/// XChaCha20-Poly1305 AEAD cipher implementation.
/// Matches the Android `MessageCrypto` XChaCha20 implementation for cross-platform compatibility.
enum XChaCha20Engine {
static let poly1305TagSize = 16
// MARK: - XChaCha20-Poly1305 Decrypt
/// Decrypts ciphertext+tag using XChaCha20-Poly1305.
static func decrypt(ciphertextWithTag: Data, key: Data, nonce: Data) throws -> Data {
guard ciphertextWithTag.count > poly1305TagSize else {
throw CryptoError.invalidData("Ciphertext too short for Poly1305 tag")
}
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
let ciphertext = ciphertextWithTag[0..<(ciphertextWithTag.count - poly1305TagSize)]
let tag = ciphertextWithTag[(ciphertextWithTag.count - poly1305TagSize)...]
// Step 1: HChaCha20 derive subkey from key + first 16 bytes of nonce
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce: [0,0,0,0] + nonce[16..<24]
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key from first 64 bytes of keystream (counter=0)
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Verify Poly1305 tag
let computedTag = Poly1305Engine.mac(data: Data(ciphertext), key: Data(poly1305Key))
guard constantTimeEqual(Data(tag), computedTag) else {
throw CryptoError.decryptionFailed
}
// Step 5: Decrypt with ChaCha20 (counter starts at 1)
return chacha20Encrypt(
data: Data(ciphertext), key: subkey, nonce: chacha20Nonce, initialCounter: 1
)
}
// MARK: - XChaCha20-Poly1305 Encrypt
/// Encrypts plaintext using XChaCha20-Poly1305.
static func encrypt(plaintext: Data, key: Data, nonce: Data) throws -> Data {
guard key.count == 32, nonce.count == 24 else {
throw CryptoError.invalidData("Key must be 32 bytes, nonce must be 24 bytes")
}
// Step 1: HChaCha20 derive subkey
let subkey = hchacha20(key: key, nonce: Data(nonce[0..<16]))
// Step 2: Build ChaCha20 nonce
var chacha20Nonce = Data(repeating: 0, count: 12)
chacha20Nonce[4..<12] = nonce[16..<24]
// Step 3: Generate Poly1305 key
let poly1305Key = chacha20Block(key: subkey, nonce: chacha20Nonce, counter: 0)[0..<32]
// Step 4: Encrypt with ChaCha20 (counter starts at 1)
let ciphertext = chacha20Encrypt(
data: plaintext, key: subkey, nonce: chacha20Nonce, initialCounter: 1
)
// Step 5: Compute Poly1305 tag
let tag = Poly1305Engine.mac(data: ciphertext, key: Data(poly1305Key))
return ciphertext + tag
}
}
// MARK: - ChaCha20 Core
extension XChaCha20Engine {
/// ChaCha20 quarter round.
static func quarterRound(_ state: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 16) | (state[d] >> 16)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 12) | (state[b] >> 20)
state[a] = state[a] &+ state[b]; state[d] ^= state[a]; state[d] = (state[d] << 8) | (state[d] >> 24)
state[c] = state[c] &+ state[d]; state[b] ^= state[c]; state[b] = (state[b] << 7) | (state[b] >> 25)
}
/// Generates a 64-byte ChaCha20 block.
static func chacha20Block(key: Data, nonce: Data, counter: UInt32) -> Data {
var state = [UInt32](repeating: 0, count: 16)
// Constants: "expand 32-byte k"
state[0] = 0x61707865; state[1] = 0x3320646e
state[2] = 0x79622d32; state[3] = 0x6b206574
// Key (8 × UInt32, little-endian)
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
state[12] = counter
// Nonce (3 × UInt32, little-endian)
for i in 0..<3 {
state[13 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
var working = state
// 20 rounds (10 double rounds)
for _ in 0..<10 {
quarterRound(&working, 0, 4, 8, 12); quarterRound(&working, 1, 5, 9, 13)
quarterRound(&working, 2, 6, 10, 14); quarterRound(&working, 3, 7, 11, 15)
quarterRound(&working, 0, 5, 10, 15); quarterRound(&working, 1, 6, 11, 12)
quarterRound(&working, 2, 7, 8, 13); quarterRound(&working, 3, 4, 9, 14)
}
// Add initial state
for i in 0..<16 { working[i] = working[i] &+ state[i] }
// Serialize to bytes (little-endian)
var result = Data(count: 64)
for i in 0..<16 {
let val = working[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
/// ChaCha20 stream cipher encryption/decryption.
static func chacha20Encrypt(data: Data, key: Data, nonce: Data, initialCounter: UInt32) -> Data {
var result = Data(count: data.count)
var counter = initialCounter
for offset in stride(from: 0, to: data.count, by: 64) {
let block = chacha20Block(key: key, nonce: nonce, counter: counter)
let blockSize = min(64, data.count - offset)
for i in 0..<blockSize {
result[offset + i] = data[data.startIndex + offset + i] ^ block[i]
}
counter &+= 1
}
return result
}
/// HChaCha20: derives a 32-byte subkey from a 32-byte key and 16-byte nonce.
static func hchacha20(key: Data, nonce: Data) -> Data {
var state = [UInt32](repeating: 0, count: 16)
state[0] = 0x61707865; state[1] = 0x3320646e
state[2] = 0x79622d32; state[3] = 0x6b206574
for i in 0..<8 {
state[4 + i] = key.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
for i in 0..<4 {
state[12 + i] = nonce.withUnsafeBytes { $0.load(fromByteOffset: i * 4, as: UInt32.self).littleEndian }
}
for _ in 0..<10 {
quarterRound(&state, 0, 4, 8, 12); quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14); quarterRound(&state, 3, 7, 11, 15)
quarterRound(&state, 0, 5, 10, 15); quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13); quarterRound(&state, 3, 4, 9, 14)
}
// Output: first 4 words + last 4 words
var result = Data(count: 32)
for i in 0..<4 {
let val = state[i].littleEndian
result[i * 4] = UInt8(truncatingIfNeeded: val)
result[i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
for i in 0..<4 {
let val = state[12 + i].littleEndian
result[16 + i * 4] = UInt8(truncatingIfNeeded: val)
result[16 + i * 4 + 1] = UInt8(truncatingIfNeeded: val >> 8)
result[16 + i * 4 + 2] = UInt8(truncatingIfNeeded: val >> 16)
result[16 + i * 4 + 3] = UInt8(truncatingIfNeeded: val >> 24)
}
return result
}
/// Constant-time comparison of two Data objects.
static func constantTimeEqual(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= a[a.startIndex + i] ^ b[b.startIndex + i]
}
return result == 0
}
}

View File

@@ -1,5 +1,19 @@
import Foundation import Foundation
// MARK: - System Accounts
enum SystemAccounts {
static let safePublicKey = "0x000000000000000000000000000000000000000002"
static let safeTitle = "Safe"
static let updatesPublicKey = "0x000000000000000000000000000000000000000001"
static let updatesTitle = "Rosetta Updates"
static let systemKeys: Set<String> = [safePublicKey, updatesPublicKey]
static func isSystemAccount(_ publicKey: String) -> Bool {
systemKeys.contains(publicKey)
}
}
// MARK: - DeliveryStatus // MARK: - DeliveryStatus
enum DeliveryStatus: Int, Codable { enum DeliveryStatus: Int, Codable {
@@ -28,7 +42,7 @@ struct Dialog: Identifiable, Codable, Equatable {
var isOnline: Bool var isOnline: Bool
var lastSeen: Int64 var lastSeen: Int64
var isVerified: Bool var verified: Int // 0 = none, 1 = public figure/brand, 2 = Rosetta admin, 3+ = group admin
var iHaveSent: Bool // I have sent at least one message (chat vs request) var iHaveSent: Bool // I have sent at least one message (chat vs request)
var isPinned: Bool var isPinned: Bool
var isMuted: Bool var isMuted: Bool
@@ -40,6 +54,20 @@ struct Dialog: Identifiable, Codable, Equatable {
var isSavedMessages: Bool { opponentKey == account } var isSavedMessages: Bool { opponentKey == account }
/// Client-side heuristic matching Android: badge shown if verified > 0 OR isRosettaOfficial.
var isRosettaOfficial: Bool {
opponentTitle.caseInsensitiveCompare("Rosetta") == .orderedSame ||
opponentUsername.caseInsensitiveCompare("rosetta") == .orderedSame ||
SystemAccounts.isSystemAccount(opponentKey)
}
/// Effective verification level for UI display.
var effectiveVerified: Int {
if verified > 0 { return verified }
if isRosettaOfficial { return 1 }
return 0
}
var avatarColorIndex: Int { var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: opponentKey) RosettaColors.avatarColorIndex(for: opponentKey)
} }

View File

@@ -41,7 +41,7 @@ final class DialogRepository {
unreadCount: 0, unreadCount: 0,
isOnline: false, isOnline: false,
lastSeen: 0, lastSeen: 0,
isVerified: false, verified: 0,
iHaveSent: false, iHaveSent: false,
isPinned: false, isPinned: false,
isMuted: false, isMuted: false,
@@ -78,10 +78,11 @@ final class DialogRepository {
dialogs[opponentKey] = dialog dialogs[opponentKey] = dialog
} }
func updateUserInfo(publicKey: String, title: String, username: String) { func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0) {
guard var dialog = dialogs[publicKey] else { return } guard var dialog = dialogs[publicKey] else { return }
if !title.isEmpty { dialog.opponentTitle = title } if !title.isEmpty { dialog.opponentTitle = title }
if !username.isEmpty { dialog.opponentUsername = username } if !username.isEmpty { dialog.opponentUsername = username }
if verified > 0 { dialog.verified = max(dialog.verified, verified) }
dialogs[publicKey] = dialog dialogs[publicKey] = dialog
} }

View File

@@ -176,22 +176,25 @@ final class ProtocolManager: @unchecked Sendable {
// MARK: - Packet Handling // MARK: - Packet Handling
private func handleIncomingData(_ data: Data) { private func handleIncomingData(_ data: Data) {
print("[Protocol] Incoming data: \(data.count) bytes, first bytes: \(data.prefix(min(8, data.count)).map { String(format: "%02x", $0) }.joined(separator: " "))") #if DEBUG
if data.count >= 2 {
let peekStream = Stream(data: data)
let rawId = peekStream.readInt16()
Self.logger.debug("📥 Incoming packet 0x\(String(rawId, radix: 16)), size: \(data.count)")
}
#endif
guard let (packetId, packet) = PacketRegistry.decode(from: data) else { guard let (packetId, packet) = PacketRegistry.decode(from: data) else {
// Try to read the packet ID manually to see what it is #if DEBUG
if data.count >= 2 { if data.count >= 2 {
let stream = Stream(data: data) let stream = Stream(data: data)
let rawId = stream.readInt16() let rawId = stream.readInt16()
print("[Protocol] Unknown packet ID: 0x\(String(rawId, radix: 16)) (\(rawId)), data size: \(data.count)") Self.logger.debug("Unknown packet ID: 0x\(String(rawId, radix: 16)), size: \(data.count)")
} else {
print("[Protocol] Packet too small: \(data.count) bytes")
} }
#endif
return return
} }
print("[Protocol] Received packet 0x\(String(packetId, radix: 16)) (\(type(of: packet)))")
switch packetId { switch packetId {
case 0x00: case 0x00:
if let p = packet as? PacketHandshake { if let p = packet as? PacketHandshake {
@@ -199,17 +202,15 @@ final class ProtocolManager: @unchecked Sendable {
} }
case 0x01: case 0x01:
if let p = packet as? PacketUserInfo { if let p = packet as? PacketUserInfo {
print("[Protocol] UserInfo received: username='\(p.username)', title='\(p.title)'")
onUserInfoReceived?(p) onUserInfoReceived?(p)
} }
case 0x02: case 0x02:
if let p = packet as? PacketResult { if let p = packet as? PacketResult {
let code = ResultCode(rawValue: p.resultCode) let _ = ResultCode(rawValue: p.resultCode)
print("[Protocol] Result received: code=\(p.resultCode) (\(code.map { "\($0)" } ?? "unknown"))")
} }
case 0x03: case 0x03:
if let p = packet as? PacketSearch { if let p = packet as? PacketSearch {
print("[Protocol] Search result received: \(p.users.count) users") Self.logger.debug("📥 Search result: \(p.users.count) users, callback=\(self.onSearchResult != nil)")
onSearchResult?(p) onSearchResult?(p)
} }
case 0x05: case 0x05:

View File

@@ -178,7 +178,7 @@ final class SessionManager {
proto.onUserInfoReceived = { [weak self] packet in proto.onUserInfoReceived = { [weak self] packet in
guard let self else { return } guard let self else { return }
Task { @MainActor in Task { @MainActor in
print("[Session] UserInfo received: username='\(packet.username)', title='\(packet.title)'") Self.logger.debug("UserInfo received: username='\(packet.username)', title='\(packet.title)'")
if !packet.title.isEmpty { if !packet.title.isEmpty {
self.displayName = packet.title self.displayName = packet.title
AccountManager.shared.updateProfile(displayName: packet.title, username: nil) AccountManager.shared.updateProfile(displayName: packet.title, username: nil)
@@ -210,10 +210,9 @@ final class SessionManager {
userInfoPacket.avatar = "" userInfoPacket.avatar = ""
userInfoPacket.title = name userInfoPacket.title = name
userInfoPacket.privateKey = hash userInfoPacket.privateKey = hash
print("[Session] Sending UserInfo: username='\(uname)', title='\(name)'")
ProtocolManager.shared.sendPacket(userInfoPacket) ProtocolManager.shared.sendPacket(userInfoPacket)
} else { } else {
print("[Session] Skipping UserInfo — no profile data to send") Self.logger.debug("Skipping UserInfo — no profile data to send")
} }
} }
} }

View File

@@ -0,0 +1,12 @@
import Foundation
// MARK: - System Account Helpers
/// Client-side heuristic for Rosetta-official accounts (matches Android logic).
/// Returns `true` if the user's title or username matches "Rosetta" (case-insensitive)
/// or if the public key belongs to a system account.
func isRosettaOfficial(_ user: SearchUser) -> Bool {
user.title.caseInsensitiveCompare("Rosetta") == .orderedSame ||
user.username.caseInsensitiveCompare("rosetta") == .orderedSame ||
SystemAccounts.isSystemAccount(user.publicKey)
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
// MARK: - Glass Navigation Bar Modifier
/// Applies glassmorphism effect to the navigation bar on iOS 26+, falling back to ultra-thin material.
struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}
}
}
extension View {
func applyGlassNavBar() -> some View {
modifier(GlassNavBarModifier())
}
}
// MARK: - Glass Search Bar Modifier
/// Applies glassmorphism capsule effect on iOS 26+.
struct GlassSearchBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: .capsule)
} else {
content
}
}
}
extension View {
func applyGlassSearchBar() -> some View {
modifier(GlassSearchBarModifier())
}
}

View File

@@ -60,8 +60,7 @@ struct RosettaTabBar: View {
searchPill searchPill
} }
.padding(.horizontal, 25) .padding(.horizontal, 25)
.padding(.top, 16) .padding(.top, 4)
.padding(.bottom, safeAreaBottom > 0 ? safeAreaBottom : 25)
} }
} }

View File

@@ -0,0 +1,76 @@
import SwiftUI
// MARK: - VerifiedBadge
/// Displays a verified rosette badge for accounts with server-assigned verification.
///
/// Verification levels (from server `verified` field):
/// - `0` not verified (badge hidden)
/// - `1` public figure, brand, or organization
/// - `2` official Rosetta administration account
/// - `3+` group administrator
///
/// Tapping the badge presents a dialog explaining the verification level.
struct VerifiedBadge: View {
let verified: Int
var size: CGFloat = 16
var badgeTint: Color?
@Environment(\.colorScheme) private var colorScheme
@State private var showExplanation = false
var body: some View {
if verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: size))
.foregroundStyle(resolvedColor)
.onTapGesture { showExplanation = true }
.accessibilityLabel("Verified account")
.alert("Verified Account", isPresented: $showExplanation) {
Button("OK", role: .cancel) {}
} message: {
Text(annotationText)
}
}
}
// MARK: - Private
private var resolvedColor: Color {
if let badgeTint { return badgeTint }
return colorScheme == .dark
? RosettaColors.primaryBlue // #248AE6
: Color(hex: 0xACD2F9) // soft blue (light theme)
}
private var annotationText: String {
switch verified {
case 1:
return "This is an official account belonging to a public figure, brand, or organization."
case 2:
return "This is an official account belonging to the administration of Rosetta."
default:
return "This user is an administrator of this group."
}
}
}
// MARK: - Preview
#Preview {
VStack(spacing: 16) {
HStack {
Text("Level 1")
VerifiedBadge(verified: 1, size: 16)
}
HStack {
Text("Level 2")
VerifiedBadge(verified: 2, size: 20)
}
HStack {
Text("Not verified")
VerifiedBadge(verified: 0, size: 16)
}
}
.padding()
}

View File

@@ -14,6 +14,7 @@ enum AuthScreen: Equatable {
struct AuthCoordinator: View { struct AuthCoordinator: View {
let onAuthComplete: () -> Void let onAuthComplete: () -> Void
var onBackToUnlock: (() -> Void)?
@State private var currentScreen: AuthScreen = .welcome @State private var currentScreen: AuthScreen = .welcome
@State private var seedPhrase: [String] = [] @State private var seedPhrase: [String] = []
@@ -94,7 +95,8 @@ private extension AuthCoordinator {
onImportSeed: { onImportSeed: {
isImportMode = true isImportMode = true
navigateTo(.importSeed) navigateTo(.importSeed)
} },
onBack: onBackToUnlock
) )
case .seedPhrase: case .seedPhrase:
@@ -146,11 +148,11 @@ private extension AuthCoordinator {
case .welcome: case .welcome:
EmptyView() EmptyView()
case .seedPhrase: case .seedPhrase:
WelcomeView(onGenerateSeed: {}, onImportSeed: {}) WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
case .confirmSeed: case .confirmSeed:
SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {}) SeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})
case .importSeed: case .importSeed:
WelcomeView(onGenerateSeed: {}, onImportSeed: {}) WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: onBackToUnlock)
case .setPassword: case .setPassword:
if isImportMode { if isImportMode {
ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {}) ImportSeedPhraseView(seedPhrase: .constant(seedPhrase), onContinue: {}, onBack: {})

View File

@@ -37,7 +37,8 @@ struct ConfirmSeedPhraseView: View {
.padding(.bottom, 100) .padding(.bottom, 100)
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedInputIndex = nil } .onTapGesture(count: 1) { focusedInputIndex = nil }
.simultaneousGesture(TapGesture().onEnded {})
confirmButton confirmButton
.padding(.horizontal, 24) .padding(.horizontal, 24)

View File

@@ -30,7 +30,8 @@ struct ImportSeedPhraseView: View {
.padding(.bottom, 100) .padding(.bottom, 100)
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedWordIndex = nil } .onTapGesture(count: 1) { focusedWordIndex = nil }
.simultaneousGesture(TapGesture().onEnded {})
continueButton continueButton
.padding(.horizontal, 24) .padding(.horizontal, 24)

View File

@@ -55,19 +55,18 @@ struct SetPasswordView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.transition(.opacity.combined(with: .scale(scale: 0.95))) .transition(.opacity.combined(with: .scale(scale: 0.95)))
} }
createButton
.padding(.top, 4)
} }
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.top, 8) .padding(.top, 8)
.padding(.bottom, 100) .padding(.bottom, 32)
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onTapGesture { focusedField = nil } .onTapGesture(count: 1) { focusedField = nil }
.simultaneousGesture(TapGesture().onEnded {})
createButton
.padding(.horizontal, 24)
.padding(.bottom, 16)
} }
.geometryGroup()
} }
} }

View File

@@ -1,8 +1,9 @@
import SwiftUI import SwiftUI
/// Password unlock screen matching rosetta-android design with liquid glass styling. /// Password unlock screen matching rosetta-android design.
struct UnlockView: View { struct UnlockView: View {
let onUnlocked: () -> Void let onUnlocked: () -> Void
var onCreateNewAccount: (() -> Void)?
@State private var password = "" @State private var password = ""
@State private var isUnlocking = false @State private var isUnlocking = false
@@ -23,29 +24,26 @@ struct UnlockView: View {
account?.publicKey ?? "" account?.publicKey ?? ""
} }
/// First 2 chars of public key, uppercased matching Android's `getAvatarText()`. /// First 2 chars of public key matching Android's avatar text.
private var avatarText: String { private var avatarText: String {
RosettaColors.avatarText(publicKey: publicKey) RosettaColors.avatarText(publicKey: publicKey)
} }
/// Color index using Java-compatible hashCode matching Android's `getAvatarColor()`.
private var avatarColorIndex: Int { private var avatarColorIndex: Int {
RosettaColors.avatarColorIndex(for: publicKey) RosettaColors.avatarColorIndex(for: publicKey)
} }
/// Display name, or first 20 chars of public key if no name set. /// Short public key 7 characters like Android (e.g. "0325a4d").
private var displayName: String { private var shortPublicKey: String {
let name = account?.displayName ?? "" guard publicKey.count >= 7 else { return publicKey }
if name.isEmpty { return String(publicKey.prefix(7))
return publicKey.isEmpty ? "Rosetta" : String(publicKey.prefix(20)) + "..."
}
return name
} }
/// Truncated public key for subtitle. /// Display name or short public key.
private var publicKeyPreview: String { private var displayTitle: String {
guard publicKey.count > 20 else { return publicKey } let name = account?.displayName ?? ""
return String(publicKey.prefix(20)) + "..." if !name.isEmpty { return name }
return shortPublicKey
} }
var body: some View { var body: some View {
@@ -69,27 +67,17 @@ struct UnlockView: View {
Spacer().frame(height: 20) Spacer().frame(height: 20)
// Display name // Short public key (7 chars like Android)
Text(displayName) Text(shortPublicKey)
.font(.system(size: 24, weight: .bold)) .font(.system(size: 24, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.opacity(showTitle ? 1 : 0) .opacity(showTitle ? 1 : 0)
.offset(y: showTitle ? 0 : 8) .offset(y: showTitle ? 0 : 8)
// Public key preview (below name)
if !(account?.displayName ?? "").isEmpty {
Text(publicKeyPreview)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
.padding(.top, 4)
.opacity(showTitle ? 1 : 0)
.offset(y: showTitle ? 0 : 8)
}
Spacer().frame(height: 8) Spacer().frame(height: 8)
// Subtitle // Subtitle matching Android
Text("Enter password to unlock") Text("For unlock account enter password")
.font(.system(size: 15)) .font(.system(size: 15))
.foregroundStyle(RosettaColors.secondaryText) .foregroundStyle(RosettaColors.secondaryText)
.opacity(showSubtitle ? 1 : 0) .opacity(showSubtitle ? 1 : 0)
@@ -97,7 +85,39 @@ struct UnlockView: View {
Spacer().frame(height: 40) Spacer().frame(height: 40)
// Password input glass card // Password input
passwordField
.padding(.horizontal, 24)
.opacity(showInput ? 1 : 0)
.offset(y: showInput ? 0 : 12)
Spacer().frame(height: 24)
// Enter button matching onboarding style
unlockButton
.padding(.horizontal, 24)
.opacity(showButton ? 1 : 0)
.offset(y: showButton ? 0 : 12)
Spacer().frame(height: 60)
// Footer "You can also recover your password or create a new account."
footerView
.opacity(showFooter ? 1 : 0)
Spacer().frame(height: 40)
}
}
.scrollDismissesKeyboard(.interactively)
}
.onAppear { startAnimations() }
}
}
// MARK: - Password Field
private extension UnlockView {
var passwordField: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
GlassCard(cornerRadius: 14, fillOpacity: 0.08) { GlassCard(cornerRadius: 14, fillOpacity: 0.08) {
HStack(spacing: 12) { HStack(spacing: 12) {
@@ -141,13 +161,13 @@ struct UnlockView: View {
.transition(.opacity) .transition(.opacity)
} }
} }
.padding(.horizontal, 24) }
.opacity(showInput ? 1 : 0) }
.offset(y: showInput ? 0 : 12)
Spacer().frame(height: 24) // MARK: - Unlock Button
// Unlock button private extension UnlockView {
var unlockButton: some View {
Button(action: unlock) { Button(action: unlock) {
HStack(spacing: 10) { HStack(spacing: 10) {
if isUnlocking { if isUnlocking {
@@ -157,77 +177,66 @@ struct UnlockView: View {
} else { } else {
Image(systemName: "lock.open.fill") Image(systemName: "lock.open.fill")
.font(.system(size: 16)) .font(.system(size: 16))
Text("Unlock") Text("Enter")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
} }
} }
.foregroundStyle(.white) .foregroundStyle(.white)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 54) .frame(height: 56)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(password.isEmpty ? RosettaColors.primaryBlue.opacity(0.4) : RosettaColors.primaryBlue)
)
} }
.buttonStyle(RosettaPrimaryButtonStyle(isEnabled: !password.isEmpty && !isUnlocking))
.disabled(password.isEmpty || isUnlocking) .disabled(password.isEmpty || isUnlocking)
.padding(.horizontal, 24)
.opacity(showButton ? 1 : 0)
.offset(y: showButton ? 0 : 12)
Spacer().frame(height: 40)
// Footer "or" divider + secondary actions
VStack(spacing: 16) {
HStack(spacing: 12) {
Rectangle()
.fill(RosettaColors.secondaryText.opacity(0.3))
.frame(height: 0.5)
Text("or")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.secondaryText)
Rectangle()
.fill(RosettaColors.secondaryText.opacity(0.3))
.frame(height: 0.5)
} }
.padding(.horizontal, 40) }
// MARK: - Footer
private extension UnlockView {
var footerView: some View {
VStack(spacing: 4) {
HStack(spacing: 0) {
Text("You can also ")
.foregroundStyle(RosettaColors.secondaryText)
Button { Button {
// TODO: Recover account flow onCreateNewAccount?()
} label: { } label: {
HStack(spacing: 8) { Text("recover your password")
Image(systemName: "key.fill") .fontWeight(.semibold)
.font(.system(size: 14))
Text("Recover account")
.font(.system(size: 15, weight: .medium))
}
.foregroundStyle(RosettaColors.primaryBlue) .foregroundStyle(RosettaColors.primaryBlue)
} }
.buttonStyle(.plain)
Button { Text(" or")
// TODO: Create new account flow
} label: {
HStack(spacing: 8) {
Image(systemName: "person.badge.plus")
.font(.system(size: 14))
Text("Create new account")
.font(.system(size: 15, weight: .medium))
}
.foregroundStyle(RosettaColors.secondaryText) .foregroundStyle(RosettaColors.secondaryText)
} }
} .font(.system(size: 15))
.opacity(showFooter ? 1 : 0)
Spacer().frame(height: 40) HStack(spacing: 0) {
Text("create a ")
.foregroundStyle(RosettaColors.secondaryText)
Button {
onCreateNewAccount?()
} label: {
Text("new account.")
.fontWeight(.semibold)
.foregroundStyle(RosettaColors.primaryBlue)
} }
.buttonStyle(.plain)
} }
.scrollDismissesKeyboard(.interactively) .font(.system(size: 15))
}
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
} }
.onAppear { startAnimations() }
} }
// MARK: - Actions // MARK: - Actions
private func unlock() { private extension UnlockView {
func unlock() {
guard !password.isEmpty, !isUnlocking else { return } guard !password.isEmpty, !isUnlocking else { return }
isUnlocking = true isUnlocking = true
errorMessage = nil errorMessage = nil
@@ -245,7 +254,7 @@ struct UnlockView: View {
} }
} }
private func startAnimations() { func startAnimations() {
withAnimation(.easeOut(duration: 0.3)) { showAvatar = true } withAnimation(.easeOut(duration: 0.3)) { showAvatar = true }
withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true } withAnimation(.easeOut(duration: 0.3).delay(0.08)) { showTitle = true }
withAnimation(.easeOut(duration: 0.3).delay(0.12)) { showSubtitle = true } withAnimation(.easeOut(duration: 0.3).delay(0.12)) { showSubtitle = true }

View File

@@ -4,10 +4,12 @@ import Lottie
struct WelcomeView: View { struct WelcomeView: View {
let onGenerateSeed: () -> Void let onGenerateSeed: () -> Void
let onImportSeed: () -> Void let onImportSeed: () -> Void
var onBack: (() -> Void)?
@State private var isVisible = false @State private var isVisible = false
var body: some View { var body: some View {
ZStack(alignment: .topLeading) {
VStack(spacing: 0) { VStack(spacing: 0) {
Spacer() Spacer()
@@ -30,6 +32,20 @@ struct WelcomeView: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.bottom, 16) .padding(.bottom, 16)
} }
// Back button (only shows when coming from Unlock screen)
if let onBack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white)
.frame(width: 44, height: 44)
}
.padding(.leading, 12)
.padding(.top, 8)
.opacity(isVisible ? 1.0 : 0.0)
}
}
.onAppear { .onAppear {
withAnimation(.easeOut(duration: 0.5)) { isVisible = true } withAnimation(.easeOut(duration: 0.5)) { isVisible = true }
} }
@@ -146,7 +162,7 @@ private extension WelcomeView {
} }
#Preview { #Preview {
WelcomeView(onGenerateSeed: {}, onImportSeed: {}) WelcomeView(onGenerateSeed: {}, onImportSeed: {}, onBack: {})
.preferredColorScheme(.dark) .preferredColorScheme(.dark)
.background(RosettaColors.authBackground) .background(RosettaColors.authBackground)
} }

View File

@@ -0,0 +1,67 @@
import SwiftUI
import Lottie
// MARK: - ChatEmptyStateView
struct ChatEmptyStateView: View {
let searchText: String
var body: some View {
VStack(spacing: 0) {
if searchText.isEmpty {
noConversationsContent
} else {
noSearchResultsContent
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: -40)
}
}
// MARK: - Content Variants
private extension ChatEmptyStateView {
var noConversationsContent: some View {
Group {
LottieView(animationName: "letter", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 150, height: 150)
Spacer().frame(height: 24)
Text("No conversations yet")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
Spacer().frame(height: 8)
Text("Start a new conversation to get started")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
var noSearchResultsContent: some View {
Group {
Image(systemName: "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Spacer().frame(height: 16)
Text("No results for \"\(searchText)\"")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
}
}
}
// MARK: - Preview
#Preview {
ChatEmptyStateView(searchText: "")
}

View File

@@ -0,0 +1,244 @@
import Lottie
import SwiftUI
// MARK: - Chat List Search Content
/// Search overlay for ChatListView shows recent searches or search results.
/// Matches Android's three-state pattern: skeleton empty results.
struct ChatListSearchContent: View {
let searchText: String
@ObservedObject var viewModel: ChatListViewModel
var onSelectRecent: (String) -> Void
var body: some View {
let trimmed = searchText.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty {
recentSearchesSection
} else {
activeSearchContent
}
}
}
// MARK: - Active Search (Three States)
private extension ChatListSearchContent {
/// Android-style: skeleton empty results only one visible at a time.
@ViewBuilder
var activeSearchContent: some View {
let localResults = viewModel.filteredDialogs
let localKeys = Set(localResults.map(\.opponentKey))
let serverOnly = viewModel.serverSearchResults.filter {
!localKeys.contains($0.publicKey)
}
let hasAnyResult = !localResults.isEmpty || !serverOnly.isEmpty
if viewModel.isServerSearching && !hasAnyResult {
SearchSkeletonView()
} else if !viewModel.isServerSearching && !hasAnyResult {
noResultsState
} else {
resultsList(localResults: localResults, serverOnly: serverOnly)
}
}
/// Lottie animation + "No results found" matches Android's empty state.
var noResultsState: some View {
VStack(spacing: 20) {
Spacer()
LottieView(animationName: "search", loopMode: .playOnce, animationSpeed: 1.0)
.frame(width: 120, height: 120)
Text("Search for users")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Enter username or public key")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
/// Scrollable list of local dialogs + server results.
func resultsList(localResults: [Dialog], serverOnly: [SearchUser]) -> some View {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(localResults) { dialog in
ChatRowView(dialog: dialog)
}
ForEach(serverOnly, id: \.publicKey) { user in
serverUserRow(user)
if user.publicKey != serverOnly.last?.publicKey {
Divider()
.padding(.leading, 76)
.foregroundStyle(RosettaColors.Adaptive.divider)
}
}
Spacer().frame(height: 80)
}
}
.scrollDismissesKeyboard(.interactively)
}
}
// MARK: - Recent Searches
private extension ChatListSearchContent {
@ViewBuilder
var recentSearchesSection: some View {
if viewModel.recentSearches.isEmpty {
searchPlaceholder
} else {
ScrollView {
VStack(spacing: 0) {
HStack {
Text("RECENT")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
Button { viewModel.clearRecentSearches() } label: {
Text("Clear")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 6)
ForEach(viewModel.recentSearches, id: \.publicKey) { recent in
recentRow(recent)
}
}
}
.scrollDismissesKeyboard(.interactively)
}
}
var searchPlaceholder: some View {
VStack(spacing: 20) {
Spacer()
LottieView(animationName: "search", loopMode: .loop, animationSpeed: 1.0)
.frame(width: 120, height: 120)
Text("Search for users")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
Text("Find people by username or public key")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
func recentRow(_ user: RecentSearch) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
onSelectRecent(user.username.isEmpty ? user.publicKey : user.username)
} label: {
HStack(spacing: 10) {
ZStack(alignment: .topTrailing) {
AvatarView(
initials: initials, colorIndex: colorIdx,
size: 42, isSavedMessages: isSelf
)
Button {
viewModel.removeRecentSearch(publicKey: user.publicKey)
} label: {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.frame(width: 18, height: 18)
.background(Circle().fill(RosettaColors.figmaBlue))
}
.offset(x: 4, y: -4)
}
VStack(alignment: .leading, spacing: 1) {
Text(isSelf ? "Saved Messages" : (
user.title.isEmpty
? String(user.publicKey.prefix(16)) + "..."
: user.title
))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !user.lastSeenText.isEmpty {
Text(user.lastSeenText)
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Server User Row
private extension ChatListSearchContent {
func serverUserRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(
name: user.title, publicKey: user.publicKey
)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
viewModel.addToRecent(user)
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials, colorIndex: colorIdx,
size: 48, isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (
user.title.isEmpty
? String(user.publicKey.prefix(10))
: user.title
))
.font(.system(size: 16, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if !isSelf && (user.verified > 0 || isRosettaOfficial(user)) {
VerifiedBadge(
verified: user.verified > 0 ? user.verified : 1,
size: 16
)
}
}
Text(isSelf ? "Notes" : (
user.username.isEmpty
? "@\(String(user.publicKey.prefix(10)))..."
: "@\(user.username)"
))
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}

View File

@@ -3,9 +3,9 @@ import SwiftUI
// MARK: - ChatListView // MARK: - ChatListView
struct ChatListView: View { struct ChatListView: View {
@State private var viewModel = ChatListViewModel() @Binding var isSearchActive: Bool
@StateObject private var viewModel = ChatListViewModel()
@State private var searchText = "" @State private var searchText = ""
@State private var isSearchPresented = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -13,7 +13,15 @@ struct ChatListView: View {
RosettaColors.Adaptive.background RosettaColors.Adaptive.background
.ignoresSafeArea() .ignoresSafeArea()
chatContent if isSearchActive {
ChatListSearchContent(
searchText: searchText,
viewModel: viewModel,
onSelectRecent: { searchText = $0 }
)
} else {
normalContent
}
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { toolbarContent } .toolbar { toolbarContent }
@@ -21,7 +29,7 @@ struct ChatListView: View {
.applyGlassNavBar() .applyGlassNavBar()
.searchable( .searchable(
text: $searchText, text: $searchText,
isPresented: $isSearchPresented, isPresented: $isSearchActive,
placement: .navigationBarDrawer(displayMode: .always), placement: .navigationBarDrawer(displayMode: .always),
prompt: "Search" prompt: "Search"
) )
@@ -33,29 +41,19 @@ struct ChatListView: View {
} }
} }
// MARK: - Glass Nav Bar Modifier // MARK: - Normal Content
private struct GlassNavBarModifier: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
} else {
content
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
}
}
}
private extension View {
func applyGlassNavBar() -> some View {
modifier(GlassNavBarModifier())
}
}
// MARK: - Chat Content
private extension ChatListView { private extension ChatListView {
var chatContent: some View { @ViewBuilder
var normalContent: some View {
if viewModel.filteredDialogs.isEmpty && !viewModel.isLoading {
ChatEmptyStateView(searchText: "")
} else {
dialogList
}
}
var dialogList: some View {
List { List {
if viewModel.isLoading { if viewModel.isLoading {
ForEach(0..<8, id: \.self) { _ in ForEach(0..<8, id: \.self) { _ in
@@ -64,29 +62,19 @@ private extension ChatListView {
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
} }
} else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else { } else {
// Local dialog results
if !viewModel.pinnedDialogs.isEmpty { if !viewModel.pinnedDialogs.isEmpty {
pinnedSection ForEach(viewModel.pinnedDialogs) { dialog in
chatRow(dialog)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
}
} }
ForEach(viewModel.unpinnedDialogs) { dialog in ForEach(viewModel.unpinnedDialogs) { dialog in
chatRow(dialog) chatRow(dialog)
} }
// Server search results
if viewModel.showServerResults {
serverSearchSection
}
} }
Color.clear Color.clear.frame(height: 80)
.frame(height: 80)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
@@ -95,17 +83,6 @@ private extension ChatListView {
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
} }
}
// MARK: - Pinned Section
private extension ChatListView {
var pinnedSection: some View {
ForEach(viewModel.pinnedDialogs) { dialog in
chatRow(dialog)
.listRowBackground(RosettaColors.Adaptive.backgroundSecondary)
}
}
func chatRow(_ dialog: Dialog) -> some View { func chatRow(_ dialog: Dialog) -> some View {
ChatRowView(dialog: dialog) ChatRowView(dialog: dialog)
@@ -119,7 +96,6 @@ private extension ChatListView {
} label: { } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
Button { Button {
viewModel.toggleMute(dialog) viewModel.toggleMute(dialog)
} label: { } label: {
@@ -137,7 +113,6 @@ private extension ChatListView {
Label("Read", systemImage: "envelope.open") Label("Read", systemImage: "envelope.open")
} }
.tint(RosettaColors.figmaBlue) .tint(RosettaColors.figmaBlue)
Button { Button {
viewModel.togglePin(dialog) viewModel.togglePin(dialog)
} label: { } label: {
@@ -154,9 +129,7 @@ private extension ChatListView {
@ToolbarContentBuilder @ToolbarContentBuilder
var toolbarContent: some ToolbarContent { var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) { ToolbarItem(placement: .navigationBarLeading) {
Button { Button { } label: {
// TODO: Edit mode
} label: {
Text("Edit") Text("Edit")
.font(.system(size: 17, weight: .medium)) .font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
@@ -166,218 +139,41 @@ private extension ChatListView {
ToolbarItem(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: 4) { HStack(spacing: 4) {
storiesAvatars storiesAvatars
Text("Chats") Text("Chats")
.font(.system(size: 17, weight: .semibold)) .font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.figmaBlue)
} }
} }
ToolbarItemGroup(placement: .navigationBarTrailing) { ToolbarItemGroup(placement: .navigationBarTrailing) {
Button { HStack(spacing: 8) {
// TODO: Camera Button { } label: {
} label: {
Image(systemName: "camera") Image(systemName: "camera")
.font(.system(size: 18)) .font(.system(size: 16, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
} }
.accessibilityLabel("Camera") .accessibilityLabel("Camera")
Button { } label: {
Button {
// TODO: Compose new message
} label: {
Image(systemName: "square.and.pencil") Image(systemName: "square.and.pencil")
.font(.system(size: 18)) .font(.system(size: 17, weight: .regular))
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
} }
.padding(.bottom, 2)
.accessibilityLabel("New chat") .accessibilityLabel("New chat")
} }
} }
}
@ViewBuilder @ViewBuilder
private var storiesAvatars: some View { private var storiesAvatars: some View {
let pk = AccountManager.shared.currentAccount?.publicKey ?? "" let pk = AccountManager.shared.currentAccount?.publicKey ?? ""
let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk) let initials = RosettaColors.initials(
name: SessionManager.shared.displayName, publicKey: pk
)
let colorIdx = RosettaColors.avatarColorIndex(for: pk) let colorIdx = RosettaColors.avatarColorIndex(for: pk)
ZStack { AvatarView(initials: initials, colorIndex: colorIdx, size: 28) }
ZStack {
AvatarView(initials: initials, colorIndex: colorIdx, size: 28)
}
}
}
// MARK: - Server Search Results
private extension ChatListView {
@ViewBuilder
var serverSearchSection: some View {
if viewModel.isServerSearching {
HStack {
Spacer()
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching users...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Spacer()
}
.padding(.vertical, 16)
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} else if !viewModel.serverSearchResults.isEmpty {
Section {
ForEach(viewModel.serverSearchResults, id: \.publicKey) { user in
serverSearchRow(user)
}
} header: {
Text("GLOBAL SEARCH")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
} else if viewModel.filteredDialogs.isEmpty {
emptyState
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
} }
} }
func serverSearchRow(_ user: SearchUser) -> some View { #Preview { ChatListView(isSearchActive: .constant(false)) }
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
// TODO: Navigate to ChatDetailView
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 52,
isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if user.verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 14))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets())
}
}
// MARK: - Empty State
private extension ChatListView {
var emptyState: some View {
VStack(spacing: 16) {
Image(systemName: searchText.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass")
.font(.system(size: 52))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
.padding(.top, 80)
Text(searchText.isEmpty ? "No chats yet" : "No results for \"\(searchText)\"")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
if searchText.isEmpty {
Text("Start a conversation by tapping the search tab")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Shimmer Row
private struct ChatRowShimmerView: View {
@State private var phase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 140, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.onAppear {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
var shimmerGradient: LinearGradient {
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
return LinearGradient(
colors: [
Color.gray.opacity(baseOpacity),
Color.gray.opacity(peakOpacity),
Color.gray.opacity(baseOpacity),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}
// MARK: - Preview
#Preview {
ChatListView()
}

View File

@@ -1,23 +1,34 @@
import Combine
import Foundation import Foundation
import os
// MARK: - ChatListViewModel // MARK: - ChatListViewModel
@Observable
@MainActor @MainActor
final class ChatListViewModel { final class ChatListViewModel: ObservableObject {
private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "ChatListVM")
// MARK: - State // MARK: - State
private(set) var isLoading = false @Published var isLoading = false
private(set) var searchQuery = "" @Published var searchQuery = ""
@Published var serverSearchResults: [SearchUser] = []
@Published var isServerSearching = false
@Published var recentSearches: [RecentSearch] = []
// Server search state
private(set) var serverSearchResults: [SearchUser] = []
private(set) var isServerSearching = false
private var searchTask: Task<Void, Never>? private var searchTask: Task<Void, Never>?
private var lastSearchedText = "" private var lastSearchedText = ""
private static let maxRecent = 20
private var recentKey: String {
"rosetta_recent_searches_\(SessionManager.shared.currentPublicKey)"
}
// MARK: - Init
init() { init() {
loadRecentSearches()
setupSearchCallback() setupSearchCallback()
} }
@@ -25,7 +36,6 @@ final class ChatListViewModel {
var filteredDialogs: [Dialog] { var filteredDialogs: [Dialog] {
var result = DialogRepository.shared.sortedDialogs var result = DialogRepository.shared.sortedDialogs
let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased() let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased()
if !query.isEmpty { if !query.isEmpty {
result = result.filter { result = result.filter {
@@ -34,17 +44,11 @@ final class ChatListViewModel {
|| $0.lastMessage.lowercased().contains(query) || $0.lastMessage.lowercased().contains(query)
} }
} }
return result return result
} }
var pinnedDialogs: [Dialog] { var pinnedDialogs: [Dialog] { filteredDialogs.filter(\.isPinned) }
filteredDialogs.filter(\.isPinned) var unpinnedDialogs: [Dialog] { filteredDialogs.filter { !$0.isPinned } }
}
var unpinnedDialogs: [Dialog] {
filteredDialogs.filter { !$0.isPinned }
}
var totalUnreadCount: Int { var totalUnreadCount: Int {
DialogRepository.shared.sortedDialogs DialogRepository.shared.sortedDialogs
@@ -54,12 +58,6 @@ final class ChatListViewModel {
var hasUnread: Bool { totalUnreadCount > 0 } var hasUnread: Bool { totalUnreadCount > 0 }
/// True when searching and no local results shows server results section
var showServerResults: Bool {
let query = searchQuery.trimmingCharacters(in: .whitespaces)
return !query.isEmpty
}
// MARK: - Actions // MARK: - Actions
func setSearchQuery(_ query: String) { func setSearchQuery(_ query: String) {
@@ -85,6 +83,30 @@ final class ChatListViewModel {
// MARK: - Server Search // MARK: - Server Search
private func setupSearchCallback() {
Self.logger.debug("Setting up search callback")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
DispatchQueue.main.async { [weak self] in
guard let self else {
Self.logger.debug("Search callback: self is nil")
return
}
Self.logger.debug("📥 Search results received: \(packet.users.count) users")
self.serverSearchResults = packet.users
self.isServerSearching = false
Self.logger.debug("📥 isServerSearching=\(self.isServerSearching), count=\(self.serverSearchResults.count)")
for user in packet.users {
DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey,
title: user.title,
username: user.username,
verified: user.verified
)
}
}
}
}
private func triggerServerSearch() { private func triggerServerSearch() {
searchTask?.cancel() searchTask?.cancel()
searchTask = nil searchTask = nil
@@ -97,15 +119,11 @@ final class ChatListViewModel {
return return
} }
if trimmed == lastSearchedText { if trimmed == lastSearchedText { return }
return
}
isServerSearching = true isServerSearching = true
searchTask = Task { [weak self] in searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { return } guard let self, !Task.isCancelled else { return }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
@@ -113,41 +131,65 @@ final class ChatListViewModel {
let connState = ProtocolManager.shared.connectionState let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash let hash = SessionManager.shared.privateKeyHash
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
guard connState == .authenticated, let hash else { guard connState == .authenticated, let hash else {
print("[Search] NOT AUTHENTICATED - aborting")
self.isServerSearching = false self.isServerSearching = false
return return
} }
self.lastSearchedText = currentQuery self.lastSearchedText = currentQuery
var packet = PacketSearch() var packet = PacketSearch()
packet.privateKey = hash packet.privateKey = hash
packet.search = currentQuery packet.search = currentQuery
print("[Search] Sending PacketSearch for '\(currentQuery)'") Self.logger.debug("📤 Sending search packet for '\(currentQuery)' with hash \(hash.prefix(10))...")
ProtocolManager.shared.sendPacket(packet) ProtocolManager.shared.sendPacket(packet)
} }
} }
private func setupSearchCallback() { // MARK: - Recent Searches
print("[Search] Setting up search callback")
ProtocolManager.shared.onSearchResult = { [weak self] packet in
print("[Search] CALLBACK: received \(packet.users.count) users")
Task { @MainActor [weak self] in
guard let self else { return }
self.serverSearchResults = packet.users
self.isServerSearching = false
for user in packet.users { func addToRecent(_ user: SearchUser) {
DialogRepository.shared.updateUserInfo( let recent = RecentSearch(
publicKey: user.publicKey, publicKey: user.publicKey,
title: user.title, title: user.title,
username: user.username username: user.username,
lastSeenText: user.online == 1 ? "online" : "last seen recently"
) )
recentSearches.removeAll { $0.publicKey == user.publicKey }
recentSearches.insert(recent, at: 0)
if recentSearches.count > Self.maxRecent {
recentSearches = Array(recentSearches.prefix(Self.maxRecent))
}
saveRecentSearches()
}
func removeRecentSearch(publicKey: String) {
recentSearches.removeAll { $0.publicKey == publicKey }
saveRecentSearches()
}
func clearRecentSearches() {
recentSearches = []
saveRecentSearches()
}
private func loadRecentSearches() {
if let data = UserDefaults.standard.data(forKey: recentKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
return
}
let oldKey = "rosetta_recent_searches"
if let data = UserDefaults.standard.data(forKey: oldKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
saveRecentSearches()
UserDefaults.standard.removeObject(forKey: oldKey)
} }
} }
}
private func saveRecentSearches() {
guard let data = try? JSONEncoder().encode(recentSearches) else { return }
UserDefaults.standard.set(data, forKey: recentKey)
} }
} }

View File

@@ -0,0 +1,50 @@
import SwiftUI
// MARK: - ChatRowShimmerView
/// Placeholder shimmer row displayed during chat list loading.
struct ChatRowShimmerView: View {
@State private var phase: CGFloat = 0
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: 12) {
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 140, height: 14)
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: 200, height: 12)
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.task {
withAnimation(.linear(duration: 1.4).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private var shimmerGradient: LinearGradient {
let baseOpacity = colorScheme == .dark ? 0.06 : 0.08
let peakOpacity = colorScheme == .dark ? 0.12 : 0.16
return LinearGradient(
colors: [
Color.gray.opacity(baseOpacity),
Color.gray.opacity(peakOpacity),
Color.gray.opacity(baseOpacity),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}

View File

@@ -2,11 +2,14 @@ import SwiftUI
// MARK: - ChatRowView // MARK: - ChatRowView
/// Chat row matching Figma spec: /// Chat row matching Figma "Row - Chats" component spec:
/// Row: paddingLeft=10, paddingRight=16, height=78 /// Row: height 78, paddingLeft 10, paddingRight 16, vertical center
/// Avatar: 62px + 10pt right padding /// Avatar: 62px circle, 10pt trailing padding
/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt /// Title: SF Pro Medium 17pt, tracking -0.43, primary color
/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60% /// Message: SF Pro Regular 15pt, tracking -0.23, secondary color
/// Time: SF Pro Regular 14pt, tracking -0.23, secondary color
/// Badges gap: 6pt verified 12px, muted 12px
/// Trailing: pt 8, pb 14 readStatus + time (gap 2), pin/count at bottom
struct ChatRowView: View { struct ChatRowView: View {
let dialog: Dialog let dialog: Dialog
@@ -38,21 +41,27 @@ private extension ChatRowView {
} }
} }
// MARK: - Content Section // MARK: - Content Section (two-column: title+detail | trailing accessories)
private extension ChatRowView { private extension ChatRowView {
var contentSection: some View { var contentSection: some View {
VStack(alignment: .leading, spacing: 0) { HStack(alignment: .center, spacing: 6) {
Spacer(minLength: 0) // Left column: title + message
VStack(alignment: .leading, spacing: 2) {
titleRow titleRow
Spacer().frame(height: 3) messageRow
subtitleRow
Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, alignment: .leading)
.clipped()
// Right column: time + pin/badge
trailingColumn
}
.frame(height: 63)
} }
} }
// MARK: - Title Row (name + badges + delivery + time) // MARK: - Title Row (name + badges)
private extension ChatRowView { private extension ChatRowView {
var titleRow: some View { var titleRow: some View {
@@ -63,10 +72,11 @@ private extension ChatRowView {
.foregroundStyle(RosettaColors.Adaptive.text) .foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1) .lineLimit(1)
if dialog.isVerified { if !dialog.isSavedMessages && dialog.effectiveVerified > 0 {
Image(systemName: "checkmark.seal.fill") VerifiedBadge(
.font(.system(size: 14)) verified: dialog.effectiveVerified,
.foregroundStyle(RosettaColors.figmaBlue) size: 12
)
} }
if dialog.isMuted { if dialog.isMuted {
@@ -74,39 +84,58 @@ private extension ChatRowView {
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(RosettaColors.Adaptive.textSecondary) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
} }
}
}
}
Spacer(minLength: 4) // MARK: - Message Row
private extension ChatRowView {
var messageRow: some View {
Text(messageText)
.font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
var messageText: String {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
return dialog.lastMessage
}
}
// MARK: - Trailing Column (time + delivery on top, pin/badge on bottom)
private extension ChatRowView {
var trailingColumn: some View {
VStack(alignment: .trailing, spacing: 0) {
// Top: read status + time
HStack(spacing: 2) {
if dialog.lastMessageFromMe && !dialog.isSavedMessages { if dialog.lastMessageFromMe && !dialog.isSavedMessages {
deliveryIcon deliveryIcon
} }
Text(formattedTime) Text(formattedTime)
.font(.system(size: 14)) .font(.system(size: 14))
.tracking(-0.23)
.foregroundStyle( .foregroundStyle(
dialog.unreadCount > 0 && !dialog.isMuted dialog.unreadCount > 0 && !dialog.isMuted
? RosettaColors.figmaBlue ? RosettaColors.figmaBlue
: RosettaColors.Adaptive.textSecondary : RosettaColors.Adaptive.textSecondary
) )
} }
} .padding(.top, 2)
}
// MARK: - Subtitle Row (message + pin + badge) Spacer(minLength: 0)
private extension ChatRowView {
var subtitleRow: some View {
HStack(spacing: 4) {
Text(messageText)
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
Spacer(minLength: 4)
// Bottom: pin or unread badge
HStack(spacing: 8) {
if dialog.isPinned && dialog.unreadCount == 0 { if dialog.isPinned && dialog.unreadCount == 0 {
Image(systemName: "pin.fill") Image(systemName: "pin.fill")
.font(.system(size: 13)) .font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary) .foregroundStyle(RosettaColors.Adaptive.textSecondary)
.rotationEffect(.degrees(45)) .rotationEffect(.degrees(45))
} }
@@ -115,13 +144,8 @@ private extension ChatRowView {
unreadBadge unreadBadge
} }
} }
.padding(.bottom, 2)
} }
var messageText: String {
if dialog.lastMessage.isEmpty {
return "No messages yet"
}
return dialog.lastMessage
} }
@ViewBuilder @ViewBuilder
@@ -160,9 +184,11 @@ private extension ChatRowView {
return Text(text) return Text(text)
.font(.system(size: 15)) .font(.system(size: 15))
.tracking(-0.23)
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, 4) .padding(.horizontal, 4)
.frame(minWidth: 20, minHeight: 20) .frame(minWidth: 20, minHeight: 20)
.frame(maxWidth: 37)
.background { .background {
Capsule() Capsule()
.fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue) .fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue)
@@ -208,7 +234,7 @@ private extension ChatRowView {
lastMessage: "Hey, how are you?", lastMessage: "Hey, how are you?",
lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000), lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000),
unreadCount: 3, isOnline: true, lastSeen: 0, unreadCount: 3, isOnline: true, lastSeen: 0,
isVerified: true, iHaveSent: true, verified: 1, iHaveSent: true,
isPinned: false, isMuted: false, isPinned: false, isMuted: false,
lastMessageFromMe: true, lastMessageDelivered: .read lastMessageFromMe: true, lastMessageDelivered: .read
) )

View File

@@ -0,0 +1,86 @@
import SwiftUI
// MARK: - SearchSkeletonView
/// Telegram-style skeleton loading for search results.
/// Matches the Figma chat row layout: 62px avatar, two-line text, trailing time.
struct SearchSkeletonView: View {
@State private var phase: CGFloat = 0
var body: some View {
ScrollView {
VStack(spacing: 0) {
ForEach(0..<7, id: \.self) { index in
skeletonRow(index: index)
if index < 6 {
Divider()
.foregroundStyle(RosettaColors.Adaptive.divider)
.padding(.leading, 82)
}
}
}
}
.scrollDisabled(true)
.task {
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
private func skeletonRow(index: Int) -> some View {
HStack(spacing: 0) {
// Avatar 62pt circle matching Figma
Circle()
.fill(shimmerGradient)
.frame(width: 62, height: 62)
.padding(.leading, 10)
.padding(.trailing, 10)
// Text block two lines matching Figma row heights
VStack(alignment: .leading, spacing: 8) {
// Title line name width varies per row
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: titleWidth(for: index), height: 16)
// Subtitle line message preview
RoundedRectangle(cornerRadius: 4)
.fill(shimmerGradient)
.frame(width: subtitleWidth(for: index), height: 14)
}
Spacer()
// Trailing time placeholder
RoundedRectangle(cornerRadius: 3)
.fill(shimmerGradient)
.frame(width: 40, height: 12)
.padding(.trailing, 16)
}
.frame(height: 78)
}
// Vary widths to look natural (not uniform blocks)
private func titleWidth(for index: Int) -> CGFloat {
let widths: [CGFloat] = [130, 100, 160, 90, 140, 110, 150]
return widths[index % widths.count]
}
private func subtitleWidth(for index: Int) -> CGFloat {
let widths: [CGFloat] = [200, 170, 220, 150, 190, 180, 210]
return widths[index % widths.count]
}
private var shimmerGradient: LinearGradient {
LinearGradient(
colors: [
Color.gray.opacity(0.08),
Color.gray.opacity(0.15),
Color.gray.opacity(0.08),
],
startPoint: UnitPoint(x: phase - 0.4, y: 0),
endPoint: UnitPoint(x: phase + 0.4, y: 0)
)
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
// MARK: - SearchResultsSection
struct SearchResultsSection: View {
let isSearching: Bool
let searchResults: [SearchUser]
var onSelectUser: (SearchUser) -> Void
var body: some View {
if isSearching {
loadingState
} else if searchResults.isEmpty {
noResultsState
} else {
VStack(spacing: 0) {
ForEach(searchResults, id: \.publicKey) { user in
searchResultRow(user)
}
}
}
}
}
// MARK: - States
private extension SearchResultsSection {
var loadingState: some View {
VStack(spacing: 12) {
Spacer().frame(height: 40)
ProgressView()
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
.frame(maxWidth: .infinity)
}
var noResultsState: some View {
VStack(spacing: 12) {
Spacer().frame(height: 40)
Image(systemName: "person.slash")
.font(.system(size: 40))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Text("No users found")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text("Try a different username or public key")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Result Row
private extension SearchResultsSection {
func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
onSelectUser(user)
} label: {
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 42,
isOnline: user.online == 1,
isSavedMessages: isSelf
)
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if user.verified > 0 || isRosettaOfficial(user) {
VerifiedBadge(
verified: user.verified > 0 ? user.verified : 1,
size: 12
)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
}
}
// MARK: - Preview
#Preview {
SearchResultsSection(
isSearching: false,
searchResults: [],
onSelectUser: { _ in }
)
}

View File

@@ -15,6 +15,7 @@ struct SearchView: View {
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
if searchText.isEmpty { if searchText.isEmpty {
favoriteContactsRow
recentSection recentSection
} else { } else {
searchResultsContent searchResultsContent
@@ -28,9 +29,13 @@ struct SearchView: View {
searchBar searchBar
} }
.onChange(of: searchText) { _, newValue in .onChange(of: searchText) { _, newValue in
print("[SearchView] onChange fired: '\(newValue)'")
viewModel.setSearchQuery(newValue) viewModel.setSearchQuery(newValue)
} }
.task {
// Auto-focus search field when the view appears
try? await Task.sleep(for: .milliseconds(300))
isSearchFocused = true
}
} }
} }
@@ -111,22 +116,45 @@ private extension SearchView {
} }
} }
// MARK: - Glass Search Bar Modifier
private struct GlassSearchBarModifier: ViewModifier { // MARK: - Favorite Contacts (Figma: horizontal scroll at top)
func body(content: Content) -> some View {
if #available(iOS 26, *) {
content
.glassEffect(.regular, in: .capsule)
} else {
content
}
}
}
private extension View { private extension SearchView {
func applyGlassSearchBar() -> some View { @ViewBuilder
modifier(GlassSearchBarModifier()) var favoriteContactsRow: some View {
let dialogs = DialogRepository.shared.sortedDialogs.prefix(10)
if !dialogs.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(Array(dialogs), id: \.id) { dialog in
Button {
// TODO: Navigate to chat
} label: {
VStack(spacing: 4) {
AvatarView(
initials: dialog.initials,
colorIndex: dialog.avatarColorIndex,
size: 62,
isOnline: dialog.isOnline,
isSavedMessages: dialog.isSavedMessages
)
Text(dialog.isSavedMessages ? "Saved" : dialog.opponentTitle.components(separatedBy: " ").first ?? "")
.font(.system(size: 11))
.tracking(0.06)
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
.frame(width: 78)
}
.frame(width: 78)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 2)
}
.padding(.top, 12)
}
} }
} }
@@ -243,93 +271,15 @@ private extension SearchView {
// MARK: - Search Results Content // MARK: - Search Results Content
private extension SearchView { private extension SearchView {
@ViewBuilder
var searchResultsContent: some View { var searchResultsContent: some View {
if viewModel.isSearching { SearchResultsSection(
VStack(spacing: 12) { isSearching: viewModel.isSearching,
Spacer().frame(height: 40) searchResults: viewModel.searchResults,
ProgressView() onSelectUser: { user in
.tint(RosettaColors.Adaptive.textSecondary)
Text("Searching...")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
}
.frame(maxWidth: .infinity)
} else if viewModel.searchResults.isEmpty {
VStack(spacing: 12) {
Spacer().frame(height: 40)
Image(systemName: "person.slash")
.font(.system(size: 40))
.foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5))
Text("No users found")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
Text("Try a different username or public key")
.font(.system(size: 15))
.foregroundStyle(RosettaColors.Adaptive.textTertiary)
}
.frame(maxWidth: .infinity)
} else {
VStack(spacing: 0) {
ForEach(viewModel.searchResults, id: \.publicKey) { user in
searchResultRow(user)
}
}
}
}
func searchResultRow(_ user: SearchUser) -> some View {
let isSelf = user.publicKey == SessionManager.shared.currentPublicKey
let initials = isSelf ? "S" : RosettaColors.initials(name: user.title, publicKey: user.publicKey)
let colorIdx = RosettaColors.avatarColorIndex(for: user.publicKey)
return Button {
viewModel.addToRecent(user) viewModel.addToRecent(user)
// TODO: Navigate to ChatDetailView for user.publicKey // TODO: Navigate to ChatDetailView for user.publicKey
} label: { }
HStack(spacing: 12) {
AvatarView(
initials: initials,
colorIndex: colorIdx,
size: 42,
isOnline: user.online == 1,
isSavedMessages: isSelf
) )
VStack(alignment: .leading, spacing: 1) {
HStack(spacing: 4) {
Text(isSelf ? "Saved Messages" : (user.title.isEmpty ? String(user.publicKey.prefix(16)) + "..." : user.title))
.font(.system(size: 17, weight: .medium))
.foregroundStyle(RosettaColors.Adaptive.text)
.lineLimit(1)
if user.verified > 0 {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 12))
.foregroundStyle(RosettaColors.figmaBlue)
}
}
if !user.username.isEmpty {
Text("@\(user.username)")
.font(.system(size: 13))
.foregroundStyle(RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
}
Spacer()
if user.online == 1 {
Circle()
.fill(RosettaColors.online)
.frame(width: 8, height: 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 5)
}
.buttonStyle(.plain)
} }
} }
@@ -338,3 +288,4 @@ private extension SearchView {
#Preview { #Preview {
SearchView() SearchView()
} }

View File

@@ -29,7 +29,10 @@ final class SearchViewModel {
private var searchTask: Task<Void, Never>? private var searchTask: Task<Void, Never>?
private var lastSearchedText = "" private var lastSearchedText = ""
private static let recentKey = "rosetta_recent_searches" private var recentKey: String {
let pk = SessionManager.shared.currentPublicKey ?? ""
return "rosetta_recent_searches_\(pk)"
}
private static let maxRecent = 20 private static let maxRecent = 20
// MARK: - Init // MARK: - Init
@@ -42,7 +45,7 @@ final class SearchViewModel {
// MARK: - Search Logic // MARK: - Search Logic
func setSearchQuery(_ query: String) { func setSearchQuery(_ query: String) {
print("[Search] setSearchQuery called: '\(query)'")
searchQuery = query searchQuery = query
onSearchQueryChanged() onSearchQueryChanged()
} }
@@ -60,34 +63,34 @@ final class SearchViewModel {
} }
if trimmed == lastSearchedText { if trimmed == lastSearchedText {
print("[Search] Query unchanged, skipping")
return return
} }
isSearching = true isSearching = true
print("[Search] Starting debounce for '\(trimmed)'")
// Debounce 1 second (like Android) // Debounce 1 second (like Android)
searchTask = Task { [weak self] in searchTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { guard let self, !Task.isCancelled else {
print("[Search] Task cancelled during debounce")
return return
} }
let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces)
guard !currentQuery.isEmpty, currentQuery == trimmed else { guard !currentQuery.isEmpty, currentQuery == trimmed else {
print("[Search] Query changed during debounce, aborting")
return return
} }
let connState = ProtocolManager.shared.connectionState let connState = ProtocolManager.shared.connectionState
let hash = SessionManager.shared.privateKeyHash let hash = SessionManager.shared.privateKeyHash
print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'")
guard connState == .authenticated, let hash else { guard connState == .authenticated, let hash else {
print("[Search] NOT AUTHENTICATED - aborting search")
self.isSearching = false self.isSearching = false
return return
} }
@@ -97,7 +100,7 @@ final class SearchViewModel {
var packet = PacketSearch() var packet = PacketSearch()
packet.privateKey = hash packet.privateKey = hash
packet.search = currentQuery packet.search = currentQuery
print("[Search] Sending PacketSearch for '\(currentQuery)' with hash prefix: \(String(hash.prefix(16)))...")
ProtocolManager.shared.sendPacket(packet) ProtocolManager.shared.sendPacket(packet)
} }
} }
@@ -114,10 +117,8 @@ final class SearchViewModel {
// MARK: - Search Callback // MARK: - Search Callback
private func setupSearchCallback() { private func setupSearchCallback() {
print("[Search] Setting up search callback on ProtocolManager")
ProtocolManager.shared.onSearchResult = { [weak self] packet in ProtocolManager.shared.onSearchResult = { [weak self] packet in
print("[Search] CALLBACK: received \(packet.users.count) users") DispatchQueue.main.async { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
self.searchResults = packet.users self.searchResults = packet.users
self.isSearching = false self.isSearching = false
@@ -127,7 +128,8 @@ final class SearchViewModel {
DialogRepository.shared.updateUserInfo( DialogRepository.shared.updateUserInfo(
publicKey: user.publicKey, publicKey: user.publicKey,
title: user.title, title: user.title,
username: user.username username: user.username,
verified: user.verified
) )
} }
} }
@@ -166,15 +168,23 @@ final class SearchViewModel {
} }
private func loadRecentSearches() { private func loadRecentSearches() {
guard let data = UserDefaults.standard.data(forKey: Self.recentKey), if let data = UserDefaults.standard.data(forKey: recentKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) else { let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list
return return
} }
// Migrate from old static key
let oldKey = "rosetta_recent_searches"
if let data = UserDefaults.standard.data(forKey: oldKey),
let list = try? JSONDecoder().decode([RecentSearch].self, from: data) {
recentSearches = list recentSearches = list
saveRecentSearches()
UserDefaults.standard.removeObject(forKey: oldKey)
}
} }
private func saveRecentSearches() { private func saveRecentSearches() {
guard let data = try? JSONEncoder().encode(recentSearches) else { return } guard let data = try? JSONEncoder().encode(recentSearches) else { return }
UserDefaults.standard.set(data, forKey: Self.recentKey) UserDefaults.standard.set(data, forKey: recentKey)
} }
} }

View File

@@ -4,6 +4,7 @@ import SwiftUI
struct MainTabView: View { struct MainTabView: View {
var onLogout: (() -> Void)? var onLogout: (() -> Void)?
@State private var selectedTab: RosettaTab = .chats @State private var selectedTab: RosettaTab = .chats
@State private var isChatSearchActive = false
var body: some View { var body: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
@@ -13,24 +14,31 @@ struct MainTabView: View {
Group { Group {
switch selectedTab { switch selectedTab {
case .chats: case .chats:
ChatListView() ChatListView(isSearchActive: $isChatSearchActive)
.transition(.opacity)
case .settings: case .settings:
SettingsView(onLogout: onLogout) SettingsView(onLogout: onLogout)
.transition(.opacity)
case .search: case .search:
SearchView() SearchView()
.transition(.move(edge: .bottom).combined(with: .opacity))
} }
} }
.animation(.easeInOut(duration: 0.3), value: selectedTab)
if !isChatSearchActive {
RosettaTabBar( RosettaTabBar(
selectedTab: selectedTab, selectedTab: selectedTab,
onTabSelected: { tab in onTabSelected: { tab in
withAnimation(.easeInOut(duration: 0.15)) { withAnimation(.easeInOut(duration: 0.3)) {
selectedTab = tab selectedTab = tab
} }
}, },
badges: tabBadges badges: tabBadges
) )
.ignoresSafeArea(.keyboard) .ignoresSafeArea(.keyboard)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -17,6 +17,14 @@ struct RosettaApp: App {
init() { init() {
UIWindow.appearance().backgroundColor = .systemBackground UIWindow.appearance().backgroundColor = .systemBackground
// Detect fresh install: UserDefaults are wiped on uninstall, Keychain is not.
// If this is the first launch after install, clear any stale Keychain data.
if !UserDefaults.standard.bool(forKey: "hasLaunchedBefore") {
try? AccountManager.shared.deleteAccount()
UserDefaults.standard.set(true, forKey: "hasLaunchedBefore")
}
// Preload Lottie animations early // Preload Lottie animations early
Task.detached(priority: .userInitiated) { Task.detached(priority: .userInitiated) {
LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName)) LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName))
@@ -25,6 +33,7 @@ struct RosettaApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
@AppStorage("isLoggedIn") private var isLoggedIn = false @AppStorage("isLoggedIn") private var isLoggedIn = false
@AppStorage("hasLaunchedBefore") private var hasLaunchedBefore = false
@State private var appState: AppState = .splash @State private var appState: AppState = .splash
var body: some Scene { var body: some Scene {
@@ -35,6 +44,7 @@ struct RosettaApp: App {
rootView rootView
} }
.preferredColorScheme(.dark)
} }
} }
@@ -57,21 +67,37 @@ struct RosettaApp: App {
} }
case .auth: case .auth:
AuthCoordinator { AuthCoordinator(
onAuthComplete: {
withAnimation(.easeInOut(duration: 0.4)) { withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true isLoggedIn = true
// Start session automatically with the password from auth flow
appState = .main appState = .main
} }
},
onBackToUnlock: AccountManager.shared.hasAccount ? {
// Go back to unlock screen if an account exists
withAnimation(.easeInOut(duration: 0.4)) {
appState = .unlock
} }
} : nil
)
case .unlock: case .unlock:
UnlockView { UnlockView(
onUnlocked: {
withAnimation(.easeInOut(duration: 0.4)) { withAnimation(.easeInOut(duration: 0.4)) {
isLoggedIn = true isLoggedIn = true
appState = .main appState = .main
} }
},
onCreateNewAccount: {
// Go to auth flow (Welcome screen with back button)
// Does NOT delete the old account Android keeps multiple accounts
withAnimation(.easeInOut(duration: 0.4)) {
appState = .auth
} }
}
)
case .main: case .main:
MainTabView(onLogout: { MainTabView(onLogout: {
@@ -84,12 +110,12 @@ struct RosettaApp: App {
} }
private func determineNextState() { private func determineNextState() {
if AccountManager.shared.hasAccount { if !hasCompletedOnboarding {
// New install or fresh user show onboarding first
appState = .onboarding
} else if AccountManager.shared.hasAccount {
// Existing user unlock with password // Existing user unlock with password
appState = .unlock appState = .unlock
} else if !hasCompletedOnboarding {
// New user show onboarding first
appState = .onboarding
} else { } else {
// Onboarding done but no account go to auth // Onboarding done but no account go to auth
appState = .auth appState = .auth