From 99a35302fae14cdee9410338cf8ab89046221ab6 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Wed, 25 Feb 2026 21:27:41 +0500 Subject: [PATCH] feat: Implement chat list and search functionality - Added ChatListViewModel to manage chat list state and server search. - Created ChatRowView for displaying individual chat rows. - Developed SearchView and SearchViewModel for user search functionality. - Introduced MainTabView for tab-based navigation between chats and settings. - Implemented OnboardingPager for onboarding experience. - Created SettingsView and SettingsViewModel for user settings management. - Added SplashView for initial app launch experience. --- .claude/settings.json | 11 + Info.plist | 5 + Rosetta.xcodeproj/project.pbxproj | 21 + .../AccentColor.colorset/Contents.json | 9 + .../AppIcon.appiconset/AppIcon.png | Bin 0 -> 30443 bytes .../AppIcon.appiconset/Contents.json | 3 + .../Contents.json | 38 + .../RosettaIcon.imageset/Contents.json | 21 + .../RosettaIcon.imageset/rosetta_icon.png | Bin 0 -> 30443 bytes Rosetta/Core/Crypto/BIP39WordList.swift | 269 +++++++ Rosetta/Core/Crypto/CryptoManager.swift | 379 +++++++++ Rosetta/Core/Crypto/KeychainManager.swift | 119 +++ Rosetta/Core/Crypto/MessageCrypto.swift | 725 ++++++++++++++++++ Rosetta/Core/Data/Models/Account.swift | 40 + Rosetta/Core/Data/Models/Dialog.swift | 52 ++ .../Data/Repositories/DialogRepository.swift | 109 +++ .../Network/Protocol/Packets/Packet.swift | 74 ++ .../Protocol/Packets/PacketDelivery.swift | 20 + .../Protocol/Packets/PacketHandshake.swift | 57 ++ .../Protocol/Packets/PacketMessage.swift | 59 ++ .../Protocol/Packets/PacketOnlineState.swift | 29 + .../Network/Protocol/Packets/PacketRead.swift | 23 + .../Protocol/Packets/PacketResult.swift | 24 + .../Protocol/Packets/PacketSearch.swift | 41 + .../Network/Protocol/Packets/PacketSync.swift | 33 + .../Protocol/Packets/PacketTyping.swift | 23 + .../Protocol/Packets/PacketUserInfo.swift | 28 + .../Network/Protocol/ProtocolManager.swift | 302 ++++++++ Rosetta/Core/Network/Protocol/Stream.swift | 176 +++++ .../Network/Protocol/WebSocketClient.swift | 130 ++++ Rosetta/Core/Services/AccountManager.swift | 129 ++++ Rosetta/Core/Services/SessionManager.swift | 221 ++++++ Rosetta/DesignSystem/Colors.swift | 90 ++- .../DesignSystem/Components/AvatarView.swift | 69 ++ .../DesignSystem/Components/GlassCard.swift | 10 +- .../DesignSystem/Components/LottieView.swift | 62 +- .../Components/RosettaTabBar.swift | 234 ++++++ .../Features/Auth/ConfirmSeedPhraseView.swift | 3 +- .../Features/Auth/ImportSeedPhraseView.swift | 8 +- Rosetta/Features/Auth/SeedPhraseView.swift | 27 +- Rosetta/Features/Auth/SetPasswordView.swift | 36 +- Rosetta/Features/Auth/UnlockView.swift | 256 +++++++ .../Chats/ChatList/ChatListView.swift | 383 +++++++++ .../Chats/ChatList/ChatListViewModel.swift | 153 ++++ .../Features/Chats/ChatList/ChatRowView.swift | 220 ++++++ .../Features/Chats/Search/SearchView.swift | 340 ++++++++ .../Chats/Search/SearchViewModel.swift | 180 +++++ Rosetta/Features/MainTabView.swift | 87 +++ .../Features/Onboarding/OnboardingPager.swift | 79 ++ .../Features/Onboarding/OnboardingView.swift | 207 ++--- Rosetta/Features/Settings/SettingsView.swift | 219 ++++++ .../Features/Settings/SettingsViewModel.swift | 52 ++ Rosetta/Features/Splash/SplashView.swift | 57 ++ Rosetta/RosettaApp.swift | 89 ++- 54 files changed, 5818 insertions(+), 213 deletions(-) create mode 100644 .claude/settings.json create mode 100644 Info.plist create mode 100644 Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png create mode 100644 Rosetta/Assets.xcassets/LaunchScreenBackground.colorset/Contents.json create mode 100644 Rosetta/Assets.xcassets/RosettaIcon.imageset/Contents.json create mode 100644 Rosetta/Assets.xcassets/RosettaIcon.imageset/rosetta_icon.png create mode 100644 Rosetta/Core/Crypto/BIP39WordList.swift create mode 100644 Rosetta/Core/Crypto/CryptoManager.swift create mode 100644 Rosetta/Core/Crypto/KeychainManager.swift create mode 100644 Rosetta/Core/Crypto/MessageCrypto.swift create mode 100644 Rosetta/Core/Data/Models/Account.swift create mode 100644 Rosetta/Core/Data/Models/Dialog.swift create mode 100644 Rosetta/Core/Data/Repositories/DialogRepository.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/Packet.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketOnlineState.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketRead.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketResult.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketSearch.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketSync.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketTyping.swift create mode 100644 Rosetta/Core/Network/Protocol/Packets/PacketUserInfo.swift create mode 100644 Rosetta/Core/Network/Protocol/ProtocolManager.swift create mode 100644 Rosetta/Core/Network/Protocol/Stream.swift create mode 100644 Rosetta/Core/Network/Protocol/WebSocketClient.swift create mode 100644 Rosetta/Core/Services/AccountManager.swift create mode 100644 Rosetta/Core/Services/SessionManager.swift create mode 100644 Rosetta/DesignSystem/Components/AvatarView.swift create mode 100644 Rosetta/DesignSystem/Components/RosettaTabBar.swift create mode 100644 Rosetta/Features/Auth/UnlockView.swift create mode 100644 Rosetta/Features/Chats/ChatList/ChatListView.swift create mode 100644 Rosetta/Features/Chats/ChatList/ChatListViewModel.swift create mode 100644 Rosetta/Features/Chats/ChatList/ChatRowView.swift create mode 100644 Rosetta/Features/Chats/Search/SearchView.swift create mode 100644 Rosetta/Features/Chats/Search/SearchViewModel.swift create mode 100644 Rosetta/Features/MainTabView.swift create mode 100644 Rosetta/Features/Onboarding/OnboardingPager.swift create mode 100644 Rosetta/Features/Settings/SettingsView.swift create mode 100644 Rosetta/Features/Settings/SettingsViewModel.swift create mode 100644 Rosetta/Features/Splash/SplashView.swift diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c6c6202 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "figma": { + "type": "url", + "url": "https://mcp.figma.com/mcp", + "headers": { + "Authorization": "Bearer figd_WFn1CnWSubbZSaeSMsRW_q2WZLPam49AI1DYicwE" + } + } + } +} diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index b9aee41..a1f0dd1 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 853F29992F4B63D20092AD05 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29982F4B63D20092AD05 /* Lottie */; }; + 853F29A02F4B63D20092AD05 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 853F29A12F4B63D20092AD05 /* P256K */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,6 +29,7 @@ buildActionMask = 2147483647; files = ( 853F29992F4B63D20092AD05 /* Lottie in Frameworks */, + 853F29A02F4B63D20092AD05 /* P256K in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -71,6 +73,7 @@ name = Rosetta; packageProductDependencies = ( 853F29982F4B63D20092AD05 /* Lottie */, + 853F29A12F4B63D20092AD05 /* P256K */, ); productName = Rosetta; productReference = 853F29622F4B50410092AD05 /* Rosetta.app */; @@ -102,6 +105,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */, + 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, ); preferredProjectObjectVersion = 77; productRefGroup = 853F29632F4B50410092AD05 /* Products */; @@ -263,8 +267,10 @@ DEVELOPMENT_TEAM = U6DMAKWNV3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -295,8 +301,10 @@ DEVELOPMENT_TEAM = U6DMAKWNV3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_BackgroundColor = LaunchScreenBackground; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -349,6 +357,14 @@ minimumVersion = 4.0.0; }; }; + 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/GigaBitcoin/secp256k1.swift.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.16.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -357,6 +373,11 @@ package = 853F29972F4B63D20092AD05 /* XCRemoteSwiftPackageReference "lottie-ios" */; productName = Lottie; }; + 853F29A12F4B63D20092AD05 /* P256K */ = { + isa = XCSwiftPackageProductDependency; + package = 853F29A22F4B63D20092AD05 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; + productName = P256K; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 853F295A2F4B50410092AD05 /* Project object */; diff --git a/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json b/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..daf91f0 100644 --- a/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/Rosetta/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,15 @@ { "colors" : [ { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.902", + "green" : "0.541", + "red" : "0.141" + } + }, "idiom" : "universal" } ], diff --git a/Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Rosetta/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..5518ebc28e3c5bf9037a4ed4fb884e964950af0a GIT binary patch literal 30443 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 V^Yt+{&~$>%pV2y#ET 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 V^Yt+{&~$>%pV2y = Set(wordList) + + static func index(of word: String) -> Int? { + wordList.firstIndex(of: word) + } +} diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift new file mode 100644 index 0000000..1072741 --- /dev/null +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -0,0 +1,379 @@ +import Foundation +import CryptoKit +import CommonCrypto +import Compression +import P256K + +// MARK: - Error Types + +enum CryptoError: LocalizedError { + case invalidEntropy + case invalidChecksum + case invalidMnemonic + case invalidPrivateKey + case encryptionFailed + case decryptionFailed + case keyDerivationFailed + case compressionFailed + case invalidData(String) + + var errorDescription: String? { + switch self { + case .invalidMnemonic: + return "Invalid recovery phrase. Please check each word for typos." + case .decryptionFailed: + return "Decryption failed. The password may be incorrect." + case .invalidData(let reason): + return "Invalid data: \(reason)" + default: + return "A cryptographic operation failed." + } + } +} + +// MARK: - CryptoManager + +/// All methods are `nonisolated` — safe to call from any actor/thread. +final class CryptoManager: @unchecked Sendable { + + static let shared = CryptoManager() + private init() {} + + // MARK: - BIP39: Mnemonic Generation + + /// Generates a cryptographically secure 12-word BIP39 mnemonic. + nonisolated func generateMnemonic() throws -> [String] { + var entropy = Data(count: 16) // 128 bits + let status = entropy.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!) + } + guard status == errSecSuccess else { throw CryptoError.invalidEntropy } + return try mnemonicFromEntropy(entropy) + } + + // 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 { + guard words.count == 12 else { return false } + let normalized = words.map { $0.lowercased().trimmingCharacters(in: .whitespaces) } + guard normalized.allSatisfy({ BIP39.wordSet.contains($0) }) else { return false } + return (try? entropyFromMnemonic(normalized)) != nil + } + + // 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 { + let phrase = words.joined(separator: " ") + return pbkdf2(password: phrase, salt: "mnemonic", iterations: 2048, keyLength: 64, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA512)) + } + + // 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) { + let seed = mnemonicToSeed(mnemonic) + 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 publicKey = try deriveCompressedPublicKey(from: privateKey) + return (privateKey, publicKey) + } + + // MARK: - Account Encryption (PBKDF2-SHA1 + 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 { + let compressed = try rawDeflate(data) + let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)) + let iv = try randomBytes(count: 16) + let ciphertext = try aesCBCEncrypt(compressed, key: key, iv: iv) + 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 { + let parts = encrypted.components(separatedBy: ":") + guard parts.count == 2, + let iv = Data(base64Encoded: parts[0]), + let ciphertext = Data(base64Encoded: parts[1]) else { + throw CryptoError.invalidData("Malformed encrypted string") + } + + // Try current format: PBKDF2-SHA1 + AES-CBC + zlib inflate + if let result = try? { + let key = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1)) + let decrypted = try aesCBCDecrypt(ciphertext, key: key, iv: iv) + return try rawInflate(decrypted) + }() { + return result + } + + // Fallback: legacy iOS format (PBKDF2-SHA256, no compression) + let legacyKey = pbkdf2(password: password, salt: "rosetta", iterations: 1000, keyLength: 32, prf: CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256)) + return try aesCBCDecrypt(ciphertext, key: legacyKey, iv: iv) + } + + // MARK: - Utilities + + nonisolated func sha256(_ data: 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 { + let combined = Data((privateKeyHex + "rosetta").utf8) + return sha256(combined).hexString + } +} + +// MARK: - BIP39 Internal + +private extension CryptoManager { + + func mnemonicFromEntropy(_ entropy: Data) throws -> [String] { + guard entropy.count == 16 else { throw CryptoError.invalidEntropy } + + let hashBytes = Data(SHA256.hash(data: entropy)) + let checksumByte = hashBytes[0] + + // Build bit array: 128 entropy bits + 4 checksum bits = 132 bits + var bits = [Bool]() + bits.reserveCapacity(132) + for byte in entropy { + for shift in stride(from: 7, through: 0, by: -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) { + bits.append((checksumByte >> shift) & 1 == 1) + } + + // Split into 12 × 11-bit groups, map to words + return try (0..<12).map { chunk in + let index = (0..<11).reduce(0) { acc, bit in + acc * 2 + (bits[chunk * 11 + bit] ? 1 : 0) + } + guard index < BIP39.wordList.count else { throw CryptoError.invalidEntropy } + return BIP39.wordList[index] + } + } + + func entropyFromMnemonic(_ words: [String]) throws -> Data { + guard words.count == 12 else { throw CryptoError.invalidMnemonic } + + // Convert 12 × 11-bit word indices into a 132-bit array + var bits = [Bool]() + bits.reserveCapacity(132) + for word in words { + guard let index = BIP39.index(of: word) else { throw CryptoError.invalidMnemonic } + for shift in stride(from: 10, through: 0, by: -1) { + bits.append((index >> shift) & 1 == 1) + } + } + + // First 128 bits = entropy, last 4 bits = checksum + var entropy = Data(count: 16) + for byteIdx in 0..<16 { + let value: UInt8 = (0..<8).reduce(0) { acc, bit in + acc * 2 + (bits[byteIdx * 8 + bit] ? 1 : 0) + } + entropy[byteIdx] = value + } + + // Verify checksum + let hashBytes = Data(SHA256.hash(data: entropy)) + let expectedTopNibble = hashBytes[0] >> 4 + let actualTopNibble: UInt8 = (0..<4).reduce(0) { acc, bit in + bits[128 + bit] ? acc | (1 << (3 - bit)) : acc + } + guard actualTopNibble == expectedTopNibble else { throw CryptoError.invalidChecksum } + + return entropy + } +} + +// MARK: - secp256k1 + +extension CryptoManager { + + /// Computes the 33-byte compressed secp256k1 public key from a 32-byte private key. + nonisolated func deriveCompressedPublicKey(from privateKey: Data) throws -> Data { + 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) + // .compressed format → dataRepresentation returns 33-byte compressed public key + 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() + } +} diff --git a/Rosetta/Core/Crypto/KeychainManager.swift b/Rosetta/Core/Crypto/KeychainManager.swift new file mode 100644 index 0000000..ee8468b --- /dev/null +++ b/Rosetta/Core/Crypto/KeychainManager.swift @@ -0,0 +1,119 @@ +import Foundation +import Security + +// MARK: - KeychainError + +enum KeychainError: LocalizedError { + case saveFailed(OSStatus) + case loadFailed(OSStatus) + case deleteFailed(OSStatus) + case notFound + case unexpectedData + + var errorDescription: String? { + switch self { + case .saveFailed(let status): return "Keychain save failed (OSStatus \(status))" + case .loadFailed(let status): return "Keychain load failed (OSStatus \(status))" + case .deleteFailed(let status): return "Keychain delete failed (OSStatus \(status))" + case .notFound: return "Item not found in Keychain" + case .unexpectedData: return "Unexpected data format in Keychain" + } + } +} + +// MARK: - KeychainManager + +/// Thread-safe iOS Keychain wrapper. +/// Stores data under `com.rosetta.messenger.`. +final class KeychainManager: @unchecked Sendable { + + static let shared = KeychainManager() + private init() {} + + private let service = "com.rosetta.messenger" + + // MARK: - CRUD + + func save(_ data: Data, forKey key: String) throws { + let query = baseQuery(forKey: key).merging([ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ]) { $1 } + + // Delete existing entry first (update not atomic without SecItemUpdate) + SecItemDelete(query as CFDictionary) + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { throw KeychainError.saveFailed(status) } + } + + func load(forKey key: String) throws -> Data { + let query = baseQuery(forKey: key).merging([ + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ]) { $1 } + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data else { throw KeychainError.unexpectedData } + return data + case errSecItemNotFound: + throw KeychainError.notFound + default: + throw KeychainError.loadFailed(status) + } + } + + func delete(forKey key: String) throws { + let query = baseQuery(forKey: key) + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainError.deleteFailed(status) + } + } + + func contains(key: String) -> Bool { + (try? load(forKey: key)) != nil + } + + // MARK: - Private + + private func baseQuery(forKey key: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + } +} + +// MARK: - Convenience + +extension KeychainManager { + + func saveString(_ string: String, forKey key: String) throws { + guard let data = string.data(using: .utf8) else { return } + try save(data, forKey: key) + } + + func loadString(forKey key: String) throws -> String { + let data = try load(forKey: key) + guard let string = String(data: data, encoding: .utf8) else { + throw KeychainError.unexpectedData + } + return string + } + + func saveCodable(_ value: T, forKey key: String) throws { + let data = try JSONEncoder().encode(value) + try save(data, forKey: key) + } + + func loadCodable(_ type: T.Type, forKey key: String) throws -> T { + let data = try load(forKey: key) + return try JSONDecoder().decode(type, from: data) + } +} diff --git a/Rosetta/Core/Crypto/MessageCrypto.swift b/Rosetta/Core/Crypto/MessageCrypto.swift new file mode 100644 index 0000000..785f60a --- /dev/null +++ b/Rosetta/Core/Crypto/MessageCrypto.swift @@ -0,0 +1,725 @@ +import Foundation +import CommonCrypto +import P256K + +// MARK: - MessageCrypto + +/// Handles message-level encryption/decryption using XChaCha20-Poly1305 + ECDH. +/// Matches the Android `MessageCrypto` implementation for cross-platform compatibility. +enum MessageCrypto { + + // MARK: - Public API + + /// Decrypts an incoming message using ECDH key exchange + XChaCha20-Poly1305. + /// - Parameters: + /// - ciphertext: Hex-encoded XChaCha20-Poly1305 encrypted content (ciphertext + 16-byte tag). + /// - encryptedKey: Base64-encoded ECDH-encrypted key+nonce (`iv:encryptedKey:ephemeralPrivateKey`). + /// - myPrivateKeyHex: Recipient's secp256k1 private key (hex). + /// - Returns: Decrypted plaintext string. + static func decryptIncoming( + ciphertext: String, + encryptedKey: String, + myPrivateKeyHex: String + ) throws -> String { + // Step 1: ECDH decrypt the XChaCha20 key+nonce + let keyAndNonce = try decryptKeyFromSender(encryptedKey: encryptedKey, myPrivateKeyHex: myPrivateKeyHex) + + guard keyAndNonce.count >= 56 else { + throw CryptoError.invalidData("Key+nonce must be 56 bytes, got \(keyAndNonce.count)") + } + + let key = keyAndNonce[0..<32] // 32-byte XChaCha20 key + let nonce = keyAndNonce[32..<56] // 24-byte XChaCha20 nonce + + // Step 2: XChaCha20-Poly1305 decrypt + let ciphertextData = Data(hexString: ciphertext) + let plaintext = try xchacha20Poly1305Decrypt( + ciphertextWithTag: ciphertextData, + key: Data(key), + nonce: Data(nonce) + ) + + guard let text = String(data: plaintext, encoding: .utf8) else { + throw CryptoError.invalidData("Decrypted data is not valid UTF-8") + } + + return text + } + + /// Encrypts a message using XChaCha20-Poly1305 + ECDH for the recipient. + /// - Parameters: + /// - plaintext: The message text. + /// - recipientPublicKeyHex: Recipient's secp256k1 compressed public key (hex). + /// - senderPrivateKeyHex: Sender's private key (hex) — used to also encrypt for self. + /// - Returns: Tuple of (content: hex ciphertext+tag, chachaKey: base64 encrypted key for recipient, aesChachaKey: base64 encrypted key for sender). + static func encryptOutgoing( + plaintext: String, + recipientPublicKeyHex: String, + senderPrivateKeyHex: String + ) throws -> (content: String, chachaKey: String, aesChachaKey: String) { + guard let plaintextData = plaintext.data(using: .utf8) else { + throw CryptoError.invalidData("Cannot encode plaintext as UTF-8") + } + + // Generate random 32-byte key + 24-byte nonce + let key = try randomBytes(count: 32) + let nonce = try randomBytes(count: 24) + let keyAndNonce = key + nonce + + // XChaCha20-Poly1305 encrypt + let ciphertextWithTag = try xchacha20Poly1305Encrypt( + plaintext: plaintextData, key: key, nonce: nonce + ) + + // Encrypt key+nonce for recipient via ECDH + let chachaKey = try encryptKeyForRecipient( + keyAndNonce: keyAndNonce, + recipientPublicKeyHex: recipientPublicKeyHex + ) + + // Encrypt key+nonce for sender (self) via ECDH with sender's own public key + let senderPrivKey = try P256K.Signing.PrivateKey( + dataRepresentation: Data(hexString: senderPrivateKeyHex), + format: .compressed + ) + let senderPublicKeyHex = senderPrivKey.publicKey.dataRepresentation.hexString + + let aesChachaKey = try encryptKeyForRecipient( + keyAndNonce: keyAndNonce, + recipientPublicKeyHex: senderPublicKeyHex + ) + + return ( + content: ciphertextWithTag.hexString, + chachaKey: chachaKey, + aesChachaKey: aesChachaKey + ) + } +} + +// MARK: - ECDH Key Exchange + +private extension MessageCrypto { + + /// Decrypts the XChaCha20 key+nonce from the sender using ECDH. + /// Format: Base64(ivHex:encryptedKeyHex:ephemeralPrivateKeyHex) + static func decryptKeyFromSender(encryptedKey: String, myPrivateKeyHex: String) throws -> Data { + guard let decoded = Data(base64Encoded: encryptedKey), + let combined = String(data: decoded, encoding: .utf8) else { + throw CryptoError.invalidData("Cannot decode Base64 key") + } + + let parts = combined.split(separator: ":", maxSplits: 2).map(String.init) + guard parts.count == 3 else { + throw CryptoError.invalidData("Expected iv:encrypted:ephemeralKey, got \(parts.count) parts") + } + + let ivHex = parts[0] + let encryptedKeyHex = parts[1] + var ephemeralPrivateKeyHex = parts[2] + + // Handle odd-length hex from JS toString(16) + if ephemeralPrivateKeyHex.count % 2 != 0 { + ephemeralPrivateKeyHex = "0" + ephemeralPrivateKeyHex + } + + let iv = Data(hexString: ivHex) + 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( + dataRepresentation: ephemeralPrivKeyData, format: .compressed + ) + let myPrivKey = try P256K.KeyAgreement.PrivateKey( + dataRepresentation: myPrivKeyData, format: .compressed + ) + let myPublicKey = myPrivKey.publicKey + + // 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(with: myPublicKey, format: .compressed) + let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + + // 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 + let decryptedBytes = try aesCBCDecrypt(encryptedKeyData, key: sharedKey, iv: iv) + + // 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 { + throw CryptoError.invalidData("Decrypted key is not valid UTF-8") + } + let originalBytes = Data(utf8String.unicodeScalars.map { UInt8(truncatingIfNeeded: $0.value) }) + + return originalBytes + } + + /// Encrypts the XChaCha20 key+nonce for a recipient using ECDH. + static func encryptKeyForRecipient(keyAndNonce: Data, recipientPublicKeyHex: String) throws -> String { + // Generate ephemeral key pair + let ephemeralPrivKey = try P256K.KeyAgreement.PrivateKey() + + // Parse recipient public key + let recipientPubKey = try P256K.KeyAgreement.PublicKey( + dataRepresentation: Data(hexString: recipientPublicKeyHex), format: .compressed + ) + + // ECDH: ephemeralPrivKey × recipientPubKey → shared secret + let sharedSecret = try ephemeralPrivKey.sharedSecretFromKeyAgreement(with: recipientPubKey, format: .compressed) + let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } + + // 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) + let utf8Representation = String(keyAndNonce.map { Character(UnicodeScalar($0)) }) + guard let dataToEncrypt = utf8Representation.data(using: .utf8) else { + throw CryptoError.encryptionFailed + } + + // AES-256-CBC encrypt + let iv = try randomBytes(count: 16) + let ciphertext = try aesCBCEncrypt(dataToEncrypt, key: sharedKey, iv: iv) + + // Get ephemeral private key hex + let ephemeralPrivKeyHex = ephemeralPrivKey.rawRepresentation.hexString + + // Format: Base64(ivHex:ciphertextHex:ephemeralPrivateKeyHex) + let combined = "\(iv.hexString):\(ciphertext.hexString):\(ephemeralPrivKeyHex)" + guard let base64 = combined.data(using: .utf8)?.base64EncodedString() else { + throw CryptoError.encryptionFailed + } + + return base64 + } +} + +// 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.. 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.. [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..> 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.. 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.. $1.lastMessageTimestamp + } + } + + private init() {} + + // MARK: - Updates + + func upsertDialog(_ dialog: Dialog) { + dialogs[dialog.opponentKey] = dialog + } + + /// Creates or updates a dialog from an incoming message packet. + func updateFromMessage(_ packet: PacketMessage, myPublicKey: String, decryptedText: String) { + let fromMe = packet.fromPublicKey == myPublicKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + + var dialog = dialogs[opponentKey] ?? Dialog( + id: opponentKey, + account: myPublicKey, + opponentKey: opponentKey, + opponentTitle: "", + opponentUsername: "", + lastMessage: "", + lastMessageTimestamp: 0, + unreadCount: 0, + isOnline: false, + lastSeen: 0, + isVerified: false, + iHaveSent: false, + isPinned: false, + isMuted: false, + lastMessageFromMe: false, + lastMessageDelivered: .waiting + ) + + dialog.lastMessage = decryptedText + dialog.lastMessageTimestamp = Int64(packet.timestamp) + dialog.lastMessageFromMe = fromMe + dialog.lastMessageDelivered = fromMe ? .waiting : .delivered + + if fromMe { + dialog.iHaveSent = true + } else { + dialog.unreadCount += 1 + } + + dialogs[opponentKey] = dialog + } + + func updateOnlineState(publicKey: String, isOnline: Bool) { + guard var dialog = dialogs[publicKey] else { return } + dialog.isOnline = isOnline + if !isOnline { + dialog.lastSeen = Int64(Date().timeIntervalSince1970 * 1000) + } + dialogs[publicKey] = dialog + } + + func updateDeliveryStatus(messageId: String, opponentKey: String, status: DeliveryStatus) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.lastMessageDelivered = status + dialogs[opponentKey] = dialog + } + + func updateUserInfo(publicKey: String, title: String, username: String) { + guard var dialog = dialogs[publicKey] else { return } + if !title.isEmpty { dialog.opponentTitle = title } + if !username.isEmpty { dialog.opponentUsername = username } + dialogs[publicKey] = dialog + } + + func markAsRead(opponentKey: String) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.unreadCount = 0 + dialogs[opponentKey] = dialog + } + + func deleteDialog(opponentKey: String) { + dialogs.removeValue(forKey: opponentKey) + } + + func togglePin(opponentKey: String) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.isPinned.toggle() + dialogs[opponentKey] = dialog + } + + func toggleMute(opponentKey: String) { + guard var dialog = dialogs[opponentKey] else { return } + dialog.isMuted.toggle() + dialogs[opponentKey] = dialog + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/Packet.swift b/Rosetta/Core/Network/Protocol/Packets/Packet.swift new file mode 100644 index 0000000..2de9f7a --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/Packet.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Base protocol for all Rosetta binary packets. +protocol Packet { + static var packetId: Int { get } + func write(to stream: Stream) + mutating func read(from stream: Stream) +} + +// MARK: - Packet Registry + +enum PacketRegistry { + + /// All known packet factories, keyed by packet ID. + private static let factories: [Int: () -> any Packet] = [ + 0x00: { PacketHandshake() }, + 0x01: { PacketUserInfo() }, + 0x02: { PacketResult() }, + 0x03: { PacketSearch() }, + 0x05: { PacketOnlineState() }, + 0x06: { PacketMessage() }, + 0x07: { PacketRead() }, + 0x08: { PacketDelivery() }, + 0x0B: { PacketTyping() }, + 0x19: { PacketSync() }, + ] + + /// Deserializes a packet from raw binary data. + static func decode(from data: Data) -> (packetId: Int, packet: any Packet)? { + let stream = Stream(data: data) + let packetId = stream.readInt16() + + guard let factory = factories[packetId] else { + return nil + } + + var packet = factory() + packet.read(from: stream) + return (packetId, packet) + } + + /// Serializes a packet to raw binary data (including the 2-byte packet ID header). + static func encode(_ packet: any Packet) -> Data { + let stream = Stream() + stream.writeInt16(type(of: packet).packetId) + packet.write(to: stream) + return stream.toData() + } +} + +// MARK: - Attachment Types + +enum AttachmentType: Int, Codable { + case image = 0 + case messages = 1 + case file = 2 +} + +struct MessageAttachment: Codable { + var id: String = "" + var preview: String = "" + var blob: String = "" + var type: AttachmentType = .image +} + +// MARK: - Search User + +struct SearchUser { + var username: String = "" + var title: String = "" + var publicKey: String = "" + var verified: Int = 0 + var online: Int = 0 +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift b/Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift new file mode 100644 index 0000000..cdde60b --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketDelivery.swift @@ -0,0 +1,20 @@ +import Foundation + +/// Delivery packet (0x08) — delivery confirmation from server. +/// Field order matches TypeScript server: toPublicKey, messageId. +struct PacketDelivery: Packet { + static let packetId = 0x08 + + var toPublicKey: String = "" + var messageId: String = "" + + func write(to stream: Stream) { + stream.writeString(toPublicKey) + stream.writeString(messageId) + } + + mutating func read(from stream: Stream) { + toPublicKey = stream.readString() + messageId = stream.readString() + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift b/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift new file mode 100644 index 0000000..5834bb2 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketHandshake.swift @@ -0,0 +1,57 @@ +import Foundation + +// MARK: - HandshakeState + +enum HandshakeState: Int { + case completed = 0 + case needDeviceVerification = 1 + + init(value: Int) { + self = HandshakeState(rawValue: value) ?? .completed + } +} + +// MARK: - HandshakeDevice + +struct HandshakeDevice { + var deviceId: String = "" + var deviceName: String = "" + var deviceOs: String = "" +} + +// MARK: - PacketHandshake (0x00) + +struct PacketHandshake: Packet { + static let packetId = 0x00 + + var privateKey: String = "" // SHA256(privateKey + "rosetta") + var publicKey: String = "" // Compressed secp256k1 public key (hex) + var protocolVersion: Int = 1 + var heartbeatInterval: Int = 15 + var device = HandshakeDevice() + var handshakeState: HandshakeState = .needDeviceVerification + + func write(to stream: Stream) { + stream.writeString(privateKey) + stream.writeString(publicKey) + stream.writeInt8(protocolVersion) + stream.writeInt8(heartbeatInterval) + stream.writeString(device.deviceId) + stream.writeString(device.deviceName) + stream.writeString(device.deviceOs) + stream.writeInt8(handshakeState.rawValue) + } + + mutating func read(from stream: Stream) { + privateKey = stream.readString() + publicKey = stream.readString() + protocolVersion = stream.readInt8() + heartbeatInterval = stream.readInt8() + device = HandshakeDevice( + deviceId: stream.readString(), + deviceName: stream.readString(), + deviceOs: stream.readString() + ) + handshakeState = HandshakeState(value: stream.readInt8()) + } +} diff --git a/Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift b/Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift new file mode 100644 index 0000000..2094327 --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Packets/PacketMessage.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Message packet (0x06) — sending and receiving encrypted messages. +struct PacketMessage: Packet { + static let packetId = 0x06 + + var fromPublicKey: String = "" + var toPublicKey: String = "" + var content: String = "" // XChaCha20-Poly1305 encrypted (hex) + var chachaKey: String = "" // ECDH-encrypted key+nonce + var timestamp: Int32 = 0 + var privateKey: String = "" // Hash for server auth + var messageId: String = "" + var attachments: [MessageAttachment] = [] + var aesChachaKey: String = "" // ChaCha key+nonce encrypted by sender + + func write(to stream: Stream) { + // Match Android field order exactly + stream.writeString(fromPublicKey) + stream.writeString(toPublicKey) + stream.writeString(content) + stream.writeString(chachaKey) + stream.writeInt32(Int(timestamp)) + stream.writeString(privateKey) + stream.writeString(messageId) + stream.writeInt8(attachments.count) + + for attachment in attachments { + stream.writeString(attachment.id) + stream.writeString(attachment.preview) + stream.writeString(attachment.blob) + stream.writeInt8(attachment.type.rawValue) + } + // No aesChachaKey — Android doesn't send it + } + + mutating func read(from stream: Stream) { + fromPublicKey = stream.readString() + toPublicKey = stream.readString() + content = stream.readString() + chachaKey = stream.readString() + timestamp = Int32(stream.readInt32()) + privateKey = stream.readString() + messageId = stream.readString() + + let attachmentCount = stream.readInt8() + var list: [MessageAttachment] = [] + for _ in 0.. Void)? + var onDeliveryReceived: ((PacketDelivery) -> Void)? + var onReadReceived: ((PacketRead) -> Void)? + var onOnlineStateReceived: ((PacketOnlineState) -> Void)? + var onUserInfoReceived: ((PacketUserInfo) -> Void)? + var onSearchResult: ((PacketSearch) -> Void)? + var onTypingReceived: ((PacketTyping) -> Void)? + var onSyncReceived: ((PacketSync) -> Void)? + var onHandshakeCompleted: ((PacketHandshake) -> Void)? + + // MARK: - Private + + private let client = WebSocketClient() + private var packetQueue: [any Packet] = [] + private var handshakeComplete = false + private var heartbeatTask: Task? + private var handshakeTimeoutTask: Task? + + // Saved credentials for auto-reconnect + private var savedPublicKey: String? + private var savedPrivateHash: String? + + var publicKey: String? { savedPublicKey } + var privateHash: String? { savedPrivateHash } + + private init() { + setupClientCallbacks() + } + + // MARK: - Connection + + /// Connect to server and perform handshake. + func connect(publicKey: String, privateKeyHash: String) { + savedPublicKey = publicKey + savedPrivateHash = privateKeyHash + + if connectionState == .authenticated || connectionState == .handshaking { + Self.logger.info("Already connected/handshaking, skipping") + return + } + + connectionState = .connecting + client.connect() + } + + func disconnect() { + Self.logger.info("Disconnecting") + heartbeatTask?.cancel() + handshakeTimeoutTask?.cancel() + handshakeComplete = false + client.disconnect() + connectionState = .disconnected + savedPublicKey = nil + savedPrivateHash = nil + } + + // MARK: - Sending + + func sendPacket(_ packet: any Packet) { + if !handshakeComplete && !(packet is PacketHandshake) { + Self.logger.info("Queueing packet \(type(of: packet).packetId)") + packetQueue.append(packet) + return + } + sendPacketDirect(packet) + } + + // MARK: - Private Setup + + private func setupClientCallbacks() { + client.onConnected = { [weak self] in + guard let self else { return } + Self.logger.info("WebSocket connected") + + Task { @MainActor in + self.connectionState = .connected + } + + // Auto-handshake with saved credentials + if let pk = savedPublicKey, let hash = savedPrivateHash { + startHandshake(publicKey: pk, privateHash: hash) + } + } + + client.onDisconnected = { [weak self] error in + guard let self else { return } + if let error { + Self.logger.error("Disconnected: \(error.localizedDescription)") + } + heartbeatTask?.cancel() + handshakeComplete = false + + Task { @MainActor in + self.connectionState = .disconnected + } + } + + client.onDataReceived = { [weak self] data in + self?.handleIncomingData(data) + } + } + + // MARK: - Handshake + + private func startHandshake(publicKey: String, privateHash: String) { + Self.logger.info("Starting handshake for \(publicKey.prefix(20))...") + + Task { @MainActor in + connectionState = .handshaking + } + + let device = HandshakeDevice( + deviceId: UIDevice.current.identifierForVendor?.uuidString ?? "unknown", + deviceName: UIDevice.current.name, + deviceOs: "iOS \(UIDevice.current.systemVersion)" + ) + + let handshake = PacketHandshake( + privateKey: privateHash, + publicKey: publicKey, + protocolVersion: 1, + heartbeatInterval: 15, + device: device, + handshakeState: .needDeviceVerification + ) + + sendPacketDirect(handshake) + + // Timeout + handshakeTimeoutTask?.cancel() + handshakeTimeoutTask = Task { [weak self] in + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + } catch { + return + } + guard let self, !Task.isCancelled else { return } + if !self.handshakeComplete { + Self.logger.error("Handshake timeout") + self.client.disconnect() + } + } + } + + // MARK: - Packet Handling + + 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: " "))") + + guard let (packetId, packet) = PacketRegistry.decode(from: data) else { + // Try to read the packet ID manually to see what it is + if data.count >= 2 { + let stream = Stream(data: data) + let rawId = stream.readInt16() + print("[Protocol] Unknown packet ID: 0x\(String(rawId, radix: 16)) (\(rawId)), data size: \(data.count)") + } else { + print("[Protocol] Packet too small: \(data.count) bytes") + } + return + } + + print("[Protocol] Received packet 0x\(String(packetId, radix: 16)) (\(type(of: packet)))") + + switch packetId { + case 0x00: + if let p = packet as? PacketHandshake { + handleHandshakeResponse(p) + } + case 0x01: + if let p = packet as? PacketUserInfo { + print("[Protocol] UserInfo received: username='\(p.username)', title='\(p.title)'") + onUserInfoReceived?(p) + } + case 0x02: + if let p = packet as? PacketResult { + let code = ResultCode(rawValue: p.resultCode) + print("[Protocol] Result received: code=\(p.resultCode) (\(code.map { "\($0)" } ?? "unknown"))") + } + case 0x03: + if let p = packet as? PacketSearch { + print("[Protocol] Search result received: \(p.users.count) users") + onSearchResult?(p) + } + case 0x05: + if let p = packet as? PacketOnlineState { + onOnlineStateReceived?(p) + } + case 0x06: + if let p = packet as? PacketMessage { + onMessageReceived?(p) + } + case 0x07: + if let p = packet as? PacketRead { + onReadReceived?(p) + } + case 0x08: + if let p = packet as? PacketDelivery { + onDeliveryReceived?(p) + } + case 0x0B: + if let p = packet as? PacketTyping { + onTypingReceived?(p) + } + case 0x19: + if let p = packet as? PacketSync { + onSyncReceived?(p) + } + default: + break + } + } + + private func handleHandshakeResponse(_ packet: PacketHandshake) { + // Set handshakeComplete BEFORE cancelling timeout to prevent race + handshakeComplete = true + handshakeTimeoutTask?.cancel() + handshakeTimeoutTask = nil + + switch packet.handshakeState { + case .completed: + Self.logger.info("Handshake completed. Protocol v\(packet.protocolVersion), heartbeat \(packet.heartbeatInterval)s") + + Task { @MainActor in + self.connectionState = .authenticated + } + + flushPacketQueue() + startHeartbeat(interval: packet.heartbeatInterval) + onHandshakeCompleted?(packet) + + case .needDeviceVerification: + Self.logger.info("Server requires device verification") + startHeartbeat(interval: packet.heartbeatInterval) + } + } + + // MARK: - Heartbeat + + private func startHeartbeat(interval: Int) { + heartbeatTask?.cancel() + let intervalMs = UInt64(interval) * 1_000_000_000 / 3 + + heartbeatTask = Task { + // Send first heartbeat immediately + client.sendText("heartbeat") + + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: intervalMs) + guard !Task.isCancelled else { break } + client.sendText("heartbeat") + } + } + } + + // MARK: - Packet Queue + + private func sendPacketDirect(_ packet: any Packet) { + let data = PacketRegistry.encode(packet) + Self.logger.info("Sending packet 0x\(String(type(of: packet).packetId, radix: 16)) (\(data.count) bytes)") + client.send(data) + } + + private func flushPacketQueue() { + Self.logger.info("Flushing \(self.packetQueue.count) queued packets") + let packets = packetQueue + packetQueue.removeAll() + for packet in packets { + sendPacketDirect(packet) + } + } +} diff --git a/Rosetta/Core/Network/Protocol/Stream.swift b/Rosetta/Core/Network/Protocol/Stream.swift new file mode 100644 index 0000000..a9c78de --- /dev/null +++ b/Rosetta/Core/Network/Protocol/Stream.swift @@ -0,0 +1,176 @@ +import Foundation + +/// Bit-aligned binary stream for protocol packets. +/// Matches the React Native / Android implementation exactly. +final class Stream: @unchecked Sendable { + + private var bytes: [Int] + private var readPointer: Int = 0 + private var writePointer: Int = 0 + + // MARK: - Init + + init() { + bytes = [] + } + + init(data: Data) { + bytes = data.map { Int($0) & 0xFF } + } + + // MARK: - Output + + func toData() -> Data { + Data(bytes.map { UInt8($0 & 0xFF) }) + } + + // MARK: - Bit-Level I/O + + func writeBit(_ value: Int) { + let bit = value & 1 + let byteIndex = writePointer >> 3 + ensureCapacity(byteIndex) + bytes[byteIndex] = bytes[byteIndex] | (bit << (7 - (writePointer & 7))) + writePointer += 1 + } + + func readBit() -> Int { + let byteIndex = readPointer >> 3 + let bit = (bytes[byteIndex] >> (7 - (readPointer & 7))) & 1 + readPointer += 1 + return bit + } + + // MARK: - Bool + + func writeBoolean(_ value: Bool) { + writeBit(value ? 1 : 0) + } + + func readBoolean() -> Bool { + readBit() == 1 + } + + // MARK: - Int8 (9 bits: 1 sign + 8 data) + + func writeInt8(_ value: Int) { + let negationBit = value < 0 ? 1 : 0 + let int8Value = abs(value) & 0xFF + + let byteIndex = writePointer >> 3 + ensureCapacity(byteIndex) + bytes[byteIndex] = bytes[byteIndex] | (negationBit << (7 - (writePointer & 7))) + writePointer += 1 + + for i in 0..<8 { + let bit = (int8Value >> (7 - i)) & 1 + let idx = writePointer >> 3 + ensureCapacity(idx) + bytes[idx] = bytes[idx] | (bit << (7 - (writePointer & 7))) + writePointer += 1 + } + } + + func readInt8() -> Int { + var value = 0 + let negationBit = (bytes[readPointer >> 3] >> (7 - (readPointer & 7))) & 1 + readPointer += 1 + + for i in 0..<8 { + let bit = (bytes[readPointer >> 3] >> (7 - (readPointer & 7))) & 1 + value = value | (bit << (7 - i)) + readPointer += 1 + } + + return negationBit == 1 ? -value : value + } + + // MARK: - Int16 (2 × Int8) + + func writeInt16(_ value: Int) { + writeInt8(value >> 8) + writeInt8(value & 0xFF) + } + + func readInt16() -> Int { + let high = readInt8() << 8 + return high | readInt8() + } + + // MARK: - Int32 (2 × Int16) + + func writeInt32(_ value: Int) { + writeInt16(value >> 16) + writeInt16(value & 0xFFFF) + } + + func readInt32() -> Int { + let high = readInt16() << 16 + return high | readInt16() + } + + // MARK: - Int64 (2 × Int32) + + func writeInt64(_ value: Int64) { + let high = Int((value >> 32) & 0xFFFFFFFF) + let low = Int(value & 0xFFFFFFFF) + writeInt32(high) + writeInt32(low) + } + + func readInt64() -> Int64 { + let high = Int64(readInt32()) + let low = Int64(readInt32()) & 0xFFFFFFFF + return (high << 32) | low + } + + // MARK: - String (Int32 length + UTF-16 code units) + + func writeString(_ value: String) { + writeInt32(value.count) + for char in value { + for scalar in char.utf16 { + writeInt16(Int(scalar)) + } + } + } + + func readString() -> String { + let length = readInt32() + var result = "" + result.reserveCapacity(length) + for _ in 0.. Data { + let length = readInt32() + var result = Data(capacity: length) + for _ in 0..? + private var hasNotifiedConnected = false + + var onConnected: (() -> Void)? + var onDisconnected: ((Error?) -> Void)? + var onDataReceived: ((Data) -> Void)? + + override init() { + super.init() + let config = URLSessionConfiguration.default + config.waitsForConnectivity = true + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) + } + + // MARK: - Connection + + func connect() { + guard webSocketTask == nil else { return } + isManuallyClosed = false + hasNotifiedConnected = false + + Self.logger.info("Connecting to \(self.url.absoluteString)") + + let task = session.webSocketTask(with: url) + webSocketTask = task + task.resume() + + receiveLoop() + } + + func disconnect() { + Self.logger.info("Manual disconnect") + isManuallyClosed = true + reconnectTask?.cancel() + reconnectTask = nil + webSocketTask?.cancel(with: .goingAway, reason: nil) + webSocketTask = nil + } + + func send(_ data: Data) { + guard let task = webSocketTask else { + Self.logger.warning("Cannot send: no active connection") + return + } + task.send(.data(data)) { error in + if let error { + Self.logger.error("Send error: \(error.localizedDescription)") + } + } + } + + func sendText(_ text: String) { + guard let task = webSocketTask else { return } + task.send(.string(text)) { error in + if let error { + Self.logger.error("Send text error: \(error.localizedDescription)") + } + } + } + + // MARK: - URLSessionWebSocketDelegate + + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { + Self.logger.info("WebSocket didOpen") + guard !isManuallyClosed else { return } + hasNotifiedConnected = true + onConnected?() + } + + nonisolated func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + Self.logger.info("WebSocket didClose: \(closeCode.rawValue)") + handleDisconnect(error: nil) + } + + // MARK: - Receive Loop + + private func receiveLoop() { + guard let task = webSocketTask else { return } + + task.receive { [weak self] result in + guard let self, !isManuallyClosed else { return } + + switch result { + case .success(let message): + switch message { + case .data(let data): + self.onDataReceived?(data) + case .string(let text): + Self.logger.debug("Received text: \(text)") + @unknown default: + break + } + self.receiveLoop() + + case .failure(let error): + Self.logger.error("Receive error: \(error.localizedDescription)") + self.handleDisconnect(error: error) + } + } + } + + // MARK: - Reconnection + + private func handleDisconnect(error: Error?) { + webSocketTask = nil + onDisconnected?(error) + + guard !isManuallyClosed else { return } + + reconnectTask?.cancel() + reconnectTask = Task { [weak self] in + Self.logger.info("Reconnecting in 5 seconds...") + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, !isManuallyClosed, !Task.isCancelled else { return } + self.connect() + } + } +} diff --git a/Rosetta/Core/Services/AccountManager.swift b/Rosetta/Core/Services/AccountManager.swift new file mode 100644 index 0000000..efe7f59 --- /dev/null +++ b/Rosetta/Core/Services/AccountManager.swift @@ -0,0 +1,129 @@ +import Foundation +import Observation +import os + +// MARK: - AccountManager + +/// Manages the current user account: creation, import, persistence, and retrieval. +/// Persists encrypted account data to the iOS Keychain. +@Observable +@MainActor +final class AccountManager { + + static let shared = AccountManager() + + private(set) var currentAccount: Account? + + private let crypto = CryptoManager.shared + private let keychain = KeychainManager.shared + + private init() { + currentAccount = loadCachedAccount() + } + + // MARK: - Account Creation + + /// Creates a new account from a BIP39 mnemonic and password. + /// Derives secp256k1 key pair, encrypts private key and seed phrase, saves to Keychain. + func createAccount(seedPhrase: [String], password: String) async throws -> Account { + let account = try await Task.detached(priority: .userInitiated) { [crypto] in + let (privateKey, publicKey) = try crypto.deriveKeyPair(from: seedPhrase) + + let privateKeyEncrypted = try crypto.encryptWithPassword(privateKey, password: password) + let seedEncrypted = try crypto.encryptWithPassword( + Data(seedPhrase.joined(separator: " ").utf8), + password: password + ) + + return Account( + publicKey: publicKey.hexString, + privateKeyEncrypted: privateKeyEncrypted, + seedPhraseEncrypted: seedEncrypted + ) + }.value + + try saveAccount(account) + currentAccount = account + return account + } + + // MARK: - Account Import + + /// Imports an existing account from a BIP39 mnemonic and new password. + func importAccount(seedPhrase: [String], password: String) async throws -> Account { + // Import uses the same derivation as create — just different UI flow + try await createAccount(seedPhrase: seedPhrase, password: password) + } + + // MARK: - Account Unlock + + /// Decrypts and verifies the stored account with the given password. + /// Returns `true` if the password is correct. + func unlock(password: String) async throws -> Bool { + guard let account = currentAccount else { return false } + _ = try await Task.detached(priority: .userInitiated) { [crypto] in + try crypto.decryptWithPassword(account.privateKeyEncrypted, password: password) + }.value + return true + } + + /// Decrypts the private key and returns the raw hex string. + /// Used to derive the handshake hash for server authentication. + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "AccountManager") + + func decryptPrivateKey(password: String) async throws -> String { + guard let account = currentAccount else { + Self.logger.error("No account found for decryption") + throw CryptoError.decryptionFailed + } + let expectedPublicKey = account.publicKey + Self.logger.info("Attempting to decrypt private key, expected pubkey: \(expectedPublicKey.prefix(20))...") + let privateKeyData = try await Task.detached(priority: .userInitiated) { [crypto] in + let data = try crypto.decryptWithPassword(account.privateKeyEncrypted, password: password) + Self.logger.info("Decrypted data length: \(data.count) bytes") + guard data.count == 32 else { + Self.logger.error("Wrong password: decrypted data is \(data.count) bytes, expected 32") + throw CryptoError.decryptionFailed + } + let derivedPublicKey = try crypto.deriveCompressedPublicKey(from: data) + Self.logger.info("Derived pubkey: \(derivedPublicKey.hexString.prefix(20))...") + guard derivedPublicKey.hexString == expectedPublicKey else { + Self.logger.error("Wrong password: derived pubkey doesn't match stored pubkey") + throw CryptoError.decryptionFailed + } + Self.logger.info("Password validation passed") + return data + }.value + return privateKeyData.hexString + } + + /// Updates the display name and username on the current account. + func updateProfile(displayName: String?, username: String?) { + guard var account = currentAccount else { return } + if let displayName { account.displayName = displayName } + if let username { account.username = username } + currentAccount = account + try? saveAccount(account) + } + + // MARK: - Persistence + + func saveAccount(_ account: Account) throws { + try keychain.saveCodable(account, forKey: Account.KeychainKey.account) + } + + func deleteAccount() throws { + try keychain.delete(forKey: Account.KeychainKey.account) + currentAccount = nil + } + + var hasAccount: Bool { + keychain.contains(key: Account.KeychainKey.account) + } + + // MARK: - Private + + private func loadCachedAccount() -> Account? { + try? keychain.loadCodable(Account.self, forKey: Account.KeychainKey.account) + } +} diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift new file mode 100644 index 0000000..c077918 --- /dev/null +++ b/Rosetta/Core/Services/SessionManager.swift @@ -0,0 +1,221 @@ +import Foundation +import Observation +import os + +/// Bridges AccountManager, CryptoManager, and ProtocolManager into a unified session lifecycle. +@Observable +@MainActor +final class SessionManager { + + static let shared = SessionManager() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Session") + + private(set) var isAuthenticated = false + private(set) var currentPublicKey: String = "" + private(set) var displayName: String = "" + private(set) var username: String = "" + + /// Hex-encoded private key hash, kept in memory for the session duration. + private(set) var privateKeyHash: String? + /// Hex-encoded raw private key, kept in memory for message decryption. + private(set) var privateKeyHex: String? + + private init() { + setupProtocolCallbacks() + } + + // MARK: - Session Lifecycle + + /// Called after password verification. Decrypts private key, connects WebSocket, and starts handshake. + func startSession(password: String) async throws { + let accountManager = AccountManager.shared + let crypto = CryptoManager.shared + + // Decrypt private key + let privateKeyHex = try await accountManager.decryptPrivateKey(password: password) + self.privateKeyHex = privateKeyHex + Self.logger.info("Private key decrypted") + + guard let account = accountManager.currentAccount else { + throw CryptoError.decryptionFailed + } + + currentPublicKey = account.publicKey + displayName = account.displayName ?? "" + username = account.username ?? "" + + // Generate private key hash for handshake + let hash = crypto.generatePrivateKeyHash(privateKeyHex: privateKeyHex) + privateKeyHash = hash + + Self.logger.info("Connecting to server...") + + // Connect + handshake + ProtocolManager.shared.connect(publicKey: account.publicKey, privateKeyHash: hash) + + isAuthenticated = true + } + + // MARK: - Message Sending + + /// Sends an encrypted message to a recipient, matching Android's outgoing flow. + func sendMessage(text: String, toPublicKey: String) async throws { + guard let privKey = privateKeyHex, let hash = privateKeyHash else { + throw CryptoError.decryptionFailed + } + + let messageId = UUID().uuidString + let timestamp = Int32(Date().timeIntervalSince1970) + + // Encrypt the message + let encrypted = try MessageCrypto.encryptOutgoing( + plaintext: text, + recipientPublicKeyHex: toPublicKey, + senderPrivateKeyHex: privKey + ) + + // Build packet + var packet = PacketMessage() + packet.fromPublicKey = currentPublicKey + packet.toPublicKey = toPublicKey + packet.content = encrypted.content + packet.chachaKey = encrypted.chachaKey + packet.timestamp = timestamp + packet.privateKey = hash + packet.messageId = messageId + + // Optimistic UI update — show message immediately as "waiting" + DialogRepository.shared.updateFromMessage( + packet, myPublicKey: currentPublicKey, decryptedText: text + ) + + // Send via WebSocket + ProtocolManager.shared.sendPacket(packet) + } + + /// Ends the session and disconnects. + func endSession() { + ProtocolManager.shared.disconnect() + privateKeyHash = nil + privateKeyHex = nil + isAuthenticated = false + currentPublicKey = "" + displayName = "" + username = "" + } + + // MARK: - Protocol Callbacks + + private func setupProtocolCallbacks() { + let proto = ProtocolManager.shared + + proto.onMessageReceived = { [weak self] packet in + guard let self else { return } + Task { @MainActor in + let myKey = self.currentPublicKey + var text: String + + if let privKey = self.privateKeyHex, !packet.content.isEmpty, !packet.chachaKey.isEmpty { + do { + text = try MessageCrypto.decryptIncoming( + ciphertext: packet.content, + encryptedKey: packet.chachaKey, + myPrivateKeyHex: privKey + ) + } catch { + Self.logger.error("Message decryption failed: \(error.localizedDescription)") + text = "(decryption failed)" + } + } else { + text = packet.content.isEmpty ? "" : "(encrypted)" + } + + DialogRepository.shared.updateFromMessage( + packet, myPublicKey: myKey, decryptedText: text + ) + + // Request user info for unknown opponents + let fromMe = packet.fromPublicKey == myKey + let opponentKey = fromMe ? packet.toPublicKey : packet.fromPublicKey + let dialog = DialogRepository.shared.dialogs[opponentKey] + if dialog?.opponentTitle.isEmpty == true, let hash = self.privateKeyHash { + var searchPacket = PacketSearch() + searchPacket.privateKey = hash + searchPacket.search = opponentKey + ProtocolManager.shared.sendPacket(searchPacket) + } + } + } + + proto.onDeliveryReceived = { packet in + Task { @MainActor in + DialogRepository.shared.updateDeliveryStatus( + messageId: packet.messageId, + opponentKey: packet.toPublicKey, + status: .delivered + ) + } + } + + proto.onReadReceived = { packet in + Task { @MainActor in + DialogRepository.shared.markAsRead(opponentKey: packet.toPublicKey) + } + } + + proto.onOnlineStateReceived = { packet in + Task { @MainActor in + for entry in packet.entries { + DialogRepository.shared.updateOnlineState( + publicKey: entry.publicKey, + isOnline: entry.isOnline + ) + } + } + } + + proto.onUserInfoReceived = { [weak self] packet in + guard let self else { return } + Task { @MainActor in + print("[Session] UserInfo received: username='\(packet.username)', title='\(packet.title)'") + if !packet.title.isEmpty { + self.displayName = packet.title + AccountManager.shared.updateProfile(displayName: packet.title, username: nil) + } + if !packet.username.isEmpty { + self.username = packet.username + AccountManager.shared.updateProfile(displayName: nil, username: packet.username) + } + } + } + + // Note: onSearchResult is set by ChatListViewModel for user search. + // SessionManager does NOT override it — the ViewModel handles both + // displaying results and updating dialog user info. + + proto.onHandshakeCompleted = { [weak self] _ in + guard let self else { return } + Task { @MainActor in + Self.logger.info("Handshake completed") + + guard let hash = self.privateKeyHash else { return } + + // Only send UserInfo if we have profile data to update + let name = self.displayName + let uname = self.username + if !name.isEmpty || !uname.isEmpty { + var userInfoPacket = PacketUserInfo() + userInfoPacket.username = uname + userInfoPacket.avatar = "" + userInfoPacket.title = name + userInfoPacket.privateKey = hash + print("[Session] Sending UserInfo: username='\(uname)', title='\(name)'") + ProtocolManager.shared.sendPacket(userInfoPacket) + } else { + print("[Session] Skipping UserInfo — no profile data to send") + } + } + } + } +} diff --git a/Rosetta/DesignSystem/Colors.swift b/Rosetta/DesignSystem/Colors.swift index 8f47633..d95eb71 100644 --- a/Rosetta/DesignSystem/Colors.swift +++ b/Rosetta/DesignSystem/Colors.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit // MARK: - Rosetta Color Tokens @@ -33,15 +34,22 @@ enum RosettaColors { static let subtleBorder = Color.white.opacity(0.15) static let cardFill = Color.white.opacity(0.06) + // MARK: Chat-Specific (from Figma) + + /// Figma subtitle/time/icon color in light mode: #3C3C43 at ~60% opacity + static let chatSubtitle = Color(hex: 0x3C3C43).opacity(0.6) + /// Figma accent blue used for badges, delivery status: #008BFF + static let figmaBlue = Color(hex: 0x008BFF) + // MARK: Light Theme enum Light { static let background = Color.white - static let backgroundSecondary = Color(hex: 0xF2F3F5) + static let backgroundSecondary = Color(hex: 0xF2F2F7) // iOS system grouped bg static let surface = Color(hex: 0xF5F5F5) static let text = Color.black - static let textSecondary = Color(hex: 0x666666) - static let textTertiary = Color(hex: 0x999999) + static let textSecondary = Color(hex: 0x3C3C43).opacity(0.6) // Figma subtitle gray + static let textTertiary = Color(hex: 0x3C3C43).opacity(0.3) // Figma hint gray static let border = Color(hex: 0xE0E0E0) static let divider = Color(hex: 0xEEEEEE) static let messageBubble = Color(hex: 0xF5F5F5) @@ -65,6 +73,30 @@ enum RosettaColors { static let inputBackground = Color(hex: 0x2A2A2A) } + // MARK: Adaptive Colors (light/dark based on system appearance) + + static func adaptive(light: Color, dark: Color) -> Color { + Color(UIColor { traits in + traits.userInterfaceStyle == .dark + ? UIColor(dark) + : UIColor(light) + }) + } + + enum Adaptive { + static let background = RosettaColors.adaptive(light: RosettaColors.Light.background, dark: RosettaColors.Dark.background) + static let backgroundSecondary = RosettaColors.adaptive(light: RosettaColors.Light.backgroundSecondary, dark: RosettaColors.Dark.backgroundSecondary) + static let surface = RosettaColors.adaptive(light: RosettaColors.Light.surface, dark: RosettaColors.Dark.surface) + static let text = RosettaColors.adaptive(light: RosettaColors.Light.text, dark: RosettaColors.Dark.text) + static let textSecondary = RosettaColors.adaptive(light: RosettaColors.Light.textSecondary, dark: RosettaColors.Dark.textSecondary) + static let textTertiary = RosettaColors.adaptive(light: RosettaColors.Light.textTertiary, dark: RosettaColors.Dark.textTertiary) + static let border = RosettaColors.adaptive(light: RosettaColors.Light.border, dark: RosettaColors.Dark.border) + static let divider = RosettaColors.adaptive(light: RosettaColors.Light.divider, dark: RosettaColors.Dark.divider) + static let messageBubble = RosettaColors.adaptive(light: RosettaColors.Light.messageBubble, dark: RosettaColors.Dark.messageBubble) + static let messageBubbleOwn = RosettaColors.adaptive(light: RosettaColors.Light.messageBubbleOwn, dark: RosettaColors.Dark.messageBubbleOwn) + static let inputBackground = RosettaColors.adaptive(light: RosettaColors.Light.inputBackground, dark: RosettaColors.Dark.inputBackground) + } + // MARK: Seed Word Colors (12 unique, matching Android) static let seedWordColors: [Color] = [ @@ -82,18 +114,52 @@ enum RosettaColors { Color(hex: 0xF7DC6F), ] - // MARK: Avatar Palette + // MARK: Avatar Palette (11 colors, matching rosetta-android dark theme) static let avatarColors: [(background: Color, text: Color)] = [ - (Color(hex: 0xFF6B6B), .white), - (Color(hex: 0x4ECDC4), .white), - (Color(hex: 0x45B7D1), .white), - (Color(hex: 0xF7B731), .white), - (Color(hex: 0x5F27CD), .white), - (Color(hex: 0x00D2D3), .white), - (Color(hex: 0xFF9FF3), .white), - (Color(hex: 0x54A0FF), .white), + (Color(hex: 0x2D3548), Color(hex: 0x7DD3FC)), // blue + (Color(hex: 0x2D4248), Color(hex: 0x67E8F9)), // cyan + (Color(hex: 0x39334C), Color(hex: 0xD8B4FE)), // grape + (Color(hex: 0x2D3F32), Color(hex: 0x86EFAC)), // green + (Color(hex: 0x333448), Color(hex: 0xA5B4FC)), // indigo + (Color(hex: 0x383F2D), Color(hex: 0xBEF264)), // lime + (Color(hex: 0x483529), Color(hex: 0xFDBA74)), // orange + (Color(hex: 0x482D3D), Color(hex: 0xF9A8D4)), // pink + (Color(hex: 0x482D2D), Color(hex: 0xFCA5A5)), // red + (Color(hex: 0x2D4340), Color(hex: 0x5EEAD4)), // teal + (Color(hex: 0x3A334C), Color(hex: 0xC4B5FD)), // violet ] + + static func avatarColorIndex(for key: String) -> Int { + var hash: Int32 = 0 + for char in key.unicodeScalars { + hash = hash &* 31 &+ Int32(truncatingIfNeeded: char.value) + } + let count = Int32(avatarColors.count) + var index = hash % count + if index < 0 { index += count } + return Int(index) + } + + static func avatarText(publicKey: String) -> String { + String(publicKey.prefix(2)).uppercased() + } + + static func initials(name: String, publicKey: String) -> String { + let words = name.trimmingCharacters(in: .whitespaces) + .split(whereSeparator: { $0.isWhitespace }) + .filter { !$0.isEmpty } + switch words.count { + case 0: + return publicKey.isEmpty ? "??" : String(publicKey.prefix(2)).uppercased() + case 1: + return String(words[0].prefix(2)).uppercased() + default: + let first = words[0].first.map(String.init) ?? "" + let second = words[1].first.map(String.init) ?? "" + return (first + second).uppercased() + } + } } // MARK: - Color Hex Initializer diff --git a/Rosetta/DesignSystem/Components/AvatarView.swift b/Rosetta/DesignSystem/Components/AvatarView.swift new file mode 100644 index 0000000..e31eec6 --- /dev/null +++ b/Rosetta/DesignSystem/Components/AvatarView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +// MARK: - AvatarView + +struct AvatarView: View { + let initials: String + let colorIndex: Int + let size: CGFloat + var isOnline: Bool = false + var isSavedMessages: Bool = false + + private var backgroundColor: Color { + RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].background + } + + private var textColor: Color { + RosettaColors.avatarColors[colorIndex % RosettaColors.avatarColors.count].text + } + + private var fontSize: CGFloat { size * 0.38 } + private var badgeSize: CGFloat { size * 0.31 } + + var body: some View { + ZStack { + Circle() + .fill(isSavedMessages ? RosettaColors.primaryBlue : backgroundColor) + + if isSavedMessages { + Image(systemName: "bookmark.fill") + .font(.system(size: fontSize, weight: .semibold)) + .foregroundStyle(.white) + } else { + Text(initials) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(textColor) + .lineLimit(1) + .minimumScaleFactor(0.5) + } + } + .frame(width: size, height: size) + .overlay(alignment: .bottomTrailing) { + if isOnline { + Circle() + .fill(RosettaColors.online) + .frame(width: badgeSize, height: badgeSize) + .overlay { + Circle() + .stroke(RosettaColors.Adaptive.background, lineWidth: 2) + } + .offset(x: 1, y: 1) + } + } + .accessibilityLabel(isSavedMessages ? "Saved Messages" : initials) + .accessibilityAddTraits(isOnline ? [.isStaticText] : []) + } +} + +// MARK: - Preview + +#Preview { + HStack(spacing: 16) { + AvatarView(initials: "AJ", colorIndex: 0, size: 56, isOnline: true) + AvatarView(initials: "BS", colorIndex: 2, size: 56, isOnline: false) + AvatarView(initials: "S", colorIndex: 4, size: 56, isSavedMessages: true) + AvatarView(initials: "CD", colorIndex: 6, size: 40, isOnline: true) + } + .padding() + .background(RosettaColors.Adaptive.background) +} diff --git a/Rosetta/DesignSystem/Components/GlassCard.swift b/Rosetta/DesignSystem/Components/GlassCard.swift index b797d2e..3265eb9 100644 --- a/Rosetta/DesignSystem/Components/GlassCard.swift +++ b/Rosetta/DesignSystem/Components/GlassCard.swift @@ -23,10 +23,16 @@ struct GlassCard: View { content() .background { RoundedRectangle(cornerRadius: cornerRadius) - .fill(Color.white.opacity(fillOpacity)) + .fill(RosettaColors.adaptive( + light: Color.black.opacity(fillOpacity), + dark: Color.white.opacity(fillOpacity) + )) .overlay { RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color.white.opacity(0.08), lineWidth: 0.5) + .stroke(RosettaColors.adaptive( + light: Color.black.opacity(0.06), + dark: Color.white.opacity(0.08) + ), lineWidth: 0.5) } } } diff --git a/Rosetta/DesignSystem/Components/LottieView.swift b/Rosetta/DesignSystem/Components/LottieView.swift index 51952a6..ac1166c 100644 --- a/Rosetta/DesignSystem/Components/LottieView.swift +++ b/Rosetta/DesignSystem/Components/LottieView.swift @@ -1,14 +1,57 @@ import SwiftUI import Lottie -struct LottieView: UIViewRepresentable { +// MARK: - Animation Cache + +final class LottieAnimationCache { + static let shared = LottieAnimationCache() + private var cache: [String: LottieAnimation] = [:] + private let lock = NSLock() + + private init() {} + + func animation(named name: String) -> LottieAnimation? { + lock.lock() + defer { lock.unlock() } + if let cached = cache[name] { + return cached + } + if let animation = LottieAnimation.named(name) { + cache[name] = animation + return animation + } + return nil + } + + func preload(_ names: [String]) { + for name in names { + _ = animation(named: name) + } + } +} + +// MARK: - LottieView + +struct LottieView: UIViewRepresentable, Equatable { let animationName: String var loopMode: LottieLoopMode = .playOnce var animationSpeed: CGFloat = 1.5 var isPlaying: Bool = true + static func == (lhs: LottieView, rhs: LottieView) -> Bool { + lhs.animationName == rhs.animationName && + lhs.loopMode == rhs.loopMode && + lhs.animationSpeed == rhs.animationSpeed && + lhs.isPlaying == rhs.isPlaying + } + func makeUIView(context: Context) -> LottieAnimationView { - let animationView = LottieAnimationView(name: animationName) + let animationView: LottieAnimationView + if let cached = LottieAnimationCache.shared.animation(named: animationName) { + animationView = LottieAnimationView(animation: cached) + } else { + animationView = LottieAnimationView(name: animationName) + } animationView.contentMode = .scaleAspectFit animationView.loopMode = loopMode animationView.animationSpeed = animationSpeed @@ -21,13 +64,14 @@ struct LottieView: UIViewRepresentable { } func updateUIView(_ uiView: LottieAnimationView, context: Context) { - uiView.loopMode = loopMode - uiView.animationSpeed = animationSpeed - - if isPlaying && !uiView.isAnimationPlaying { - uiView.play() - } else if !isPlaying { - uiView.stop() + if isPlaying { + if !uiView.isAnimationPlaying { + uiView.play() + } + } else { + if uiView.isAnimationPlaying { + uiView.stop() + } } } } diff --git a/Rosetta/DesignSystem/Components/RosettaTabBar.swift b/Rosetta/DesignSystem/Components/RosettaTabBar.swift new file mode 100644 index 0000000..da34410 --- /dev/null +++ b/Rosetta/DesignSystem/Components/RosettaTabBar.swift @@ -0,0 +1,234 @@ +import SwiftUI +import UIKit + +// MARK: - Tab + +enum RosettaTab: CaseIterable { + case chats + case settings + case search + + var label: String { + switch self { + case .chats: return "Chats" + case .settings: return "Settings" + case .search: return "" + } + } + + var icon: String { + switch self { + case .chats: return "bubble.left.and.bubble.right" + case .settings: return "gearshape" + case .search: return "magnifyingglass" + } + } + + var selectedIcon: String { + switch self { + case .chats: return "bubble.left.and.bubble.right.fill" + case .settings: return "gearshape.fill" + case .search: return "magnifyingglass" + } + } +} + +// MARK: - Tab Badge + +struct TabBadge { + let tab: RosettaTab + let text: String +} + +// MARK: - RosettaTabBar +/// Figma spec: +/// Container: padding(25h, 16t, 25b), gap=8 +/// Main pill: 282x62, r=296, padding(4h, 3v), glass+shadow +/// Each tab: 99x56, icon 30pt, label 10pt +/// Selected: #EDEDED rect r=100, icon+label #008BFF, label bold +/// Unselected: icon+label #404040 +/// Search pill: 62x62, glass+shadow, icon 17pt #404040 + +struct RosettaTabBar: View { + let selectedTab: RosettaTab + var onTabSelected: ((RosettaTab) -> Void)? + var badges: [TabBadge] = [] + + var body: some View { + HStack(spacing: 8) { + mainTabsPill + searchPill + } + .padding(.horizontal, 25) + .padding(.top, 16) + .padding(.bottom, safeAreaBottom > 0 ? safeAreaBottom : 25) + } +} + +// MARK: - Main Tabs Pill + +private extension RosettaTabBar { + var mainTabsPill: some View { + HStack(spacing: 0) { + ForEach(RosettaTab.allCases.filter { $0 != .search }, id: \.self) { tab in + tabItem(tab) + } + } + .padding(.horizontal, 4) + .padding(.top, 3) + .padding(.bottom, 3) + .frame(height: 62) + .applyGlassPill() + } + + func tabItem(_ tab: RosettaTab) -> some View { + let isSelected = tab == selectedTab + let badgeText = badges.first(where: { $0.tab == tab })?.text + + return Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + onTabSelected?(tab) + } + } label: { + VStack(spacing: 1) { + ZStack(alignment: .topTrailing) { + Image(systemName: isSelected ? tab.selectedIcon : tab.icon) + .font(.system(size: 22)) + .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + .frame(height: 30) + + if let badgeText { + badgeView(badgeText) + } + } + + Text(tab.label) + .font(.system(size: 10, weight: isSelected ? .bold : .medium)) + .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + } + .padding(.horizontal, 8) + .padding(.top, 6) + .padding(.bottom, 7) + .frame(maxWidth: .infinity) + .background { + if isSelected { + RoundedRectangle(cornerRadius: 100) + .fill(RosettaColors.adaptive( + light: Color(hex: 0xEDEDED), + dark: Color.white.opacity(0.12) + )) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(tab.label) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - Search Pill + +private extension RosettaTabBar { + var searchPill: some View { + let isSelected = selectedTab == .search + + return Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + onTabSelected?(.search) + } + } label: { + Image(systemName: isSelected ? "magnifyingglass" : "magnifyingglass") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(isSelected ? RosettaColors.primaryBlue : RosettaColors.adaptive(light: Color(hex: 0x404040), dark: Color(hex: 0x8E8E93))) + .frame(width: 54, height: 54) + .background { + if isSelected { + Circle() + .fill(RosettaColors.adaptive( + light: Color(hex: 0xEDEDED), + dark: Color.white.opacity(0.12) + )) + } + } + } + .buttonStyle(.plain) + .padding(4) + .frame(width: 62, height: 62) + .applyGlassPill() + .accessibilityLabel("Search") + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +// MARK: - Glass Pill + +private struct GlassPillModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .glassEffect(.regular, in: .capsule) + } else { + content + .background( + Capsule() + .fill(RosettaColors.adaptive( + light: Color.white.opacity(0.65), + dark: Color(hex: 0x2A2A2A).opacity(0.8) + )) + .shadow(color: RosettaColors.adaptive( + light: Color(hex: 0xDDDDDD).opacity(0.5), + dark: Color.black.opacity(0.3) + ), radius: 16, y: 4) + ) + } + } +} + +private extension View { + func applyGlassPill() -> some View { + modifier(GlassPillModifier()) + } +} + +// MARK: - Helpers + +private extension RosettaTabBar { + func badgeView(_ text: String) -> some View { + Text(text) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, text.count > 2 ? 4 : 0) + .frame(minWidth: 18, minHeight: 18) + .background(Capsule().fill(RosettaColors.error)) + .offset(x: 10, y: -4) + } + + var safeAreaBottom: CGFloat { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = scene.windows.first(where: \.isKeyWindow) else { return 0 } + return window.safeAreaInsets.bottom + } +} + +// MARK: - Preview + +#Preview { + ZStack(alignment: .bottom) { + RosettaColors.Adaptive.background.ignoresSafeArea() + + VStack { + Spacer() + Text("Content here") + .foregroundStyle(RosettaColors.Adaptive.text) + Spacer() + } + + RosettaTabBar( + selectedTab: .chats, + badges: [ + TabBadge(tab: .chats, text: "7"), + ] + ) + } +} diff --git a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift index fea6c35..b9ea0a0 100644 --- a/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ConfirmSeedPhraseView.swift @@ -280,7 +280,8 @@ private extension ConfirmSeedPhraseView { #Preview { ConfirmSeedPhraseView( - seedPhrase: SeedPhraseGenerator.generate(), + seedPhrase: ["abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident"], onConfirmed: {}, onBack: {} ) diff --git a/Rosetta/Features/Auth/ImportSeedPhraseView.swift b/Rosetta/Features/Auth/ImportSeedPhraseView.swift index 3c0023b..42e2a28 100644 --- a/Rosetta/Features/Auth/ImportSeedPhraseView.swift +++ b/Rosetta/Features/Auth/ImportSeedPhraseView.swift @@ -194,8 +194,12 @@ private extension ImportSeedPhraseView { showError("Please fill in all words") return } - // TODO: Validate seed phrase with CryptoManager.validateSeedPhrase() - seedPhrase = importedWords + let normalized = importedWords.map { $0.lowercased().trimmingCharacters(in: .whitespaces) } + guard CryptoManager.shared.validateMnemonic(normalized) else { + showError("Invalid recovery phrase. Check each word for typos.") + return + } + seedPhrase = normalized onContinue() } label: { Text("Continue") diff --git a/Rosetta/Features/Auth/SeedPhraseView.swift b/Rosetta/Features/Auth/SeedPhraseView.swift index 24c5dc8..77ccfb3 100644 --- a/Rosetta/Features/Auth/SeedPhraseView.swift +++ b/Rosetta/Features/Auth/SeedPhraseView.swift @@ -174,31 +174,16 @@ private extension SeedPhraseView { isContentVisible = true return } - // TODO: Replace with real BIP39 generation from CryptoManager - seedPhrase = SeedPhraseGenerator.generate() + do { + seedPhrase = try CryptoManager.shared.generateMnemonic() + } catch { + // Entropy failure is extremely rare; show empty state rather than crash + seedPhrase = [] + } withAnimation { isContentVisible = true } } } -// MARK: - Placeholder BIP39 Generator - -enum SeedPhraseGenerator { - private static let wordList = [ - "abandon", "ability", "able", "about", "above", "absent", - "absorb", "abstract", "absurd", "abuse", "access", "accident", - "account", "accuse", "achieve", "acid", "acoustic", "acquire", - "across", "act", "action", "actor", "actress", "actual", - "adapt", "add", "addict", "address", "adjust", "admit", - "adult", "advance", "advice", "aerobic", "affair", "afford", - "afraid", "again", "age", "agent", "agree", "ahead", - "aim", "air", "airport", "aisle", "alarm", "album", - ] - - static func generate() -> [String] { - (0..<12).map { _ in wordList.randomElement() ?? "abandon" } - } -} - #Preview { SeedPhraseView(seedPhrase: .constant([]), onContinue: {}, onBack: {}) .preferredColorScheme(.dark) diff --git a/Rosetta/Features/Auth/SetPasswordView.swift b/Rosetta/Features/Auth/SetPasswordView.swift index 85644b8..b052e13 100644 --- a/Rosetta/Features/Auth/SetPasswordView.swift +++ b/Rosetta/Features/Auth/SetPasswordView.swift @@ -11,6 +11,7 @@ struct SetPasswordView: View { @State private var showPassword = false @State private var showConfirmPassword = false @State private var isCreating = false + @State private var errorMessage: String? @FocusState private var focusedField: Field? fileprivate enum Field { @@ -46,6 +47,14 @@ struct SetPasswordView: View { WeakPasswordWarning(password: password) infoCard + + if let message = errorMessage { + Text(message) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.error) + .multilineTextAlignment(.center) + .transition(.opacity.combined(with: .scale(scale: 0.95))) + } } .padding(.horizontal, 24) .padding(.top, 8) @@ -219,23 +228,28 @@ private extension SetPasswordView { guard canCreate else { return } isCreating = true - // TODO: Implement real account creation: - // 1. CryptoManager.generateKeyPairFromSeed(seedPhrase) - // 2. CryptoManager.encryptWithPassword(privateKey, password) - // 3. CryptoManager.encryptWithPassword(seedPhrase.joined(separator: " "), password) - // 4. Save EncryptedAccount to persistence - // 5. Authenticate with server via Protocol handshake - - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - isCreating = false - onAccountCreated() + Task { + do { + _ = try await AccountManager.shared.createAccount( + seedPhrase: seedPhrase, + password: password + ) + // Start session (WebSocket + handshake) immediately after account creation + try await SessionManager.shared.startSession(password: password) + isCreating = false + onAccountCreated() + } catch { + isCreating = false + errorMessage = error.localizedDescription + } } } } #Preview { SetPasswordView( - seedPhrase: SeedPhraseGenerator.generate(), + seedPhrase: ["abandon", "ability", "able", "about", "above", "absent", + "absorb", "abstract", "absurd", "abuse", "access", "accident"], isImportMode: false, onAccountCreated: {}, onBack: {} diff --git a/Rosetta/Features/Auth/UnlockView.swift b/Rosetta/Features/Auth/UnlockView.swift new file mode 100644 index 0000000..f672a4c --- /dev/null +++ b/Rosetta/Features/Auth/UnlockView.swift @@ -0,0 +1,256 @@ +import SwiftUI + +/// Password unlock screen matching rosetta-android design with liquid glass styling. +struct UnlockView: View { + let onUnlocked: () -> Void + + @State private var password = "" + @State private var isUnlocking = false + @State private var errorMessage: String? + @State private var showPassword = false + + // Staggered fade-in animation + @State private var showAvatar = false + @State private var showTitle = false + @State private var showSubtitle = false + @State private var showInput = false + @State private var showButton = false + @State private var showFooter = false + + private var account: Account? { AccountManager.shared.currentAccount } + + private var publicKey: String { + account?.publicKey ?? "" + } + + /// First 2 chars of public key, uppercased — matching Android's `getAvatarText()`. + private var avatarText: String { + RosettaColors.avatarText(publicKey: publicKey) + } + + /// Color index using Java-compatible hashCode — matching Android's `getAvatarColor()`. + private var avatarColorIndex: Int { + RosettaColors.avatarColorIndex(for: publicKey) + } + + /// Display name, or first 20 chars of public key if no name set. + private var displayName: String { + let name = account?.displayName ?? "" + if name.isEmpty { + return publicKey.isEmpty ? "Rosetta" : String(publicKey.prefix(20)) + "..." + } + return name + } + + /// Truncated public key for subtitle. + private var publicKeyPreview: String { + guard publicKey.count > 20 else { return publicKey } + return String(publicKey.prefix(20)) + "..." + } + + var body: some View { + ZStack { + RosettaColors.authBackground + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + Spacer().frame(height: 80) + + // Avatar + AvatarView( + initials: avatarText, + colorIndex: avatarColorIndex, + size: 100, + isSavedMessages: false + ) + .opacity(showAvatar ? 1 : 0) + .scaleEffect(showAvatar ? 1 : 0.8) + + Spacer().frame(height: 20) + + // Display name + Text(displayName) + .font(.system(size: 24, weight: .bold)) + .foregroundStyle(.white) + .opacity(showTitle ? 1 : 0) + .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) + + // Subtitle + Text("Enter password to unlock") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.secondaryText) + .opacity(showSubtitle ? 1 : 0) + .offset(y: showSubtitle ? 0 : 8) + + Spacer().frame(height: 40) + + // Password input — glass card + VStack(alignment: .leading, spacing: 8) { + GlassCard(cornerRadius: 14, fillOpacity: 0.08) { + HStack(spacing: 12) { + Group { + if showPassword { + TextField("Password", text: $password) + } else { + SecureField("Password", text: $password) + } + } + .font(.system(size: 16)) + .foregroundStyle(.white) + .textContentType(.password) + .submitLabel(.done) + .onSubmit { unlock() } + + Button { + showPassword.toggle() + } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + .font(.system(size: 18)) + .foregroundStyle(Color(white: 0.45)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .overlay { + RoundedRectangle(cornerRadius: 14) + .stroke( + errorMessage != nil ? RosettaColors.error : Color.clear, + lineWidth: 1 + ) + } + + if let error = errorMessage { + Text(error) + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.error) + .padding(.leading, 4) + .transition(.opacity) + } + } + .padding(.horizontal, 24) + .opacity(showInput ? 1 : 0) + .offset(y: showInput ? 0 : 12) + + Spacer().frame(height: 24) + + // Unlock button + Button(action: unlock) { + HStack(spacing: 10) { + if isUnlocking { + ProgressView() + .tint(.white) + .scaleEffect(0.9) + } else { + Image(systemName: "lock.open.fill") + .font(.system(size: 16)) + Text("Unlock") + .font(.system(size: 16, weight: .semibold)) + } + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 54) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(password.isEmpty ? RosettaColors.primaryBlue.opacity(0.4) : RosettaColors.primaryBlue) + ) + } + .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) + + Button { + // TODO: Recover account flow + } label: { + HStack(spacing: 8) { + Image(systemName: "key.fill") + .font(.system(size: 14)) + Text("Recover account") + .font(.system(size: 15, weight: .medium)) + } + .foregroundStyle(RosettaColors.primaryBlue) + } + + Button { + // 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) + } + } + .opacity(showFooter ? 1 : 0) + + Spacer().frame(height: 40) + } + } + .scrollDismissesKeyboard(.interactively) + } + .onAppear { startAnimations() } + } + + // MARK: - Actions + + private func unlock() { + guard !password.isEmpty, !isUnlocking else { return } + isUnlocking = true + errorMessage = nil + + Task { + do { + try await SessionManager.shared.startSession(password: password) + onUnlocked() + } catch { + withAnimation(.easeInOut(duration: 0.2)) { + errorMessage = "Wrong password. Please try again." + } + isUnlocking = false + } + } + } + + private func startAnimations() { + withAnimation(.easeOut(duration: 0.3)) { showAvatar = 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.16)) { showInput = true } + withAnimation(.easeOut(duration: 0.3).delay(0.20)) { showButton = true } + withAnimation(.easeOut(duration: 0.3).delay(0.24)) { showFooter = true } + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift new file mode 100644 index 0000000..2ea9825 --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -0,0 +1,383 @@ +import SwiftUI + +// MARK: - ChatListView + +struct ChatListView: View { + @State private var viewModel = ChatListViewModel() + @State private var searchText = "" + @State private var isSearchPresented = false + + var body: some View { + NavigationStack { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + chatContent + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { toolbarContent } + .toolbarBackground(.visible, for: .navigationBar) + .applyGlassNavBar() + .searchable( + text: $searchText, + isPresented: $isSearchPresented, + placement: .navigationBarDrawer(displayMode: .always), + prompt: "Search" + ) + .onChange(of: searchText) { _, newValue in + viewModel.setSearchQuery(newValue) + } + } + .tint(RosettaColors.figmaBlue) + } +} + +// MARK: - Glass Nav Bar Modifier + +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 { + var chatContent: some View { + List { + if viewModel.isLoading { + ForEach(0..<8, id: \.self) { _ in + ChatRowShimmerView() + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } else if viewModel.filteredDialogs.isEmpty && !viewModel.showServerResults { + emptyState + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } else { + // Local dialog results + if !viewModel.pinnedDialogs.isEmpty { + pinnedSection + } + + ForEach(viewModel.unpinnedDialogs) { dialog in + chatRow(dialog) + } + + // Server search results + if viewModel.showServerResults { + serverSearchSection + } + } + + Color.clear + .frame(height: 80) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .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 { + ChatRowView(dialog: dialog) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.visible) + .listRowSeparatorTint(RosettaColors.Adaptive.divider) + .alignmentGuide(.listRowSeparatorLeading) { _ in 82 } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + withAnimation { viewModel.deleteDialog(dialog) } + } label: { + Label("Delete", systemImage: "trash") + } + + Button { + viewModel.toggleMute(dialog) + } label: { + Label( + dialog.isMuted ? "Unmute" : "Mute", + systemImage: dialog.isMuted ? "bell" : "bell.slash" + ) + } + .tint(.indigo) + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + viewModel.markAsRead(dialog) + } label: { + Label("Read", systemImage: "envelope.open") + } + .tint(RosettaColors.figmaBlue) + + Button { + viewModel.togglePin(dialog) + } label: { + Label(dialog.isPinned ? "Unpin" : "Pin", systemImage: "pin") + } + .tint(.orange) + } + } +} + +// MARK: - Toolbar + +private extension ChatListView { + @ToolbarContentBuilder + var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .navigationBarLeading) { + Button { + // TODO: Edit mode + } label: { + Text("Edit") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + + ToolbarItem(placement: .principal) { + HStack(spacing: 4) { + storiesAvatars + + Text("Chats") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.figmaBlue) + } + } + + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + // TODO: Camera + } label: { + Image(systemName: "camera") + .font(.system(size: 18)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .accessibilityLabel("Camera") + + Button { + // TODO: Compose new message + } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 18)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + .accessibilityLabel("New chat") + } + } + + @ViewBuilder + private var storiesAvatars: some View { + let pk = AccountManager.shared.currentAccount?.publicKey ?? "" + let initials = RosettaColors.initials(name: SessionManager.shared.displayName, publicKey: pk) + let colorIdx = RosettaColors.avatarColorIndex(for: pk) + + 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 { + 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() +} diff --git a/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift new file mode 100644 index 0000000..b8fdbfc --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/ChatListViewModel.swift @@ -0,0 +1,153 @@ +import Foundation + +// MARK: - ChatListViewModel + +@Observable +@MainActor +final class ChatListViewModel { + + // MARK: - State + + private(set) var isLoading = false + private(set) var searchQuery = "" + + // Server search state + private(set) var serverSearchResults: [SearchUser] = [] + private(set) var isServerSearching = false + private var searchTask: Task? + private var lastSearchedText = "" + + init() { + setupSearchCallback() + } + + // MARK: - Computed (local dialog filtering) + + var filteredDialogs: [Dialog] { + var result = DialogRepository.shared.sortedDialogs + + let query = searchQuery.trimmingCharacters(in: .whitespaces).lowercased() + if !query.isEmpty { + result = result.filter { + $0.opponentTitle.lowercased().contains(query) + || $0.opponentUsername.lowercased().contains(query) + || $0.lastMessage.lowercased().contains(query) + } + } + + return result + } + + var pinnedDialogs: [Dialog] { + filteredDialogs.filter(\.isPinned) + } + + var unpinnedDialogs: [Dialog] { + filteredDialogs.filter { !$0.isPinned } + } + + var totalUnreadCount: Int { + DialogRepository.shared.sortedDialogs + .filter { !$0.isMuted } + .reduce(0) { $0 + $1.unreadCount } + } + + 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 + + func setSearchQuery(_ query: String) { + searchQuery = query + triggerServerSearch() + } + + func deleteDialog(_ dialog: Dialog) { + DialogRepository.shared.deleteDialog(opponentKey: dialog.opponentKey) + } + + func togglePin(_ dialog: Dialog) { + DialogRepository.shared.togglePin(opponentKey: dialog.opponentKey) + } + + func toggleMute(_ dialog: Dialog) { + DialogRepository.shared.toggleMute(opponentKey: dialog.opponentKey) + } + + func markAsRead(_ dialog: Dialog) { + DialogRepository.shared.markAsRead(opponentKey: dialog.opponentKey) + } + + // MARK: - Server Search + + private func triggerServerSearch() { + searchTask?.cancel() + searchTask = nil + + let trimmed = searchQuery.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + serverSearchResults = [] + isServerSearching = false + lastSearchedText = "" + return + } + + if trimmed == lastSearchedText { + return + } + + isServerSearching = true + + searchTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + + guard let self, !Task.isCancelled else { return } + + let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) + guard !currentQuery.isEmpty, currentQuery == trimmed else { return } + + let connState = ProtocolManager.shared.connectionState + let hash = SessionManager.shared.privateKeyHash + print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'") + + guard connState == .authenticated, let hash else { + print("[Search] NOT AUTHENTICATED - aborting") + self.isServerSearching = false + return + } + + self.lastSearchedText = currentQuery + + var packet = PacketSearch() + packet.privateKey = hash + packet.search = currentQuery + print("[Search] Sending PacketSearch for '\(currentQuery)'") + ProtocolManager.shared.sendPacket(packet) + } + } + + private func setupSearchCallback() { + 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 { + DialogRepository.shared.updateUserInfo( + publicKey: user.publicKey, + title: user.title, + username: user.username + ) + } + } + } + } +} diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift new file mode 100644 index 0000000..4670637 --- /dev/null +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -0,0 +1,220 @@ +import SwiftUI + +// MARK: - ChatRowView + +/// Chat row matching Figma spec: +/// Row: paddingLeft=10, paddingRight=16, height=78 +/// Avatar: 62px + 10pt right padding +/// Title: SFPro-Medium 17pt, message: SFPro-Regular 15pt +/// Time: SFPro-Regular 14pt, subtitle color: #3C3C43/60% +struct ChatRowView: View { + let dialog: Dialog + + var body: some View { + HStack(spacing: 0) { + avatarSection + .padding(.trailing, 10) + + contentSection + } + .padding(.leading, 10) + .padding(.trailing, 16) + .frame(height: 78) + .contentShape(Rectangle()) + } +} + +// MARK: - Avatar + +private extension ChatRowView { + var avatarSection: some View { + AvatarView( + initials: dialog.initials, + colorIndex: dialog.avatarColorIndex, + size: 62, + isOnline: dialog.isOnline, + isSavedMessages: dialog.isSavedMessages + ) + } +} + +// MARK: - Content Section + +private extension ChatRowView { + var contentSection: some View { + VStack(alignment: .leading, spacing: 0) { + Spacer(minLength: 0) + titleRow + Spacer().frame(height: 3) + subtitleRow + Spacer(minLength: 0) + } + } +} + +// MARK: - Title Row (name + badges + delivery + time) + +private extension ChatRowView { + var titleRow: some View { + HStack(spacing: 4) { + Text(dialog.isSavedMessages ? "Saved Messages" : dialog.opponentTitle) + .font(.system(size: 17, weight: .medium)) + .tracking(-0.43) + .foregroundStyle(RosettaColors.Adaptive.text) + .lineLimit(1) + + if dialog.isVerified { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.figmaBlue) + } + + if dialog.isMuted { + Image(systemName: "speaker.slash.fill") + .font(.system(size: 12)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + } + + Spacer(minLength: 4) + + if dialog.lastMessageFromMe && !dialog.isSavedMessages { + deliveryIcon + } + + Text(formattedTime) + .font(.system(size: 14)) + .foregroundStyle( + dialog.unreadCount > 0 && !dialog.isMuted + ? RosettaColors.figmaBlue + : RosettaColors.Adaptive.textSecondary + ) + } + } +} + +// MARK: - Subtitle Row (message + pin + badge) + +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) + + if dialog.isPinned && dialog.unreadCount == 0 { + Image(systemName: "pin.fill") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + .rotationEffect(.degrees(45)) + } + + if dialog.unreadCount > 0 { + unreadBadge + } + } + } + + var messageText: String { + if dialog.lastMessage.isEmpty { + return "No messages yet" + } + return dialog.lastMessage + } + + @ViewBuilder + var deliveryIcon: some View { + switch dialog.lastMessageDelivered { + case .waiting: + Image(systemName: "clock") + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + case .delivered: + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + case .read: + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(RosettaColors.figmaBlue) + .overlay(alignment: .leading) { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(RosettaColors.figmaBlue) + .offset(x: -4) + } + .padding(.trailing, 2) + case .error: + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(RosettaColors.error) + } + } + + var unreadBadge: some View { + let count = dialog.unreadCount + let text = count > 999 ? "\(count / 1000)K" : (count > 99 ? "99+" : "\(count)") + let isMuted = dialog.isMuted + + return Text(text) + .font(.system(size: 15)) + .foregroundStyle(.white) + .padding(.horizontal, 4) + .frame(minWidth: 20, minHeight: 20) + .background { + Capsule() + .fill(isMuted ? Color(hex: 0x787880) : RosettaColors.figmaBlue) + } + } +} + +// MARK: - Time Formatting + +private extension ChatRowView { + var formattedTime: String { + guard dialog.lastMessageTimestamp > 0 else { return "" } + + let date = Date(timeIntervalSince1970: Double(dialog.lastMessageTimestamp) / 1000) + let now = Date() + let calendar = Calendar.current + + if calendar.isDateInToday(date) { + let f = DateFormatter() + f.dateFormat = "h:mm a" + return f.string(from: date) + } else if calendar.isDateInYesterday(date) { + return "Yesterday" + } else if let days = calendar.dateComponents([.day], from: date, to: now).day, days < 7 { + let f = DateFormatter() + f.dateFormat = "EEE" + return f.string(from: date) + } else { + let f = DateFormatter() + f.dateFormat = "dd.MM.yy" + return f.string(from: date) + } + } +} + +// MARK: - Preview + +#Preview { + let sampleDialog = Dialog( + id: "preview", account: "mykey", opponentKey: "abc001", + opponentTitle: "Alice Johnson", + opponentUsername: "alice", + lastMessage: "Hey, how are you?", + lastMessageTimestamp: Int64(Date().timeIntervalSince1970 * 1000), + unreadCount: 3, isOnline: true, lastSeen: 0, + isVerified: true, iHaveSent: true, + isPinned: false, isMuted: false, + lastMessageFromMe: true, lastMessageDelivered: .read + ) + + VStack(spacing: 0) { + ChatRowView(dialog: sampleDialog) + } + .background(RosettaColors.Adaptive.background) +} diff --git a/Rosetta/Features/Chats/Search/SearchView.swift b/Rosetta/Features/Chats/Search/SearchView.swift new file mode 100644 index 0000000..94b5207 --- /dev/null +++ b/Rosetta/Features/Chats/Search/SearchView.swift @@ -0,0 +1,340 @@ +import SwiftUI + +// MARK: - SearchView + +struct SearchView: View { + @State private var viewModel = SearchViewModel() + @State private var searchText = "" + @FocusState private var isSearchFocused: Bool + + var body: some View { + ZStack(alignment: .bottom) { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 0) { + if searchText.isEmpty { + recentSection + } else { + searchResultsContent + } + + Spacer().frame(height: 120) + } + } + .scrollDismissesKeyboard(.interactively) + + searchBar + } + .onChange(of: searchText) { _, newValue in + print("[SearchView] onChange fired: '\(newValue)'") + viewModel.setSearchQuery(newValue) + } + } +} + +// MARK: - Search Bar (bottom, Figma style) + +private extension SearchView { + var searchBar: some View { + HStack(spacing: 12) { + HStack(spacing: 4) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.adaptive( + light: Color(hex: 0x8C8C8C), + dark: Color(hex: 0x8E8E93) + )) + + TextField("Search", text: $searchText) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .focused($isSearchFocused) + .submitLabel(.search) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + if !searchText.isEmpty { + Button { + searchText = "" + viewModel.clearSearch() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.adaptive( + light: Color(hex: 0x8C8C8C), + dark: Color(hex: 0x8E8E93) + )) + } + } + } + .padding(.horizontal, 11) + .frame(height: 42) + .background { + Capsule() + .fill(RosettaColors.adaptive( + light: Color(hex: 0xF7F7F7), + dark: Color(hex: 0x2A2A2A) + )) + } + .applyGlassSearchBar() + + Button { + // TODO: Compose new chat + } label: { + Image(systemName: "square.and.pencil") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(RosettaColors.adaptive( + light: Color(hex: 0x404040), + dark: Color(hex: 0x8E8E93) + )) + .frame(width: 42, height: 42) + .background { + Circle() + .fill(RosettaColors.adaptive( + light: Color(hex: 0xF7F7F7), + dark: Color(hex: 0x2A2A2A) + )) + } + .applyGlassSearchBar() + } + .accessibilityLabel("New chat") + } + .padding(.horizontal, 28) + .padding(.bottom, 8) + .background { + RosettaColors.Adaptive.background + .opacity(0.95) + .ignoresSafeArea() + } + } +} + +// MARK: - Glass Search Bar Modifier + +private struct GlassSearchBarModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26, *) { + content + .glassEffect(.regular, in: .capsule) + } else { + content + } + } +} + +private extension View { + func applyGlassSearchBar() -> some View { + modifier(GlassSearchBarModifier()) + } +} + +// MARK: - Recent Section + +private extension SearchView { + @ViewBuilder + var recentSection: some View { + if viewModel.recentSearches.isEmpty { + emptyState + } else { + VStack(spacing: 0) { + // Section header + 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) + + // Recent items + ForEach(viewModel.recentSearches, id: \.publicKey) { user in + recentRow(user) + } + } + } + } + + var emptyState: some View { + VStack(spacing: 16) { + Image(systemName: "magnifyingglass") + .font(.system(size: 52)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + .padding(.top, 100) + + Text("Search for users") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + Text("Find people by username or public key") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textTertiary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .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 { + searchText = user.username.isEmpty ? user.publicKey : user.username + } label: { + HStack(spacing: 12) { + ZStack(alignment: .topTrailing) { + AvatarView( + initials: initials, + colorIndex: colorIdx, + size: 42, + isSavedMessages: isSelf + ) + + // Close button to remove from recent + 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: - Search Results Content + +private extension SearchView { + @ViewBuilder + var searchResultsContent: some View { + if viewModel.isSearching { + 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) + } 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) + // 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) + } +} + +// MARK: - Preview + +#Preview { + SearchView() +} diff --git a/Rosetta/Features/Chats/Search/SearchViewModel.swift b/Rosetta/Features/Chats/Search/SearchViewModel.swift new file mode 100644 index 0000000..84acdea --- /dev/null +++ b/Rosetta/Features/Chats/Search/SearchViewModel.swift @@ -0,0 +1,180 @@ +import Foundation +import os + +// MARK: - Recent Search Model + +struct RecentSearch: Codable, Equatable { + let publicKey: String + var title: String + var username: String + var lastSeenText: String +} + +// MARK: - SearchViewModel + +@Observable +@MainActor +final class SearchViewModel { + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Search") + + // MARK: - State + + var searchQuery = "" + + private(set) var searchResults: [SearchUser] = [] + private(set) var isSearching = false + private(set) var recentSearches: [RecentSearch] = [] + + private var searchTask: Task? + private var lastSearchedText = "" + + private static let recentKey = "rosetta_recent_searches" + private static let maxRecent = 20 + + // MARK: - Init + + init() { + loadRecentSearches() + setupSearchCallback() + } + + // MARK: - Search Logic + + func setSearchQuery(_ query: String) { + print("[Search] setSearchQuery called: '\(query)'") + searchQuery = query + onSearchQueryChanged() + } + + private func onSearchQueryChanged() { + searchTask?.cancel() + searchTask = nil + + let trimmed = searchQuery.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { + searchResults = [] + isSearching = false + lastSearchedText = "" + return + } + + if trimmed == lastSearchedText { + print("[Search] Query unchanged, skipping") + return + } + + isSearching = true + print("[Search] Starting debounce for '\(trimmed)'") + + // Debounce 1 second (like Android) + searchTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + + guard let self, !Task.isCancelled else { + print("[Search] Task cancelled during debounce") + return + } + + let currentQuery = self.searchQuery.trimmingCharacters(in: .whitespaces) + guard !currentQuery.isEmpty, currentQuery == trimmed else { + print("[Search] Query changed during debounce, aborting") + return + } + + let connState = ProtocolManager.shared.connectionState + let hash = SessionManager.shared.privateKeyHash + print("[Search] connState=\(connState.rawValue), hasHash=\(hash != nil), query='\(currentQuery)'") + + guard connState == .authenticated, let hash else { + print("[Search] NOT AUTHENTICATED - aborting search") + self.isSearching = false + return + } + + self.lastSearchedText = currentQuery + + var packet = PacketSearch() + packet.privateKey = hash + packet.search = currentQuery + print("[Search] Sending PacketSearch for '\(currentQuery)' with hash prefix: \(String(hash.prefix(16)))...") + ProtocolManager.shared.sendPacket(packet) + } + } + + func clearSearch() { + searchQuery = "" + searchResults = [] + isSearching = false + lastSearchedText = "" + searchTask?.cancel() + searchTask = nil + } + + // MARK: - Search Callback + + private func setupSearchCallback() { + print("[Search] Setting up search callback on ProtocolManager") + 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.searchResults = packet.users + self.isSearching = false + + // Update dialog info from results + for user in packet.users { + DialogRepository.shared.updateUserInfo( + publicKey: user.publicKey, + title: user.title, + username: user.username + ) + } + } + } + } + + // MARK: - Recent Searches + + func addToRecent(_ user: SearchUser) { + let recent = RecentSearch( + publicKey: user.publicKey, + title: user.title, + username: user.username, + lastSeenText: user.online == 1 ? "online" : "last seen recently" + ) + + // Remove duplicate if exists + recentSearches.removeAll { $0.publicKey == user.publicKey } + // Insert at top + recentSearches.insert(recent, at: 0) + // Keep max + 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() { + guard let data = UserDefaults.standard.data(forKey: Self.recentKey), + let list = try? JSONDecoder().decode([RecentSearch].self, from: data) else { + return + } + recentSearches = list + } + + private func saveRecentSearches() { + guard let data = try? JSONEncoder().encode(recentSearches) else { return } + UserDefaults.standard.set(data, forKey: Self.recentKey) + } +} diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift new file mode 100644 index 0000000..6443b9d --- /dev/null +++ b/Rosetta/Features/MainTabView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// Main container view with tab-based navigation. +struct MainTabView: View { + var onLogout: (() -> Void)? + @State private var selectedTab: RosettaTab = .chats + + var body: some View { + ZStack(alignment: .bottom) { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + Group { + switch selectedTab { + case .chats: + ChatListView() + case .settings: + SettingsView(onLogout: onLogout) + case .search: + SearchView() + } + } + + RosettaTabBar( + selectedTab: selectedTab, + onTabSelected: { tab in + withAnimation(.easeInOut(duration: 0.15)) { + selectedTab = tab + } + }, + badges: tabBadges + ) + .ignoresSafeArea(.keyboard) + } + } + + private var tabBadges: [TabBadge] { + var result: [TabBadge] = [] + let unread = DialogRepository.shared.sortedDialogs + .filter { !$0.isMuted } + .reduce(0) { $0 + $1.unreadCount } + if unread > 0 { + result.append(TabBadge(tab: .chats, text: unread > 999 ? "\(unread / 1000)K" : "\(unread)")) + } + return result + } +} + +// MARK: - Placeholder + +struct PlaceholderTabView: View { + let title: String + let icon: String + + var body: some View { + NavigationStack { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + VStack(spacing: 16) { + Image(systemName: icon) + .font(.system(size: 52)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.5)) + + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + + Text("Coming soon") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textTertiary) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + } + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + } + } +} diff --git a/Rosetta/Features/Onboarding/OnboardingPager.swift b/Rosetta/Features/Onboarding/OnboardingPager.swift new file mode 100644 index 0000000..a8574ae --- /dev/null +++ b/Rosetta/Features/Onboarding/OnboardingPager.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// UIPageViewController wrapper that handles paging entirely in UIKit. +/// SwiftUI state is only updated after a page fully settles — zero overhead during swipe. +struct OnboardingPager: UIViewControllerRepresentable { + @Binding var currentIndex: Int + let count: Int + let buildPage: (Int) -> Page + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIViewController(context: Context) -> UIPageViewController { + let vc = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + vc.view.backgroundColor = .clear + vc.dataSource = context.coordinator + vc.delegate = context.coordinator + vc.setViewControllers( + [context.coordinator.controllers[currentIndex]], + direction: .forward, + animated: false + ) + // Make the inner scroll view transparent + for sub in vc.view.subviews { + (sub as? UIScrollView)?.backgroundColor = .clear + } + return vc + } + + func updateUIViewController(_ vc: UIPageViewController, context: Context) { + context.coordinator.parent = self + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate { + var parent: OnboardingPager + let controllers: [UIHostingController] + + init(_ parent: OnboardingPager) { + self.parent = parent + // Create hosting controllers without triggering view loading + self.controllers = (0.. UIViewController? { + guard let idx = controllers.firstIndex(where: { $0 === vc }), idx > 0 else { return nil } + return controllers[idx - 1] + } + + func pageViewController( + _ pvc: UIPageViewController, + viewControllerAfter vc: UIViewController + ) -> UIViewController? { + guard let idx = controllers.firstIndex(where: { $0 === vc }), + idx < parent.count - 1 else { return nil } + return controllers[idx + 1] + } + + func pageViewController( + _ pvc: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + guard completed, + let current = pvc.viewControllers?.first, + let idx = controllers.firstIndex(where: { $0 === current }) else { return } + parent.currentIndex = idx + } + } +} diff --git a/Rosetta/Features/Onboarding/OnboardingView.swift b/Rosetta/Features/Onboarding/OnboardingView.swift index a90a426..a45ebc4 100644 --- a/Rosetta/Features/Onboarding/OnboardingView.swift +++ b/Rosetta/Features/Onboarding/OnboardingView.swift @@ -5,33 +5,29 @@ struct OnboardingView: View { let onStartMessaging: () -> Void @State private var currentPage = 0 - @State private var dragOffset: CGFloat = 0 - @State private var activeIndex: CGFloat = 0 - private let pages = OnboardingPages.all - private let springResponse: Double = 0.4 - private let springDamping: Double = 0.85 var body: some View { GeometryReader { geometry in - let screenWidth = geometry.size.width + let pagerHeight = geometry.size.height * 0.32 + 152 ZStack { RosettaColors.Dark.background.ignoresSafeArea() VStack(spacing: 0) { - Spacer() - .frame(height: geometry.size.height * 0.1) + OnboardingPager( + currentIndex: $currentPage, + count: pages.count + ) { i in + OnboardingPageSlide( + page: pages[i], + screenWidth: geometry.size.width, + screenHeight: geometry.size.height + ) + } + .frame(height: pagerHeight) - lottieSection(screenWidth: screenWidth) - .frame(height: geometry.size.height * 0.22) - - Spacer().frame(height: 32) - - textSection(screenWidth: screenWidth) - .frame(height: 120) - - pageIndicator(screenWidth: screenWidth) + pageIndicator() .padding(.top, 24) Spacer() @@ -41,71 +37,72 @@ struct OnboardingView: View { .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36) } } - .gesture(swipeGesture(screenWidth: screenWidth)) } .preferredColorScheme(.dark) .statusBarHidden(false) } } -// MARK: - Lottie Section +// MARK: - Page Slide +// Standalone view — has no dependency on parent state. +// Lottie playback controlled entirely by .onAppear / .onDisappear. -private extension OnboardingView { - func lottieSection(screenWidth: CGFloat) -> some View { - ZStack { - ForEach(pages) { page in - LottieView( - animationName: page.animationName, - loopMode: .playOnce, - animationSpeed: 1.5, - isPlaying: currentPage == page.id - ) - .frame(width: screenWidth * 0.42, height: screenWidth * 0.42) - .offset(x: textOffset(for: page.id, screenWidth: screenWidth)) - } +private struct OnboardingPageSlide: View { + let page: OnboardingPage + let screenWidth: CGFloat + let screenHeight: CGFloat + + @State private var isPlaying = false + + var body: some View { + VStack(spacing: 0) { + Spacer().frame(height: screenHeight * 0.1) + + LottieView( + animationName: page.animationName, + loopMode: .playOnce, + animationSpeed: 1.5, + isPlaying: isPlaying + ) + .frame(width: screenWidth * 0.42, height: screenHeight * 0.22) + + Spacer().frame(height: 32) + + textBlock() + .frame(height: 120) } - .frame(width: screenWidth, height: screenWidth * 0.42) - .clipped() - .accessibilityLabel("Illustration for \(pages[currentPage].title)") - } -} - -// MARK: - Text Section - -private extension OnboardingView { - func textSection(screenWidth: CGFloat) -> some View { - ZStack { - ForEach(pages) { page in - VStack(spacing: 14) { - Text(page.title) - .font(.system(size: 32, weight: .bold)) - .foregroundStyle(.white) - - highlightedDescription(page: page) - } - .frame(width: screenWidth) - .offset(x: textOffset(for: page.id, screenWidth: screenWidth)) - .opacity(textOpacity(for: page.id, screenWidth: screenWidth)) - } - } - .clipped() - .accessibilityElement(children: .combine) - .accessibilityLabel("\(pages[currentPage].title). \(pages[currentPage].description)") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(RosettaColors.Dark.background) + .onAppear { isPlaying = true } + .onDisappear { isPlaying = false } + .accessibilityElement(children: .contain) + .accessibilityLabel("\(page.title). \(page.description)") } - func highlightedDescription(page: OnboardingPage) -> some View { - let lines = page.description.components(separatedBy: "\n") - return VStack(spacing: 4) { - ForEach(lines, id: \.self) { line in - buildHighlightedLine(line, highlights: page.highlightedWords) - } + private func textBlock() -> some View { + VStack(spacing: 14) { + Text(page.title) + .font(.system(size: 32, weight: .bold)) + .foregroundStyle(.white) + + highlightedText() } + .frame(width: screenWidth) .multilineTextAlignment(.center) } - func buildHighlightedLine(_ line: String, highlights: [String]) -> some View { + private func highlightedText() -> some View { + let lines = page.description.components(separatedBy: "\n") + return VStack(spacing: 4) { + ForEach(lines, id: \.self) { line in + highlightedLine(line) + } + } + } + + private func highlightedLine(_ line: String) -> some View { var attributed = AttributedString(line) - for word in highlights { + for word in page.highlightedWords { if let range = attributed.range(of: word, options: .caseInsensitive) { attributed[range].foregroundColor = RosettaColors.primaryBlue attributed[range].font = .system(size: 17, weight: .semibold) @@ -115,56 +112,27 @@ private extension OnboardingView { .font(.system(size: 17)) .foregroundStyle(RosettaColors.secondaryText) } - - func textOffset(for index: Int, screenWidth: CGFloat) -> CGFloat { - CGFloat(index - currentPage) * screenWidth + dragOffset - } - - func textOpacity(for index: Int, screenWidth: CGFloat) -> Double { - let offset = abs(textOffset(for: index, screenWidth: screenWidth)) - return max(1.0 - (offset / screenWidth) * 1.5, 0.0) - } } -// MARK: - Page Indicator +// MARK: - Bottom Controls private extension OnboardingView { - func pageIndicator(screenWidth: CGFloat) -> some View { + func pageIndicator() -> some View { let dotSize: CGFloat = 7 let spacing: CGFloat = 15 - let count = pages.count - let center = CGFloat(count - 1) / 2.0 - let frac = activeIndex - floor(activeIndex) - let distFromWhole = min(frac, 1.0 - frac) - let stretchFactor = distFromWhole * 2.0 - - let capsuleWidth = dotSize + stretchFactor * spacing * 0.85 - let capsuleHeight = dotSize * (1.0 - stretchFactor * 0.12) - let capsuleX = (activeIndex - center) * spacing - - return ZStack { - ForEach(0.. some View { Button(action: onStartMessaging) { Text("Start Messaging") @@ -179,41 +147,6 @@ private extension OnboardingView { } } -// MARK: - Swipe Gesture - -private extension OnboardingView { - func swipeGesture(screenWidth: CGFloat) -> some Gesture { - DragGesture(minimumDistance: 20) - .onChanged { value in - let translation = value.translation.width - let isAtStart = currentPage == 0 && translation > 0 - let isAtEnd = currentPage == pages.count - 1 && translation < 0 - - dragOffset = (isAtStart || isAtEnd) ? translation * 0.25 : translation - - let progress = -dragOffset / screenWidth - activeIndex = min(max(CGFloat(currentPage) + progress, 0), CGFloat(pages.count - 1)) - } - .onEnded { value in - let threshold = screenWidth * 0.25 - let velocity = value.predictedEndTranslation.width - value.translation.width - var newPage = currentPage - - if value.translation.width < -threshold || velocity < -150 { - newPage = min(currentPage + 1, pages.count - 1) - } else if value.translation.width > threshold || velocity > 150 { - newPage = max(currentPage - 1, 0) - } - - withAnimation(.spring(response: springResponse, dampingFraction: springDamping)) { - currentPage = newPage - dragOffset = 0 - activeIndex = CGFloat(newPage) - } - } - } -} - // MARK: - Preview #Preview { diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..0ce6414 --- /dev/null +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -0,0 +1,219 @@ +import SwiftUI + +/// Settings / Profile screen with Telegram iOS-style grouped glass cards. +struct SettingsView: View { + var onLogout: (() -> Void)? + + @State private var viewModel = SettingsViewModel() + @State private var showCopiedToast = false + @State private var showLogoutConfirmation = false + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + profileHeader + accountSection + generalSection + dangerSection + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 100) + } + .background(RosettaColors.Adaptive.background) + .scrollContentBackground(.hidden) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text("Settings") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button("Edit") {} + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.primaryBlue) + } + } + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbarBackground(.visible, for: .navigationBar) + .alert("Log Out", isPresented: $showLogoutConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Log Out", role: .destructive) { + SessionManager.shared.endSession() + onLogout?() + } + } message: { + Text("Are you sure you want to log out?") + } + } + .overlay(alignment: .top) { + if showCopiedToast { + copiedToast + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } + + // MARK: - Profile Header + + private var profileHeader: some View { + VStack(spacing: 12) { + AvatarView( + initials: viewModel.initials, + colorIndex: viewModel.avatarColorIndex, + size: 80, + isSavedMessages: false + ) + + VStack(spacing: 4) { + Text(viewModel.displayName.isEmpty ? "Set Display Name" : viewModel.displayName) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(RosettaColors.Adaptive.text) + + if !viewModel.username.isEmpty { + Text("@\(viewModel.username)") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.secondaryText) + } + + // Connection status + HStack(spacing: 6) { + Circle() + .fill(viewModel.isConnected ? RosettaColors.online : RosettaColors.tertiaryText) + .frame(width: 8, height: 8) + Text(viewModel.connectionStatus) + .font(.system(size: 13)) + .foregroundStyle(RosettaColors.tertiaryText) + } + .padding(.top, 4) + } + + // Public key + Button { + viewModel.copyPublicKey() + withAnimation(.easeInOut(duration: 0.25)) { showCopiedToast = true } + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + withAnimation { showCopiedToast = false } + } + } label: { + HStack(spacing: 6) { + Text(formatPublicKey(viewModel.publicKey)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(RosettaColors.tertiaryText) + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + .foregroundStyle(RosettaColors.primaryBlue) + } + } + } + .padding(.vertical, 16) + } + + // MARK: - Account Section + + private var accountSection: some View { + GlassCard(cornerRadius: 12, fillOpacity: 0.08) { + VStack(spacing: 0) { + settingsRow(icon: "person.fill", title: "My Profile", color: .red) {} + sectionDivider + settingsRow(icon: "bookmark.fill", title: "Saved Messages", color: .purple) {} + sectionDivider + settingsRow(icon: "desktopcomputer", title: "Devices", color: .orange) {} + sectionDivider + settingsRow(icon: "folder.fill", title: "Chat Folders", color: .blue) {} + } + } + } + + // MARK: - General Section + + private var generalSection: some View { + GlassCard(cornerRadius: 12, fillOpacity: 0.08) { + VStack(spacing: 0) { + settingsRow(icon: "bell.fill", title: "Notifications", color: .red) {} + sectionDivider + settingsRow(icon: "lock.fill", title: "Privacy and Security", color: .gray) {} + sectionDivider + settingsRow(icon: "paintbrush.fill", title: "Appearance", color: .blue) {} + sectionDivider + settingsRow(icon: "globe", title: "Language", color: .purple) {} + } + } + } + + // MARK: - Danger Section + + private var dangerSection: some View { + GlassCard(cornerRadius: 12, fillOpacity: 0.08) { + Button { + showLogoutConfirmation = true + } label: { + HStack { + Spacer() + Text("Log Out") + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.error) + Spacer() + } + .padding(.vertical, 14) + } + } + } + + // MARK: - Helpers + + private func settingsRow( + icon: String, + title: String, + color: Color, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: icon) + .font(.system(size: 16)) + .foregroundStyle(RosettaColors.Adaptive.text) + .frame(width: 30, height: 30) + .background(color) + .clipShape(RoundedRectangle(cornerRadius: 7)) + + Text(title) + .font(.system(size: 17)) + .foregroundStyle(RosettaColors.Adaptive.text) + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(RosettaColors.tertiaryText) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + + private var sectionDivider: some View { + Divider() + .background(RosettaColors.Adaptive.divider) + .padding(.leading, 60) + } + + private func formatPublicKey(_ key: String) -> String { + guard key.count > 16 else { return key } + return String(key.prefix(8)) + "..." + String(key.suffix(6)) + } + + private var copiedToast: some View { + Text("Public key copied") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.text) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + .padding(.top, 60) + } +} diff --git a/Rosetta/Features/Settings/SettingsViewModel.swift b/Rosetta/Features/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..f7bc4c6 --- /dev/null +++ b/Rosetta/Features/Settings/SettingsViewModel.swift @@ -0,0 +1,52 @@ +import Foundation +import Observation +import UIKit + +@Observable +@MainActor +final class SettingsViewModel { + + var displayName: String { + SessionManager.shared.displayName.isEmpty + ? (AccountManager.shared.currentAccount?.displayName ?? "") + : SessionManager.shared.displayName + } + + var username: String { + SessionManager.shared.username.isEmpty + ? (AccountManager.shared.currentAccount?.username ?? "") + : SessionManager.shared.username + } + + var publicKey: String { + AccountManager.shared.currentAccount?.publicKey ?? "" + } + + var initials: String { + RosettaColors.initials(name: displayName, publicKey: publicKey) + } + + var avatarColorIndex: Int { + RosettaColors.avatarColorIndex(for: publicKey) + } + + var connectionStatus: String { + switch ProtocolManager.shared.connectionState { + case .disconnected: return "Disconnected" + case .connecting: return "Connecting..." + case .connected: return "Connected" + case .handshaking: return "Authenticating..." + case .authenticated: return "Online" + } + } + + var isConnected: Bool { + ProtocolManager.shared.connectionState == .authenticated + } + + func copyPublicKey() { + #if canImport(UIKit) + UIPasteboard.general.string = publicKey + #endif + } +} diff --git a/Rosetta/Features/Splash/SplashView.swift b/Rosetta/Features/Splash/SplashView.swift new file mode 100644 index 0000000..64163be --- /dev/null +++ b/Rosetta/Features/Splash/SplashView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct SplashView: View { + let onSplashComplete: () -> Void + + @State private var logoScale: CGFloat = 0.3 + @State private var logoOpacity: Double = 0 + @State private var glowScale: CGFloat = 0.3 + @State private var glowPulsing = false + + var body: some View { + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + ZStack { + // Glow behind logo + Circle() + .fill(Color(hex: 0x54A9EB).opacity(0.2)) + .frame(width: 180, height: 180) + .scaleEffect(glowScale * (glowPulsing ? 1.1 : 1.0)) + .opacity(logoOpacity) + + // Logo + Image("RosettaIcon") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 150, height: 150) + .clipShape(Circle()) + .scaleEffect(logoScale) + .opacity(logoOpacity) + } + } + .task { + try? await Task.sleep(for: .milliseconds(100)) + + withAnimation(.easeOut(duration: 0.6)) { + logoOpacity = 1 + } + withAnimation(.spring(response: 0.6, dampingFraction: 0.6)) { + logoScale = 1 + glowScale = 1 + } + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + glowPulsing = true + } + + try? await Task.sleep(for: .seconds(2)) + onSplashComplete() + } + .accessibilityLabel("Rosetta") + } +} + +#Preview { + SplashView(onSplashComplete: {}) +} diff --git a/Rosetta/RosettaApp.swift b/Rosetta/RosettaApp.swift index d3f9b54..d96bc2d 100644 --- a/Rosetta/RosettaApp.swift +++ b/Rosetta/RosettaApp.swift @@ -1,45 +1,98 @@ -// -// RosettaApp.swift -// Rosetta -// -// Created by Gaidar Timirbaev on 22.02.2026. -// - import SwiftUI +// MARK: - App State + +private enum AppState { + case splash + case onboarding + case auth + case unlock + case main +} + +// MARK: - RosettaApp + @main struct RosettaApp: App { - @State private var hasCompletedOnboarding = false + + init() { + UIWindow.appearance().backgroundColor = .systemBackground + // Preload Lottie animations early + Task.detached(priority: .userInitiated) { + LottieAnimationCache.shared.preload(OnboardingPages.all.map(\.animationName)) + } + } + + @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("isLoggedIn") private var isLoggedIn = false + @State private var appState: AppState = .splash var body: some Scene { WindowGroup { - rootView - .preferredColorScheme(.dark) + ZStack { + RosettaColors.Adaptive.background + .ignoresSafeArea() + + rootView + } } } @ViewBuilder private var rootView: some View { - if !hasCompletedOnboarding { + switch appState { + case .splash: + SplashView { + withAnimation(.easeInOut(duration: 0.4)) { + determineNextState() + } + } + + case .onboarding: OnboardingView { withAnimation(.easeInOut(duration: 0.4)) { hasCompletedOnboarding = true + appState = .auth } } - } else if !isLoggedIn { + + case .auth: AuthCoordinator { withAnimation(.easeInOut(duration: 0.4)) { isLoggedIn = true + // Start session automatically with the password from auth flow + appState = .main } } + + case .unlock: + UnlockView { + withAnimation(.easeInOut(duration: 0.4)) { + isLoggedIn = true + appState = .main + } + } + + case .main: + MainTabView(onLogout: { + withAnimation(.easeInOut(duration: 0.4)) { + isLoggedIn = false + appState = .unlock + } + }) + } + } + + private func determineNextState() { + if AccountManager.shared.hasAccount { + // Existing user — unlock with password + appState = .unlock + } else if !hasCompletedOnboarding { + // New user — show onboarding first + appState = .onboarding } else { - // TODO: Replace with main ChatListView - Text("Welcome to Rosetta") - .font(RosettaFont.headlineLarge) - .foregroundStyle(.white) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(RosettaColors.Dark.background) + // Onboarding done but no account — go to auth + appState = .auth } } }