ios_client
This commit is contained in:
BIN
ios_client 0.8.5/Kecalek/.DS_Store
vendored
Normal file
BIN
ios_client 0.8.5/Kecalek/.DS_Store
vendored
Normal file
Binary file not shown.
161
ios_client 0.8.5/Kecalek/AppState.swift
Normal file
161
ios_client 0.8.5/Kecalek/AppState.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum ConnectionStatus: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case reconnecting
|
||||
}
|
||||
|
||||
@Observable
|
||||
final class AppState {
|
||||
var isLoggedIn = false
|
||||
var currentUser: User?
|
||||
var connectionStatus: ConnectionStatus = .disconnected
|
||||
var email: String = ""
|
||||
|
||||
let chatClient = ChatClient()
|
||||
|
||||
private var reconnectTask: Task<Void, Never>?
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
private var isReconnecting = false
|
||||
private var backgroundedAt: Date?
|
||||
|
||||
/// Start listening for connection state changes (call after login)
|
||||
func startConnectionMonitor() {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await chatClient.makeNotificationStream()
|
||||
for await notification in stream {
|
||||
guard !Task.isCancelled else { break }
|
||||
if case .connectionStateChanged(let connected) = notification {
|
||||
await MainActor.run {
|
||||
if connected {
|
||||
self.connectionStatus = .connected
|
||||
self.isReconnecting = false
|
||||
self.reconnectTask?.cancel()
|
||||
self.reconnectTask = nil
|
||||
} else if self.isLoggedIn, !self.isReconnecting {
|
||||
// Only start reconnect if not already reconnecting
|
||||
// (reconnect() internally calls disconnect() which fires this)
|
||||
self.connectionStatus = .disconnected
|
||||
self.attemptReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt reconnect with exponential backoff; immediate logout on auth failure
|
||||
@MainActor
|
||||
private func attemptReconnect() {
|
||||
reconnectTask?.cancel()
|
||||
isReconnecting = true
|
||||
reconnectTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let maxAttempts = 5
|
||||
var delay: TimeInterval = Constants.reconnectBaseDelay
|
||||
|
||||
for attempt in 1...maxAttempts {
|
||||
guard !Task.isCancelled, self.isLoggedIn else { return }
|
||||
|
||||
self.connectionStatus = .reconnecting
|
||||
#if DEBUG
|
||||
print("DEBUG AppState: reconnect attempt \(attempt)/\(maxAttempts), delay=\(delay)s")
|
||||
#endif
|
||||
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
guard !Task.isCancelled, self.isLoggedIn else { return }
|
||||
|
||||
let result = await self.chatClient.reconnect()
|
||||
switch result {
|
||||
case .success:
|
||||
self.connectionStatus = .connected
|
||||
self.isReconnecting = false
|
||||
#if DEBUG
|
||||
print("DEBUG AppState: reconnected on attempt \(attempt)")
|
||||
#endif
|
||||
return
|
||||
case .authFailed:
|
||||
// Keys rotated or invalid — logout immediately, don't retry
|
||||
self.isReconnecting = false
|
||||
#if DEBUG
|
||||
print("DEBUG AppState: auth failed (keys likely rotated), logging out immediately")
|
||||
#endif
|
||||
await self.logout()
|
||||
return
|
||||
case .networkError:
|
||||
// Network issue — retry with backoff
|
||||
delay = min(delay * 2, Constants.reconnectMaxDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// All network retries exhausted → force logout
|
||||
self.isReconnecting = false
|
||||
guard !Task.isCancelled, self.isLoggedIn else { return }
|
||||
#if DEBUG
|
||||
print("DEBUG AppState: reconnect failed after \(maxAttempts) attempts, logging out")
|
||||
#endif
|
||||
await self.logout()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - App Lifecycle
|
||||
|
||||
func handleEnteredBackground() {
|
||||
backgroundedAt = Date()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleBecameActive() {
|
||||
guard isLoggedIn, !isReconnecting else { return }
|
||||
let wasInBackground = backgroundedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
backgroundedAt = nil
|
||||
|
||||
Task {
|
||||
let alive = await chatClient.isConnectionAlive()
|
||||
if !alive {
|
||||
#if DEBUG
|
||||
print("DEBUG AppState: foreground — connection dead, reconnecting")
|
||||
#endif
|
||||
await MainActor.run {
|
||||
guard !self.isReconnecting else { return }
|
||||
self.connectionStatus = .reconnecting
|
||||
self.attemptReconnect()
|
||||
}
|
||||
} else if wasInBackground > 30 {
|
||||
// Connection appears alive but was backgrounded a long time —
|
||||
// force reconnect to ensure fresh state
|
||||
#if DEBUG
|
||||
print("DEBUG AppState: foreground — stale connection (\(Int(wasInBackground))s), reconnecting")
|
||||
#endif
|
||||
await MainActor.run {
|
||||
guard !self.isReconnecting else { return }
|
||||
self.connectionStatus = .reconnecting
|
||||
self.attemptReconnect()
|
||||
}
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("DEBUG AppState: foreground — connection alive (\(Int(wasInBackground))s in bg)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
isReconnecting = false
|
||||
reconnectTask?.cancel()
|
||||
reconnectTask = nil
|
||||
notificationTask?.cancel()
|
||||
notificationTask = nil
|
||||
await chatClient.disconnect()
|
||||
KeychainService.deleteCredentials()
|
||||
isLoggedIn = false
|
||||
currentUser = nil
|
||||
connectionStatus = .disconnected
|
||||
email = ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 702 KiB |
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios_client 0.8.5/Kecalek/Assets.xcassets/Contents.json
Normal file
6
ios_client 0.8.5/Kecalek/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
3666
ios_client 0.8.5/Kecalek/Core/ChatClient.swift
Normal file
3666
ios_client 0.8.5/Kecalek/Core/ChatClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
485
ios_client 0.8.5/Kecalek/Core/KeyStorage.swift
Normal file
485
ios_client 0.8.5/Kecalek/Core/KeyStorage.swift
Normal file
@@ -0,0 +1,485 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Local file storage for keys, sessions, and sender keys.
|
||||
/// Matches Python: chat_core.py key storage functions.
|
||||
///
|
||||
/// Base directory: Application Support / EncryptedChat / {email}
|
||||
/// Same file names as Python client for cross-platform compatibility.
|
||||
enum KeyStorage {
|
||||
|
||||
// MARK: - Base Directory
|
||||
|
||||
/// Get or create the key storage directory for a user
|
||||
static func getKeyDir(email: String) throws -> URL {
|
||||
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
let dir = appSupport.appendingPathComponent("EncryptedChat").appendingPathComponent(email)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
// iOS file protection
|
||||
try (dir as NSURL).setResourceValue(URLFileProtection.complete, forKey: .fileProtectionKey)
|
||||
return dir
|
||||
}
|
||||
|
||||
// MARK: - RSA Keys
|
||||
|
||||
/// Save RSA keypair
|
||||
static func saveRSAKeys(email: String, privateKey: SecKey, publicKey: SecKey, password: Data? = nil) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let privData = try RSACrypto.serializePrivateKey(privateKey, password: password)
|
||||
let pubData = try RSACrypto.serializePublicKey(publicKey)
|
||||
try writeProtected(privData, to: dir.appendingPathComponent("private.pem"))
|
||||
try writeProtected(pubData, to: dir.appendingPathComponent("public.pem"))
|
||||
}
|
||||
|
||||
/// Load RSA keypair. Returns (private, public, error).
|
||||
static func loadRSAKeys(email: String, password: Data? = nil) -> (SecKey?, SecKey?, String?) {
|
||||
guard let dir = try? getKeyDir(email: email) else {
|
||||
return (nil, nil, "Cannot access key directory")
|
||||
}
|
||||
let privPath = dir.appendingPathComponent("private.pem")
|
||||
let pubPath = dir.appendingPathComponent("public.pem")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path) else {
|
||||
return (nil, nil, "No local keys found.")
|
||||
}
|
||||
|
||||
guard let privData = try? Data(contentsOf: privPath),
|
||||
let pubData = try? Data(contentsOf: pubPath) else {
|
||||
return (nil, nil, "Cannot read key files.")
|
||||
}
|
||||
|
||||
do {
|
||||
let privateKey = try RSACrypto.loadPrivateKey(privData, password: password)
|
||||
let publicKey = try RSACrypto.loadPublicKey(pubData)
|
||||
return (privateKey, publicKey, nil)
|
||||
} catch {
|
||||
// Try without password (unencrypted)
|
||||
do {
|
||||
let privateKey = try RSACrypto.loadPrivateKey(privData, password: nil)
|
||||
let publicKey = try RSACrypto.loadPublicKey(pubData)
|
||||
// Re-save with password if provided
|
||||
if let password = password {
|
||||
try? saveRSAKeys(email: email, privateKey: privateKey, publicKey: publicKey, password: password)
|
||||
}
|
||||
return (privateKey, publicKey, nil)
|
||||
} catch {
|
||||
return (nil, nil, "Invalid or missing password.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Identity Keys (Ed25519)
|
||||
|
||||
static func saveIdentityKeys(
|
||||
email: String,
|
||||
privateKey: Curve25519.Signing.PrivateKey,
|
||||
publicKey: Curve25519.Signing.PublicKey,
|
||||
password: Data? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let privData = try Ed25519Crypto.serializePrivate(privateKey, password: password)
|
||||
let pubData = Ed25519Crypto.serializePublic(publicKey)
|
||||
try writeProtected(privData, to: dir.appendingPathComponent("identity_private.bin"))
|
||||
try writeProtected(pubData, to: dir.appendingPathComponent("identity_public.bin"))
|
||||
}
|
||||
|
||||
static func loadIdentityKeys(
|
||||
email: String,
|
||||
password: Data? = nil
|
||||
) -> (Curve25519.Signing.PrivateKey?, Curve25519.Signing.PublicKey?) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
|
||||
let privPath = dir.appendingPathComponent("identity_private.bin")
|
||||
let pubPath = dir.appendingPathComponent("identity_public.bin")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path),
|
||||
let privData = try? Data(contentsOf: privPath),
|
||||
let pubData = try? Data(contentsOf: pubPath) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
do {
|
||||
let priv = try Ed25519Crypto.loadPrivate(privData, password: password)
|
||||
let pub = try Ed25519Crypto.loadPublic(pubData)
|
||||
return (priv, pub)
|
||||
} catch {
|
||||
return (nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Signed Pre-Key
|
||||
|
||||
static func saveSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("spk_private.bin"))
|
||||
try writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("spk_id.txt"))
|
||||
}
|
||||
|
||||
static func loadSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
|
||||
let privPath = dir.appendingPathComponent("spk_private.bin")
|
||||
let idPath = dir.appendingPathComponent("spk_id.txt")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path),
|
||||
let privData = try? Data(contentsOf: privPath),
|
||||
let priv = try? X25519Crypto.loadPrivate(privData) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
let spkId = (try? String(data: Data(contentsOf: idPath), encoding: .utf8))?.trimmed ?? ""
|
||||
return (priv, spkId)
|
||||
}
|
||||
|
||||
// MARK: - Previous SPK (Grace Period)
|
||||
|
||||
static func savePrevSPK(email: String, privateKey: Curve25519.KeyAgreement.PrivateKey, spkId: String) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("prev_spk_private.bin"))
|
||||
try writeProtected(Data(spkId.utf8), to: dir.appendingPathComponent("prev_spk_id.txt"))
|
||||
}
|
||||
|
||||
static func loadPrevSPK(email: String) -> (Curve25519.KeyAgreement.PrivateKey?, String?) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return (nil, nil) }
|
||||
let privPath = dir.appendingPathComponent("prev_spk_private.bin")
|
||||
let idPath = dir.appendingPathComponent("prev_spk_id.txt")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: privPath.path),
|
||||
let privData = try? Data(contentsOf: privPath),
|
||||
let priv = try? X25519Crypto.loadPrivate(privData) else {
|
||||
return (nil, nil)
|
||||
}
|
||||
let spkId = (try? String(data: Data(contentsOf: idPath), encoding: .utf8))?.trimmed ?? ""
|
||||
return (priv, spkId)
|
||||
}
|
||||
|
||||
// MARK: - One-Time Pre-Keys
|
||||
|
||||
static func saveOPKPrivate(email: String, opkId: String, privateKey: Curve25519.KeyAgreement.PrivateKey) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("opk_private")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
try writeProtected(X25519Crypto.serializePrivate(privateKey), to: dir.appendingPathComponent("\(opkId).bin"))
|
||||
}
|
||||
|
||||
static func loadOPKPrivate(email: String, opkId: String) -> Curve25519.KeyAgreement.PrivateKey? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
|
||||
guard let data = try? Data(contentsOf: path) else { return nil }
|
||||
return try? X25519Crypto.loadPrivate(data)
|
||||
}
|
||||
|
||||
static func deleteOPKPrivate(email: String, opkId: String) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let path = dir.appendingPathComponent("opk_private").appendingPathComponent("\(opkId).bin")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
}
|
||||
|
||||
// MARK: - Device ID
|
||||
|
||||
static func saveDeviceId(email: String, deviceId: String) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
try writeProtected(Data(deviceId.utf8), to: dir.appendingPathComponent("device_id.txt"))
|
||||
}
|
||||
|
||||
static func loadDeviceId(email: String) -> String? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("device_id.txt")
|
||||
guard let data = try? Data(contentsOf: path) else { return nil }
|
||||
let str = String(data: data, encoding: .utf8)?.trimmed
|
||||
return (str?.isEmpty ?? true) ? nil : str
|
||||
}
|
||||
|
||||
// MARK: - Sessions (Double Ratchet)
|
||||
|
||||
static func saveSession(
|
||||
email: String,
|
||||
peerUserId: String,
|
||||
ratchet: DoubleRatchet,
|
||||
localKey: Data? = nil,
|
||||
peerDeviceId: String? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("sessions")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let filename: String
|
||||
if let deviceId = peerDeviceId {
|
||||
filename = "\(peerUserId)_\(deviceId).bin"
|
||||
} else {
|
||||
filename = "\(peerUserId).bin"
|
||||
}
|
||||
|
||||
let exported = try ratchet.exportState()
|
||||
guard let localKey = localKey else {
|
||||
throw CryptoError.encryptionFailed("localKey required for session storage")
|
||||
}
|
||||
let data = try CryptoUtils.encryptLocal(exported, key: localKey)
|
||||
try writeProtected(data, to: dir.appendingPathComponent(filename))
|
||||
}
|
||||
|
||||
static func loadSession(
|
||||
email: String,
|
||||
peerUserId: String,
|
||||
localKey: Data? = nil,
|
||||
peerDeviceId: String? = nil
|
||||
) -> DoubleRatchet? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let sessionsDir = dir.appendingPathComponent("sessions")
|
||||
|
||||
let filename: String
|
||||
if let deviceId = peerDeviceId {
|
||||
filename = "\(peerUserId)_\(deviceId).bin"
|
||||
} else {
|
||||
filename = "\(peerUserId).bin"
|
||||
}
|
||||
|
||||
let path = sessionsDir.appendingPathComponent(filename)
|
||||
return loadSessionFile(path, localKey: localKey)
|
||||
}
|
||||
|
||||
static func deleteSession(email: String, peerUserId: String, peerDeviceId: String? = nil) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let sessionsDir = dir.appendingPathComponent("sessions")
|
||||
|
||||
if let deviceId = peerDeviceId {
|
||||
let path = sessionsDir.appendingPathComponent("\(peerUserId)_\(deviceId).bin")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
} else {
|
||||
// Delete all sessions for this user
|
||||
if let files = try? FileManager.default.contentsOfDirectory(atPath: sessionsDir.path) {
|
||||
for file in files where file.hasPrefix(peerUserId) {
|
||||
try? FileManager.default.removeItem(at: sessionsDir.appendingPathComponent(file))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadSessionFile(_ path: URL, localKey: Data?) -> DoubleRatchet? {
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
if let localKey = localKey {
|
||||
// Try encrypted first
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
|
||||
return try? DoubleRatchet.importState(decrypted)
|
||||
}
|
||||
// Migration: try plaintext, immediately re-encrypt
|
||||
if let ratchet = try? DoubleRatchet.importState(raw) {
|
||||
try? writeProtected(CryptoUtils.encryptLocal(try ratchet.exportState(), key: localKey), to: path)
|
||||
return ratchet
|
||||
}
|
||||
// Corrupted — delete
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// No localKey — refuse to load plaintext sessions
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Sender Keys
|
||||
|
||||
static func saveSenderKeyState(
|
||||
email: String,
|
||||
convId: String,
|
||||
state: SenderKeyState,
|
||||
localKey: Data? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
guard let localKey = localKey else {
|
||||
throw CryptoError.encryptionFailed("localKey required for sender key storage")
|
||||
}
|
||||
let data = try CryptoUtils.encryptLocal(state.exportState(), key: localKey)
|
||||
try writeProtected(data, to: dir.appendingPathComponent("\(convId).bin"))
|
||||
}
|
||||
|
||||
static func loadSenderKeyState(
|
||||
email: String,
|
||||
convId: String,
|
||||
localKey: Data? = nil
|
||||
) -> SenderKeyState? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
if let localKey = localKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
|
||||
return try? SenderKeyState.importState(decrypted)
|
||||
}
|
||||
// Migration: try plaintext, immediately re-encrypt
|
||||
if let state = try? SenderKeyState.importState(raw) {
|
||||
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
|
||||
return state
|
||||
}
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func deleteSenderKeyState(email: String, convId: String) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let path = dir.appendingPathComponent("sender_keys").appendingPathComponent("\(convId).bin")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
}
|
||||
|
||||
// MARK: - Received Sender Keys
|
||||
|
||||
static func saveRecvSenderKey(
|
||||
email: String,
|
||||
convId: String,
|
||||
senderId: String,
|
||||
senderDeviceId: String,
|
||||
state: SenderKeyState,
|
||||
localKey: Data? = nil
|
||||
) throws {
|
||||
let dir = try getKeyDir(email: email).appendingPathComponent("sender_keys_recv")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
guard let localKey = localKey else {
|
||||
throw CryptoError.encryptionFailed("localKey required for sender key storage")
|
||||
}
|
||||
let data = try CryptoUtils.encryptLocal(state.exportState(), key: localKey)
|
||||
try writeProtected(data, to: dir.appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin"))
|
||||
}
|
||||
|
||||
static func loadRecvSenderKey(
|
||||
email: String,
|
||||
convId: String,
|
||||
senderId: String,
|
||||
senderDeviceId: String,
|
||||
localKey: Data? = nil
|
||||
) -> SenderKeyState? {
|
||||
guard let dir = try? getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("sender_keys_recv").appendingPathComponent("\(convId)_\(senderId)_\(senderDeviceId).bin")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
if let localKey = localKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
|
||||
return try? SenderKeyState.importState(decrypted)
|
||||
}
|
||||
// Migration: try plaintext, immediately re-encrypt
|
||||
if let state = try? SenderKeyState.importState(raw) {
|
||||
try? writeProtected(CryptoUtils.encryptLocal(state.exportState(), key: localKey), to: path)
|
||||
return state
|
||||
}
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func deleteRecvSenderKeys(email: String, convId: String) {
|
||||
guard let dir = try? getKeyDir(email: email) else { return }
|
||||
let recvDir = dir.appendingPathComponent("sender_keys_recv")
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(atPath: recvDir.path) else { return }
|
||||
for file in files where file.hasPrefix(convId) {
|
||||
try? FileManager.default.removeItem(at: recvDir.appendingPathComponent(file))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favorites
|
||||
|
||||
static func saveFavorites(email: String, favorites: Set<String>, localKey: Data? = nil) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: Array(favorites))
|
||||
let dataToWrite: Data
|
||||
if let localKey = localKey {
|
||||
dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: localKey)
|
||||
} else {
|
||||
dataToWrite = jsonData
|
||||
}
|
||||
try writeProtected(dataToWrite, to: dir.appendingPathComponent("favorites.json"))
|
||||
}
|
||||
|
||||
static func loadFavorites(email: String, localKey: Data? = nil) -> Set<String> {
|
||||
guard let dir = try? getKeyDir(email: email) else { return [] }
|
||||
let path = dir.appendingPathComponent("favorites.json")
|
||||
guard let raw = try? Data(contentsOf: path) else { return [] }
|
||||
let jsonData: Data
|
||||
if let localKey = localKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: localKey) {
|
||||
jsonData = decrypted
|
||||
} else {
|
||||
jsonData = raw // migration fallback
|
||||
}
|
||||
} else {
|
||||
jsonData = raw
|
||||
}
|
||||
guard let array = try? JSONSerialization.jsonObject(with: jsonData) as? [String] else {
|
||||
return []
|
||||
}
|
||||
return Set(array)
|
||||
}
|
||||
|
||||
// MARK: - TOFU Identity Key Registry
|
||||
|
||||
static func saveKnownIdentityKeys(email: String, keys: [String: [String: String]], localKey: Data?) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let jsonObj: [String: Any] = ["version": 1, "keys": keys]
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: jsonObj)
|
||||
guard let localKey = localKey else {
|
||||
try writeProtected(jsonData, to: dir.appendingPathComponent("known_identity_keys.bin"))
|
||||
return
|
||||
}
|
||||
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey)
|
||||
try writeProtected(encrypted, to: dir.appendingPathComponent("known_identity_keys.bin"))
|
||||
}
|
||||
|
||||
static func loadKnownIdentityKeys(email: String, localKey: Data?) -> [String: [String: String]] {
|
||||
guard let dir = try? getKeyDir(email: email) else { return [:] }
|
||||
let path = dir.appendingPathComponent("known_identity_keys.bin")
|
||||
guard let raw = try? Data(contentsOf: path) else { return [:] }
|
||||
do {
|
||||
let jsonData: Data
|
||||
if let localKey = localKey {
|
||||
jsonData = try CryptoUtils.decryptLocal(raw, key: localKey)
|
||||
} else {
|
||||
jsonData = raw
|
||||
}
|
||||
guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
||||
let keys = obj["keys"] as? [String: [String: String]] else { return [:] }
|
||||
return keys
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verified Contacts
|
||||
|
||||
static func saveVerifiedContacts(email: String, contacts: [String: [String: String]], localKey: Data?) throws {
|
||||
let dir = try getKeyDir(email: email)
|
||||
let jsonObj: [String: Any] = ["version": 1, "contacts": contacts]
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: jsonObj)
|
||||
guard let localKey = localKey else {
|
||||
try writeProtected(jsonData, to: dir.appendingPathComponent("verified_contacts.bin"))
|
||||
return
|
||||
}
|
||||
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: localKey)
|
||||
try writeProtected(encrypted, to: dir.appendingPathComponent("verified_contacts.bin"))
|
||||
}
|
||||
|
||||
static func loadVerifiedContacts(email: String, localKey: Data?) -> [String: [String: String]] {
|
||||
guard let dir = try? getKeyDir(email: email) else { return [:] }
|
||||
let path = dir.appendingPathComponent("verified_contacts.bin")
|
||||
guard let raw = try? Data(contentsOf: path) else { return [:] }
|
||||
do {
|
||||
let jsonData: Data
|
||||
if let localKey = localKey {
|
||||
jsonData = try CryptoUtils.decryptLocal(raw, key: localKey)
|
||||
} else {
|
||||
jsonData = raw
|
||||
}
|
||||
guard let obj = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
||||
let contacts = obj["contacts"] as? [String: [String: String]] else { return [:] }
|
||||
return contacts
|
||||
} catch {
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func writeProtected(_ data: Data, to url: URL) throws {
|
||||
try data.write(to: url, options: .completeFileProtection)
|
||||
}
|
||||
}
|
||||
132
ios_client 0.8.5/Kecalek/Core/KeychainService.swift
Normal file
132
ios_client 0.8.5/Kecalek/Core/KeychainService.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import LocalAuthentication
|
||||
|
||||
enum KeychainService {
|
||||
private static let service = "com.encryptedchat.credentials"
|
||||
private static let account = "userCredentials"
|
||||
|
||||
struct Credentials: Codable {
|
||||
let email: String
|
||||
let password: String
|
||||
let host: String
|
||||
let port: UInt16
|
||||
}
|
||||
|
||||
/// Check if saved credentials exist without triggering biometric prompt.
|
||||
static func hasSavedCredentials() -> Bool {
|
||||
let context = LAContext()
|
||||
context.interactionNotAllowed = true
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecUseAuthenticationContext as String: context
|
||||
]
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
// errSecInteractionNotAllowed means item exists but needs biometric
|
||||
return status == errSecSuccess || status == errSecInteractionNotAllowed
|
||||
}
|
||||
|
||||
/// Save credentials to Keychain with biometric protection.
|
||||
static func saveCredentials(email: String, password: String, host: String, port: UInt16) throws {
|
||||
// Delete any existing entry first
|
||||
deleteCredentials()
|
||||
|
||||
let credentials = Credentials(email: email, password: password, host: host, port: port)
|
||||
let data = try JSONEncoder().encode(credentials)
|
||||
|
||||
var accessError: Unmanaged<CFError>?
|
||||
guard let accessControl = SecAccessControlCreateWithFlags(
|
||||
kCFAllocatorDefault,
|
||||
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
|
||||
.biometryAny,
|
||||
&accessError
|
||||
) else {
|
||||
throw KeychainError.accessControlCreationFailed
|
||||
}
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessControl as String: accessControl
|
||||
]
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load credentials from Keychain. Triggers biometric prompt.
|
||||
static func loadCredentials() throws -> Credentials {
|
||||
let context = LAContext()
|
||||
context.localizedReason = "Unlock to log in"
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseAuthenticationContext as String: context
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
if status == errSecUserCanceled || status == errSecAuthFailed {
|
||||
throw KeychainError.biometricFailed
|
||||
}
|
||||
throw KeychainError.loadFailed(status)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(Credentials.self, from: data)
|
||||
}
|
||||
|
||||
/// Delete stored credentials from Keychain.
|
||||
@discardableResult
|
||||
static func deleteCredentials() -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
return status == errSecSuccess || status == errSecItemNotFound
|
||||
}
|
||||
|
||||
/// Check if biometric authentication is available on this device.
|
||||
static func isBiometricAvailable() -> Bool {
|
||||
let context = LAContext()
|
||||
var error: NSError?
|
||||
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
}
|
||||
|
||||
enum KeychainError: LocalizedError {
|
||||
case accessControlCreationFailed
|
||||
case saveFailed(OSStatus)
|
||||
case loadFailed(OSStatus)
|
||||
case biometricFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .accessControlCreationFailed:
|
||||
return "Failed to create biometric access control"
|
||||
case .saveFailed(let status):
|
||||
return "Failed to save credentials (error \(status))"
|
||||
case .loadFailed(let status):
|
||||
return "Failed to load credentials (error \(status))"
|
||||
case .biometricFailed:
|
||||
return "Biometric authentication failed or was cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal file
200
ios_client 0.8.5/Kecalek/Core/MessageCache.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
|
||||
/// Encrypted local message cache.
|
||||
/// Matches Python: chat_core.py message cache (message_cache/{conv_id}.json)
|
||||
enum MessageCache {
|
||||
|
||||
/// Save messages for a conversation (encrypted with local storage key)
|
||||
static func save(email: String, convId: String, messages: [[String: Any]], cacheKey: Data?) throws {
|
||||
let dir = try KeyStorage.getKeyDir(email: email).appendingPathComponent("message_cache")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: messages)
|
||||
|
||||
guard let cacheKey = cacheKey else {
|
||||
return // Refuse to save plaintext message cache
|
||||
}
|
||||
let dataToWrite = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
|
||||
try dataToWrite.write(to: dir.appendingPathComponent("\(convId).json"), options: .completeFileProtection)
|
||||
}
|
||||
|
||||
/// Load messages for a conversation
|
||||
static func load(email: String, convId: String, cacheKey: Data?) -> [[String: Any]]? {
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
let jsonData: Data
|
||||
if let cacheKey = cacheKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
|
||||
jsonData = decrypted
|
||||
} else if let parsed = try? JSONSerialization.jsonObject(with: raw) as? [[String: Any]] {
|
||||
// Migration: re-encrypt plaintext cache and return
|
||||
try? save(email: email, convId: convId, messages: parsed, cacheKey: cacheKey)
|
||||
return parsed
|
||||
} else {
|
||||
// Corrupted — delete stale cache
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
jsonData = raw
|
||||
}
|
||||
|
||||
return try? JSONSerialization.jsonObject(with: jsonData) as? [[String: Any]]
|
||||
}
|
||||
|
||||
/// Search messages in a conversation
|
||||
static func search(email: String, convId: String, query: String, cacheKey: Data?) -> [[String: Any]] {
|
||||
guard let messages = load(email: email, convId: convId, cacheKey: cacheKey) else {
|
||||
return []
|
||||
}
|
||||
let lowerQuery = query.lowercased()
|
||||
return messages.filter { msg in
|
||||
if let text = msg["text"] as? String, text.lowercased().contains(lowerQuery) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete cache for a conversation
|
||||
static func delete(email: String, convId: String) {
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
|
||||
let path = dir.appendingPathComponent("message_cache").appendingPathComponent("\(convId).json")
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
}
|
||||
|
||||
// MARK: - Per-Message Cache (for Double Ratchet - messages can only be decrypted once)
|
||||
|
||||
/// Cache a decrypted message by its ID
|
||||
static func cacheDecryptedMessage(email: String, convId: String, messageId: String, plaintext: Data, cacheKey: Data?) {
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
|
||||
let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId)
|
||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
|
||||
let path = cacheDir.appendingPathComponent("\(messageId).bin")
|
||||
do {
|
||||
guard let cacheKey = cacheKey else { return } // Refuse plaintext
|
||||
let dataToWrite = try CryptoUtils.encryptLocal(plaintext, key: cacheKey)
|
||||
try dataToWrite.write(to: path, options: .completeFileProtection)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("DEBUG MessageCache: failed to cache message \(messageId): \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all cached decrypted messages for a conversation.
|
||||
/// Returns array of (messageId, plaintext) tuples.
|
||||
static func loadAllCachedMessages(email: String, convId: String, cacheKey: Data?) -> [(String, Data)] {
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [] }
|
||||
let cacheDir = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId)
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [] }
|
||||
|
||||
var result: [(String, Data)] = []
|
||||
for file in files where file.pathExtension == "bin" {
|
||||
let messageId = file.deletingPathExtension().lastPathComponent
|
||||
guard let raw = try? Data(contentsOf: file) else { continue }
|
||||
if let cacheKey = cacheKey,
|
||||
let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
|
||||
result.append((messageId, decrypted))
|
||||
} else if cacheKey == nil {
|
||||
result.append((messageId, raw))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Get a cached decrypted message by ID
|
||||
static func getCachedMessage(email: String, convId: String, messageId: String, cacheKey: Data?) -> Data? {
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("decrypted_messages").appendingPathComponent(convId).appendingPathComponent("\(messageId).bin")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
|
||||
if let cacheKey = cacheKey {
|
||||
if let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) {
|
||||
return decrypted
|
||||
}
|
||||
// Migration: try as plaintext, re-encrypt
|
||||
if let _ = try? JSONSerialization.jsonObject(with: raw) {
|
||||
cacheDecryptedMessage(email: email, convId: convId, messageId: messageId, plaintext: raw, cacheKey: cacheKey)
|
||||
return raw
|
||||
}
|
||||
// Corrupted — delete
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// MARK: - Conversation List Cache
|
||||
|
||||
/// Save conversation list to disk (encrypted with local key)
|
||||
static func saveConversations(email: String, conversations: [Conversation], cacheKey: Data?) {
|
||||
guard let cacheKey = cacheKey else { return }
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
|
||||
do {
|
||||
let jsonData = try JSONEncoder().encode(conversations)
|
||||
let encrypted = try CryptoUtils.encryptLocal(jsonData, key: cacheKey)
|
||||
try encrypted.write(to: dir.appendingPathComponent("conversation_cache.json"), options: .completeFileProtection)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("DEBUG MessageCache: failed to save conversations: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Load conversation list from disk
|
||||
static func loadConversations(email: String, cacheKey: Data?) -> [Conversation]? {
|
||||
guard let cacheKey = cacheKey else { return nil }
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("conversation_cache.json")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
guard let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { return nil }
|
||||
return try? JSONDecoder().decode([Conversation].self, from: decrypted)
|
||||
}
|
||||
|
||||
// MARK: - Avatar Disk Cache
|
||||
|
||||
/// Save avatar data to disk (encrypted with local key)
|
||||
static func saveAvatar(email: String, key: String, data: Data, cacheKey: Data?) {
|
||||
guard let cacheKey = cacheKey else { return }
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return }
|
||||
let cacheDir = dir.appendingPathComponent("avatar_cache")
|
||||
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
|
||||
do {
|
||||
let encrypted = try CryptoUtils.encryptLocal(data, key: cacheKey)
|
||||
try encrypted.write(to: cacheDir.appendingPathComponent("\(key).dat"), options: .completeFileProtection)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("DEBUG MessageCache: failed to save avatar \(key): \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Load avatar data from disk
|
||||
static func loadAvatar(email: String, key: String, cacheKey: Data?) -> Data? {
|
||||
guard let cacheKey = cacheKey else { return nil }
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return nil }
|
||||
let path = dir.appendingPathComponent("avatar_cache").appendingPathComponent("\(key).dat")
|
||||
guard let raw = try? Data(contentsOf: path) else { return nil }
|
||||
return try? CryptoUtils.decryptLocal(raw, key: cacheKey)
|
||||
}
|
||||
|
||||
/// Load all cached avatars from disk
|
||||
static func loadAllAvatars(email: String, cacheKey: Data?) -> [String: Data] {
|
||||
guard let cacheKey = cacheKey else { return [:] }
|
||||
guard let dir = try? KeyStorage.getKeyDir(email: email) else { return [:] }
|
||||
let cacheDir = dir.appendingPathComponent("avatar_cache")
|
||||
guard let files = try? FileManager.default.contentsOfDirectory(at: cacheDir, includingPropertiesForKeys: nil) else { return [:] }
|
||||
var result: [String: Data] = [:]
|
||||
for file in files where file.pathExtension == "dat" {
|
||||
let key = file.deletingPathExtension().lastPathComponent
|
||||
guard let raw = try? Data(contentsOf: file),
|
||||
let decrypted = try? CryptoUtils.decryptLocal(raw, key: cacheKey) else { continue }
|
||||
result[key] = decrypted
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
162
ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift
Normal file
162
ios_client 0.8.5/Kecalek/Crypto/ContactVerification.swift
Normal file
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Contact key verification: fingerprints, safety numbers, QR codes.
|
||||
/// Matches Python: crypto_utils.py compute_fingerprint, compute_safety_number, etc.
|
||||
enum ContactVerification {
|
||||
|
||||
/// Version byte for fingerprint computation (Signal's NumericFingerprint).
|
||||
private static let fingerprintVersion: UInt16 = 0
|
||||
|
||||
/// Number of SHA-512 iterations for fingerprint computation.
|
||||
private static let fingerprintIterations = 5200
|
||||
|
||||
// MARK: - Fingerprint
|
||||
|
||||
/// Compute a 32-byte fingerprint for a user's identity key.
|
||||
///
|
||||
/// Uses iterated SHA-512 (Signal's NumericFingerprint algorithm).
|
||||
/// Seed: version(2B big-endian) + identity_key(32B) + user_id(UTF-8).
|
||||
/// Each iteration: SHA-512(previous_hash + identity_key).
|
||||
/// Output: first 32 bytes of final hash.
|
||||
static func computeFingerprint(userId: String, identityKey: Data, iterations: Int = fingerprintIterations) -> Data {
|
||||
let versionBytes = fingerprintVersion.bigEndianData
|
||||
var data = versionBytes + identityKey + Data(userId.utf8)
|
||||
for _ in 0..<iterations {
|
||||
var hasher = SHA512()
|
||||
hasher.update(data: data)
|
||||
hasher.update(data: identityKey)
|
||||
let digest = hasher.finalize()
|
||||
data = Data(digest)
|
||||
}
|
||||
return Data(data.prefix(32))
|
||||
}
|
||||
|
||||
/// Format 32-byte fingerprint as 6 groups of 5 zero-padded digits (30 digits).
|
||||
///
|
||||
/// Each group: int(bytes[i*5:(i+1)*5], big-endian) % 100000.
|
||||
/// Output: two lines of 3 groups each, space-separated.
|
||||
static func formatFingerprint(_ fpBytes: Data) -> String {
|
||||
var groups: [String] = []
|
||||
for i in 0..<6 {
|
||||
let start = i * 5
|
||||
let end = min(start + 5, fpBytes.count)
|
||||
let slice = fpBytes[fpBytes.startIndex + start ..< fpBytes.startIndex + end]
|
||||
let num = bigEndianUInt64(slice) % 100000
|
||||
groups.append(String(format: "%05d", num))
|
||||
}
|
||||
return groups[0..<3].joined(separator: " ") + "\n" + groups[3..<6].joined(separator: " ")
|
||||
}
|
||||
|
||||
// MARK: - Safety Number
|
||||
|
||||
/// Compute a 60-digit safety number for a pair of users.
|
||||
///
|
||||
/// Both users see the same number regardless of who computes it.
|
||||
/// Lower user_id's fingerprint comes first (deterministic ordering).
|
||||
/// Output: 12 groups of 5 digits, formatted as 3 lines of 4 groups.
|
||||
static func computeSafetyNumber(
|
||||
myUserId: String, myIdentityKey: Data,
|
||||
theirUserId: String, theirIdentityKey: Data
|
||||
) -> String {
|
||||
let fpMine = computeFingerprint(userId: myUserId, identityKey: myIdentityKey)
|
||||
let fpTheirs = computeFingerprint(userId: theirUserId, identityKey: theirIdentityKey)
|
||||
|
||||
let combined: Data
|
||||
if myUserId < theirUserId {
|
||||
combined = fpMine + fpTheirs
|
||||
} else {
|
||||
combined = fpTheirs + fpMine
|
||||
}
|
||||
|
||||
// 64 bytes -> 12 groups of 5 digits
|
||||
var groups: [String] = []
|
||||
for i in 0..<12 {
|
||||
let start = i * 5
|
||||
let end = min(start + 5, combined.count)
|
||||
let slice = combined[combined.startIndex + start ..< combined.startIndex + end]
|
||||
let num = bigEndianUInt64(slice) % 100000
|
||||
groups.append(String(format: "%05d", num))
|
||||
}
|
||||
|
||||
return [
|
||||
groups[0..<4].joined(separator: " "),
|
||||
groups[4..<8].joined(separator: " "),
|
||||
groups[8..<12].joined(separator: " "),
|
||||
].joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - QR Code
|
||||
|
||||
/// Encode user identity for QR code verification.
|
||||
///
|
||||
/// Format: version(1B=0x01) + uid_len(1B) + uid(UTF-8) + identity_key(32B).
|
||||
static func encodeVerificationQR(userId: String, identityKey: Data) -> Data {
|
||||
let uidBytes = Data(userId.utf8)
|
||||
var data = Data([0x01, UInt8(uidBytes.count)])
|
||||
data.append(uidBytes)
|
||||
data.append(identityKey)
|
||||
return data
|
||||
}
|
||||
|
||||
/// Decode QR code verification payload.
|
||||
///
|
||||
/// Returns (userId, identityKey).
|
||||
/// Throws on invalid format.
|
||||
static func decodeVerificationQR(_ data: Data) throws -> (userId: String, identityKey: Data) {
|
||||
guard data.count >= 3 else {
|
||||
throw VerificationError.qrDataTooShort
|
||||
}
|
||||
guard data[data.startIndex] == 0x01 else {
|
||||
throw VerificationError.unknownQRVersion(data[data.startIndex])
|
||||
}
|
||||
let uidLen = Int(data[data.startIndex + 1])
|
||||
guard data.count >= 2 + uidLen + 32 else {
|
||||
throw VerificationError.qrDataTruncated
|
||||
}
|
||||
let uidData = data[data.startIndex + 2 ..< data.startIndex + 2 + uidLen]
|
||||
guard let userId = String(data: uidData, encoding: .utf8) else {
|
||||
throw VerificationError.invalidUTF8
|
||||
}
|
||||
let identityKey = Data(data[data.startIndex + 2 + uidLen ..< data.startIndex + 2 + uidLen + 32])
|
||||
return (userId, identityKey)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Convert up to 8 bytes to UInt64, big-endian.
|
||||
private static func bigEndianUInt64(_ data: Data) -> UInt64 {
|
||||
var result: UInt64 = 0
|
||||
for byte in data {
|
||||
result = result << 8 | UInt64(byte)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UInt16 Big-Endian
|
||||
|
||||
private extension UInt16 {
|
||||
var bigEndianData: Data {
|
||||
var value = self.bigEndian
|
||||
return Data(bytes: &value, count: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Verification Errors
|
||||
|
||||
enum VerificationError: Error, LocalizedError {
|
||||
case qrDataTooShort
|
||||
case unknownQRVersion(UInt8)
|
||||
case qrDataTruncated
|
||||
case invalidUTF8
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .qrDataTooShort: return "QR data too short"
|
||||
case .unknownQRVersion(let v): return "Unknown QR version: \(v)"
|
||||
case .qrDataTruncated: return "QR data truncated"
|
||||
case .invalidUTF8: return "Invalid UTF-8 in QR data"
|
||||
}
|
||||
}
|
||||
}
|
||||
95
ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift
Normal file
95
ios_client 0.8.5/Kecalek/Crypto/CryptoErrors.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
|
||||
enum CryptoError: Error, LocalizedError {
|
||||
case invalidBase64
|
||||
case invalidHex
|
||||
case invalidKeyData(String)
|
||||
case invalidSignature
|
||||
case signatureVerificationFailed
|
||||
case encryptionFailed(String)
|
||||
case decryptionFailed(String)
|
||||
case invalidECP1Format
|
||||
case pbkdf2Failed
|
||||
case rsaKeyGenerationFailed
|
||||
case rsaOperationFailed(String)
|
||||
case x3dhFailed(String)
|
||||
case ratchetError(String)
|
||||
case senderKeyError(String)
|
||||
case maxSkipExceeded
|
||||
case duplicateMessage
|
||||
case invalidHeader(String)
|
||||
case stateImportFailed(String)
|
||||
case keyConversionFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidBase64: return "Invalid base64 encoding"
|
||||
case .invalidHex: return "Invalid hex encoding"
|
||||
case .invalidKeyData(let msg): return "Invalid key data: \(msg)"
|
||||
case .invalidSignature: return "Invalid signature format"
|
||||
case .signatureVerificationFailed: return "Signature verification failed"
|
||||
case .encryptionFailed(let msg): return "Encryption failed: \(msg)"
|
||||
case .decryptionFailed(let msg): return "Decryption failed: \(msg)"
|
||||
case .invalidECP1Format: return "Invalid ECP1 key format"
|
||||
case .pbkdf2Failed: return "PBKDF2 key derivation failed"
|
||||
case .rsaKeyGenerationFailed: return "RSA key generation failed"
|
||||
case .rsaOperationFailed(let msg): return "RSA operation failed: \(msg)"
|
||||
case .x3dhFailed(let msg): return "X3DH failed: \(msg)"
|
||||
case .ratchetError(let msg): return "Ratchet error: \(msg)"
|
||||
case .senderKeyError(let msg): return "Sender key error: \(msg)"
|
||||
case .maxSkipExceeded: return "Maximum message skip exceeded"
|
||||
case .duplicateMessage: return "Duplicate message detected"
|
||||
case .invalidHeader(let msg): return "Invalid header: \(msg)"
|
||||
case .stateImportFailed(let msg): return "State import failed: \(msg)"
|
||||
case .keyConversionFailed(let msg): return "Key conversion failed: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkError: Error, LocalizedError {
|
||||
case notConnected
|
||||
case connectionFailed(String)
|
||||
case timeout
|
||||
case serverError(String)
|
||||
case protocolError(String)
|
||||
case messageTooLarge
|
||||
case invalidResponse(String)
|
||||
case authenticationFailed(String)
|
||||
case alreadyConnected
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConnected: return "Not connected to server"
|
||||
case .connectionFailed(let msg): return "Connection failed: \(msg)"
|
||||
case .timeout: return "Request timed out"
|
||||
case .serverError(let msg): return "Server error: \(msg)"
|
||||
case .protocolError(let msg): return "Protocol error: \(msg)"
|
||||
case .messageTooLarge: return "Message exceeds maximum size"
|
||||
case .invalidResponse(let msg): return "Invalid response: \(msg)"
|
||||
case .authenticationFailed(let msg): return "Authentication failed: \(msg)"
|
||||
case .alreadyConnected: return "Already connected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatError: Error, LocalizedError {
|
||||
case notLoggedIn
|
||||
case conversationNotFound
|
||||
case membershipRequired
|
||||
case permissionDenied(String)
|
||||
case operationFailed(String)
|
||||
case fileError(String)
|
||||
case invalidData(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notLoggedIn: return "Not logged in"
|
||||
case .conversationNotFound: return "Conversation not found"
|
||||
case .membershipRequired: return "Must be a member of this conversation"
|
||||
case .permissionDenied(let msg): return "Permission denied: \(msg)"
|
||||
case .operationFailed(let msg): return "Operation failed: \(msg)"
|
||||
case .fileError(let msg): return "File error: \(msg)"
|
||||
case .invalidData(let msg): return "Invalid data: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
196
ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift
Normal file
196
ios_client 0.8.5/Kecalek/Crypto/CryptoUtils.swift
Normal file
@@ -0,0 +1,196 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Core cryptographic utilities: AES-GCM, HKDF, KDF helpers
|
||||
enum CryptoUtils {
|
||||
|
||||
// MARK: - AES-256-GCM
|
||||
|
||||
/// Encrypt with AES-256-GCM. Returns (key, nonce, ciphertext, tag) — all as Data.
|
||||
/// If key is nil, generates a random 256-bit key.
|
||||
/// Matches Python: aes_encrypt(plaintext, key=None)
|
||||
static func aesEncrypt(_ plaintext: Data, key: Data? = nil) throws -> (key: Data, nonce: Data, ciphertext: Data, tag: Data) {
|
||||
let keyData = key ?? Data.randomBytes(32)
|
||||
let symmetricKey = SymmetricKey(data: keyData)
|
||||
let nonceData = Data.randomBytes(12)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonceData)
|
||||
|
||||
let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey, nonce: gcmNonce)
|
||||
|
||||
return (
|
||||
key: keyData,
|
||||
nonce: nonceData,
|
||||
ciphertext: Data(sealedBox.ciphertext),
|
||||
tag: Data(sealedBox.tag)
|
||||
)
|
||||
}
|
||||
|
||||
/// Decrypt with AES-256-GCM.
|
||||
/// Matches Python: aes_decrypt(key, nonce, ciphertext, tag)
|
||||
static func aesDecrypt(key: Data, nonce: Data, ciphertext: Data, tag: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ciphertext,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("AES-GCM decryption failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt with AES-256-GCM using AAD. Returns ciphertext with tag appended.
|
||||
/// Used by Double Ratchet and Sender Keys.
|
||||
static func aesGcmEncrypt(_ plaintext: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.seal(
|
||||
plaintext,
|
||||
using: symmetricKey,
|
||||
nonce: gcmNonce,
|
||||
authenticating: aad
|
||||
)
|
||||
|
||||
// Return ciphertext + tag concatenated (matches Python AESGCM.encrypt)
|
||||
return Data(sealedBox.ciphertext) + Data(sealedBox.tag)
|
||||
}
|
||||
|
||||
/// Decrypt AES-256-GCM with AAD. Input ciphertext has tag appended (last 16 bytes).
|
||||
static func aesGcmDecrypt(_ ctWithTag: Data, key: Data, nonce: Data, aad: Data) throws -> Data {
|
||||
guard ctWithTag.count >= 16 else {
|
||||
throw CryptoError.decryptionFailed("Ciphertext too short")
|
||||
}
|
||||
|
||||
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||
let tag = ctWithTag.suffix(16)
|
||||
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: aad)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("AES-GCM decryption with AAD failed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HKDF
|
||||
|
||||
/// HKDF-SHA256 key derivation.
|
||||
/// Matches Python: hkdf_derive(input_key, salt, info, length=32)
|
||||
static func hkdfDerive(inputKey: Data, salt: Data, info: Data, length: Int = 32) -> Data {
|
||||
let symmetricKey = SymmetricKey(data: inputKey)
|
||||
let derived = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: symmetricKey,
|
||||
salt: salt,
|
||||
info: info,
|
||||
outputByteCount: length
|
||||
)
|
||||
return derived.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
// MARK: - KDF for Double Ratchet
|
||||
|
||||
/// Root key KDF. Returns (newRootKey, chainKey).
|
||||
/// HKDF with rootKey as salt and DH output as input. Derives 64 bytes, split in half.
|
||||
/// Matches Python: kdf_rk(root_key, dh_output)
|
||||
static func kdfRK(rootKey: Data, dhOutput: Data) -> (newRootKey: Data, chainKey: Data) {
|
||||
let derived = hkdfDerive(
|
||||
inputKey: dhOutput,
|
||||
salt: rootKey,
|
||||
info: Data(Constants.rootKeyInfo.utf8),
|
||||
length: 64
|
||||
)
|
||||
return (derived.prefix(32), Data(derived.suffix(32)))
|
||||
}
|
||||
|
||||
/// Chain key KDF. Returns (newChainKey, messageKey).
|
||||
/// HMAC-SHA256: messageKey = HMAC(chainKey, 0x01), newChainKey = HMAC(chainKey, 0x02)
|
||||
/// Matches Python: kdf_ck(chain_key)
|
||||
static func kdfCK(chainKey: Data) -> (newChainKey: Data, messageKey: Data) {
|
||||
let symmetricKey = SymmetricKey(data: chainKey)
|
||||
let messageKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x01]), using: symmetricKey))
|
||||
let newChainKey = Data(HMAC<SHA256>.authenticationCode(for: Data([0x02]), using: symmetricKey))
|
||||
return (newChainKey, messageKey)
|
||||
}
|
||||
|
||||
// MARK: - Self-Encryption Key
|
||||
|
||||
/// Derive static AES-256 key from identity key for self-encrypted message copies.
|
||||
/// Matches Python: derive_self_encryption_key(identity_private)
|
||||
static func deriveSelfEncryptionKey(identityPrivateRaw: Data) -> Data {
|
||||
hkdfDerive(
|
||||
inputKey: identityPrivateRaw,
|
||||
salt: Data(Constants.selfEncryptionSalt.utf8),
|
||||
info: Data(Constants.selfEncryptionInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Local Storage Key
|
||||
|
||||
/// Derive AES-256 key for encrypting local session/sender key files.
|
||||
/// Matches Python: derive_local_storage_key(identity_private)
|
||||
static func deriveLocalStorageKey(identityPrivateRaw: Data) -> Data {
|
||||
hkdfDerive(
|
||||
inputKey: identityPrivateRaw,
|
||||
salt: Data(Constants.localStorageSalt.utf8),
|
||||
info: Data(Constants.localStorageInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Local File Encryption
|
||||
|
||||
/// Encrypt data for local storage. Format: nonce(12) + tag(16) + ciphertext
|
||||
/// Matches Python: _encrypt_local(data, key)
|
||||
static func encryptLocal(_ data: Data, key: Data) throws -> Data {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let sealedBox = try AES.GCM.seal(data, using: symmetricKey)
|
||||
|
||||
var result = Data()
|
||||
result.append(Data(sealedBox.nonce)) // 12 bytes
|
||||
result.append(Data(sealedBox.tag)) // 16 bytes
|
||||
result.append(Data(sealedBox.ciphertext)) // N bytes
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decrypt locally stored data. Format: nonce(12) + tag(16) + ciphertext
|
||||
/// Matches Python: _decrypt_local(raw, key)
|
||||
static func decryptLocal(_ raw: Data, key: Data) throws -> Data {
|
||||
guard raw.count >= 28 else { // 12 + 16 minimum
|
||||
throw CryptoError.decryptionFailed("Local encrypted data too short")
|
||||
}
|
||||
|
||||
let nonce = raw[0..<12]
|
||||
let tag = raw[12..<28]
|
||||
let ct = raw[28...]
|
||||
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("Local storage decryption failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
393
ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift
Normal file
393
ios_client 0.8.5/Kecalek/Crypto/DoubleRatchet.swift
Normal file
@@ -0,0 +1,393 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Ratchet header sent with each message
|
||||
struct RatchetHeader {
|
||||
let dhPub: Data // sender's current ratchet public key (32 bytes)
|
||||
let n: Int // message number in current sending chain
|
||||
let pn: Int // number of messages in previous sending chain
|
||||
|
||||
/// Serialize header to JSON bytes for use as AAD.
|
||||
/// Matches Python: RatchetHeader.serialize()
|
||||
/// IMPORTANT: Must match Python's json.dumps() format exactly (with spaces after : and ,)
|
||||
func serialize() -> Data {
|
||||
// Python json.dumps produces: {"dh_pub": "...", "n": 0, "pn": 0}
|
||||
// Note the spaces after colons and commas - this is critical for AAD matching
|
||||
let jsonString = "{\"dh_pub\": \"\(dhPub.hexString)\", \"n\": \(n), \"pn\": \(pn)}"
|
||||
return jsonString.data(using: .utf8)!
|
||||
}
|
||||
|
||||
/// Convert to dictionary for protocol.
|
||||
/// Matches Python: RatchetHeader.to_dict()
|
||||
func toDict() -> [String: Any] {
|
||||
[
|
||||
"dh_pub": dhPub.hexString,
|
||||
"n": n,
|
||||
"pn": pn,
|
||||
]
|
||||
}
|
||||
|
||||
/// Parse from dictionary.
|
||||
/// Matches Python: RatchetHeader.from_dict(d)
|
||||
static func fromDict(_ d: [String: Any]) throws -> RatchetHeader {
|
||||
guard let dhPubHex = d["dh_pub"] as? String,
|
||||
let dhPub = Data(hexString: dhPubHex),
|
||||
let n = d["n"] as? Int,
|
||||
let pn = d["pn"] as? Int else {
|
||||
throw CryptoError.invalidHeader("Missing or invalid header fields")
|
||||
}
|
||||
return RatchetHeader(dhPub: dhPub, n: n, pn: pn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal Double Ratchet implementation.
|
||||
/// Matches Python: DoubleRatchet class in crypto_utils.py
|
||||
class DoubleRatchet {
|
||||
|
||||
private(set) var dhPair: (privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey)?
|
||||
private(set) var dhRemote: Curve25519.KeyAgreement.PublicKey?
|
||||
private(set) var rootKey: Data = Data()
|
||||
private(set) var sendChainKey: Data?
|
||||
private(set) var recvChainKey: Data?
|
||||
private(set) var sendN: Int = 0
|
||||
private(set) var recvN: Int = 0
|
||||
private(set) var prevSendN: Int = 0
|
||||
// Skipped message keys: "dh_pub_hex:n" → message_key
|
||||
private(set) var skipped: [String: Data] = [:]
|
||||
|
||||
/// Attached X3DH header — set when creating a new session, consumed on first send.
|
||||
/// Matches Python: ratchet._x3dh_header
|
||||
var x3dhHeader: [String: Any]?
|
||||
|
||||
init() {}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
/// Initialize as initiator (Alice) after X3DH.
|
||||
/// Matches Python: DoubleRatchet.init_alice(shared_secret, bob_spk_pub)
|
||||
static func initAlice(sharedSecret: Data, bobSpkPub: Curve25519.KeyAgreement.PublicKey) throws -> DoubleRatchet {
|
||||
let ratchet = DoubleRatchet()
|
||||
let (priv, pub) = X25519Crypto.generateKeypair()
|
||||
ratchet.dhPair = (priv, pub)
|
||||
ratchet.dhRemote = bobSpkPub
|
||||
|
||||
// Debug: print ratchet inputs (matching Python _dh_ratchet)
|
||||
#if DEBUG
|
||||
print("DEBUG initAlice: shared_secret (root_key) = \(sharedSecret.hexString)")
|
||||
print("DEBUG initAlice: my_dh_pub = \(X25519Crypto.serializePublic(pub).hexString)")
|
||||
print("DEBUG initAlice: remote_dh_pub (bob_spk) = \(X25519Crypto.serializePublic(bobSpkPub).hexString)")
|
||||
#endif
|
||||
|
||||
// Perform DH ratchet to derive send chain
|
||||
let dhOutput = try X25519Crypto.dh(priv, bobSpkPub)
|
||||
let (newRK, sendCK) = CryptoUtils.kdfRK(rootKey: sharedSecret, dhOutput: dhOutput)
|
||||
#if DEBUG
|
||||
print("DEBUG initAlice: dh_output = \(dhOutput.hexString)")
|
||||
print("DEBUG initAlice: new_root_key = \(newRK.hexString)")
|
||||
print("DEBUG initAlice: send_chain_key = \(sendCK.hexString)")
|
||||
#endif
|
||||
ratchet.rootKey = newRK
|
||||
ratchet.sendChainKey = sendCK
|
||||
ratchet.recvChainKey = nil
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
return ratchet
|
||||
}
|
||||
|
||||
/// Initialize as responder (Bob) after X3DH.
|
||||
/// Matches Python: DoubleRatchet.init_bob(shared_secret, spk_pair)
|
||||
static func initBob(
|
||||
sharedSecret: Data,
|
||||
spkPair: (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey)
|
||||
) -> DoubleRatchet {
|
||||
let ratchet = DoubleRatchet()
|
||||
ratchet.dhPair = spkPair
|
||||
ratchet.rootKey = sharedSecret
|
||||
ratchet.sendChainKey = nil
|
||||
ratchet.recvChainKey = nil
|
||||
ratchet.sendN = 0
|
||||
ratchet.recvN = 0
|
||||
ratchet.prevSendN = 0
|
||||
return ratchet
|
||||
}
|
||||
|
||||
// MARK: - Encrypt
|
||||
|
||||
/// Encrypt a message.
|
||||
/// Returns (header dict, ciphertext with tag, nonce).
|
||||
/// Matches Python: DoubleRatchet.encrypt(plaintext)
|
||||
func encrypt(_ plaintext: Data) throws -> (header: [String: Any], ciphertext: Data, nonce: Data) {
|
||||
guard sendChainKey != nil else {
|
||||
throw CryptoError.ratchetError("Send chain not initialized")
|
||||
}
|
||||
guard let dhPair = dhPair else {
|
||||
throw CryptoError.ratchetError("DH pair not set")
|
||||
}
|
||||
|
||||
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: sendChainKey!)
|
||||
sendChainKey = newCK
|
||||
|
||||
let header = RatchetHeader(
|
||||
dhPub: X25519Crypto.serializePublic(dhPair.publicKey),
|
||||
n: sendN,
|
||||
pn: prevSendN
|
||||
)
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
let aad = header.serialize()
|
||||
|
||||
// Debug: print encrypt values (matching Python decrypt)
|
||||
#if DEBUG
|
||||
print("DEBUG encrypt: message_key = \(messageKey.hexString)")
|
||||
print("DEBUG encrypt: aad = \(aad.hexString)")
|
||||
print("DEBUG encrypt: aad_str = \(String(data: aad, encoding: .utf8) ?? "nil")")
|
||||
print("DEBUG encrypt: nonce = \(nonce.hexString)")
|
||||
#endif
|
||||
|
||||
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
|
||||
#if DEBUG
|
||||
print("DEBUG encrypt: ciphertext_len = \(ctWithTag.count)")
|
||||
#endif
|
||||
|
||||
sendN += 1
|
||||
|
||||
return (header.toDict(), ctWithTag, nonce)
|
||||
}
|
||||
|
||||
// MARK: - Decrypt
|
||||
|
||||
/// Decrypt a message. Handles DH ratchet step if new dh_pub.
|
||||
/// State is snapshotted before modification and restored on failure (M9 fix).
|
||||
/// Matches Python: DoubleRatchet.decrypt(header_dict, ciphertext, nonce)
|
||||
func decrypt(headerDict: [String: Any], ciphertext: Data, nonce: Data) throws -> Data {
|
||||
let header = try RatchetHeader.fromDict(headerDict)
|
||||
let remoteDhPubBytes = header.dhPub
|
||||
|
||||
// Check if this is from a skipped message
|
||||
let skipKey = "\(remoteDhPubBytes.hexString):\(header.n)"
|
||||
if let mk = skipped[skipKey] {
|
||||
skipped.removeValue(forKey: skipKey)
|
||||
let aad = header.serialize()
|
||||
do {
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
// Restore skipped key on failure
|
||||
skipped[skipKey] = mk
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot state before modifications
|
||||
let snap = snapshot()
|
||||
|
||||
do {
|
||||
let remoteDhPub = try X25519Crypto.loadPublic(remoteDhPubBytes)
|
||||
let currentRemoteBytes: Data? = dhRemote.map { X25519Crypto.serializePublic($0) }
|
||||
|
||||
if currentRemoteBytes == nil || remoteDhPubBytes != currentRemoteBytes {
|
||||
// New DH ratchet step
|
||||
try skipMessages(until: header.pn)
|
||||
try dhRatchet(remoteDhPub: remoteDhPub)
|
||||
}
|
||||
|
||||
try skipMessages(until: header.n)
|
||||
|
||||
// Derive message key from receive chain
|
||||
guard recvChainKey != nil else {
|
||||
throw CryptoError.ratchetError("Receive chain key is nil")
|
||||
}
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
|
||||
recvChainKey = newCK
|
||||
recvN += 1
|
||||
|
||||
let aad = header.serialize()
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
restore(snap)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State Snapshot/Restore (M9)
|
||||
|
||||
private struct Snapshot {
|
||||
let dhPairPriv: Data?
|
||||
let dhPairPub: Data?
|
||||
let dhRemote: Data?
|
||||
let rootKey: Data
|
||||
let sendChainKey: Data?
|
||||
let recvChainKey: Data?
|
||||
let sendN: Int
|
||||
let recvN: Int
|
||||
let prevSendN: Int
|
||||
let skipped: [String: Data]
|
||||
}
|
||||
|
||||
private func snapshot() -> Snapshot {
|
||||
Snapshot(
|
||||
dhPairPriv: dhPair.map { X25519Crypto.serializePrivate($0.privateKey) },
|
||||
dhPairPub: dhPair.map { X25519Crypto.serializePublic($0.publicKey) },
|
||||
dhRemote: dhRemote.map { X25519Crypto.serializePublic($0) },
|
||||
rootKey: rootKey,
|
||||
sendChainKey: sendChainKey,
|
||||
recvChainKey: recvChainKey,
|
||||
sendN: sendN,
|
||||
recvN: recvN,
|
||||
prevSendN: prevSendN,
|
||||
skipped: skipped
|
||||
)
|
||||
}
|
||||
|
||||
private func restore(_ snap: Snapshot) {
|
||||
if let privData = snap.dhPairPriv, let pubData = snap.dhPairPub,
|
||||
let priv = try? X25519Crypto.loadPrivate(privData),
|
||||
let pub = try? X25519Crypto.loadPublic(pubData) {
|
||||
dhPair = (priv, pub)
|
||||
} else {
|
||||
dhPair = nil
|
||||
}
|
||||
if let remoteData = snap.dhRemote, let remote = try? X25519Crypto.loadPublic(remoteData) {
|
||||
dhRemote = remote
|
||||
} else {
|
||||
dhRemote = nil
|
||||
}
|
||||
rootKey = snap.rootKey
|
||||
sendChainKey = snap.sendChainKey
|
||||
recvChainKey = snap.recvChainKey
|
||||
sendN = snap.sendN
|
||||
recvN = snap.recvN
|
||||
prevSendN = snap.prevSendN
|
||||
skipped = snap.skipped
|
||||
}
|
||||
|
||||
// MARK: - Internal Ratchet Operations
|
||||
|
||||
private func skipMessages(until: Int) throws {
|
||||
guard recvChainKey != nil else { return }
|
||||
if until - recvN > Constants.maxSkip {
|
||||
throw CryptoError.maxSkipExceeded
|
||||
}
|
||||
while recvN < until {
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: recvChainKey!)
|
||||
recvChainKey = newCK
|
||||
let remoteHex = dhRemote.map { X25519Crypto.serializePublic($0).hexString } ?? ""
|
||||
skipped["\(remoteHex):\(recvN)"] = mk
|
||||
recvN += 1
|
||||
}
|
||||
}
|
||||
|
||||
private func dhRatchet(remoteDhPub: Curve25519.KeyAgreement.PublicKey) throws {
|
||||
prevSendN = sendN
|
||||
sendN = 0
|
||||
recvN = 0
|
||||
dhRemote = remoteDhPub
|
||||
|
||||
// Derive new receive chain key
|
||||
guard let dhPair = dhPair else {
|
||||
throw CryptoError.ratchetError("DH pair not set")
|
||||
}
|
||||
let dhOutput1 = try X25519Crypto.dh(dhPair.privateKey, remoteDhPub)
|
||||
let (newRK1, recvCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput1)
|
||||
rootKey = newRK1
|
||||
recvChainKey = recvCK
|
||||
|
||||
// Generate new DH pair and derive new send chain key
|
||||
let (newPriv, newPub) = X25519Crypto.generateKeypair()
|
||||
self.dhPair = (newPriv, newPub)
|
||||
let dhOutput2 = try X25519Crypto.dh(newPriv, remoteDhPub)
|
||||
let (newRK2, sendCK) = CryptoUtils.kdfRK(rootKey: rootKey, dhOutput: dhOutput2)
|
||||
rootKey = newRK2
|
||||
sendChainKey = sendCK
|
||||
}
|
||||
|
||||
// MARK: - State Export/Import
|
||||
|
||||
/// Serialize full ratchet state for persistent storage.
|
||||
/// Produces JSON matching Python's DoubleRatchet.export_state() exactly.
|
||||
func exportState() throws -> Data {
|
||||
var state: [String: Any] = [:]
|
||||
|
||||
if let pair = dhPair {
|
||||
state["dh_priv"] = X25519Crypto.serializePrivate(pair.privateKey).hexString
|
||||
state["dh_pub"] = X25519Crypto.serializePublic(pair.publicKey).hexString
|
||||
} else {
|
||||
state["dh_priv"] = NSNull()
|
||||
state["dh_pub"] = NSNull()
|
||||
}
|
||||
|
||||
if let remote = dhRemote {
|
||||
state["dh_remote"] = X25519Crypto.serializePublic(remote).hexString
|
||||
} else {
|
||||
state["dh_remote"] = NSNull()
|
||||
}
|
||||
|
||||
state["root_key"] = rootKey.hexString
|
||||
state["send_ck"] = sendChainKey?.hexString ?? NSNull()
|
||||
state["recv_ck"] = recvChainKey?.hexString ?? NSNull()
|
||||
state["send_n"] = sendN
|
||||
state["recv_n"] = recvN
|
||||
state["prev_send_n"] = prevSendN
|
||||
|
||||
// Skipped keys: Python format is "dh_pub_hex:n" -> message_key_hex
|
||||
var skippedDict: [String: String] = [:]
|
||||
for (key, value) in skipped {
|
||||
skippedDict[key] = value.hexString
|
||||
}
|
||||
state["skipped"] = skippedDict
|
||||
|
||||
return try JSONSerialization.data(withJSONObject: state)
|
||||
}
|
||||
|
||||
/// Deserialize ratchet state.
|
||||
/// Matches Python: DoubleRatchet.import_state(data)
|
||||
static func importState(_ data: Data) throws -> DoubleRatchet {
|
||||
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw CryptoError.stateImportFailed("Invalid JSON")
|
||||
}
|
||||
|
||||
let r = DoubleRatchet()
|
||||
|
||||
if let dhPrivHex = state["dh_priv"] as? String,
|
||||
let dhPubHex = state["dh_pub"] as? String,
|
||||
let privData = Data(hexString: dhPrivHex),
|
||||
let pubData = Data(hexString: dhPubHex) {
|
||||
let priv = try X25519Crypto.loadPrivate(privData)
|
||||
let pub = try X25519Crypto.loadPublic(pubData)
|
||||
r.dhPair = (priv, pub)
|
||||
}
|
||||
|
||||
if let dhRemoteHex = state["dh_remote"] as? String,
|
||||
let remoteData = Data(hexString: dhRemoteHex) {
|
||||
r.dhRemote = try X25519Crypto.loadPublic(remoteData)
|
||||
}
|
||||
|
||||
guard let rootKeyHex = state["root_key"] as? String,
|
||||
let rootKey = Data(hexString: rootKeyHex) else {
|
||||
throw CryptoError.stateImportFailed("Missing root_key")
|
||||
}
|
||||
r.rootKey = rootKey
|
||||
|
||||
if let sendCKHex = state["send_ck"] as? String, let ck = Data(hexString: sendCKHex) {
|
||||
r.sendChainKey = ck
|
||||
}
|
||||
if let recvCKHex = state["recv_ck"] as? String, let ck = Data(hexString: recvCKHex) {
|
||||
r.recvChainKey = ck
|
||||
}
|
||||
|
||||
r.sendN = state["send_n"] as? Int ?? 0
|
||||
r.recvN = state["recv_n"] as? Int ?? 0
|
||||
r.prevSendN = state["prev_send_n"] as? Int ?? 0
|
||||
|
||||
if let skippedDict = state["skipped"] as? [String: String] {
|
||||
for (key, valueHex) in skippedDict {
|
||||
if let value = Data(hexString: valueHex) {
|
||||
r.skipped[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
}
|
||||
73
ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift
Normal file
73
ios_client 0.8.5/Kecalek/Crypto/Ed25519Crypto.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Ed25519 signing operations — Identity Key management
|
||||
enum Ed25519Crypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate Ed25519 keypair
|
||||
static func generateKeypair() -> (privateKey: Curve25519.Signing.PrivateKey, publicKey: Curve25519.Signing.PublicKey) {
|
||||
let privateKey = Curve25519.Signing.PrivateKey()
|
||||
return (privateKey, privateKey.publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize Ed25519 private key. With password: raw 32B → ECP1. Without: raw 32B.
|
||||
/// Matches Python: serialize_ed25519_private(key, password=None)
|
||||
static func serializePrivate(_ key: Curve25519.Signing.PrivateKey, password: Data? = nil) throws -> Data {
|
||||
let raw = key.rawData // 32 bytes
|
||||
if let password = password {
|
||||
return try KeyEncryption.encrypt(raw, password: password)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/// Serialize Ed25519 public key to 32 raw bytes.
|
||||
/// Matches Python: serialize_ed25519_public(key)
|
||||
static func serializePublic(_ key: Curve25519.Signing.PublicKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
// MARK: - Loading
|
||||
|
||||
/// Load Ed25519 private key. Auto-detects ECP1 / raw 32B.
|
||||
/// Matches Python: load_ed25519_private(data, password=None)
|
||||
static func loadPrivate(_ data: Data, password: Data? = nil) throws -> Curve25519.Signing.PrivateKey {
|
||||
if KeyEncryption.isECP1Format(data) {
|
||||
guard let pwd = password else {
|
||||
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||
}
|
||||
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||
return try Curve25519.Signing.PrivateKey(rawRepresentation: raw)
|
||||
}
|
||||
if data.count == 32 {
|
||||
return try Curve25519.Signing.PrivateKey(rawRepresentation: data)
|
||||
}
|
||||
throw CryptoError.invalidKeyData("Cannot parse Ed25519 private key (\(data.count) bytes)")
|
||||
}
|
||||
|
||||
/// Load Ed25519 public key from 32 raw bytes.
|
||||
/// Matches Python: load_ed25519_public(data)
|
||||
static func loadPublic(_ data: Data) throws -> Curve25519.Signing.PublicKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("Ed25519 public key must be 32 bytes, got \(data.count)")
|
||||
}
|
||||
return try Curve25519.Signing.PublicKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
// MARK: - Sign / Verify
|
||||
|
||||
/// Sign data with Ed25519. Returns 64-byte signature.
|
||||
/// Matches Python: ed25519_sign(private_key, data)
|
||||
static func sign(_ privateKey: Curve25519.Signing.PrivateKey, data: Data) throws -> Data {
|
||||
Data(try privateKey.signature(for: data))
|
||||
}
|
||||
|
||||
/// Verify Ed25519 signature.
|
||||
/// Matches Python: ed25519_verify(public_key, signature, data)
|
||||
static func verify(_ publicKey: Curve25519.Signing.PublicKey, signature: Data, data: Data) -> Bool {
|
||||
publicKey.isValidSignature(signature, for: data)
|
||||
}
|
||||
}
|
||||
231
ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift
Normal file
231
ios_client 0.8.5/Kecalek/Crypto/FieldArithmetic.swift
Normal file
@@ -0,0 +1,231 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure Swift GF(2^255-19) arithmetic for Ed25519 → X25519 public key conversion.
|
||||
///
|
||||
/// The conversion formula is: u = (1 + y) / (1 - y) mod p
|
||||
/// where p = 2^255 - 19, and y is the Ed25519 public key's y-coordinate.
|
||||
///
|
||||
/// Uses 4-limb UInt64 representation (little-endian).
|
||||
enum FieldArithmetic {
|
||||
|
||||
// p = 2^255 - 19
|
||||
static let p: [UInt64] = [
|
||||
0xFFFF_FFFF_FFFF_FFED, // limb 0 (least significant)
|
||||
0xFFFF_FFFF_FFFF_FFFF, // limb 1
|
||||
0xFFFF_FFFF_FFFF_FFFF, // limb 2
|
||||
0x7FFF_FFFF_FFFF_FFFF, // limb 3 (most significant, 2^63 - 1 accounting for -19)
|
||||
]
|
||||
|
||||
/// Load a 256-bit little-endian byte array into 4 UInt64 limbs
|
||||
static func load(_ bytes: Data) -> [UInt64] {
|
||||
precondition(bytes.count == 32)
|
||||
var limbs = [UInt64](repeating: 0, count: 4)
|
||||
for i in 0..<4 {
|
||||
var val: UInt64 = 0
|
||||
for j in 0..<8 {
|
||||
val |= UInt64(bytes[i * 8 + j]) << (j * 8)
|
||||
}
|
||||
limbs[i] = val
|
||||
}
|
||||
return limbs
|
||||
}
|
||||
|
||||
/// Store 4 UInt64 limbs as 32 little-endian bytes
|
||||
static func store(_ limbs: [UInt64]) -> Data {
|
||||
var bytes = Data(count: 32)
|
||||
for i in 0..<4 {
|
||||
for j in 0..<8 {
|
||||
bytes[i * 8 + j] = UInt8((limbs[i] >> (j * 8)) & 0xFF)
|
||||
}
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// a + b mod p
|
||||
static func add(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var carry: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (sum1, c1) = a[i].addingReportingOverflow(b[i])
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
result[i] = sum2
|
||||
carry = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
// Reduce mod p
|
||||
return reduceOnce(result, carry: carry)
|
||||
}
|
||||
|
||||
/// a - b mod p
|
||||
static func sub(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var borrow: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (diff1, b1) = a[i].subtractingReportingOverflow(b[i])
|
||||
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
|
||||
result[i] = diff2
|
||||
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
|
||||
}
|
||||
if borrow > 0 {
|
||||
// Add p back
|
||||
var c: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (s1, c1) = result[i].addingReportingOverflow(p[i])
|
||||
let (s2, c2) = s1.addingReportingOverflow(c)
|
||||
result[i] = s2
|
||||
c = (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Multiply two 256-bit numbers mod p using schoolbook multiplication
|
||||
static func mul(_ a: [UInt64], _ b: [UInt64]) -> [UInt64] {
|
||||
// Full 512-bit product in 8 limbs
|
||||
var product = [UInt64](repeating: 0, count: 8)
|
||||
|
||||
for i in 0..<4 {
|
||||
var carry: UInt64 = 0
|
||||
for j in 0..<4 {
|
||||
let (hi, lo) = a[i].multipliedFullWidth(by: b[j])
|
||||
let (sum1, c1) = product[i + j].addingReportingOverflow(lo)
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
product[i + j] = sum2
|
||||
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
product[i + 4] = carry
|
||||
}
|
||||
|
||||
// Reduce mod p using Barrett-like reduction
|
||||
// Since p = 2^255 - 19, for a 512-bit number we can use:
|
||||
// x mod p = (x_low + x_high * 2^256) mod p
|
||||
// Since 2^255 ≡ 19 (mod p), 2^256 ≡ 38 (mod p)
|
||||
return reduceFull(product)
|
||||
}
|
||||
|
||||
/// Reduce 512-bit product mod p using 2^256 ≡ 38 (mod p)
|
||||
private static func reduceFull(_ product: [UInt64]) -> [UInt64] {
|
||||
// Split: low = product[0..3], high = product[4..7]
|
||||
// result = low + high * 38
|
||||
var result = [UInt64](repeating: 0, count: 5)
|
||||
|
||||
// Start with low part
|
||||
for i in 0..<4 {
|
||||
result[i] = product[i]
|
||||
}
|
||||
|
||||
// Add high * 38
|
||||
var carry: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (hi, lo) = product[i + 4].multipliedFullWidth(by: 38)
|
||||
let (sum1, c1) = result[i].addingReportingOverflow(lo)
|
||||
let (sum2, c2) = sum1.addingReportingOverflow(carry)
|
||||
result[i] = sum2
|
||||
carry = hi + (c1 ? 1 : 0) + (c2 ? 1 : 0)
|
||||
}
|
||||
result[4] = carry
|
||||
|
||||
// The result might still be >= p, so reduce once more
|
||||
// result[4] * 2^256 ≡ result[4] * 38 (mod p)
|
||||
let extra: UInt64 = result[4]
|
||||
result[4] = 0
|
||||
if extra > 0 {
|
||||
let (hi, lo) = extra.multipliedFullWidth(by: 38)
|
||||
let (sum1, c1) = result[0].addingReportingOverflow(lo)
|
||||
result[0] = sum1
|
||||
var c = hi + (c1 ? 1 : 0)
|
||||
for i in 1..<4 {
|
||||
let (s, cf) = result[i].addingReportingOverflow(c)
|
||||
result[i] = s
|
||||
c = cf ? 1 : 0
|
||||
}
|
||||
// One more round if carry
|
||||
if c > 0 {
|
||||
let (s, _) = result[0].addingReportingOverflow(c * 38)
|
||||
result[0] = s
|
||||
}
|
||||
}
|
||||
|
||||
var out = Array(result[0..<4])
|
||||
// Final reduction: if >= p, subtract p
|
||||
out = reduceOnce(out, carry: 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/// If the number >= p, subtract p
|
||||
private static func reduceOnce(_ val: [UInt64], carry: UInt64) -> [UInt64] {
|
||||
if carry > 0 || isGreaterOrEqual(val, p) {
|
||||
var result = [UInt64](repeating: 0, count: 4)
|
||||
var borrow: UInt64 = 0
|
||||
for i in 0..<4 {
|
||||
let (diff1, b1) = val[i].subtractingReportingOverflow(p[i])
|
||||
let (diff2, b2) = diff1.subtractingReportingOverflow(borrow)
|
||||
result[i] = diff2
|
||||
borrow = (b1 ? 1 : 0) + (b2 ? 1 : 0)
|
||||
}
|
||||
// If borrow after subtracting p, the original was fine (shouldn't happen with carry)
|
||||
if borrow > 0 && carry == 0 {
|
||||
return val
|
||||
}
|
||||
return result
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
/// Compare a >= b
|
||||
private static func isGreaterOrEqual(_ a: [UInt64], _ b: [UInt64]) -> Bool {
|
||||
for i in stride(from: 3, through: 0, by: -1) {
|
||||
if a[i] > b[i] { return true }
|
||||
if a[i] < b[i] { return false }
|
||||
}
|
||||
return true // equal
|
||||
}
|
||||
|
||||
/// Modular inverse using Fermat's little theorem: a^(-1) = a^(p-2) mod p
|
||||
static func inverse(_ a: [UInt64]) -> [UInt64] {
|
||||
// p - 2 = 2^255 - 21
|
||||
let pMinus2 = sub(p, [2, 0, 0, 0])
|
||||
return power(a, pMinus2)
|
||||
}
|
||||
|
||||
/// Modular exponentiation using square-and-multiply
|
||||
static func power(_ base: [UInt64], _ exp: [UInt64]) -> [UInt64] {
|
||||
var result: [UInt64] = [1, 0, 0, 0] // 1
|
||||
var b = base
|
||||
|
||||
for i in 0..<4 {
|
||||
var limb = exp[i]
|
||||
let bits = (i == 3) ? 63 : 64 // top limb has 63 bits for p-2
|
||||
for _ in 0..<bits {
|
||||
if limb & 1 == 1 {
|
||||
result = mul(result, b)
|
||||
}
|
||||
b = mul(b, b)
|
||||
limb >>= 1
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Ed25519 → X25519 Public Key Conversion
|
||||
|
||||
/// Convert Ed25519 public key (32 bytes) to X25519 public key (32 bytes).
|
||||
/// Formula: u = (1 + y) * inverse(1 - y) mod p
|
||||
static func ed25519PublicToX25519(_ ed25519Pub: Data) -> Data {
|
||||
precondition(ed25519Pub.count == 32)
|
||||
|
||||
// Ed25519 public key is the y-coordinate with sign bit in the top bit of byte 31
|
||||
var keyBytes = ed25519Pub
|
||||
// Clear the sign bit
|
||||
keyBytes[31] &= 0x7F
|
||||
|
||||
let y = load(keyBytes)
|
||||
let one: [UInt64] = [1, 0, 0, 0]
|
||||
|
||||
let onePlusY = add(one, y)
|
||||
let oneMinusY = sub(one, y)
|
||||
let inv = inverse(oneMinusY)
|
||||
let u = mul(onePlusY, inv)
|
||||
|
||||
return store(u)
|
||||
}
|
||||
}
|
||||
106
ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift
Normal file
106
ios_client 0.8.5/Kecalek/Crypto/KeyEncryption.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import CommonCrypto
|
||||
|
||||
/// ECP1 key encryption format: PBKDF2-HMAC-SHA256 (600k iterations) + AES-256-GCM
|
||||
/// Wire format: magic(4) + salt(16) + nonce(12) + ciphertext_with_tag(N+16)
|
||||
enum KeyEncryption {
|
||||
|
||||
/// Encrypt raw key bytes with password using ECP1 format
|
||||
static func encrypt(_ rawBytes: Data, password: Data) throws -> Data {
|
||||
let salt = Data.randomBytes(16)
|
||||
let derivedKey = try pbkdf2(password: password, salt: salt)
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
// AAD = ECP1 magic bytes (matching Python)
|
||||
let sealedBox = try AES.GCM.seal(
|
||||
rawBytes,
|
||||
using: symmetricKey,
|
||||
nonce: gcmNonce,
|
||||
authenticating: Constants.ecp1Magic
|
||||
)
|
||||
|
||||
// ciphertext + tag concatenated (matches Python's AESGCM.encrypt output)
|
||||
var result = Data()
|
||||
result.append(Constants.ecp1Magic) // 4 bytes
|
||||
result.append(salt) // 16 bytes
|
||||
result.append(nonce) // 12 bytes
|
||||
result.append(sealedBox.ciphertext) // N bytes
|
||||
result.append(sealedBox.tag) // 16 bytes
|
||||
return result
|
||||
}
|
||||
|
||||
/// Decrypt ECP1-encrypted key bytes with password
|
||||
static func decrypt(_ data: Data, password: Data) throws -> Data {
|
||||
guard data.count >= 48 else { // 4 + 16 + 12 + 16 minimum
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
guard data.prefix(4) == Constants.ecp1Magic else {
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
|
||||
let salt = data[4..<20]
|
||||
let nonce = data[20..<32]
|
||||
let ctWithTag = data[32...]
|
||||
|
||||
guard ctWithTag.count >= 16 else {
|
||||
throw CryptoError.invalidECP1Format
|
||||
}
|
||||
|
||||
let derivedKey = try pbkdf2(password: password, salt: Data(salt))
|
||||
let symmetricKey = SymmetricKey(data: derivedKey)
|
||||
let gcmNonce = try AES.GCM.Nonce(data: nonce)
|
||||
|
||||
// Split ciphertext and tag
|
||||
let ct = ctWithTag.prefix(ctWithTag.count - 16)
|
||||
let tag = ctWithTag.suffix(16)
|
||||
|
||||
let sealedBox = try AES.GCM.SealedBox(
|
||||
nonce: gcmNonce,
|
||||
ciphertext: ct,
|
||||
tag: tag
|
||||
)
|
||||
|
||||
do {
|
||||
return try AES.GCM.open(sealedBox, using: symmetricKey, authenticating: Constants.ecp1Magic)
|
||||
} catch {
|
||||
throw CryptoError.decryptionFailed("ECP1 decryption failed - wrong password?")
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if data starts with ECP1 magic
|
||||
static func isECP1Format(_ data: Data) -> Bool {
|
||||
data.count >= 4 && data.prefix(4) == Constants.ecp1Magic
|
||||
}
|
||||
|
||||
// MARK: - PBKDF2
|
||||
|
||||
/// Derive 32-byte key using PBKDF2-HMAC-SHA256 with 600k iterations
|
||||
static func pbkdf2(password: Data, salt: Data) throws -> Data {
|
||||
var derivedKey = Data(count: 32)
|
||||
let status = derivedKey.withUnsafeMutableBytes { derivedKeyPtr in
|
||||
password.withUnsafeBytes { passwordPtr in
|
||||
salt.withUnsafeBytes { saltPtr in
|
||||
CCKeyDerivationPBKDF(
|
||||
CCPBKDFAlgorithm(kCCPBKDF2),
|
||||
passwordPtr.baseAddress?.assumingMemoryBound(to: Int8.self),
|
||||
password.count,
|
||||
saltPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||
salt.count,
|
||||
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
|
||||
Constants.pbkdf2Iterations,
|
||||
derivedKeyPtr.baseAddress?.assumingMemoryBound(to: UInt8.self),
|
||||
32
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard status == kCCSuccess else {
|
||||
throw CryptoError.pbkdf2Failed
|
||||
}
|
||||
return derivedKey
|
||||
}
|
||||
}
|
||||
59
ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift
Normal file
59
ios_client 0.8.5/Kecalek/Crypto/MessagePadding.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/// Message padding for metadata privacy — hides plaintext length.
|
||||
/// Matches Python: crypto_utils.py pad_plaintext / unpad_plaintext
|
||||
enum MessagePadding {
|
||||
|
||||
/// Magic byte prefix to distinguish padded from legacy unpadded messages.
|
||||
private static let padMagic: UInt8 = 0x01
|
||||
|
||||
/// Bucket sizes for length hiding (64B to 64KB).
|
||||
private static let padBuckets = [64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536]
|
||||
|
||||
/// Pad plaintext to nearest bucket size to hide message length.
|
||||
///
|
||||
/// Format: `0x01 + plaintext + random_padding + pad_length(4B big-endian)`
|
||||
/// Prefix 0x01 distinguishes padded messages from legacy unpadded (which start with '{').
|
||||
static func pad(_ plaintext: Data) -> Data {
|
||||
var content = Data([padMagic])
|
||||
content.append(plaintext)
|
||||
|
||||
// +4 for the length suffix
|
||||
let minSize = content.count + 4
|
||||
let target = padBuckets.first(where: { $0 >= minSize }) ?? minSize
|
||||
let padLen = target - content.count
|
||||
|
||||
// random_padding (padLen - 4 bytes) + pad_length (4 bytes big-endian)
|
||||
var result = content
|
||||
result.append(Data.randomBytes(padLen - 4))
|
||||
result.append(UInt32(padLen).bigEndianData)
|
||||
return result
|
||||
}
|
||||
|
||||
/// Remove padding. Returns raw plaintext for both padded and legacy unpadded messages.
|
||||
static func unpad(_ data: Data) -> Data {
|
||||
guard !data.isEmpty else { return data }
|
||||
|
||||
// Legacy unpadded message (starts with '{' for JSON)
|
||||
guard data[data.startIndex] == padMagic else { return data }
|
||||
|
||||
// Too short to be validly padded (magic + at least 4 bytes for length)
|
||||
guard data.count >= 5 else { return data }
|
||||
|
||||
// Read pad_length from last 4 bytes (big-endian UInt32)
|
||||
let padLenOffset = data.count - 4
|
||||
let padLen = data.withUnsafeBytes { ptr -> UInt32 in
|
||||
var value: UInt32 = 0
|
||||
withUnsafeMutableBytes(of: &value) { dest in
|
||||
dest.copyBytes(from: UnsafeRawBufferPointer(rebasing: ptr[padLenOffset...]))
|
||||
}
|
||||
return UInt32(bigEndian: value)
|
||||
}
|
||||
|
||||
// Validate padding metadata
|
||||
guard padLen >= 4, padLen <= data.count - 1 else { return data }
|
||||
|
||||
// Strip: skip magic byte (index 0), take up to (data.count - padLen)
|
||||
return data[data.startIndex + 1 ..< data.startIndex + data.count - Int(padLen)]
|
||||
}
|
||||
}
|
||||
356
ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift
Normal file
356
ios_client 0.8.5/Kecalek/Crypto/RSACrypto.swift
Normal file
@@ -0,0 +1,356 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// RSA-4096 operations — used for login challenge-response ONLY
|
||||
enum RSACrypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate RSA-4096 keypair
|
||||
static func generateKeypair() throws -> (privateKey: SecKey, publicKey: SecKey) {
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeySizeInBits as String: 4096,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
return (privateKey, publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize RSA private key. With password: DER → ECP1. Without: PEM PKCS#8.
|
||||
static func serializePrivateKey(_ key: SecKey, password: Data? = nil) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("Failed to export private key")
|
||||
}
|
||||
|
||||
// SecKey exports in PKCS#1 format on iOS — wrap in PKCS#8 for Python compat
|
||||
let pkcs8 = wrapRSAPrivateKeyPKCS8(derData)
|
||||
|
||||
if let password = password {
|
||||
return try KeyEncryption.encrypt(pkcs8, password: password)
|
||||
}
|
||||
|
||||
// PEM encode for Python compatibility
|
||||
return pemEncode(pkcs8, label: "PRIVATE KEY")
|
||||
}
|
||||
|
||||
/// Serialize RSA public key as PEM SubjectPublicKeyInfo (Python-compatible)
|
||||
static func serializePublicKey(_ key: SecKey) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let derData = SecKeyCopyExternalRepresentation(key, &error) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("Failed to export public key")
|
||||
}
|
||||
|
||||
// SecKey exports PKCS#1 on iOS — wrap in SubjectPublicKeyInfo
|
||||
let spki = wrapRSAPublicKeySPKI(derData)
|
||||
return pemEncode(spki, label: "PUBLIC KEY")
|
||||
}
|
||||
|
||||
/// Load RSA private key. Auto-detects ECP1 vs PEM format.
|
||||
static func loadPrivateKey(_ data: Data, password: Data? = nil) throws -> SecKey {
|
||||
let derData: Data
|
||||
|
||||
if KeyEncryption.isECP1Format(data) {
|
||||
guard let pwd = password else {
|
||||
throw CryptoError.invalidKeyData("ECP1 key requires password")
|
||||
}
|
||||
let raw = try KeyEncryption.decrypt(data, password: pwd)
|
||||
derData = unwrapPKCS8ToRSAPrivateKey(raw)
|
||||
} else {
|
||||
// PEM format
|
||||
let pem = String(data: data, encoding: .utf8) ?? ""
|
||||
derData = try pemDecode(pem, label: "PRIVATE KEY")
|
||||
.flatMap { unwrapPKCS8ToRSAPrivateKey($0) }
|
||||
?? pemDecode(pem, label: "RSA PRIVATE KEY")
|
||||
?? { throw CryptoError.invalidKeyData("Cannot parse RSA private key PEM") }()
|
||||
}
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.invalidKeyData("Failed to create RSA private key from DER")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
/// Load RSA public key from PEM
|
||||
static func loadPublicKey(_ pemData: Data) throws -> SecKey {
|
||||
let pem = String(data: pemData, encoding: .utf8) ?? ""
|
||||
|
||||
// Try SubjectPublicKeyInfo (PUBLIC KEY), unwrap to PKCS#1
|
||||
let derData: Data
|
||||
if let spki = pemDecode(pem, label: "PUBLIC KEY") {
|
||||
derData = unwrapSPKIToRSAPublicKey(spki)
|
||||
} else if let pkcs1 = pemDecode(pem, label: "RSA PUBLIC KEY") {
|
||||
derData = pkcs1
|
||||
} else {
|
||||
throw CryptoError.invalidKeyData("Cannot parse RSA public key PEM")
|
||||
}
|
||||
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(derData as CFData, attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.invalidKeyData("Failed to create RSA public key from DER")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// MARK: - Sign / Verify
|
||||
|
||||
/// Sign data with RSA-PSS SHA-256.
|
||||
/// Note: iOS uses salt_length = hash_length (32). Server must use PSS.AUTO to verify.
|
||||
static func sign(_ privateKey: SecKey, data: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let signature = SecKeyCreateSignature(
|
||||
privateKey,
|
||||
.rsaSignatureMessagePSSSHA256,
|
||||
data as CFData,
|
||||
&error
|
||||
) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("RSA signing failed")
|
||||
}
|
||||
return signature
|
||||
}
|
||||
|
||||
/// Verify RSA-PSS SHA-256 signature
|
||||
static func verify(_ publicKey: SecKey, signature: Data, data: Data) -> Bool {
|
||||
SecKeyVerifySignature(
|
||||
publicKey,
|
||||
.rsaSignatureMessagePSSSHA256,
|
||||
data as CFData,
|
||||
signature as CFData,
|
||||
nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - RSA-OAEP Encrypt / Decrypt (for device pairing)
|
||||
|
||||
/// Encrypt data with RSA-OAEP SHA-256 using a public key
|
||||
static func encrypt(_ publicKey: SecKey, plaintext: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let encrypted = SecKeyCreateEncryptedData(
|
||||
publicKey,
|
||||
.rsaEncryptionOAEPSHA256,
|
||||
plaintext as CFData,
|
||||
&error
|
||||
) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("RSA-OAEP encryption failed")
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
/// Decrypt data with RSA-OAEP SHA-256 using a private key
|
||||
static func decrypt(_ privateKey: SecKey, ciphertext: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let decrypted = SecKeyCreateDecryptedData(
|
||||
privateKey,
|
||||
.rsaEncryptionOAEPSHA256,
|
||||
ciphertext as CFData,
|
||||
&error
|
||||
) as Data? else {
|
||||
throw CryptoError.rsaOperationFailed("RSA-OAEP decryption failed")
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
/// Generate RSA-2048 keypair (for pairing temp keys — smaller for OAEP payload)
|
||||
static func generateKeypair2048() throws -> (privateKey: SecKey, publicKey: SecKey) {
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeySizeInBits as String: 2048,
|
||||
]
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||
throw CryptoError.rsaKeyGenerationFailed
|
||||
}
|
||||
return (privateKey, publicKey)
|
||||
}
|
||||
|
||||
// MARK: - PEM Helpers
|
||||
|
||||
private static func pemEncode(_ der: Data, label: String) -> Data {
|
||||
let base64 = der.base64EncodedString(options: .lineLength64Characters)
|
||||
let pem = "-----BEGIN \(label)-----\n\(base64)\n-----END \(label)-----\n"
|
||||
return Data(pem.utf8)
|
||||
}
|
||||
|
||||
private static func pemDecode(_ pem: String, label: String) -> Data? {
|
||||
let beginMarker = "-----BEGIN \(label)-----"
|
||||
let endMarker = "-----END \(label)-----"
|
||||
|
||||
guard let beginRange = pem.range(of: beginMarker),
|
||||
let endRange = pem.range(of: endMarker) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let base64String = pem[beginRange.upperBound..<endRange.lowerBound]
|
||||
.replacingOccurrences(of: "\n", with: "")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
|
||||
return Data(base64Encoded: base64String)
|
||||
}
|
||||
|
||||
// MARK: - ASN.1 PKCS#8 / SPKI Wrappers
|
||||
|
||||
// SecKey on iOS exports RSA keys in PKCS#1 format, but Python expects PKCS#8 / SPKI.
|
||||
// These functions add/remove the ASN.1 wrapping.
|
||||
|
||||
// RSA OID: 1.2.840.113549.1.1.1
|
||||
private static let rsaOID: [UInt8] = [0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01]
|
||||
private static let nullParam: [UInt8] = [0x05, 0x00]
|
||||
|
||||
/// Wrap PKCS#1 RSA private key in PKCS#8 PrivateKeyInfo envelope
|
||||
private static func wrapRSAPrivateKeyPKCS8(_ pkcs1: Data) -> Data {
|
||||
// PrivateKeyInfo ::= SEQUENCE {
|
||||
// version INTEGER (0),
|
||||
// algorithm AlgorithmIdentifier,
|
||||
// privateKey OCTET STRING (containing PKCS#1 key)
|
||||
// }
|
||||
let version = Data([0x02, 0x01, 0x00]) // INTEGER 0
|
||||
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||
let privateKeyOctet = asn1OctetString(pkcs1)
|
||||
return asn1Sequence(version + algorithmSeq + privateKeyOctet)
|
||||
}
|
||||
|
||||
/// Unwrap PKCS#8 to get PKCS#1 RSA private key
|
||||
private static func unwrapPKCS8ToRSAPrivateKey(_ pkcs8: Data) -> Data {
|
||||
// Parse SEQUENCE, skip version + algorithm, extract OCTET STRING
|
||||
guard pkcs8.count > 2 else { return pkcs8 }
|
||||
|
||||
var offset = 0
|
||||
// Outer SEQUENCE
|
||||
guard pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||
offset += 1
|
||||
offset = skipASN1Length(pkcs8, offset: offset)
|
||||
|
||||
// Version INTEGER
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x02 else { return pkcs8 }
|
||||
offset += 1
|
||||
let versionLen = readASN1Length(pkcs8, offset: &offset)
|
||||
offset += versionLen
|
||||
|
||||
// Algorithm SEQUENCE
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x30 else { return pkcs8 }
|
||||
offset += 1
|
||||
let algoLen = readASN1Length(pkcs8, offset: &offset)
|
||||
offset += algoLen
|
||||
|
||||
// Private key OCTET STRING
|
||||
guard offset < pkcs8.count, pkcs8[offset] == 0x04 else { return pkcs8 }
|
||||
offset += 1
|
||||
let keyLen = readASN1Length(pkcs8, offset: &offset)
|
||||
guard offset + keyLen <= pkcs8.count else { return pkcs8 }
|
||||
return Data(pkcs8[offset..<(offset + keyLen)])
|
||||
}
|
||||
|
||||
/// Wrap PKCS#1 RSA public key in SubjectPublicKeyInfo
|
||||
private static func wrapRSAPublicKeySPKI(_ pkcs1: Data) -> Data {
|
||||
// SubjectPublicKeyInfo ::= SEQUENCE {
|
||||
// algorithm AlgorithmIdentifier,
|
||||
// subjectPublicKey BIT STRING (containing PKCS#1 key)
|
||||
// }
|
||||
let algorithmSeq = asn1Sequence(Data(rsaOID) + Data(nullParam))
|
||||
let bitString = asn1BitString(pkcs1)
|
||||
return asn1Sequence(algorithmSeq + bitString)
|
||||
}
|
||||
|
||||
/// Unwrap SubjectPublicKeyInfo to get PKCS#1 RSA public key
|
||||
private static func unwrapSPKIToRSAPublicKey(_ spki: Data) -> Data {
|
||||
guard spki.count > 2 else { return spki }
|
||||
|
||||
var offset = 0
|
||||
// Outer SEQUENCE
|
||||
guard spki[offset] == 0x30 else { return spki }
|
||||
offset += 1
|
||||
offset = skipASN1Length(spki, offset: offset)
|
||||
|
||||
// Algorithm SEQUENCE
|
||||
guard offset < spki.count, spki[offset] == 0x30 else { return spki }
|
||||
offset += 1
|
||||
let algoLen = readASN1Length(spki, offset: &offset)
|
||||
offset += algoLen
|
||||
|
||||
// BIT STRING
|
||||
guard offset < spki.count, spki[offset] == 0x03 else { return spki }
|
||||
offset += 1
|
||||
let bitLen = readASN1Length(spki, offset: &offset)
|
||||
// Skip the unused bits byte
|
||||
guard offset < spki.count, spki[offset] == 0x00 else { return spki }
|
||||
offset += 1
|
||||
let keyLen = bitLen - 1
|
||||
guard offset + keyLen <= spki.count else { return spki }
|
||||
return Data(spki[offset..<(offset + keyLen)])
|
||||
}
|
||||
|
||||
// MARK: - ASN.1 Primitives
|
||||
|
||||
private static func asn1Length(_ length: Int) -> Data {
|
||||
if length < 0x80 {
|
||||
return Data([UInt8(length)])
|
||||
} else if length <= 0xFF {
|
||||
return Data([0x81, UInt8(length)])
|
||||
} else if length <= 0xFFFF {
|
||||
return Data([0x82, UInt8(length >> 8), UInt8(length & 0xFF)])
|
||||
} else {
|
||||
return Data([0x83, UInt8(length >> 16), UInt8((length >> 8) & 0xFF), UInt8(length & 0xFF)])
|
||||
}
|
||||
}
|
||||
|
||||
private static func asn1Sequence(_ content: Data) -> Data {
|
||||
Data([0x30]) + asn1Length(content.count) + content
|
||||
}
|
||||
|
||||
private static func asn1OctetString(_ content: Data) -> Data {
|
||||
Data([0x04]) + asn1Length(content.count) + content
|
||||
}
|
||||
|
||||
private static func asn1BitString(_ content: Data) -> Data {
|
||||
// BIT STRING: tag + length + unused_bits(0) + content
|
||||
Data([0x03]) + asn1Length(content.count + 1) + Data([0x00]) + content
|
||||
}
|
||||
|
||||
private static func readASN1Length(_ data: Data, offset: inout Int) -> Int {
|
||||
guard offset < data.count else { return 0 }
|
||||
let first = data[offset]
|
||||
offset += 1
|
||||
if first < 0x80 {
|
||||
return Int(first)
|
||||
}
|
||||
let numBytes = Int(first & 0x7F)
|
||||
var length = 0
|
||||
for _ in 0..<numBytes {
|
||||
guard offset < data.count else { return length }
|
||||
length = (length << 8) | Int(data[offset])
|
||||
offset += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
private static func skipASN1Length(_ data: Data, offset: Int) -> Int {
|
||||
var off = offset
|
||||
_ = readASN1Length(data, offset: &off)
|
||||
return off
|
||||
}
|
||||
}
|
||||
175
ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift
Normal file
175
ios_client 0.8.5/Kecalek/Crypto/SenderKeyState.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// Sender key chain for group messaging.
|
||||
/// Each sender in a group has their own chain. Others receive the initial key via pairwise ratchet.
|
||||
/// Matches Python: SenderKeyState class in crypto_utils.py
|
||||
class SenderKeyState {
|
||||
|
||||
let senderKey: Data
|
||||
let chainId: Data
|
||||
private(set) var chainKey: Data
|
||||
private(set) var n: Int
|
||||
private var knownKeys: [Int: Data]
|
||||
|
||||
/// Initialize with optional sender key (generates random 32B if nil).
|
||||
/// Matches Python: SenderKeyState.__init__(sender_key=None)
|
||||
init(senderKey: Data? = nil) {
|
||||
let key = senderKey ?? Data.randomBytes(32)
|
||||
self.senderKey = key
|
||||
self.chainId = Data(SHA256.hash(data: key))
|
||||
self.chainKey = CryptoUtils.hkdfDerive(
|
||||
inputKey: key,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.senderKeyChainInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
self.n = 0
|
||||
self.knownKeys = [:]
|
||||
}
|
||||
|
||||
/// Private init for import
|
||||
private init(senderKey: Data, chainId: Data, chainKey: Data, n: Int, knownKeys: [Int: Data]) {
|
||||
self.senderKey = senderKey
|
||||
self.chainId = chainId
|
||||
self.chainKey = chainKey
|
||||
self.n = n
|
||||
self.knownKeys = knownKeys
|
||||
}
|
||||
|
||||
// MARK: - Encrypt
|
||||
|
||||
/// Encrypt with current chain key.
|
||||
/// Returns (chainId hex, n, ciphertext with tag, nonce).
|
||||
/// Matches Python: SenderKeyState.encrypt(plaintext)
|
||||
func encrypt(_ plaintext: Data) throws -> (chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) {
|
||||
let (newCK, messageKey) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||
chainKey = newCK
|
||||
|
||||
let nonce = Data.randomBytes(12)
|
||||
// AAD = chainId + bigEndian(UInt32(n))
|
||||
let aad = chainId + UInt32(n).bigEndianData
|
||||
let ctWithTag = try CryptoUtils.aesGcmEncrypt(plaintext, key: messageKey, nonce: nonce, aad: aad)
|
||||
|
||||
let result = (chainIdHex: chainId.hexString, n: n, ciphertext: ctWithTag, nonce: nonce)
|
||||
n += 1
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Decrypt
|
||||
|
||||
/// Decrypt a group message. Fast-forwards the chain if needed.
|
||||
/// State is snapshotted before modification and restored on failure.
|
||||
/// Matches Python: SenderKeyState.decrypt(chain_id_hex, n, ciphertext, nonce)
|
||||
func decrypt(chainIdHex: String, n: Int, ciphertext: Data, nonce: Data) throws -> Data {
|
||||
guard let expectedChainId = Data(hexString: chainIdHex) else {
|
||||
throw CryptoError.senderKeyError("Invalid chain ID hex")
|
||||
}
|
||||
guard expectedChainId == chainId else {
|
||||
throw CryptoError.senderKeyError("Chain ID mismatch")
|
||||
}
|
||||
|
||||
if n - self.n > Constants.maxSenderKeySkip {
|
||||
throw CryptoError.senderKeyError("Sender key skip too large (\(n - self.n) > \(Constants.maxSenderKeySkip))")
|
||||
}
|
||||
|
||||
// Snapshot before fast-forward
|
||||
let snapChainKey = chainKey
|
||||
let snapN = self.n
|
||||
let snapKnown = knownKeys
|
||||
|
||||
do {
|
||||
// Fast-forward the chain to reach message n
|
||||
while self.n <= n {
|
||||
let (newCK, mk) = CryptoUtils.kdfCK(chainKey: chainKey)
|
||||
chainKey = newCK
|
||||
knownKeys[self.n] = mk
|
||||
self.n += 1
|
||||
}
|
||||
|
||||
guard let mk = knownKeys.removeValue(forKey: n) else {
|
||||
throw CryptoError.senderKeyError("Message key for n=\(n) not available")
|
||||
}
|
||||
|
||||
let aad = chainId + UInt32(n).bigEndianData
|
||||
return try CryptoUtils.aesGcmDecrypt(ciphertext, key: mk, nonce: nonce, aad: aad)
|
||||
} catch {
|
||||
// Restore state on failure
|
||||
chainKey = snapChainKey
|
||||
self.n = snapN
|
||||
knownKeys = snapKnown
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Key Export/Import
|
||||
|
||||
/// Export sender key for distribution to group members.
|
||||
/// Matches Python: SenderKeyState.export_key()
|
||||
func exportKey() -> Data {
|
||||
let dict: [String: Any] = ["sender_key": senderKey.hexString]
|
||||
return try! JSONSerialization.data(withJSONObject: dict)
|
||||
}
|
||||
|
||||
/// Initialize a receiving SenderKeyState from an exported key.
|
||||
/// Matches Python: SenderKeyState.from_key(exported_key)
|
||||
static func fromKey(_ exportedKey: Data) throws -> SenderKeyState {
|
||||
guard let dict = try JSONSerialization.jsonObject(with: exportedKey) as? [String: Any],
|
||||
let senderKeyHex = dict["sender_key"] as? String,
|
||||
let senderKey = Data(hexString: senderKeyHex) else {
|
||||
throw CryptoError.stateImportFailed("Invalid sender key export")
|
||||
}
|
||||
return SenderKeyState(senderKey: senderKey)
|
||||
}
|
||||
|
||||
// MARK: - Full State Export/Import
|
||||
|
||||
/// Serialize full state for persistent storage.
|
||||
/// Matches Python: SenderKeyState.export_state()
|
||||
func exportState() -> Data {
|
||||
var knownKeysDict: [String: String] = [:]
|
||||
for (k, v) in knownKeys {
|
||||
knownKeysDict[String(k)] = v.hexString
|
||||
}
|
||||
let state: [String: Any] = [
|
||||
"sender_key": senderKey.hexString,
|
||||
"chain_id": chainId.hexString,
|
||||
"chain_key": chainKey.hexString,
|
||||
"n": n,
|
||||
"known_keys": knownKeysDict,
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: state)
|
||||
}
|
||||
|
||||
/// Deserialize full state.
|
||||
/// Matches Python: SenderKeyState.import_state(data)
|
||||
static func importState(_ data: Data) throws -> SenderKeyState {
|
||||
guard let state = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let senderKeyHex = state["sender_key"] as? String,
|
||||
let senderKey = Data(hexString: senderKeyHex),
|
||||
let chainIdHex = state["chain_id"] as? String,
|
||||
let chainId = Data(hexString: chainIdHex),
|
||||
let chainKeyHex = state["chain_key"] as? String,
|
||||
let chainKey = Data(hexString: chainKeyHex),
|
||||
let n = state["n"] as? Int else {
|
||||
throw CryptoError.stateImportFailed("Invalid sender key state")
|
||||
}
|
||||
|
||||
var knownKeys: [Int: Data] = [:]
|
||||
if let knownKeysDict = state["known_keys"] as? [String: String] {
|
||||
for (k, v) in knownKeysDict {
|
||||
if let idx = Int(k), let data = Data(hexString: v) {
|
||||
knownKeys[idx] = data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SenderKeyState(
|
||||
senderKey: senderKey,
|
||||
chainId: chainId,
|
||||
chainKey: chainKey,
|
||||
n: n,
|
||||
knownKeys: knownKeys
|
||||
)
|
||||
}
|
||||
}
|
||||
77
ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift
Normal file
77
ios_client 0.8.5/Kecalek/Crypto/X25519Crypto.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// X25519 Diffie-Hellman key agreement
|
||||
enum X25519Crypto {
|
||||
|
||||
// MARK: - Key Generation
|
||||
|
||||
/// Generate X25519 keypair
|
||||
static func generateKeypair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey, publicKey: Curve25519.KeyAgreement.PublicKey) {
|
||||
let privateKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
return (privateKey, privateKey.publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Serialization
|
||||
|
||||
/// Serialize X25519 private key to 32 raw bytes
|
||||
static func serializePrivate(_ key: Curve25519.KeyAgreement.PrivateKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
/// Serialize X25519 public key to 32 raw bytes
|
||||
static func serializePublic(_ key: Curve25519.KeyAgreement.PublicKey) -> Data {
|
||||
key.rawData // 32 bytes
|
||||
}
|
||||
|
||||
/// Load X25519 private key from 32 raw bytes
|
||||
static func loadPrivate(_ data: Data) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("X25519 private key must be 32 bytes")
|
||||
}
|
||||
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
/// Load X25519 public key from 32 raw bytes
|
||||
static func loadPublic(_ data: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
guard data.count == 32 else {
|
||||
throw CryptoError.invalidKeyData("X25519 public key must be 32 bytes")
|
||||
}
|
||||
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: data)
|
||||
}
|
||||
|
||||
// MARK: - Diffie-Hellman
|
||||
|
||||
/// Perform X25519 DH key agreement. Returns 32-byte shared secret.
|
||||
/// Matches Python: x25519_dh(private_key, public_key)
|
||||
static func dh(_ privateKey: Curve25519.KeyAgreement.PrivateKey, _ publicKey: Curve25519.KeyAgreement.PublicKey) throws -> Data {
|
||||
let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: publicKey)
|
||||
// Extract raw bytes from SharedSecret
|
||||
return sharedSecret.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
// MARK: - Ed25519 → X25519 Key Conversion
|
||||
|
||||
/// Convert Ed25519 private key to X25519 private key.
|
||||
/// SHA-512(seed) → take first 32 bytes → clamp per RFC 7748
|
||||
/// Matches Python: ed25519_private_to_x25519(ed_private)
|
||||
static func fromEd25519Private(_ edPrivate: Curve25519.Signing.PrivateKey) throws -> Curve25519.KeyAgreement.PrivateKey {
|
||||
let raw = edPrivate.rawData // 32 bytes seed
|
||||
// SHA-512 of the seed
|
||||
let hash = SHA512.hash(data: raw)
|
||||
var clamped = Data(hash.prefix(32))
|
||||
// Clamp per RFC 7748
|
||||
clamped[0] &= 248
|
||||
clamped[31] &= 127
|
||||
clamped[31] |= 64
|
||||
return try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: clamped)
|
||||
}
|
||||
|
||||
/// Convert Ed25519 public key to X25519 public key.
|
||||
/// Uses Montgomery birational map: u = (1+y)/(1-y) mod p
|
||||
/// Matches Python: ed25519_public_to_x25519(ed_public)
|
||||
static func fromEd25519Public(_ edPublic: Curve25519.Signing.PublicKey) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
let x25519Bytes = FieldArithmetic.ed25519PublicToX25519(edPublic.rawData)
|
||||
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: x25519Bytes)
|
||||
}
|
||||
}
|
||||
139
ios_client 0.8.5/Kecalek/Crypto/X3DH.swift
Normal file
139
ios_client 0.8.5/Kecalek/Crypto/X3DH.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
/// X3DH key agreement protocol (Signal Protocol)
|
||||
enum X3DH {
|
||||
|
||||
// MARK: - Pre-Key Generation
|
||||
|
||||
/// Generate a signed pre-key (SPK).
|
||||
/// Returns (private, public, signature, id).
|
||||
/// Matches Python: generate_signed_prekey(identity_private)
|
||||
static func generateSignedPrekey(
|
||||
identityPrivate: Curve25519.Signing.PrivateKey
|
||||
) throws -> (privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey,
|
||||
signature: Data,
|
||||
id: String) {
|
||||
let (spkPriv, spkPub) = X25519Crypto.generateKeypair()
|
||||
let spkPubBytes = X25519Crypto.serializePublic(spkPub)
|
||||
let signature = try Ed25519Crypto.sign(identityPrivate, data: spkPubBytes)
|
||||
return (spkPriv, spkPub, signature, UUID().uuidString)
|
||||
}
|
||||
|
||||
/// Generate a batch of one-time pre-keys.
|
||||
/// Matches Python: generate_one_time_prekeys(count=50)
|
||||
static func generateOneTimePrekeys(count: Int = 50) -> [(privateKey: Curve25519.KeyAgreement.PrivateKey,
|
||||
publicKey: Curve25519.KeyAgreement.PublicKey,
|
||||
id: String)] {
|
||||
(0..<count).map { _ in
|
||||
let (priv, pub) = X25519Crypto.generateKeypair()
|
||||
return (priv, pub, UUID().uuidString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - X3DH Initiate (Alice)
|
||||
|
||||
/// Initiator side of X3DH.
|
||||
/// Returns (sharedSecret, ephemeralPrivate, ephemeralPublic).
|
||||
/// Matches Python: x3dh_initiate(ik_private_ed, ik_public_remote_ed, spk_remote, spk_signature, opk_remote?)
|
||||
static func initiate(
|
||||
ikPrivateEd: Curve25519.Signing.PrivateKey,
|
||||
ikPublicRemoteEd: Curve25519.Signing.PublicKey,
|
||||
spkRemote: Curve25519.KeyAgreement.PublicKey,
|
||||
spkSignature: Data,
|
||||
opkRemote: Curve25519.KeyAgreement.PublicKey? = nil
|
||||
) throws -> (sharedSecret: Data,
|
||||
ephemeralPrivate: Curve25519.KeyAgreement.PrivateKey,
|
||||
ephemeralPublic: Curve25519.KeyAgreement.PublicKey) {
|
||||
// Verify SPK signature
|
||||
let spkRemoteBytes = X25519Crypto.serializePublic(spkRemote)
|
||||
guard Ed25519Crypto.verify(ikPublicRemoteEd, signature: spkSignature, data: spkRemoteBytes) else {
|
||||
throw CryptoError.x3dhFailed("Invalid SPK signature")
|
||||
}
|
||||
|
||||
// Convert identity keys to X25519
|
||||
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
|
||||
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikPublicRemoteEd)
|
||||
|
||||
// Generate ephemeral keypair
|
||||
let (ekPriv, ekPub) = X25519Crypto.generateKeypair()
|
||||
|
||||
// Debug: print key inputs (matching Python x3dh_respond)
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: ik_remote_ed = \(Ed25519Crypto.serializePublic(ikPublicRemoteEd).hexString)")
|
||||
print("DEBUG x3dh_initiate: ik_x25519_remote = \(X25519Crypto.serializePublic(ikX25519Remote).hexString)")
|
||||
print("DEBUG x3dh_initiate: ek_pub = \(X25519Crypto.serializePublic(ekPub).hexString)")
|
||||
print("DEBUG x3dh_initiate: spk_remote = \(spkRemoteBytes.hexString)")
|
||||
#endif
|
||||
|
||||
// DH computations
|
||||
let dh1 = try X25519Crypto.dh(ikX25519Private, spkRemote) // IK_A, SPK_B
|
||||
let dh2 = try X25519Crypto.dh(ekPriv, ikX25519Remote) // EK_A, IK_B
|
||||
let dh3 = try X25519Crypto.dh(ekPriv, spkRemote) // EK_A, SPK_B
|
||||
|
||||
// Debug: print DH outputs
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: dh1 = \(dh1.hexString)")
|
||||
print("DEBUG x3dh_initiate: dh2 = \(dh2.hexString)")
|
||||
print("DEBUG x3dh_initiate: dh3 = \(dh3.hexString)")
|
||||
#endif
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if let opk = opkRemote {
|
||||
let dh4 = try X25519Crypto.dh(ekPriv, opk) // EK_A, OPK_B
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: dh4 = \(dh4.hexString)")
|
||||
#endif
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
// Derive shared secret
|
||||
let sharedSecret = CryptoUtils.hkdfDerive(
|
||||
inputKey: dhConcat,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.x3dhInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
#if DEBUG
|
||||
print("DEBUG x3dh_initiate: shared_secret = \(sharedSecret.hexString)")
|
||||
#endif
|
||||
|
||||
return (sharedSecret, ekPriv, ekPub)
|
||||
}
|
||||
|
||||
// MARK: - X3DH Respond (Bob)
|
||||
|
||||
/// Responder side of X3DH.
|
||||
/// Returns sharedSecret.
|
||||
/// Matches Python: x3dh_respond(ik_private_ed, spk_private, ik_remote_ed, ek_remote, opk_private?)
|
||||
static func respond(
|
||||
ikPrivateEd: Curve25519.Signing.PrivateKey,
|
||||
spkPrivate: Curve25519.KeyAgreement.PrivateKey,
|
||||
ikRemoteEd: Curve25519.Signing.PublicKey,
|
||||
ekRemote: Curve25519.KeyAgreement.PublicKey,
|
||||
opkPrivate: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) throws -> Data {
|
||||
let ikX25519Private = try X25519Crypto.fromEd25519Private(ikPrivateEd)
|
||||
let ikX25519Remote = try X25519Crypto.fromEd25519Public(ikRemoteEd)
|
||||
|
||||
let dh1 = try X25519Crypto.dh(spkPrivate, ikX25519Remote) // SPK_B, IK_A
|
||||
let dh2 = try X25519Crypto.dh(ikX25519Private, ekRemote) // IK_B, EK_A
|
||||
let dh3 = try X25519Crypto.dh(spkPrivate, ekRemote) // SPK_B, EK_A
|
||||
|
||||
var dhConcat = dh1 + dh2 + dh3
|
||||
if let opk = opkPrivate {
|
||||
let dh4 = try X25519Crypto.dh(opk, ekRemote) // OPK_B, EK_A
|
||||
dhConcat += dh4
|
||||
}
|
||||
|
||||
let sharedSecret = CryptoUtils.hkdfDerive(
|
||||
inputKey: dhConcat,
|
||||
salt: Data(repeating: 0x00, count: 32),
|
||||
info: Data(Constants.x3dhInfo.utf8),
|
||||
length: 32
|
||||
)
|
||||
|
||||
return sharedSecret
|
||||
}
|
||||
}
|
||||
47
ios_client 0.8.5/Kecalek/KecalekApp.swift
Normal file
47
ios_client 0.8.5/Kecalek/KecalekApp.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct KecalekApp: App {
|
||||
@State private var appState = AppState()
|
||||
@State private var authViewModel = AuthViewModel()
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
if appState.isLoggedIn {
|
||||
MainTabView(appState: appState)
|
||||
} else {
|
||||
LoginView(viewModel: authViewModel, appState: appState)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { _, newPhase in
|
||||
switch newPhase {
|
||||
case .background:
|
||||
appState.handleEnteredBackground()
|
||||
case .active:
|
||||
appState.handleBecameActive()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
var appState: AppState
|
||||
@State private var convListVM = ConversationListVM()
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ConversationListView(appState: appState, viewModel: convListVM)
|
||||
.tabItem {
|
||||
Label("Chats", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
}
|
||||
|
||||
ProfileView(appState: appState, isOwnProfile: true)
|
||||
.tabItem {
|
||||
Label("Profile", systemImage: "person.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
54
ios_client 0.8.5/Kecalek/Models/Conversation.swift
Normal file
54
ios_client 0.8.5/Kecalek/Models/Conversation.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
struct Conversation: Identifiable, Equatable, Hashable, Codable {
|
||||
let id: String
|
||||
var name: String?
|
||||
var members: [ConversationMember]
|
||||
var createdBy: String?
|
||||
var avatarFile: String?
|
||||
var unreadCount: Int
|
||||
var isFavorite: Bool
|
||||
var lastMessageTime: Date?
|
||||
|
||||
var isGroup: Bool {
|
||||
name != nil || members.count > 2
|
||||
}
|
||||
|
||||
/// Display name: group name, or DM partner username
|
||||
func displayName(currentUserId: String) -> String {
|
||||
if let name = name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
// DM: show the other person's name
|
||||
if let other = members.first(where: { $0.userId != currentUserId }) {
|
||||
return other.username
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
/// DM partner user ID (nil for groups)
|
||||
func dmPartnerId(currentUserId: String) -> String? {
|
||||
guard !isGroup else { return nil }
|
||||
return members.first(where: { $0.userId != currentUserId })?.userId
|
||||
}
|
||||
|
||||
static func == (lhs: Conversation, rhs: Conversation) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
&& lhs.name == rhs.name
|
||||
&& lhs.members == rhs.members
|
||||
&& lhs.avatarFile == rhs.avatarFile
|
||||
&& lhs.unreadCount == rhs.unreadCount
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationMember: Identifiable, Equatable, Codable {
|
||||
let userId: String
|
||||
var username: String
|
||||
var email: String
|
||||
|
||||
var id: String { userId }
|
||||
}
|
||||
69
ios_client 0.8.5/Kecalek/Models/DeviceBundle.swift
Normal file
69
ios_client 0.8.5/Kecalek/Models/DeviceBundle.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
import Foundation
|
||||
|
||||
/// Key bundle for one device, used in X3DH
|
||||
struct DeviceBundle {
|
||||
let deviceId: String
|
||||
let identityKey: Data // Ed25519 public key (32 bytes)
|
||||
let spk: Data // X25519 public key (32 bytes)
|
||||
let spkSignature: Data // Ed25519 signature (64 bytes)
|
||||
let spkId: String
|
||||
let opk: Data? // X25519 public key (32 bytes), optional
|
||||
let opkId: String?
|
||||
|
||||
/// Parse from server response dictionary
|
||||
/// Server uses: signed_prekey, signed_prekey_id, one_time_prekey, one_time_prekey_id (base64)
|
||||
static func fromDict(_ dict: [String: Any], identityKey: Data? = nil) throws -> DeviceBundle {
|
||||
guard let deviceId = dict["device_id"] as? String else {
|
||||
throw ChatError.invalidData("Missing device_id")
|
||||
}
|
||||
|
||||
// Identity key can be passed in (from parent) or in dict
|
||||
let ik: Data
|
||||
if let passedIk = identityKey {
|
||||
ik = passedIk
|
||||
} else if let ikB64 = dict["identity_key"] as? String,
|
||||
let ikData = Data(base64Encoded: ikB64) {
|
||||
ik = ikData
|
||||
} else {
|
||||
throw ChatError.invalidData("Missing identity_key")
|
||||
}
|
||||
|
||||
// SPK - try both naming conventions, base64 encoded
|
||||
let spkB64 = dict["signed_prekey"] as? String ?? dict["spk"] as? String
|
||||
guard let spkB64 = spkB64,
|
||||
let spk = Data(base64Encoded: spkB64) else {
|
||||
throw ChatError.invalidData("Missing signed_prekey")
|
||||
}
|
||||
|
||||
// SPK signature - base64 encoded
|
||||
guard let spkSigB64 = dict["spk_signature"] as? String,
|
||||
let spkSig = Data(base64Encoded: spkSigB64) else {
|
||||
throw ChatError.invalidData("Missing spk_signature")
|
||||
}
|
||||
|
||||
// SPK ID - try both naming conventions
|
||||
let spkId = dict["signed_prekey_id"] as? String ?? dict["spk_id"] as? String
|
||||
guard let spkId = spkId else {
|
||||
throw ChatError.invalidData("Missing signed_prekey_id")
|
||||
}
|
||||
|
||||
// OPK - optional, base64 encoded
|
||||
var opk: Data?
|
||||
var opkId: String?
|
||||
let opkB64 = dict["one_time_prekey"] as? String ?? dict["opk"] as? String
|
||||
if let opkB64 = opkB64, let opkData = Data(base64Encoded: opkB64) {
|
||||
opk = opkData
|
||||
opkId = dict["one_time_prekey_id"] as? String ?? dict["opk_id"] as? String
|
||||
}
|
||||
|
||||
return DeviceBundle(
|
||||
deviceId: deviceId,
|
||||
identityKey: ik,
|
||||
spk: spk,
|
||||
spkSignature: spkSig,
|
||||
spkId: spkId,
|
||||
opk: opk,
|
||||
opkId: opkId
|
||||
)
|
||||
}
|
||||
}
|
||||
9
ios_client 0.8.5/Kecalek/Models/Invitation.swift
Normal file
9
ios_client 0.8.5/Kecalek/Models/Invitation.swift
Normal file
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct Invitation: Identifiable {
|
||||
let id: String // invitation id (from server) or conversationId
|
||||
let conversationId: String
|
||||
let conversationName: String
|
||||
let invitedBy: String
|
||||
let invitedByUsername: String
|
||||
}
|
||||
210
ios_client 0.8.5/Kecalek/Models/Message.swift
Normal file
210
ios_client 0.8.5/Kecalek/Models/Message.swift
Normal file
@@ -0,0 +1,210 @@
|
||||
import Foundation
|
||||
|
||||
struct MessageReaction: Equatable {
|
||||
let userId: String
|
||||
let reaction: String
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
struct ForwardedFrom: Equatable {
|
||||
let sender: String
|
||||
let conversationId: String
|
||||
let messageId: String
|
||||
}
|
||||
|
||||
enum ReactionEmoji {
|
||||
static let allowed = ["thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"]
|
||||
static let display: [String: String] = [
|
||||
"thumbsup": "👍", "heart": "❤️", "laugh": "😂",
|
||||
"surprised": "😮", "sad": "😢", "thumbsdown": "👎",
|
||||
]
|
||||
}
|
||||
|
||||
struct Message: Identifiable, Equatable {
|
||||
let id: String
|
||||
let conversationId: String
|
||||
let senderId: String
|
||||
var senderUsername: String
|
||||
let createdAt: Date
|
||||
var text: String?
|
||||
var replyTo: String?
|
||||
var imageFileId: String?
|
||||
var file: FileInfo?
|
||||
var image: ImageInfo?
|
||||
var isDeleted: Bool
|
||||
var readBy: Set<String>
|
||||
var reactions: [MessageReaction]
|
||||
var forwardedFrom: ForwardedFrom?
|
||||
var pinnedAt: Date?
|
||||
var pinnedBy: String?
|
||||
|
||||
/// Whether this is a self-sent message
|
||||
func isMine(currentUserId: String) -> Bool {
|
||||
senderId == currentUserId
|
||||
}
|
||||
|
||||
static func == (lhs: Message, rhs: Message) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
struct FileInfo: Equatable, Codable {
|
||||
let fileId: String
|
||||
let aesKey: String // base64
|
||||
let iv: String // base64
|
||||
let filename: String
|
||||
let size: Int
|
||||
let mimeType: String
|
||||
}
|
||||
|
||||
struct ImageInfo: Equatable {
|
||||
let fileId: String
|
||||
let aesKey: String // base64
|
||||
let iv: String // base64
|
||||
let thumbnail: String? // base64 JPEG thumbnail
|
||||
let filename: String
|
||||
let size: Int
|
||||
}
|
||||
|
||||
// MARK: - Cache Dictionary Conversion
|
||||
|
||||
extension Message {
|
||||
/// Convert to dictionary matching server JSON format for MessageCache storage
|
||||
func toCacheDict() -> [String: Any] {
|
||||
var dict: [String: Any] = [
|
||||
"message_id": id,
|
||||
"conversation_id": conversationId,
|
||||
"sender_id": senderId,
|
||||
"sender_username": senderUsername,
|
||||
"created_at": DateParsing.format(createdAt),
|
||||
"is_deleted": isDeleted,
|
||||
]
|
||||
if let text = text { dict["text"] = text }
|
||||
if let replyTo = replyTo { dict["reply_to"] = replyTo }
|
||||
if let imageFileId = imageFileId { dict["image_file_id"] = imageFileId }
|
||||
if let file = file {
|
||||
dict["file"] = [
|
||||
"file_id": file.fileId,
|
||||
"aes_key": file.aesKey,
|
||||
"iv": file.iv,
|
||||
"filename": file.filename,
|
||||
"size": file.size,
|
||||
"mime_type": file.mimeType,
|
||||
] as [String: Any]
|
||||
}
|
||||
if let image = image {
|
||||
var imgDict: [String: Any] = [
|
||||
"file_id": image.fileId,
|
||||
"aes_key": image.aesKey,
|
||||
"iv": image.iv,
|
||||
"filename": image.filename,
|
||||
"size": image.size,
|
||||
]
|
||||
if let thumbnail = image.thumbnail { imgDict["thumbnail"] = thumbnail }
|
||||
dict["image"] = imgDict
|
||||
}
|
||||
if !readBy.isEmpty { dict["read_by"] = Array(readBy) }
|
||||
if !reactions.isEmpty {
|
||||
dict["reactions"] = reactions.map {
|
||||
["user_id": $0.userId, "reaction": $0.reaction,
|
||||
"created_at": DateParsing.format($0.createdAt)] as [String: Any]
|
||||
}
|
||||
}
|
||||
if let fwd = forwardedFrom {
|
||||
dict["forwarded_from"] = ["sender": fwd.sender,
|
||||
"conversation_id": fwd.conversationId,
|
||||
"message_id": fwd.messageId] as [String: Any]
|
||||
}
|
||||
if let pinnedAt { dict["pinned_at"] = DateParsing.format(pinnedAt) }
|
||||
if let pinnedBy { dict["pinned_by"] = pinnedBy }
|
||||
return dict
|
||||
}
|
||||
|
||||
/// Create Message from cache dictionary (server JSON format)
|
||||
static func fromCacheDict(_ dict: [String: Any]) -> Message? {
|
||||
guard let id = dict["message_id"] as? String,
|
||||
let conversationId = dict["conversation_id"] as? String,
|
||||
let senderId = dict["sender_id"] as? String,
|
||||
let createdAtStr = dict["created_at"] as? String,
|
||||
let createdAt = DateParsing.parse(createdAtStr) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let senderUsername = dict["sender_username"] as? String ?? ""
|
||||
|
||||
var file: FileInfo?
|
||||
if let fileDict = dict["file"] as? [String: Any],
|
||||
let fileId = fileDict["file_id"] as? String {
|
||||
file = FileInfo(
|
||||
fileId: fileId,
|
||||
aesKey: fileDict["aes_key"] as? String ?? "",
|
||||
iv: fileDict["iv"] as? String ?? "",
|
||||
filename: fileDict["filename"] as? String ?? "",
|
||||
size: fileDict["size"] as? Int ?? 0,
|
||||
mimeType: fileDict["mime_type"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
var image: ImageInfo?
|
||||
if let imgDict = dict["image"] as? [String: Any],
|
||||
let imgFileId = imgDict["file_id"] as? String {
|
||||
image = ImageInfo(
|
||||
fileId: imgFileId,
|
||||
aesKey: imgDict["aes_key"] as? String ?? "",
|
||||
iv: imgDict["iv"] as? String ?? "",
|
||||
thumbnail: imgDict["thumbnail"] as? String,
|
||||
filename: imgDict["filename"] as? String ?? "image.jpg",
|
||||
size: imgDict["size"] as? Int ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
let readBy: Set<String>
|
||||
if let readByArray = dict["read_by"] as? [String] {
|
||||
readBy = Set(readByArray)
|
||||
} else {
|
||||
readBy = []
|
||||
}
|
||||
|
||||
var reactions: [MessageReaction] = []
|
||||
if let reactionsArr = dict["reactions"] as? [[String: Any]] {
|
||||
reactions = reactionsArr.compactMap { r in
|
||||
guard let userId = r["user_id"] as? String,
|
||||
let reaction = r["reaction"] as? String else { return nil }
|
||||
let createdAt = (r["created_at"] as? String).flatMap { DateParsing.parse($0) } ?? Date()
|
||||
return MessageReaction(userId: userId, reaction: reaction, createdAt: createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
var forwardedFrom: ForwardedFrom?
|
||||
if let fwd = dict["forwarded_from"] as? [String: Any],
|
||||
let sender = fwd["sender"] as? String {
|
||||
forwardedFrom = ForwardedFrom(
|
||||
sender: sender,
|
||||
conversationId: fwd["conversation_id"] as? String ?? "",
|
||||
messageId: fwd["message_id"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
let pinnedAt = (dict["pinned_at"] as? String).flatMap { DateParsing.parse($0) }
|
||||
let pinnedBy = dict["pinned_by"] as? String
|
||||
|
||||
return Message(
|
||||
id: id,
|
||||
conversationId: conversationId,
|
||||
senderId: senderId,
|
||||
senderUsername: senderUsername,
|
||||
createdAt: createdAt,
|
||||
text: dict["text"] as? String,
|
||||
replyTo: dict["reply_to"] as? String,
|
||||
imageFileId: dict["image_file_id"] as? String,
|
||||
file: file,
|
||||
image: image,
|
||||
isDeleted: dict["is_deleted"] as? Bool ?? false,
|
||||
readBy: readBy,
|
||||
reactions: reactions,
|
||||
forwardedFrom: forwardedFrom,
|
||||
pinnedAt: pinnedAt,
|
||||
pinnedBy: pinnedBy
|
||||
)
|
||||
}
|
||||
}
|
||||
19
ios_client 0.8.5/Kecalek/Models/User.swift
Normal file
19
ios_client 0.8.5/Kecalek/Models/User.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
struct User: Identifiable, Equatable {
|
||||
let id: String
|
||||
var username: String
|
||||
var email: String
|
||||
var identityKey: Data? // Ed25519 public key (32 bytes)
|
||||
}
|
||||
|
||||
struct UserProfile: Equatable {
|
||||
var userId: String
|
||||
var username: String?
|
||||
var email: String?
|
||||
var phone: String?
|
||||
var phoneVisible: Bool
|
||||
var location: String?
|
||||
var locationVisible: Bool
|
||||
var avatarFile: String?
|
||||
}
|
||||
191
ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift
Normal file
191
ios_client 0.8.5/Kecalek/Network/ConnectionManager.swift
Normal file
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// TCP connection manager using Network.framework.
|
||||
/// Handles connection lifecycle, TLS, buffered reading (newline-delimited), and writing.
|
||||
actor ConnectionManager {
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var receiveBuffer = Data()
|
||||
private(set) var state: ConnectionState = .disconnected
|
||||
private var stateCallback: ((ConnectionState) -> Void)?
|
||||
private var messageStream: AsyncStream<[String: Any]>.Continuation?
|
||||
|
||||
/// Set a callback for connection state changes
|
||||
func onStateChange(_ callback: @escaping (ConnectionState) -> Void) {
|
||||
stateCallback = callback
|
||||
}
|
||||
|
||||
// MARK: - Connect / Disconnect
|
||||
|
||||
/// Connect to server
|
||||
func connect(host: String, port: UInt16) async throws {
|
||||
guard state == .disconnected || state != .connected else {
|
||||
throw NetworkError.alreadyConnected
|
||||
}
|
||||
|
||||
updateState(.connecting)
|
||||
|
||||
let nwHost = NWEndpoint.Host(host)
|
||||
let nwPort = NWEndpoint.Port(rawValue: port)!
|
||||
|
||||
let tlsOptions = NWProtocolTLS.Options()
|
||||
let params = NWParameters(tls: tlsOptions, tcp: .init())
|
||||
|
||||
let conn = NWConnection(host: nwHost, port: nwPort, using: params)
|
||||
self.connection = conn
|
||||
self.receiveBuffer = Data()
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
// nonisolated flag — accessed only from the stateUpdateHandler serial queue
|
||||
// Use a class wrapper so the closure can mutate it
|
||||
final class ResumedFlag: @unchecked Sendable {
|
||||
var value = false
|
||||
}
|
||||
let resumed = ResumedFlag()
|
||||
|
||||
conn.stateUpdateHandler = { [weak self] newState in
|
||||
Task { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch newState {
|
||||
case .ready:
|
||||
await self.updateState(.connected)
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume()
|
||||
case .failed(let error):
|
||||
await self.updateState(.failed(error.localizedDescription))
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
case .cancelled:
|
||||
await self.updateState(.disconnected)
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume(throwing: NetworkError.connectionFailed("Connection cancelled"))
|
||||
case .waiting(let error):
|
||||
await self.updateState(.failed(error.localizedDescription))
|
||||
guard !resumed.value else { return }
|
||||
resumed.value = true
|
||||
continuation.resume(throwing: NetworkError.connectionFailed("Waiting: \(error.localizedDescription)"))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnect from server
|
||||
func disconnect() {
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
receiveBuffer = Data()
|
||||
updateState(.disconnected)
|
||||
messageStream?.finish()
|
||||
messageStream = nil
|
||||
}
|
||||
|
||||
// MARK: - Send
|
||||
|
||||
/// Send raw data over the connection
|
||||
func send(_ data: Data) async throws {
|
||||
guard let connection = connection, state == .connected else {
|
||||
throw NetworkError.notConnected
|
||||
}
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a protocol message (builds JSON + newline, sends)
|
||||
func sendMessage(type: String, requestId: String? = nil, params: [String: Any] = [:]) async throws {
|
||||
let data = try ProtocolHandler.buildRequest(type: type, requestId: requestId, params: params)
|
||||
try await send(data)
|
||||
}
|
||||
|
||||
// MARK: - Receive
|
||||
|
||||
/// Read one newline-delimited JSON message.
|
||||
/// Returns nil on EOF / connection close.
|
||||
func readMessage() async throws -> [String: Any]? {
|
||||
while true {
|
||||
// Check buffer for a complete line
|
||||
if let newlineIndex = receiveBuffer.firstIndex(of: 0x0A) {
|
||||
let lineData = receiveBuffer.prefix(through: newlineIndex)
|
||||
receiveBuffer.removeSubrange(...newlineIndex)
|
||||
|
||||
// Check size
|
||||
if lineData.count > Constants.maxMessageBytes {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
|
||||
return try ProtocolHandler.parseMessage(Data(lineData))
|
||||
}
|
||||
|
||||
// Buffer doesn't have a complete line — read more from the connection
|
||||
guard let connection = connection else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let chunk = try await receiveChunk(connection: connection)
|
||||
guard let chunk = chunk else {
|
||||
return nil // EOF
|
||||
}
|
||||
|
||||
receiveBuffer.append(chunk)
|
||||
|
||||
// Safety: if buffer exceeds max without a newline, drop it
|
||||
if receiveBuffer.count > Constants.maxMessageBytes * 2 {
|
||||
receiveBuffer = Data()
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a chunk of data from the connection
|
||||
private func receiveChunk(connection: NWConnection) async throws -> Data? {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { content, _, isComplete, error in
|
||||
if let error = error {
|
||||
continuation.resume(throwing: NetworkError.connectionFailed(error.localizedDescription))
|
||||
return
|
||||
}
|
||||
if let content = content, !content.isEmpty {
|
||||
continuation.resume(returning: content)
|
||||
} else if isComplete {
|
||||
continuation.resume(returning: nil)
|
||||
} else {
|
||||
// No data and not complete — shouldn't happen but return nil
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var isConnected: Bool {
|
||||
state == .connected
|
||||
}
|
||||
|
||||
private func updateState(_ newState: ConnectionState) {
|
||||
state = newState
|
||||
stateCallback?(newState)
|
||||
}
|
||||
}
|
||||
88
ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift
Normal file
88
ios_client 0.8.5/Kecalek/Network/ProtocolHandler.swift
Normal file
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
/// Newline-delimited JSON protocol handler.
|
||||
/// Matches Python: protocol.py build_request, build_response, parse_message, encode_binary, decode_binary
|
||||
enum ProtocolHandler: Sendable {
|
||||
|
||||
/// Build a request message (newline-terminated JSON).
|
||||
/// Matches Python: build_request(msg_type, request_id=None, **kwargs)
|
||||
nonisolated static func buildRequest(type: String, requestId: String? = nil, params: [String: Any] = [:]) throws -> Data {
|
||||
var msg: [String: Any] = ["type": type]
|
||||
if let requestId = requestId {
|
||||
msg["request_id"] = requestId
|
||||
}
|
||||
// Merge params into msg
|
||||
for (key, value) in params {
|
||||
msg[key] = value
|
||||
}
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: msg)
|
||||
guard jsonData.count < Constants.maxMessageBytes else {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
return jsonData + Data([0x0A]) // newline
|
||||
}
|
||||
|
||||
/// Build a response message (newline-terminated JSON).
|
||||
nonisolated static func buildResponse(type: String, status: String, data: [String: Any]? = nil, requestId: String? = nil) throws -> Data {
|
||||
var msg: [String: Any] = ["type": type, "status": status]
|
||||
if let data = data {
|
||||
msg["data"] = data
|
||||
}
|
||||
if let requestId = requestId {
|
||||
msg["request_id"] = requestId
|
||||
}
|
||||
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: msg)
|
||||
guard jsonData.count < Constants.maxMessageBytes else {
|
||||
throw NetworkError.messageTooLarge
|
||||
}
|
||||
return jsonData + Data([0x0A])
|
||||
}
|
||||
|
||||
/// Parse a single protocol message from bytes.
|
||||
/// Matches Python: parse_message(line)
|
||||
nonisolated static func parseMessage(_ data: Data) throws -> [String: Any] {
|
||||
let trimmed = Self.trimmingNewlines(data)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw NetworkError.protocolError("Empty message")
|
||||
}
|
||||
guard let obj = try JSONSerialization.jsonObject(with: trimmed) as? [String: Any] else {
|
||||
throw NetworkError.protocolError("Message is not a JSON object")
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/// Encode bytes to base64 string.
|
||||
/// Matches Python: encode_binary(data)
|
||||
nonisolated static func encodeBinary(_ data: Data) -> String {
|
||||
data.base64EncodedString(options: [])
|
||||
}
|
||||
|
||||
/// Decode base64 string to bytes.
|
||||
/// Matches Python: decode_binary(data)
|
||||
nonisolated static func decodeBinary(_ string: String) throws -> Data {
|
||||
guard let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) else {
|
||||
throw CryptoError.invalidBase64
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Generate a new request ID (UUID string).
|
||||
nonisolated static func newRequestId() -> String {
|
||||
UUID().uuidString
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private nonisolated static func trimmingNewlines(_ data: Data) -> Data {
|
||||
var result = data
|
||||
while let last = result.last, last == 0x0A || last == 0x0D {
|
||||
result.removeLast()
|
||||
}
|
||||
while let first = result.first, first == 0x0A || first == 0x0D {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
38
ios_client 0.8.5/Kecalek/Utilities/Constants.swift
Normal file
38
ios_client 0.8.5/Kecalek/Utilities/Constants.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import Foundation
|
||||
|
||||
enum Constants: Sendable {
|
||||
nonisolated static let version = "0.8.5"
|
||||
nonisolated static let maxMessageBytes = 65536
|
||||
nonisolated static let maxImageBytes = 5 * 1024 * 1024 // 5 MB
|
||||
nonisolated static let maxFileBytes = 50 * 1024 * 1024 // 50 MB
|
||||
nonisolated static let imageChunkSize = 32768 // 32 KB (matches Python IMAGE_CHUNK_SIZE)
|
||||
nonisolated static let selfDeviceId = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
nonisolated static let opkReplenishThreshold = 20
|
||||
nonisolated static let opkBatchSize = 50
|
||||
nonisolated static let spkRotationDays = 7
|
||||
|
||||
nonisolated static let maxSkip = 256
|
||||
nonisolated static let maxSenderKeySkip = 256
|
||||
|
||||
nonisolated static let deviceBundleCacheTTL: TimeInterval = 300 // 5 minutes
|
||||
nonisolated static let sendReceiveTimeout: TimeInterval = 30
|
||||
nonisolated static let reconnectBaseDelay: TimeInterval = 1
|
||||
nonisolated static let reconnectMaxDelay: TimeInterval = 30
|
||||
|
||||
nonisolated static let pbkdf2Iterations: UInt32 = 600_000
|
||||
nonisolated static let ecp1Magic = Data([0x45, 0x43, 0x50, 0x31]) // "ECP1"
|
||||
|
||||
// HKDF info/salt strings matching Python
|
||||
nonisolated static let x3dhInfo = "EncryptedChat_X3DH"
|
||||
nonisolated static let rootKeyInfo = "EncryptedChat_RootKey"
|
||||
nonisolated static let selfEncryptionSalt = "self_encryption"
|
||||
nonisolated static let selfEncryptionInfo = "EncryptedChat_SelfKey"
|
||||
nonisolated static let localStorageSalt = "local_storage"
|
||||
nonisolated static let localStorageInfo = "EncryptedChat_LocalStorage"
|
||||
nonisolated static let senderKeyChainInfo = "SenderKeyChain"
|
||||
|
||||
// Server connection defaults
|
||||
nonisolated static let defaultHost = "chat.ai-tech.news"
|
||||
nonisolated static let defaultPort: UInt16 = 9999
|
||||
}
|
||||
168
ios_client 0.8.5/Kecalek/Utilities/Extensions.swift
Normal file
168
ios_client 0.8.5/Kecalek/Utilities/Extensions.swift
Normal file
@@ -0,0 +1,168 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Data ↔ Hex
|
||||
|
||||
extension Data {
|
||||
/// Convert data to lowercase hex string
|
||||
var hexString: String {
|
||||
map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
/// Initialize Data from a hex string
|
||||
init?(hexString: String) {
|
||||
let hex = hexString.lowercased()
|
||||
guard hex.count % 2 == 0 else { return nil }
|
||||
var data = Data(capacity: hex.count / 2)
|
||||
var index = hex.startIndex
|
||||
while index < hex.endIndex {
|
||||
let nextIndex = hex.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(hex[index..<nextIndex], radix: 16) else { return nil }
|
||||
data.append(byte)
|
||||
index = nextIndex
|
||||
}
|
||||
self = data
|
||||
}
|
||||
|
||||
/// Generate random bytes
|
||||
static func randomBytes(_ count: Int) -> Data {
|
||||
var data = Data(count: count)
|
||||
data.withUnsafeMutableBytes { ptr in
|
||||
_ = SecRandomCopyBytes(kSecRandomDefault, count, ptr.baseAddress!)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data ↔ Base64 (Protocol Wire Format)
|
||||
|
||||
extension Data {
|
||||
/// Encode to standard base64 string (matching Python's base64.b64encode)
|
||||
func base64EncodedString() -> String {
|
||||
self.base64EncodedString(options: [])
|
||||
}
|
||||
|
||||
/// Decode from base64 string
|
||||
static func fromBase64(_ string: String) throws -> Data {
|
||||
// Try standard base64 first, then URL-safe
|
||||
if let data = Data(base64Encoded: string, options: .ignoreUnknownCharacters) {
|
||||
return data
|
||||
}
|
||||
throw CryptoError.invalidBase64
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UInt32 Big-Endian
|
||||
|
||||
extension UInt32 {
|
||||
var bigEndianData: Data {
|
||||
var value = self.bigEndian
|
||||
return Data(bytes: &value, count: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CryptoKit Key → Data
|
||||
|
||||
extension Curve25519.KeyAgreement.PublicKey {
|
||||
nonisolated var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
extension Curve25519.KeyAgreement.PrivateKey {
|
||||
nonisolated var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
extension Curve25519.Signing.PublicKey {
|
||||
nonisolated var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
extension Curve25519.Signing.PrivateKey {
|
||||
nonisolated var rawData: Data {
|
||||
Data(rawRepresentation)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - String helpers
|
||||
|
||||
extension String {
|
||||
/// Trim whitespace and newlines
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Date Parsing (server sends ISO8601 with or without timezone)
|
||||
|
||||
enum DateParsing {
|
||||
private static let iso8601WithTZ: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let iso8601Basic: ISO8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
private static let noTZ: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
return f
|
||||
}()
|
||||
|
||||
/// Parse ISO8601 date string — handles with/without timezone, with/without fractional seconds
|
||||
static func parse(_ string: String) -> Date? {
|
||||
iso8601WithTZ.date(from: string)
|
||||
?? iso8601Basic.date(from: string)
|
||||
?? noTZ.date(from: string)
|
||||
}
|
||||
|
||||
/// Format Date to ISO8601 string (for after_ts / since_ts parameters)
|
||||
static func format(_ date: Date) -> String {
|
||||
iso8601WithTZ.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dictionary merge helper
|
||||
|
||||
extension Dictionary where Key == String, Value == Any {
|
||||
nonisolated func string(for key: String) -> String? {
|
||||
self[key] as? String
|
||||
}
|
||||
|
||||
nonisolated func int(for key: String) -> Int? {
|
||||
if let i = self[key] as? Int { return i }
|
||||
if let s = self[key] as? String, let i = Int(s) { return i }
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated func dict(for key: String) -> [String: Any]? {
|
||||
self[key] as? [String: Any]
|
||||
}
|
||||
|
||||
nonisolated func array(for key: String) -> [[String: Any]]? {
|
||||
self[key] as? [[String: Any]]
|
||||
}
|
||||
|
||||
nonisolated func data(for key: String) -> Data? {
|
||||
if let hex = self[key] as? String {
|
||||
return Data(hexString: hex)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated func bool(for key: String) -> Bool? {
|
||||
if let b = self[key] as? Bool { return b }
|
||||
if let i = self[key] as? Int { return i != 0 }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
192
ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift
Normal file
192
ios_client 0.8.5/Kecalek/ViewModels/AuthViewModel.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class AuthViewModel {
|
||||
var email = ""
|
||||
var password = ""
|
||||
var confirmPassword = ""
|
||||
var username = ""
|
||||
var confirmationCode = ""
|
||||
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var showConfirmation = false
|
||||
var registrationMessage: String?
|
||||
|
||||
var serverHost = Constants.defaultHost
|
||||
var serverPort = String(Constants.defaultPort)
|
||||
|
||||
var hasSavedCredentials = false
|
||||
var isBiometricLoading = false
|
||||
|
||||
enum AuthMode {
|
||||
case login, register, pairing
|
||||
}
|
||||
var mode: AuthMode = .login
|
||||
|
||||
func checkSavedCredentials() {
|
||||
hasSavedCredentials = KeychainService.hasSavedCredentials() && KeychainService.isBiometricAvailable()
|
||||
}
|
||||
|
||||
func login(appState: AppState) async {
|
||||
guard !email.isEmpty, !password.isEmpty else {
|
||||
errorMessage = "Email and password are required"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
// Only connect if not already connected
|
||||
if await !appState.chatClient.isConnected {
|
||||
do {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(host: serverHost, port: port)
|
||||
} catch {
|
||||
isLoading = false
|
||||
errorMessage = "Connection failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let (success, message) = await appState.chatClient.login(email: email, password: password)
|
||||
isLoading = false
|
||||
|
||||
if success {
|
||||
appState.email = email
|
||||
appState.isLoggedIn = true
|
||||
appState.connectionStatus = .connected
|
||||
appState.startConnectionMonitor()
|
||||
if let userId = await appState.chatClient.userId {
|
||||
appState.currentUser = User(id: userId, username: await appState.chatClient.username, email: email)
|
||||
}
|
||||
|
||||
// Save credentials for biometric login next time
|
||||
if KeychainService.isBiometricAvailable() {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try? KeychainService.saveCredentials(
|
||||
email: email, password: password,
|
||||
host: serverHost, port: port
|
||||
)
|
||||
}
|
||||
|
||||
// Clear password from memory after successful login
|
||||
password = ""
|
||||
confirmPassword = ""
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
func biometricLogin(appState: AppState) async {
|
||||
isBiometricLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let creds = try KeychainService.loadCredentials()
|
||||
email = creds.email
|
||||
password = creds.password
|
||||
serverHost = creds.host
|
||||
serverPort = String(creds.port)
|
||||
isBiometricLoading = false
|
||||
|
||||
await login(appState: appState)
|
||||
|
||||
// If login failed, reset to defaults so the form isn't stuck on stale values
|
||||
if !appState.isLoggedIn {
|
||||
serverHost = Constants.defaultHost
|
||||
serverPort = String(Constants.defaultPort)
|
||||
password = ""
|
||||
KeychainService.deleteCredentials()
|
||||
hasSavedCredentials = false
|
||||
}
|
||||
} catch KeychainService.KeychainError.biometricFailed {
|
||||
isBiometricLoading = false
|
||||
// User cancelled — just let them type manually
|
||||
} catch {
|
||||
isBiometricLoading = false
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
func register(appState: AppState) async {
|
||||
guard !email.isEmpty, !password.isEmpty, !username.isEmpty else {
|
||||
errorMessage = "All fields are required"
|
||||
return
|
||||
}
|
||||
guard password == confirmPassword else {
|
||||
errorMessage = "Passwords don't match"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
#if DEBUG
|
||||
print("DEBUG register: connecting to \(serverHost):\(serverPort)")
|
||||
#endif
|
||||
if await !appState.chatClient.isConnected {
|
||||
do {
|
||||
let port = UInt16(serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(host: serverHost, port: port)
|
||||
#if DEBUG
|
||||
print("DEBUG register: connected successfully")
|
||||
#endif
|
||||
} catch {
|
||||
isLoading = false
|
||||
#if DEBUG
|
||||
print("DEBUG register: connection failed - \(error)")
|
||||
#endif
|
||||
errorMessage = "Connection failed: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("DEBUG register: calling chatClient.register")
|
||||
#endif
|
||||
let (success, message) = await appState.chatClient.register(username: username, password: password, email: email)
|
||||
isLoading = false
|
||||
|
||||
#if DEBUG
|
||||
print("DEBUG AuthViewModel: register returned success=\(success), message=\(message)")
|
||||
#endif
|
||||
|
||||
if success {
|
||||
registrationMessage = message
|
||||
showConfirmation = true
|
||||
#if DEBUG
|
||||
print("DEBUG AuthViewModel: showConfirmation set to true")
|
||||
#endif
|
||||
} else {
|
||||
errorMessage = message
|
||||
#if DEBUG
|
||||
print("DEBUG AuthViewModel: errorMessage set to \(message)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func confirmRegistration(appState: AppState) async {
|
||||
guard !confirmationCode.isEmpty else {
|
||||
errorMessage = "Enter the confirmation code"
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let (success, message) = await appState.chatClient.confirmRegistration(
|
||||
email: email, username: username, code: confirmationCode
|
||||
)
|
||||
isLoading = false
|
||||
|
||||
if success {
|
||||
registrationMessage = message
|
||||
// Auto-login after registration
|
||||
await login(appState: appState)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
356
ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift
Normal file
356
ios_client 0.8.5/Kecalek/ViewModels/ChatViewModel.swift
Normal file
@@ -0,0 +1,356 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class ChatViewModel {
|
||||
var messages: [Message] = []
|
||||
var isLoading = false
|
||||
var isSending = false
|
||||
var errorMessage: String?
|
||||
var searchQuery = ""
|
||||
var searchResults: [String] = [] // message IDs matching search
|
||||
var currentSearchIndex = 0
|
||||
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
|
||||
func loadMessages(convId: String, chatClient: ChatClient) async {
|
||||
let email = await chatClient.email
|
||||
let cacheKey = await chatClient.cacheKey
|
||||
|
||||
// 1. Load from cache
|
||||
let cachedDicts = MessageCache.load(email: email, convId: convId, cacheKey: cacheKey)
|
||||
let cached = cachedDicts?.compactMap { Message.fromCacheDict($0) } ?? []
|
||||
|
||||
if !cached.isEmpty {
|
||||
// Cache hit — show immediately, no spinner
|
||||
messages = cached.sorted { $0.createdAt < $1.createdAt }
|
||||
} else {
|
||||
// No cache — show spinner (first open)
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
// 2. Determine after_ts from newest cached message
|
||||
let newestCached = messages.last
|
||||
|
||||
// 3. Fetch from server
|
||||
let serverMessages: [Message]
|
||||
if let newest = newestCached {
|
||||
let afterTs = DateParsing.format(newest.createdAt)
|
||||
#if DEBUG
|
||||
print("DEBUG getMessages after_ts=\(afterTs)")
|
||||
#endif
|
||||
serverMessages = await chatClient.getMessages(convId: convId, limit: 50, afterTs: afterTs)
|
||||
} else {
|
||||
serverMessages = await chatClient.getMessages(convId: convId, limit: 50)
|
||||
}
|
||||
|
||||
// 4. Merge
|
||||
if newestCached != nil {
|
||||
// Incremental: dedup by ID, append new, sort
|
||||
let existingIds = Set(messages.map(\.id))
|
||||
let newMessages = serverMessages.filter { !existingIds.contains($0.id) }
|
||||
if !newMessages.isEmpty {
|
||||
messages.append(contentsOf: newMessages)
|
||||
messages.sort { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
} else {
|
||||
// Full fetch: replace
|
||||
messages = serverMessages
|
||||
}
|
||||
|
||||
// 5. Sync deleted (only for incremental)
|
||||
if let newest = newestCached {
|
||||
let afterTs = DateParsing.format(newest.createdAt)
|
||||
#if DEBUG
|
||||
print("DEBUG get_deleted_since since_ts=\(afterTs)")
|
||||
#endif
|
||||
let deletedIds = await chatClient.getDeletedSince(convId: convId, sinceTs: afterTs)
|
||||
if !deletedIds.isEmpty {
|
||||
messages.removeAll { deletedIds.contains($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Loading done
|
||||
isLoading = false
|
||||
|
||||
// 7. Save to cache
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
|
||||
// 8. Mark entire conversation as read (server-side bulk mark)
|
||||
// This handles messages not in cache (e.g. failed to decrypt or never fetched)
|
||||
await chatClient.markConversationRead(convId: convId)
|
||||
// Update local readBy for cached messages so cache reflects read state
|
||||
let currentUserId = await chatClient.userId ?? ""
|
||||
var anyUpdated = false
|
||||
for i in messages.indices {
|
||||
if !messages[i].isMine(currentUserId: currentUserId) && !messages[i].readBy.contains(currentUserId) {
|
||||
messages[i].readBy.insert(currentUserId)
|
||||
anyUpdated = true
|
||||
}
|
||||
}
|
||||
if anyUpdated {
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
|
||||
func loadOlderMessages(convId: String, chatClient: ChatClient) async {
|
||||
let older = await chatClient.getMessages(convId: convId, limit: 50, offset: messages.count)
|
||||
messages.insert(contentsOf: older, at: 0)
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
|
||||
func sendMessage(convId: String, text: String, members: [ConversationMember],
|
||||
chatClient: ChatClient, replyTo: String? = nil) async {
|
||||
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
|
||||
isSending = true
|
||||
errorMessage = nil
|
||||
|
||||
let (success, msg, sentMessage) = await chatClient.sendMessage(
|
||||
convId: convId, text: text, members: members, replyTo: replyTo
|
||||
)
|
||||
|
||||
isSending = false
|
||||
|
||||
if !success {
|
||||
errorMessage = msg
|
||||
} else if let sentMessage = sentMessage {
|
||||
// Append locally — don't reload from server (ratchet keys are one-time)
|
||||
if !messages.contains(where: { $0.id == sentMessage.id }) {
|
||||
messages.append(sentMessage)
|
||||
}
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteMessage(messageId: String, convId: String, chatClient: ChatClient) async {
|
||||
let success = await chatClient.deleteMessage(messageId: messageId, convId: convId)
|
||||
if success {
|
||||
messages.removeAll { $0.id == messageId }
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
|
||||
func saveCache(convId: String, chatClient: ChatClient) async {
|
||||
let email = await chatClient.email
|
||||
let cacheKey = await chatClient.cacheKey
|
||||
let dicts = messages.map { $0.toCacheDict() }
|
||||
try? MessageCache.save(email: email, convId: convId, messages: dicts, cacheKey: cacheKey)
|
||||
}
|
||||
|
||||
func search(query: String) {
|
||||
searchQuery = query
|
||||
if query.isEmpty {
|
||||
searchResults = []
|
||||
currentSearchIndex = 0
|
||||
return
|
||||
}
|
||||
let lower = query.lowercased()
|
||||
searchResults = messages.filter { $0.text?.lowercased().contains(lower) == true }.map(\.id)
|
||||
currentSearchIndex = searchResults.isEmpty ? 0 : searchResults.count - 1
|
||||
}
|
||||
|
||||
func nextSearchResult() {
|
||||
guard !searchResults.isEmpty else { return }
|
||||
currentSearchIndex = (currentSearchIndex + 1) % searchResults.count
|
||||
}
|
||||
|
||||
func prevSearchResult() {
|
||||
guard !searchResults.isEmpty else { return }
|
||||
currentSearchIndex = (currentSearchIndex - 1 + searchResults.count) % searchResults.count
|
||||
}
|
||||
|
||||
func startNotificationListener(convId: String, chatClient: ChatClient) {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = Task {
|
||||
for await notification in await chatClient.makeNotificationStream() {
|
||||
await handleNotification(notification, convId: convId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleNotification(_ notification: ChatNotification, convId: String, chatClient: ChatClient) {
|
||||
switch notification {
|
||||
case .newMessage(let data):
|
||||
if data["conversation_id"] as? String == convId {
|
||||
Task {
|
||||
if let message = await chatClient.decryptNotification(data) {
|
||||
await MainActor.run {
|
||||
// Deduplicate — sent messages are already appended locally
|
||||
if !messages.contains(where: { $0.id == message.id }) {
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
// Only mark as read if from someone else
|
||||
let myId = await chatClient.userId ?? ""
|
||||
if message.senderId != myId {
|
||||
await chatClient.markRead(convId: convId, messageIds: [message.id])
|
||||
}
|
||||
await chatClient.flushSelfEncrypt()
|
||||
}
|
||||
}
|
||||
}
|
||||
case .messageDeleted(let data):
|
||||
if let msgId = data["message_id"] as? String {
|
||||
messages.removeAll { $0.id == msgId }
|
||||
Task {
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
case .messagesRead(let data):
|
||||
if let readUserId = data["user_id"] as? String,
|
||||
let msgIds = data["message_ids"] as? [String] {
|
||||
for i in messages.indices {
|
||||
if msgIds.contains(messages[i].id) {
|
||||
messages[i].readBy.insert(readUserId)
|
||||
}
|
||||
}
|
||||
}
|
||||
case .messageReacted(let data):
|
||||
if let msgId = data["message_id"] as? String,
|
||||
let reactUserId = data["user_id"] as? String,
|
||||
let reaction = data["reaction"] as? String,
|
||||
let action = data["action"] as? String,
|
||||
let idx = messages.firstIndex(where: { $0.id == msgId }) {
|
||||
if action == "add" {
|
||||
let newReaction = MessageReaction(userId: reactUserId, reaction: reaction, createdAt: Date())
|
||||
if !messages[idx].reactions.contains(where: { $0.userId == reactUserId && $0.reaction == reaction }) {
|
||||
messages[idx].reactions.append(newReaction)
|
||||
}
|
||||
} else {
|
||||
messages[idx].reactions.removeAll { $0.userId == reactUserId && $0.reaction == reaction }
|
||||
}
|
||||
Task { await saveCache(convId: convId, chatClient: chatClient) }
|
||||
}
|
||||
case .messagePinned(let data):
|
||||
if let msgId = data["message_id"] as? String,
|
||||
let pinUserId = data["user_id"] as? String,
|
||||
let idx = messages.firstIndex(where: { $0.id == msgId }) {
|
||||
messages[idx].pinnedAt = Date()
|
||||
messages[idx].pinnedBy = pinUserId
|
||||
Task { await saveCache(convId: convId, chatClient: chatClient) }
|
||||
}
|
||||
case .messageUnpinned(let data):
|
||||
if let msgId = data["message_id"] as? String,
|
||||
let idx = messages.firstIndex(where: { $0.id == msgId }) {
|
||||
messages[idx].pinnedAt = nil
|
||||
messages[idx].pinnedBy = nil
|
||||
Task { await saveCache(convId: convId, chatClient: chatClient) }
|
||||
}
|
||||
case .messageDelivered(let data):
|
||||
// Delivery receipt — message was successfully received by recipient
|
||||
if let msgId = data["message_id"] as? String,
|
||||
let idx = messages.firstIndex(where: { $0.id == msgId }) {
|
||||
messages[idx].readBy.insert("__delivered__")
|
||||
Task { await saveCache(convId: convId, chatClient: chatClient) }
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func reactToMessage(messageId: String, convId: String, reaction: String,
|
||||
currentUserId: String, chatClient: ChatClient) async {
|
||||
guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return }
|
||||
let existingReaction = messages[idx].reactions.first { $0.userId == currentUserId }
|
||||
let hasSameReaction = existingReaction?.reaction == reaction
|
||||
let savedReactions = messages[idx].reactions
|
||||
|
||||
// Optimistic update
|
||||
if hasSameReaction {
|
||||
// Tapping same emoji — remove it
|
||||
messages[idx].reactions.removeAll { $0.userId == currentUserId }
|
||||
} else {
|
||||
// Remove any previous reaction from this user, then add new one
|
||||
messages[idx].reactions.removeAll { $0.userId == currentUserId }
|
||||
messages[idx].reactions.append(MessageReaction(userId: currentUserId, reaction: reaction, createdAt: Date()))
|
||||
}
|
||||
|
||||
// If user had a different reaction, remove it on server first
|
||||
if let old = existingReaction, old.reaction != reaction {
|
||||
let _ = await chatClient.reactMessage(messageId: messageId, conversationId: convId,
|
||||
reaction: old.reaction, action: "remove")
|
||||
}
|
||||
|
||||
// Add or remove the target reaction on server
|
||||
let action = hasSameReaction ? "remove" : "add"
|
||||
let success = await chatClient.reactMessage(messageId: messageId, conversationId: convId,
|
||||
reaction: reaction, action: action)
|
||||
if !success {
|
||||
// Revert on failure
|
||||
messages[idx].reactions = savedReactions
|
||||
}
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
|
||||
func pinMessage(messageId: String, convId: String, pin: Bool,
|
||||
chatClient: ChatClient) async {
|
||||
guard let idx = messages.firstIndex(where: { $0.id == messageId }) else { return }
|
||||
|
||||
// Optimistic update
|
||||
if pin {
|
||||
messages[idx].pinnedAt = Date()
|
||||
messages[idx].pinnedBy = await chatClient.userId
|
||||
} else {
|
||||
messages[idx].pinnedAt = nil
|
||||
messages[idx].pinnedBy = nil
|
||||
}
|
||||
|
||||
let success = await chatClient.pinMessage(messageId: messageId, conversationId: convId,
|
||||
action: pin ? "pin" : "unpin")
|
||||
if !success {
|
||||
// Revert on failure
|
||||
if pin {
|
||||
messages[idx].pinnedAt = nil
|
||||
messages[idx].pinnedBy = nil
|
||||
}
|
||||
}
|
||||
await saveCache(convId: convId, chatClient: chatClient)
|
||||
}
|
||||
|
||||
// MARK: - Forward Message
|
||||
|
||||
func forwardMessage(message: Message, targetConvId: String,
|
||||
targetMembers: [ConversationMember], chatClient: ChatClient) async -> Bool {
|
||||
var originalMsg: [String: Any] = [
|
||||
"text": message.text ?? "",
|
||||
"sender": message.senderUsername,
|
||||
"conversation_id": message.conversationId,
|
||||
"message_id": message.id,
|
||||
]
|
||||
if let file = message.file {
|
||||
originalMsg["file"] = [
|
||||
"file_id": file.fileId,
|
||||
"aes_key": file.aesKey,
|
||||
"iv": file.iv,
|
||||
"filename": file.filename,
|
||||
"size": file.size,
|
||||
"mime_type": file.mimeType,
|
||||
] as [String: Any]
|
||||
}
|
||||
if let image = message.image {
|
||||
var imgDict: [String: Any] = [
|
||||
"file_id": image.fileId,
|
||||
"aes_key": image.aesKey,
|
||||
"iv": image.iv,
|
||||
"filename": image.filename,
|
||||
"size": image.size,
|
||||
]
|
||||
if let thumb = image.thumbnail { imgDict["thumbnail"] = thumb }
|
||||
originalMsg["image"] = imgDict
|
||||
}
|
||||
|
||||
let (success, _, _) = await chatClient.forwardMessage(
|
||||
targetConvId: targetConvId, originalMsg: originalMsg,
|
||||
targetMembers: targetMembers
|
||||
)
|
||||
return success
|
||||
}
|
||||
|
||||
func stop() {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = nil
|
||||
}
|
||||
}
|
||||
246
ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift
Normal file
246
ios_client 0.8.5/Kecalek/ViewModels/ConversationListVM.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class ConversationListVM {
|
||||
var conversations: [Conversation] = []
|
||||
var invitations: [Invitation] = []
|
||||
var onlineUsers: Set<String> = []
|
||||
var unreadCounts: [String: Int] = [:]
|
||||
var favorites: Set<String> = []
|
||||
var avatarCache: [String: Data] = [:] // convId -> avatar image data
|
||||
var isLoading = false
|
||||
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
private var avatarTask: Task<Void, Never>?
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
private var localKey: Data?
|
||||
private var email: String = ""
|
||||
private var lastRefreshTime: Date = .distantPast
|
||||
|
||||
func load(chatClient: ChatClient, email: String) async {
|
||||
isLoading = true
|
||||
self.email = email
|
||||
|
||||
// Load favorites from disk (encrypted with localKey)
|
||||
localKey = await chatClient.localKey
|
||||
favorites = KeyStorage.loadFavorites(email: email, localKey: localKey)
|
||||
|
||||
let currentUserId = await chatClient.userId ?? ""
|
||||
|
||||
// Load cached conversations immediately (show while fetching from server)
|
||||
if let cached = MessageCache.loadConversations(email: email, cacheKey: localKey) {
|
||||
conversations = sortConversations(cached, currentUserId: currentUserId)
|
||||
for conv in conversations where conv.unreadCount > 0 {
|
||||
unreadCounts[conv.id] = conv.unreadCount
|
||||
}
|
||||
}
|
||||
|
||||
// Load cached avatars from disk
|
||||
let diskAvatars = MessageCache.loadAllAvatars(email: email, cacheKey: localKey)
|
||||
if !diskAvatars.isEmpty {
|
||||
avatarCache = diskAvatars
|
||||
}
|
||||
|
||||
// Fetch conversations from server
|
||||
let convs = await chatClient.listConversations()
|
||||
if !convs.isEmpty {
|
||||
// Sync unread counts from server (authoritative source)
|
||||
for conv in convs {
|
||||
unreadCounts[conv.id] = conv.unreadCount
|
||||
}
|
||||
conversations = sortConversations(convs, currentUserId: currentUserId)
|
||||
// Save to cache
|
||||
MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey)
|
||||
}
|
||||
|
||||
// Fetch invitations
|
||||
invitations = await chatClient.listInvitations()
|
||||
|
||||
isLoading = false
|
||||
lastRefreshTime = Date()
|
||||
|
||||
// Start notification listener
|
||||
startNotificationListener(chatClient: chatClient, email: email)
|
||||
|
||||
// Read initial online users stored in ChatClient
|
||||
// (online_users notification arrives during login before any subscriber exists)
|
||||
onlineUsers = await chatClient.onlineUserIds
|
||||
|
||||
// Load avatars in background (non-blocking)
|
||||
avatarTask?.cancel()
|
||||
avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) }
|
||||
}
|
||||
|
||||
func refresh(chatClient: ChatClient) async {
|
||||
// Debounce: skip if refreshed < 2s ago
|
||||
guard Date().timeIntervalSince(lastRefreshTime) > 2 else {
|
||||
#if DEBUG
|
||||
print("DEBUG ConversationListVM: refresh debounced")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
lastRefreshTime = Date()
|
||||
|
||||
let currentUserId = await chatClient.userId ?? ""
|
||||
let convs = await chatClient.listConversations()
|
||||
if !convs.isEmpty {
|
||||
// Sync unread counts from server (authoritative source)
|
||||
for conv in convs {
|
||||
unreadCounts[conv.id] = conv.unreadCount
|
||||
}
|
||||
conversations = sortConversations(convs, currentUserId: currentUserId)
|
||||
// Save to cache
|
||||
MessageCache.saveConversations(email: email, conversations: convs, cacheKey: localKey)
|
||||
}
|
||||
invitations = await chatClient.listInvitations()
|
||||
|
||||
// Refresh avatars in background
|
||||
avatarTask?.cancel()
|
||||
avatarTask = Task { await loadAvatars(chatClient: chatClient, currentUserId: currentUserId) }
|
||||
}
|
||||
|
||||
func toggleFavorite(convId: String, email: String) {
|
||||
if favorites.contains(convId) {
|
||||
favorites.remove(convId)
|
||||
} else {
|
||||
favorites.insert(convId)
|
||||
}
|
||||
try? KeyStorage.saveFavorites(email: email, favorites: favorites, localKey: localKey)
|
||||
|
||||
// Re-sort
|
||||
let userId = conversations.first?.createdBy ?? ""
|
||||
conversations = sortConversations(conversations, currentUserId: userId)
|
||||
}
|
||||
|
||||
func forceRefresh(chatClient: ChatClient) async {
|
||||
lastRefreshTime = .distantPast
|
||||
await refresh(chatClient: chatClient)
|
||||
}
|
||||
|
||||
func updateAvatar(convId: String, data: Data) {
|
||||
avatarCache[convId] = data
|
||||
// Persist to disk so it survives load() re-reads
|
||||
MessageCache.saveAvatar(email: email, key: convId, data: data, cacheKey: localKey)
|
||||
}
|
||||
|
||||
func markConversationRead(convId: String) {
|
||||
unreadCounts[convId] = 0
|
||||
}
|
||||
|
||||
func incrementUnread(convId: String) {
|
||||
unreadCounts[convId, default: 0] += 1
|
||||
}
|
||||
|
||||
private func sortConversations(_ convs: [Conversation], currentUserId: String) -> [Conversation] {
|
||||
var result = convs.map { conv -> Conversation in
|
||||
var c = conv
|
||||
c.isFavorite = favorites.contains(conv.id)
|
||||
c.unreadCount = unreadCounts[conv.id] ?? conv.unreadCount
|
||||
return c
|
||||
}
|
||||
|
||||
result.sort { a, b in
|
||||
// Favorites first
|
||||
if a.isFavorite != b.isFavorite { return a.isFavorite }
|
||||
// Online DMs next
|
||||
let aOnline = a.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
|
||||
let bOnline = b.dmPartnerId(currentUserId: currentUserId).map { onlineUsers.contains($0) } ?? false
|
||||
if aOnline != bOnline { return aOnline }
|
||||
// Alphabetical
|
||||
return a.displayName(currentUserId: currentUserId).lowercased() < b.displayName(currentUserId: currentUserId).lowercased()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func startNotificationListener(chatClient: ChatClient, email: String) {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = Task {
|
||||
for await notification in await chatClient.makeNotificationStream() {
|
||||
await handleNotification(notification, chatClient: chatClient, email: email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleNotification(_ notification: ChatNotification, chatClient: ChatClient, email: String) {
|
||||
switch notification {
|
||||
case .newMessage(let data):
|
||||
if let convId = data["conversation_id"] as? String {
|
||||
incrementUnread(convId: convId)
|
||||
}
|
||||
case .onlineUsers(let userIds):
|
||||
onlineUsers = Set(userIds)
|
||||
case .userOnline(let userId):
|
||||
onlineUsers.insert(userId)
|
||||
case .userOffline(let userId):
|
||||
onlineUsers.remove(userId)
|
||||
case .conversationCreated, .memberAdded, .memberRemoved, .conversationRenamed, .conversationDeleted:
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task { await refresh(chatClient: chatClient) }
|
||||
case .groupInvitation:
|
||||
Task { invitations = await chatClient.listInvitations() }
|
||||
case .reconnected:
|
||||
#if DEBUG
|
||||
print("DEBUG ConversationListVM: reconnected — refreshing")
|
||||
#endif
|
||||
refreshTask?.cancel()
|
||||
refreshTask = Task { await refresh(chatClient: chatClient) }
|
||||
case .connectionStateChanged(let connected):
|
||||
if !connected {
|
||||
#if DEBUG
|
||||
print("DEBUG ConversationListVM: disconnected")
|
||||
#endif
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAvatars(chatClient: ChatClient, currentUserId: String) async {
|
||||
await withTaskGroup(of: (String, Data?).self) { group in
|
||||
for conv in conversations {
|
||||
let convId = conv.id
|
||||
// Skip if already cached in memory
|
||||
if avatarCache[convId] != nil { continue }
|
||||
if conv.isGroup {
|
||||
// Only fetch if group has an avatar file
|
||||
if conv.avatarFile != nil {
|
||||
group.addTask {
|
||||
let data = await chatClient.getGroupAvatar(convId: convId)
|
||||
return (convId, data)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// DM: fetch partner's avatar
|
||||
if let partnerId = conv.dmPartnerId(currentUserId: currentUserId) {
|
||||
group.addTask {
|
||||
let data = await chatClient.getAvatar(userId: partnerId)
|
||||
return (convId, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let emailCapture = email
|
||||
let keyCapture = localKey
|
||||
for await (convId, data) in group {
|
||||
if let data = data {
|
||||
avatarCache[convId] = data
|
||||
// Save to disk cache
|
||||
MessageCache.saveAvatar(email: emailCapture, key: convId, data: data, cacheKey: keyCapture)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
notificationTask?.cancel()
|
||||
notificationTask = nil
|
||||
avatarTask?.cancel()
|
||||
avatarTask = nil
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
}
|
||||
98
ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift
Normal file
98
ios_client 0.8.5/Kecalek/ViewModels/ProfileViewModel.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class ProfileViewModel {
|
||||
var profile: UserProfile?
|
||||
var avatarData: Data?
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var errorMessage: String?
|
||||
|
||||
// Editable fields
|
||||
var phone = ""
|
||||
var phoneVisible = false
|
||||
var location = ""
|
||||
var locationVisible = false
|
||||
|
||||
func loadProfile(userId: String? = nil, chatClient: ChatClient) async {
|
||||
isLoading = true
|
||||
profile = await chatClient.getProfile(userId: userId)
|
||||
isLoading = false
|
||||
|
||||
if let p = profile {
|
||||
phone = p.phone ?? ""
|
||||
phoneVisible = p.phoneVisible
|
||||
location = p.location ?? ""
|
||||
locationVisible = p.locationVisible
|
||||
}
|
||||
|
||||
// Load avatar
|
||||
let clientUserId = await chatClient.userId
|
||||
let uid = userId ?? clientUserId ?? ""
|
||||
if !uid.isEmpty {
|
||||
avatarData = await chatClient.getAvatar(userId: uid)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveProfile(chatClient: ChatClient) async -> Bool {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
|
||||
let success = await chatClient.updateProfile(
|
||||
phone: phone.isEmpty ? nil : phone,
|
||||
phoneVisible: phoneVisible,
|
||||
location: location.isEmpty ? nil : location,
|
||||
locationVisible: locationVisible
|
||||
)
|
||||
|
||||
isSaving = false
|
||||
|
||||
if !success {
|
||||
errorMessage = "Failed to update profile"
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
func uploadAvatar(imageData: Data, chatClient: ChatClient) async {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
let (success, msg) = await chatClient.updateAvatar(imageData: imageData)
|
||||
isSaving = false
|
||||
|
||||
if success {
|
||||
// Reload avatar from server (it was resized/compressed)
|
||||
let clientUserId = await chatClient.userId ?? ""
|
||||
avatarData = await chatClient.getAvatar(userId: clientUserId)
|
||||
} else {
|
||||
errorMessage = msg.isEmpty ? "Failed to upload avatar" : msg
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Username Change
|
||||
|
||||
func changeUsername(newUsername: String, chatClient: ChatClient) async -> Bool {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
let (success, msg) = await chatClient.changeUsername(newUsername: newUsername)
|
||||
isSaving = false
|
||||
if !success {
|
||||
errorMessage = msg
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
// MARK: - Password Change
|
||||
|
||||
func changePassword(oldPassword: String, newPassword: String, chatClient: ChatClient) async -> Bool {
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
let (success, msg) = await chatClient.changePassword(oldPassword: oldPassword, newPassword: newPassword)
|
||||
isSaving = false
|
||||
if !success {
|
||||
errorMessage = msg
|
||||
}
|
||||
return success
|
||||
}
|
||||
}
|
||||
60
ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift
Normal file
60
ios_client 0.8.5/Kecalek/ViewModels/VerificationVM.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
final class VerificationVM {
|
||||
var safetyNumber: String?
|
||||
var myFingerprint: String?
|
||||
var peerFingerprint: String?
|
||||
var verificationStatus: String = "unverified" // "verified", "trusted", "unverified"
|
||||
var qrCodeData: Data?
|
||||
var scanResult: String?
|
||||
var scanSuccess: Bool?
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
|
||||
func loadVerification(peerUserId: String, chatClient: ChatClient) async {
|
||||
isLoading = true
|
||||
|
||||
// Ensure peer's identity key is fetched (needed for safety number & verification)
|
||||
_ = await chatClient.getPeerIdentityKey(userId: peerUserId)
|
||||
|
||||
// Get safety number
|
||||
safetyNumber = await chatClient.getSafetyNumber(peerUserId: peerUserId)
|
||||
|
||||
// Get fingerprints
|
||||
myFingerprint = await chatClient.getMyFingerprint()
|
||||
peerFingerprint = await chatClient.getPeerFingerprint(peerUserId: peerUserId)
|
||||
|
||||
// Get verification status
|
||||
verificationStatus = await chatClient.getVerificationStatus(userId: peerUserId)
|
||||
|
||||
// Get QR code data for display
|
||||
qrCodeData = await chatClient.getVerificationQRData()
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func verifyContact(peerUserId: String, chatClient: ChatClient) async {
|
||||
guard let peerIK = await chatClient.getPeerIdentityKey(userId: peerUserId) else {
|
||||
errorMessage = "No identity key on record for this user."
|
||||
return
|
||||
}
|
||||
await chatClient.verifyContact(userId: peerUserId, identityKey: peerIK, method: "manual")
|
||||
verificationStatus = "verified"
|
||||
}
|
||||
|
||||
func unverifyContact(peerUserId: String, chatClient: ChatClient) async {
|
||||
await chatClient.unverifyContact(userId: peerUserId)
|
||||
verificationStatus = "trusted"
|
||||
}
|
||||
|
||||
func verifyQRCode(data: Data, chatClient: ChatClient) async {
|
||||
let (success, _, message) = await chatClient.verifyQRCode(qrData: data)
|
||||
scanSuccess = success
|
||||
scanResult = message
|
||||
if success {
|
||||
verificationStatus = "verified"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AuthorizeDeviceView: View {
|
||||
var appState: AppState
|
||||
@State private var code = ""
|
||||
@State private var isAuthorizing = false
|
||||
@State private var statusMessage: String?
|
||||
@State private var isError = false
|
||||
@State private var isDone = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "iphone.badge.checkmark")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Authorize New Device")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Enter the 8-digit pairing code shown on the new device.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
TextField("Pairing Code", text: $code)
|
||||
.font(.system(size: 24, weight: .bold, design: .monospaced))
|
||||
.multilineTextAlignment(.center)
|
||||
.keyboardType(.numberPad)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button("Authorize") {
|
||||
Task { await authorize() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(code.count < 8 || isAuthorizing || isDone)
|
||||
|
||||
if isAuthorizing {
|
||||
ProgressView("Preparing history & sending keys...")
|
||||
}
|
||||
|
||||
if let status = statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isError ? .red : .green)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
if isDone {
|
||||
Button("Done") { dismiss() }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.navigationTitle("Authorize Device")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func authorize() async {
|
||||
isAuthorizing = true
|
||||
isError = false
|
||||
statusMessage = nil
|
||||
|
||||
let (success, msg) = await appState.chatClient.authorizeDevice(code: code)
|
||||
isAuthorizing = false
|
||||
|
||||
statusMessage = msg
|
||||
isError = !success
|
||||
if success {
|
||||
isDone = true
|
||||
}
|
||||
}
|
||||
}
|
||||
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal file
179
ios_client 0.8.5/Kecalek/Views/Auth/LoginView.swift
Normal file
@@ -0,0 +1,179 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
var appState: AppState
|
||||
@State private var showPairing = false
|
||||
@State private var didAttemptBiometric = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "lock.shield.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.blue)
|
||||
.padding(.top, 40)
|
||||
|
||||
Text("Encrypted Chat")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("End-to-end encrypted messaging")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
// Server config
|
||||
DisclosureGroup("Server") {
|
||||
TextField("Host", text: $viewModel.serverHost)
|
||||
.textContentType(.URL)
|
||||
.autocapitalization(.none)
|
||||
TextField("Port", text: $viewModel.serverPort)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if viewModel.mode == .register {
|
||||
TextField("Username", text: $viewModel.username)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
TextField("Email", text: $viewModel.email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Password", text: $viewModel.password)
|
||||
.textContentType(viewModel.mode == .login ? .password : .oneTimeCode)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
if viewModel.mode == .register {
|
||||
SecureField("Confirm Password", text: $viewModel.confirmPassword)
|
||||
.textContentType(.oneTimeCode)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
if viewModel.mode == .login {
|
||||
await viewModel.login(appState: appState)
|
||||
} else {
|
||||
await viewModel.register(appState: appState)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text(viewModel.mode == .login ? "Login" : "Register")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
|
||||
Button(viewModel.mode == .login ? "Don't have an account? Register" : "Already have an account? Login") {
|
||||
viewModel.mode = viewModel.mode == .login ? .register : .login
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
if viewModel.hasSavedCredentials && viewModel.mode == .login {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button {
|
||||
Task { await viewModel.biometricLogin(appState: appState) }
|
||||
} label: {
|
||||
if viewModel.isBiometricLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Label("Sign in with Face ID", systemImage: "faceid")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(viewModel.isLoading || viewModel.isBiometricLoading)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
Button("Pair from existing device") {
|
||||
showPairing = true
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
viewModel.checkSavedCredentials()
|
||||
if viewModel.hasSavedCredentials && !didAttemptBiometric {
|
||||
didAttemptBiometric = true
|
||||
await viewModel.biometricLogin(appState: appState)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.showConfirmation) {
|
||||
ConfirmationSheet(viewModel: viewModel, appState: appState)
|
||||
}
|
||||
.sheet(isPresented: $showPairing) {
|
||||
PairingView(appState: appState, authViewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmationSheet: View {
|
||||
@Bindable var viewModel: AuthViewModel
|
||||
var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Text("Confirm Registration")
|
||||
.font(.title2.bold())
|
||||
|
||||
if let msg = viewModel.registrationMessage {
|
||||
Text(msg)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
TextField("Confirmation Code", text: $viewModel.confirmationCode)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Button("Confirm") {
|
||||
Task {
|
||||
await viewModel.confirmRegistration(appState: appState)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isLoading)
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
}
|
||||
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal file
175
ios_client 0.8.5/Kecalek/Views/Auth/PairingView.swift
Normal file
@@ -0,0 +1,175 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PairingView: View {
|
||||
var appState: AppState
|
||||
@Bindable var authViewModel: AuthViewModel
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@State private var pairingCode: String?
|
||||
@State private var isStarting = false
|
||||
@State private var isWaiting = false
|
||||
@State private var statusMessage: String?
|
||||
@State private var isError = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "iphone.and.arrow.forward")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Device Pairing")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Transfer your keys from an existing device to this one.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if pairingCode == nil {
|
||||
// Phase 1: Enter email and start pairing
|
||||
VStack(spacing: 16) {
|
||||
// Server config
|
||||
DisclosureGroup("Server") {
|
||||
TextField("Host", text: $authViewModel.serverHost)
|
||||
.textContentType(.URL)
|
||||
.autocapitalization(.none)
|
||||
TextField("Port", text: $authViewModel.serverPort)
|
||||
.keyboardType(.numberPad)
|
||||
}
|
||||
|
||||
TextField("Email", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
SecureField("Password (for key encryption)", text: $password)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button("Start Pairing") {
|
||||
Task { await startPairing() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(email.isEmpty || password.isEmpty || isStarting)
|
||||
|
||||
if isStarting {
|
||||
ProgressView("Connecting...")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Phase 2: Show code and wait for authorization
|
||||
VStack(spacing: 16) {
|
||||
Text("Pairing Code")
|
||||
.font(.headline)
|
||||
|
||||
Text(pairingCode!)
|
||||
.font(.system(size: 36, weight: .bold, design: .monospaced))
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text("Enter this code on your already logged-in device\nto authorize this device.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if isWaiting {
|
||||
ProgressView("Waiting for authorization...")
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let status = statusMessage {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isError ? .red : .green)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.navigationTitle("Pair Device")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPairing() async {
|
||||
isStarting = true
|
||||
isError = false
|
||||
statusMessage = nil
|
||||
|
||||
// Connect to server
|
||||
if await !appState.chatClient.isConnected {
|
||||
do {
|
||||
let port = UInt16(authViewModel.serverPort) ?? Constants.defaultPort
|
||||
try await appState.chatClient.connect(
|
||||
host: authViewModel.serverHost, port: port
|
||||
)
|
||||
} catch {
|
||||
isStarting = false
|
||||
statusMessage = "Connection failed: \(error.localizedDescription)"
|
||||
isError = true
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let (success, codeOrMsg) = await appState.chatClient.pairingStart(email: email)
|
||||
isStarting = false
|
||||
|
||||
if success {
|
||||
pairingCode = codeOrMsg
|
||||
// Start waiting for authorization
|
||||
isWaiting = true
|
||||
Task { await waitForAuthorization() }
|
||||
} else {
|
||||
statusMessage = codeOrMsg
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
|
||||
private func waitForAuthorization() async {
|
||||
let (success, msg) = await appState.chatClient.pairingWait(
|
||||
code: pairingCode!, email: email, password: password
|
||||
)
|
||||
isWaiting = false
|
||||
|
||||
if success {
|
||||
statusMessage = msg
|
||||
isError = false
|
||||
// Auto-login
|
||||
let (loginOk, loginMsg) = await appState.chatClient.login(email: email, password: password)
|
||||
if loginOk {
|
||||
appState.email = email
|
||||
appState.isLoggedIn = true
|
||||
appState.connectionStatus = .connected
|
||||
appState.startConnectionMonitor()
|
||||
if let userId = await appState.chatClient.userId {
|
||||
appState.currentUser = User(
|
||||
id: userId,
|
||||
username: await appState.chatClient.username,
|
||||
email: email
|
||||
)
|
||||
}
|
||||
dismiss()
|
||||
} else {
|
||||
statusMessage = "Keys imported but login failed: \(loginMsg)"
|
||||
isError = true
|
||||
}
|
||||
} else {
|
||||
statusMessage = msg
|
||||
isError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
4
ios_client 0.8.5/Kecalek/Views/Auth/RegisterView.swift
Normal file
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Registration is handled within LoginView via mode toggle.
|
||||
// This file exists for potential future separation.
|
||||
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal file
382
ios_client 0.8.5/Kecalek/Views/Chat/ChatView.swift
Normal file
@@ -0,0 +1,382 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@State private var conversation: Conversation
|
||||
var appState: AppState
|
||||
var conversationListVM: ConversationListVM?
|
||||
|
||||
init(conversation: Conversation, appState: AppState, conversationListVM: ConversationListVM? = nil) {
|
||||
self._conversation = State(initialValue: conversation)
|
||||
self.appState = appState
|
||||
self.conversationListVM = conversationListVM
|
||||
}
|
||||
@State private var viewModel = ChatViewModel()
|
||||
@State private var inputText = ""
|
||||
@State private var replyTo: Message?
|
||||
@State private var showGroupInfo = false
|
||||
@State private var showDMInfo = false
|
||||
@State private var showSearch = false
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var showError = false
|
||||
@State private var memberListenerTask: Task<Void, Never>?
|
||||
@State private var forwardingMessage: Message?
|
||||
@State private var showForwardPicker = false
|
||||
@State private var showPinnedMessages = false
|
||||
@State private var scrollTarget: String?
|
||||
@State private var showVerification = false
|
||||
@State private var verificationStatus: String = "unverified"
|
||||
|
||||
private var currentUserId: String {
|
||||
appState.currentUser?.id ?? ""
|
||||
}
|
||||
|
||||
private var isPartnerOnline: Bool {
|
||||
guard !conversation.isGroup,
|
||||
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId),
|
||||
let listVM = conversationListVM else {
|
||||
return false
|
||||
}
|
||||
return listVM.onlineUsers.contains(partnerId)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
searchBar
|
||||
messagesScrollView
|
||||
replyPreview
|
||||
inputView
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbarContent }
|
||||
.alert("Delete Conversation?", isPresented: $showDeleteConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Delete", role: .destructive) {
|
||||
Task { await appState.chatClient.deleteConversation(convId: conversation.id) }
|
||||
}
|
||||
} message: {
|
||||
Text(conversation.isGroup
|
||||
? "This will remove all members and delete the conversation."
|
||||
: "This will remove you from the conversation.")
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "Unknown error")
|
||||
}
|
||||
.sheet(isPresented: $showGroupInfo) {
|
||||
GroupInfoView(conversation: $conversation, appState: appState, conversationListVM: conversationListVM)
|
||||
}
|
||||
.sheet(isPresented: $showDMInfo) {
|
||||
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
|
||||
ProfileView(appState: appState, isOwnProfile: false, userId: partnerId)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showForwardPicker) {
|
||||
if let msg = forwardingMessage {
|
||||
ForwardPickerView(message: msg, appState: appState)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showVerification) {
|
||||
if let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
|
||||
NavigationStack {
|
||||
SafetyNumberView(
|
||||
peerUserId: partnerId,
|
||||
peerUsername: conversation.displayName(currentUserId: currentUserId),
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Done") { showVerification = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPinnedMessages) {
|
||||
PinnedMessagesView(
|
||||
messages: viewModel.messages.filter { $0.pinnedAt != nil },
|
||||
onScrollTo: { scrollTarget = $0 }
|
||||
)
|
||||
}
|
||||
.task {
|
||||
// Use already-loaded data from conversation list (avoid redundant list_conversations call)
|
||||
if let updated = conversationListVM?.conversations.first(where: { $0.id == conversation.id }) {
|
||||
conversation = updated
|
||||
}
|
||||
conversationListVM?.markConversationRead(convId: conversation.id)
|
||||
await viewModel.loadMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
viewModel.startNotificationListener(convId: conversation.id, chatClient: appState.chatClient)
|
||||
|
||||
// Load verification status for DM partner
|
||||
if !conversation.isGroup,
|
||||
let partnerId = conversation.dmPartnerId(currentUserId: currentUserId) {
|
||||
verificationStatus = await appState.chatClient.getVerificationStatus(userId: partnerId)
|
||||
}
|
||||
|
||||
memberListenerTask = Task {
|
||||
for await notification in await appState.chatClient.makeNotificationStream() {
|
||||
switch notification {
|
||||
case .memberAdded, .memberRemoved, .conversationRenamed:
|
||||
let refreshed = await appState.chatClient.listConversations()
|
||||
if let updated = refreshed.first(where: { $0.id == conversation.id }) {
|
||||
await MainActor.run { conversation = updated }
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.stop()
|
||||
memberListenerTask?.cancel()
|
||||
memberListenerTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search Bar
|
||||
|
||||
@ViewBuilder
|
||||
private var searchBar: some View {
|
||||
if showSearch {
|
||||
SearchOverlayView(
|
||||
query: $viewModel.searchQuery,
|
||||
matchCount: viewModel.searchResults.count,
|
||||
currentIndex: viewModel.currentSearchIndex,
|
||||
onSearch: { viewModel.search(query: $0) },
|
||||
onNext: { viewModel.nextSearchResult() },
|
||||
onPrev: { viewModel.prevSearchResult() },
|
||||
onClose: { showSearch = false; viewModel.search(query: "") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Messages
|
||||
|
||||
private var messagesScrollView: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 8) {
|
||||
if viewModel.messages.count >= 50 {
|
||||
Button("Load older messages") {
|
||||
Task {
|
||||
await viewModel.loadOlderMessages(convId: conversation.id, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.padding()
|
||||
}
|
||||
|
||||
ForEach(viewModel.messages) { message in
|
||||
messageBubble(for: message)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.onChange(of: viewModel.messages.count) {
|
||||
if let lastId = viewModel.messages.last?.id {
|
||||
withAnimation { proxy.scrollTo(lastId, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollTarget) {
|
||||
if let target = scrollTarget {
|
||||
withAnimation { proxy.scrollTo(target, anchor: .center) }
|
||||
scrollTarget = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func messageBubble(for message: Message) -> some View {
|
||||
let isCurrentSearch = viewModel.searchResults.indices.contains(viewModel.currentSearchIndex)
|
||||
&& viewModel.searchResults[viewModel.currentSearchIndex] == message.id
|
||||
return MessageBubbleView(
|
||||
message: message,
|
||||
isMine: message.isMine(currentUserId: currentUserId),
|
||||
isGroup: conversation.isGroup,
|
||||
isHighlighted: viewModel.searchResults.contains(message.id),
|
||||
isCurrentSearchResult: isCurrentSearch,
|
||||
chatClient: appState.chatClient,
|
||||
currentUserId: currentUserId,
|
||||
onReply: { replyTo = message },
|
||||
onReact: { reaction in
|
||||
Task {
|
||||
await viewModel.reactToMessage(
|
||||
messageId: message.id, convId: conversation.id,
|
||||
reaction: reaction, currentUserId: currentUserId,
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
}
|
||||
},
|
||||
onForward: {
|
||||
forwardingMessage = message
|
||||
showForwardPicker = true
|
||||
},
|
||||
onPin: { pin in
|
||||
Task {
|
||||
await viewModel.pinMessage(
|
||||
messageId: message.id, convId: conversation.id,
|
||||
pin: pin, chatClient: appState.chatClient
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete: {
|
||||
Task {
|
||||
await viewModel.deleteMessage(
|
||||
messageId: message.id, convId: conversation.id,
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Reply Preview
|
||||
|
||||
@ViewBuilder
|
||||
private var replyPreview: some View {
|
||||
if let reply = replyTo {
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(.blue)
|
||||
.frame(width: 3)
|
||||
VStack(alignment: .leading) {
|
||||
Text(reply.senderUsername)
|
||||
.font(.caption.bold())
|
||||
Text(reply.text ?? "")
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: { replyTo = nil }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Input
|
||||
|
||||
private var inputView: some View {
|
||||
MessageInputView(
|
||||
text: $inputText,
|
||||
isSending: viewModel.isSending,
|
||||
onSend: {
|
||||
Task {
|
||||
let text = inputText
|
||||
inputText = ""
|
||||
let reply = replyTo?.id
|
||||
replyTo = nil
|
||||
await viewModel.sendMessage(
|
||||
convId: conversation.id, text: text,
|
||||
members: conversation.members,
|
||||
chatClient: appState.chatClient, replyTo: reply
|
||||
)
|
||||
}
|
||||
},
|
||||
onImageSelected: { imageData in
|
||||
Task {
|
||||
viewModel.isSending = true
|
||||
let (success, msg, sentMessage) = await appState.chatClient.sendImage(
|
||||
convId: conversation.id, imageData: imageData,
|
||||
members: conversation.members
|
||||
)
|
||||
viewModel.isSending = false
|
||||
if success, let sentMessage {
|
||||
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
|
||||
viewModel.messages.append(sentMessage)
|
||||
}
|
||||
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
|
||||
} else if !success {
|
||||
viewModel.errorMessage = msg
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onFileSelected: { fileData, filename, mimeType in
|
||||
Task {
|
||||
viewModel.isSending = true
|
||||
let (success, msg, sentMessage) = await appState.chatClient.sendFile(
|
||||
convId: conversation.id, fileData: fileData,
|
||||
filename: filename, mimeType: mimeType,
|
||||
members: conversation.members
|
||||
)
|
||||
viewModel.isSending = false
|
||||
if success, let sentMessage {
|
||||
if !viewModel.messages.contains(where: { $0.id == sentMessage.id }) {
|
||||
viewModel.messages.append(sentMessage)
|
||||
}
|
||||
await viewModel.saveCache(convId: conversation.id, chatClient: appState.chatClient)
|
||||
} else if !success {
|
||||
viewModel.errorMessage = msg
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
},
|
||||
members: conversation.members
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItem(placement: .principal) {
|
||||
HStack(spacing: 8) {
|
||||
CircularAvatarView(
|
||||
name: conversation.displayName(currentUserId: currentUserId),
|
||||
imageData: conversationListVM?.avatarCache[conversation.id],
|
||||
size: 28,
|
||||
isGroup: conversation.isGroup
|
||||
)
|
||||
Text(conversation.displayName(currentUserId: currentUserId))
|
||||
.font(.headline)
|
||||
if !conversation.isGroup && verificationStatus == "verified" {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
if isPartnerOnline {
|
||||
Circle().fill(.green).frame(width: 8, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
if !conversation.isGroup {
|
||||
Button(action: { showVerification = true }) {
|
||||
Image(systemName: verificationStatus == "verified" ? "checkmark.shield.fill" : "shield")
|
||||
.foregroundStyle(verificationStatus == "verified" ? .green : .secondary)
|
||||
}
|
||||
}
|
||||
Button(action: { showPinnedMessages = true }) {
|
||||
Image(systemName: "pin")
|
||||
}
|
||||
Button(action: { showSearch.toggle() }) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
}
|
||||
if conversation.isGroup {
|
||||
Button(action: { showGroupInfo = true }) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
} else {
|
||||
Button(action: { showDMInfo = true }) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
}
|
||||
if !conversation.isGroup || conversation.createdBy == currentUserId {
|
||||
Button(action: { showDeleteConfirm = true }) {
|
||||
Image(systemName: "trash").foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
77
ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift
Normal file
77
ios_client 0.8.5/Kecalek/Views/Chat/ForwardPickerView.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ForwardPickerView: View {
|
||||
let message: Message
|
||||
let appState: AppState
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var conversations: [Conversation] = []
|
||||
@State private var isLoading = true
|
||||
@State private var isSending = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if isLoading {
|
||||
ProgressView("Loading conversations...")
|
||||
} else if conversations.isEmpty {
|
||||
Text("No conversations available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List(conversations) { conv in
|
||||
Button {
|
||||
forwardTo(conv)
|
||||
} label: {
|
||||
HStack {
|
||||
CircularAvatarView(
|
||||
name: conv.displayName(currentUserId: appState.currentUser?.id ?? ""),
|
||||
size: 36,
|
||||
isGroup: conv.isGroup
|
||||
)
|
||||
Text(conv.displayName(currentUserId: appState.currentUser?.id ?? ""))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.disabled(isSending)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Forward to...")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
conversations = await appState.chatClient.listConversations()
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
private func forwardTo(_ conv: Conversation) {
|
||||
isSending = true
|
||||
Task {
|
||||
let forwardPayload: [String: Any] = [
|
||||
"forwarded_from": [
|
||||
"sender": message.senderUsername,
|
||||
"conversation_id": message.conversationId,
|
||||
"message_id": message.id,
|
||||
] as [String: Any]
|
||||
]
|
||||
let (success, _, _) = await appState.chatClient.sendMessage(
|
||||
convId: conv.id,
|
||||
text: message.text ?? "",
|
||||
members: conv.members,
|
||||
extraPayload: forwardPayload
|
||||
)
|
||||
await MainActor.run {
|
||||
isSending = false
|
||||
if success {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift
Normal file
113
ios_client 0.8.5/Kecalek/Views/Chat/ImageViewerView.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
struct ImageViewerView: View {
|
||||
let imageData: Data
|
||||
@State private var scale: CGFloat = 1.0
|
||||
@State private var saved = false
|
||||
@State private var saveError: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
GeometryReader { geo in
|
||||
if let uiImage = UIImage(data: imageData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.scaleEffect(scale)
|
||||
.gesture(
|
||||
MagnifyGesture()
|
||||
.onChanged { value in
|
||||
scale = value.magnification
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation {
|
||||
scale = max(1.0, min(scale, 5.0))
|
||||
}
|
||||
}
|
||||
)
|
||||
.onTapGesture(count: 2) {
|
||||
withAnimation {
|
||||
scale = scale > 1 ? 1 : 2
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let error = saveError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
.padding(8)
|
||||
.background(Capsule().fill(.red.opacity(0.8)))
|
||||
.padding(.bottom, 40)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack(spacing: 16) {
|
||||
// Share
|
||||
if let uiImage = UIImage(data: imageData) {
|
||||
ShareLink(item: Image(uiImage: uiImage), preview: SharePreview("Image", image: Image(uiImage: uiImage))) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// Save to Photos
|
||||
Button {
|
||||
saveToPhotos()
|
||||
} label: {
|
||||
Image(systemName: saved ? "checkmark.circle.fill" : "arrow.down.to.line")
|
||||
.foregroundStyle(saved ? .green : .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.toolbarBackground(.hidden, for: .navigationBar)
|
||||
.background(.black)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveToPhotos() {
|
||||
guard let uiImage = UIImage(data: imageData) else {
|
||||
withAnimation { saveError = "Invalid image data" }
|
||||
return
|
||||
}
|
||||
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
DispatchQueue.main.async {
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetChangeRequest.creationRequestForAsset(from: uiImage)
|
||||
} completionHandler: { success, error in
|
||||
DispatchQueue.main.async {
|
||||
if success {
|
||||
withAnimation { saved = true; saveError = nil }
|
||||
} else {
|
||||
withAnimation { saveError = error?.localizedDescription ?? "Save failed" }
|
||||
}
|
||||
}
|
||||
}
|
||||
case .denied, .restricted:
|
||||
withAnimation { saveError = "Photo library access denied. Check Settings." }
|
||||
default:
|
||||
withAnimation { saveError = "Photo library access required" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal file
558
ios_client 0.8.5/Kecalek/Views/Chat/MessageBubbleView.swift
Normal file
@@ -0,0 +1,558 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct MessageBubbleView: View {
|
||||
let message: Message
|
||||
let isMine: Bool
|
||||
var isGroup: Bool = false
|
||||
var isHighlighted: Bool = false
|
||||
var isCurrentSearchResult: Bool = false
|
||||
var chatClient: ChatClient?
|
||||
var currentUserId: String = ""
|
||||
var onReply: (() -> Void)?
|
||||
var onReact: ((String) -> Void)?
|
||||
var onForward: (() -> Void)?
|
||||
var onPin: ((Bool) -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
@State private var fullImageData: Data?
|
||||
@State private var showFullImage = false
|
||||
@State private var isLoadingImage = false
|
||||
@State private var isLoadingFile = false
|
||||
@State private var downloadedFileURL: URL?
|
||||
@State private var showShareSheet = false
|
||||
@State private var imageError: String?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if isMine { Spacer(minLength: 60) }
|
||||
|
||||
VStack(alignment: isMine ? .trailing : .leading, spacing: 4) {
|
||||
if !isMine && isGroup {
|
||||
Text(message.senderUsername)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if message.isDeleted {
|
||||
Text("Message deleted")
|
||||
.font(.body.italic())
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(12)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
} else {
|
||||
// Forwarded header
|
||||
if let fwd = message.forwardedFrom {
|
||||
HStack(spacing: 4) {
|
||||
Rectangle().fill(.cyan).frame(width: 3)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Forwarded from").font(.caption2).foregroundStyle(.secondary)
|
||||
Text(fwd.sender).font(.caption.bold()).foregroundStyle(.cyan)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8).padding(.top, 4)
|
||||
}
|
||||
|
||||
// Reply reference
|
||||
if message.replyTo != nil {
|
||||
HStack(spacing: 4) {
|
||||
Rectangle()
|
||||
.fill(.blue.opacity(0.5))
|
||||
.frame(width: 2)
|
||||
Text("Reply to message")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
// Image thumbnail
|
||||
if let imageInfo = message.image {
|
||||
imageView(imageInfo: imageInfo)
|
||||
}
|
||||
|
||||
// File card
|
||||
if let file = message.file {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
if isLoadingFile {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: fileIcon(for: file.filename))
|
||||
}
|
||||
Text(file.filename)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.subheadline)
|
||||
|
||||
Text(formatFileSize(file.size))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.systemGray5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.onTapGesture {
|
||||
downloadAndShareFile(file: file)
|
||||
}
|
||||
}
|
||||
|
||||
// Text content with link detection
|
||||
if let text = message.text, !text.isEmpty {
|
||||
LinkText(text: text, isMine: isMine)
|
||||
.padding(12)
|
||||
.background(
|
||||
isMine ? Color.blue : Color(.systemGray5)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
// Timestamp + checkmarks + reactions — all on one line
|
||||
HStack(spacing: 4) {
|
||||
if message.pinnedAt != nil {
|
||||
Image(systemName: "pin.fill").font(.caption2).foregroundStyle(.orange)
|
||||
}
|
||||
Text(formatTime(message.createdAt)).font(.caption2).foregroundStyle(.secondary)
|
||||
if isMine {
|
||||
deliveryIndicator
|
||||
}
|
||||
if !message.reactions.isEmpty {
|
||||
inlineReactionBadges
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: isMine ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(isCurrentSearchResult ? Color.orange.opacity(0.3) :
|
||||
isHighlighted ? Color.yellow.opacity(0.2) : Color.clear)
|
||||
)
|
||||
.contextMenu {
|
||||
if !message.isDeleted {
|
||||
Button(action: { onReply?() }) {
|
||||
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(ReactionEmoji.allowed, id: \.self) { key in
|
||||
Button("\(ReactionEmoji.display[key] ?? "") \(key)") { onReact?(key) }
|
||||
}
|
||||
} label: {
|
||||
Label("React", systemImage: "face.smiling")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = message.text ?? ""
|
||||
// Auto-clear clipboard after 30 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
|
||||
if UIPasteboard.general.string == message.text {
|
||||
UIPasteboard.general.string = ""
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
|
||||
Button(action: { onForward?() }) {
|
||||
Label("Forward", systemImage: "arrowshape.turn.up.right")
|
||||
}
|
||||
|
||||
Button(action: { onPin?(message.pinnedAt == nil) }) {
|
||||
Label(message.pinnedAt == nil ? "Pin" : "Unpin",
|
||||
systemImage: message.pinnedAt == nil ? "pin" : "pin.slash")
|
||||
}
|
||||
|
||||
if isMine {
|
||||
Button(role: .destructive, action: { onDelete?() }) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isMine { Spacer(minLength: 60) }
|
||||
}
|
||||
.sheet(isPresented: $showFullImage) {
|
||||
if let data = fullImageData {
|
||||
ImageViewerView(imageData: data)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet, onDismiss: {
|
||||
// Clean up decrypted temp file after sharing
|
||||
if let fileURL = downloadedFileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
downloadedFileURL = nil
|
||||
}
|
||||
}) {
|
||||
if let fileURL = downloadedFileURL {
|
||||
ActivityViewController(activityItems: [fileURL])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reaction Badges (inline — used in timestamp row)
|
||||
|
||||
private var inlineReactionBadges: some View {
|
||||
let grouped = Dictionary(grouping: message.reactions, by: \.reaction)
|
||||
return HStack(spacing: 2) {
|
||||
ForEach(grouped.sorted(by: { $0.key < $1.key }), id: \.key) { reaction, users in
|
||||
Button {
|
||||
onReact?(reaction)
|
||||
} label: {
|
||||
Text(ReactionEmoji.display[reaction] ?? reaction)
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delivery Indicator (checkmarks)
|
||||
|
||||
@ViewBuilder
|
||||
private var deliveryIndicator: some View {
|
||||
let isRead = message.readBy.contains(where: { $0 != "__delivered__" && $0 != currentUserId })
|
||||
let isDelivered = message.readBy.contains("__delivered__")
|
||||
|
||||
if isRead {
|
||||
// Read: 2 green checkmarks
|
||||
HStack(spacing: -4) {
|
||||
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
|
||||
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
} else if isDelivered {
|
||||
// Delivered: 2 gray checkmarks
|
||||
HStack(spacing: -4) {
|
||||
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
|
||||
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
// Sent: 1 gray checkmark
|
||||
Image(systemName: "checkmark").font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image View
|
||||
|
||||
@ViewBuilder
|
||||
private func imageView(imageInfo: ImageInfo) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
if let thumbB64 = imageInfo.thumbnail,
|
||||
let thumbData = Data(base64Encoded: thumbB64),
|
||||
let uiImage = UIImage(data: thumbData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: 220, maxHeight: 220)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay {
|
||||
if isLoadingImage {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(.black.opacity(0.4))
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
downloadAndShowFullImage(imageInfo: imageInfo)
|
||||
}
|
||||
} else {
|
||||
// No thumbnail available — show placeholder
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 160, height: 120)
|
||||
.overlay {
|
||||
if isLoadingImage {
|
||||
ProgressView()
|
||||
} else {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "photo")
|
||||
.font(.title2)
|
||||
Text(imageInfo.filename)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
downloadAndShowFullImage(imageInfo: imageInfo)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = imageError {
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
.onTapGesture {
|
||||
imageError = nil
|
||||
downloadAndShowFullImage(imageInfo: imageInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadAndShowFullImage(imageInfo: ImageInfo) {
|
||||
guard !isLoadingImage, let client = chatClient else { return }
|
||||
// If already downloaded, show immediately
|
||||
if fullImageData != nil {
|
||||
showFullImage = true
|
||||
return
|
||||
}
|
||||
imageError = nil
|
||||
isLoadingImage = true
|
||||
Task {
|
||||
guard let aesKey = try? ProtocolHandler.decodeBinary(imageInfo.aesKey),
|
||||
let iv = try? ProtocolHandler.decodeBinary(imageInfo.iv) else {
|
||||
await MainActor.run {
|
||||
isLoadingImage = false
|
||||
imageError = "Failed to decode image keys"
|
||||
}
|
||||
return
|
||||
}
|
||||
let data = await client.downloadFile(fileId: imageInfo.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
|
||||
await MainActor.run {
|
||||
isLoadingImage = false
|
||||
if let data = data {
|
||||
fullImageData = data
|
||||
showFullImage = true
|
||||
} else {
|
||||
imageError = "Download failed, tap to retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - File Download
|
||||
|
||||
private func downloadAndShareFile(file: FileInfo) {
|
||||
guard !isLoadingFile, let client = chatClient else { return }
|
||||
// If already downloaded, show share sheet immediately
|
||||
if downloadedFileURL != nil {
|
||||
showShareSheet = true
|
||||
return
|
||||
}
|
||||
isLoadingFile = true
|
||||
Task {
|
||||
guard let aesKey = try? ProtocolHandler.decodeBinary(file.aesKey),
|
||||
let iv = try? ProtocolHandler.decodeBinary(file.iv) else {
|
||||
await MainActor.run { isLoadingFile = false }
|
||||
return
|
||||
}
|
||||
let data = await client.downloadFile(fileId: file.fileId, aesKey: aesKey, iv: iv, conversationId: message.conversationId)
|
||||
await MainActor.run {
|
||||
isLoadingFile = false
|
||||
if let data = data {
|
||||
// Save to temp with file protection, clean up on dismiss
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
let fileURL = tempDir.appendingPathComponent(file.filename)
|
||||
try? data.write(to: fileURL, options: .completeFileProtection)
|
||||
downloadedFileURL = fileURL
|
||||
showShareSheet = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fileIcon(for filename: String) -> String {
|
||||
let ext = (filename as NSString).pathExtension.lowercased()
|
||||
switch ext {
|
||||
case "pdf": return "doc.richtext"
|
||||
case "doc", "docx": return "doc.text"
|
||||
case "xls", "xlsx": return "tablecells"
|
||||
case "ppt", "pptx": return "rectangle.on.rectangle"
|
||||
case "zip", "rar", "7z": return "doc.zipper"
|
||||
case "mp3", "wav", "m4a": return "music.note"
|
||||
case "mp4", "mov", "avi": return "film"
|
||||
case "txt": return "doc.plaintext"
|
||||
default: return "paperclip"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatTime(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
if Calendar.current.isDateInToday(date) {
|
||||
formatter.dateFormat = "HH:mm"
|
||||
} else {
|
||||
formatter.dateFormat = "MMM d, HH:mm"
|
||||
}
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formatFileSize(_ bytes: Int) -> String {
|
||||
if bytes < 1024 { return "\(bytes) B" }
|
||||
if bytes < 1024 * 1024 { return "\(bytes / 1024) KB" }
|
||||
return String(format: "%.1f MB", Double(bytes) / (1024 * 1024))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Link Text
|
||||
|
||||
struct LinkText: View {
|
||||
let text: String
|
||||
let isMine: Bool
|
||||
|
||||
var body: some View {
|
||||
Text(buildAttributedString())
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
UIApplication.shared.open(url)
|
||||
return .handled
|
||||
})
|
||||
}
|
||||
|
||||
private func buildAttributedString() -> AttributedString {
|
||||
var result = AttributedString()
|
||||
|
||||
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
|
||||
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
|
||||
}
|
||||
|
||||
let nsRange = NSRange(text.startIndex..., in: text)
|
||||
let matches = detector.matches(in: text, range: nsRange)
|
||||
|
||||
if matches.isEmpty {
|
||||
return appendPlainWithMentions(text[text.startIndex..<text.endIndex], to: &result)
|
||||
}
|
||||
|
||||
var lastEnd = text.startIndex
|
||||
|
||||
for match in matches {
|
||||
guard let matchRange = Range(match.range, in: text),
|
||||
let url = match.url else { continue }
|
||||
|
||||
// Plain text before link (with mention highlighting)
|
||||
if lastEnd < matchRange.lowerBound {
|
||||
appendPlainWithMentions(text[lastEnd..<matchRange.lowerBound], to: &result)
|
||||
}
|
||||
|
||||
// Link
|
||||
let isSecure = url.scheme?.lowercased() == "https"
|
||||
var link = AttributedString(text[matchRange])
|
||||
link.link = url
|
||||
link.underlineStyle = .single
|
||||
if isMine {
|
||||
link.foregroundColor = isSecure ? .cyan : .red
|
||||
} else {
|
||||
link.foregroundColor = isSecure ? .blue : .red
|
||||
}
|
||||
result.append(link)
|
||||
|
||||
lastEnd = matchRange.upperBound
|
||||
}
|
||||
|
||||
// Remaining text
|
||||
if lastEnd < text.endIndex {
|
||||
appendPlainWithMentions(text[lastEnd..<text.endIndex], to: &result)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private static let mentionRegex = try! NSRegularExpression(pattern: "@(\\w+)", options: [])
|
||||
|
||||
@discardableResult
|
||||
private func appendPlainWithMentions(_ substring: Substring, to result: inout AttributedString) -> AttributedString {
|
||||
let str = String(substring)
|
||||
let nsRange = NSRange(str.startIndex..., in: str)
|
||||
let matches = Self.mentionRegex.matches(in: str, range: nsRange)
|
||||
|
||||
if matches.isEmpty {
|
||||
var plain = AttributedString(str)
|
||||
plain.foregroundColor = isMine ? .white : .primary
|
||||
result.append(plain)
|
||||
return result
|
||||
}
|
||||
|
||||
let mentionColor = Color(red: 0.537, green: 0.706, blue: 0.980)
|
||||
var lastEnd = str.startIndex
|
||||
|
||||
for match in matches {
|
||||
guard let matchRange = Range(match.range, in: str) else { continue }
|
||||
|
||||
if lastEnd < matchRange.lowerBound {
|
||||
var plain = AttributedString(str[lastEnd..<matchRange.lowerBound])
|
||||
plain.foregroundColor = isMine ? .white : .primary
|
||||
result.append(plain)
|
||||
}
|
||||
|
||||
var mention = AttributedString(str[matchRange])
|
||||
mention.foregroundColor = mentionColor
|
||||
mention.font = .body.bold()
|
||||
result.append(mention)
|
||||
|
||||
lastEnd = matchRange.upperBound
|
||||
}
|
||||
|
||||
if lastEnd < str.endIndex {
|
||||
var plain = AttributedString(str[lastEnd..<str.endIndex])
|
||||
plain.foregroundColor = isMine ? .white : .primary
|
||||
result.append(plain)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow Layout
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 4
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > maxWidth && x > 0 {
|
||||
x = 0
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
return CGSize(width: maxWidth, height: y + rowHeight)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
var x = bounds.minX
|
||||
var y = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > bounds.maxX && x > bounds.minX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Sheet
|
||||
|
||||
struct ActivityViewController: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
215
ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift
Normal file
215
ios_client 0.8.5/Kecalek/Views/Chat/MessageInputView.swift
Normal file
@@ -0,0 +1,215 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
struct MessageInputView: View {
|
||||
@Binding var text: String
|
||||
let isSending: Bool
|
||||
let onSend: () -> Void
|
||||
var onImageSelected: ((Data) -> Void)?
|
||||
var onFileSelected: ((Data, String, String) -> Void)? // data, filename, mimeType
|
||||
var members: [ConversationMember] = []
|
||||
|
||||
@State private var isProcessing = false
|
||||
@State private var showFilePicker = false
|
||||
@State private var showPhotoPicker = false
|
||||
@State private var showMentionPopup = false
|
||||
@State private var mentionCandidates: [ConversationMember] = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Mention autocomplete popup
|
||||
if showMentionPopup && !mentionCandidates.isEmpty {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(mentionCandidates) { member in
|
||||
Button {
|
||||
completeMention(member: member)
|
||||
} label: {
|
||||
Text("@\(member.username)")
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: 150)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
// Attach button
|
||||
Menu {
|
||||
Button {
|
||||
showPhotoPicker = true
|
||||
} label: {
|
||||
Label("Photo", systemImage: "photo")
|
||||
}
|
||||
Button {
|
||||
showFilePicker = true
|
||||
} label: {
|
||||
Label("File", systemImage: "doc")
|
||||
}
|
||||
} label: {
|
||||
if isProcessing {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.disabled(isProcessing || isSending)
|
||||
|
||||
// Text field
|
||||
TextField("Message", text: $text, axis: .vertical)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.onSubmit {
|
||||
if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
onSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Send button
|
||||
Button(action: onSend) {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(.ultraThinMaterial)
|
||||
.sheet(isPresented: $showPhotoPicker) {
|
||||
ImagePickerView { data in
|
||||
isProcessing = true
|
||||
onImageSelected?(data)
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showFilePicker) {
|
||||
DocumentPickerView { url in
|
||||
guard url.startAccessingSecurityScopedResource() else { return }
|
||||
defer { url.stopAccessingSecurityScopedResource() }
|
||||
guard let data = try? Data(contentsOf: url) else { return }
|
||||
let filename = url.lastPathComponent
|
||||
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"
|
||||
onFileSelected?(data, filename, mimeType)
|
||||
}
|
||||
}
|
||||
.onChange(of: text) {
|
||||
updateMentionCandidates()
|
||||
}
|
||||
} // end VStack
|
||||
}
|
||||
|
||||
private func updateMentionCandidates() {
|
||||
// Look for @prefix at end of text
|
||||
guard let atRange = text.range(of: "@\\w*$", options: .regularExpression) else {
|
||||
showMentionPopup = false
|
||||
mentionCandidates = []
|
||||
return
|
||||
}
|
||||
let prefix = String(text[atRange]).dropFirst().lowercased() // remove @
|
||||
mentionCandidates = members.filter { member in
|
||||
prefix.isEmpty || member.username.lowercased().hasPrefix(prefix)
|
||||
}
|
||||
showMentionPopup = !mentionCandidates.isEmpty
|
||||
}
|
||||
|
||||
private func completeMention(member: ConversationMember) {
|
||||
if let atRange = text.range(of: "@\\w*$", options: .regularExpression) {
|
||||
text.replaceSubrange(atRange, with: "@\(member.username) ")
|
||||
}
|
||||
showMentionPopup = false
|
||||
mentionCandidates = []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Picker (UIKit PHPicker wrapper)
|
||||
|
||||
struct ImagePickerView: UIViewControllerRepresentable {
|
||||
let onImagePicked: (Data) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onImagePicked: onImagePicked)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
|
||||
|
||||
class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||||
let onImagePicked: (Data) -> Void
|
||||
|
||||
init(onImagePicked: @escaping (Data) -> Void) {
|
||||
self.onImagePicked = onImagePicked
|
||||
}
|
||||
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard let provider = results.first?.itemProvider,
|
||||
provider.canLoadObject(ofClass: UIImage.self) else { return }
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
|
||||
guard let uiImage = image as? UIImage,
|
||||
let data = uiImage.jpegData(compressionQuality: 0.9) else { return }
|
||||
DispatchQueue.main.async {
|
||||
self?.onImagePicked(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document Picker (UIKit wrapper)
|
||||
|
||||
struct DocumentPickerView: UIViewControllerRepresentable {
|
||||
let onPick: (URL) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onPick: onPick)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.item])
|
||||
picker.delegate = context.coordinator
|
||||
picker.allowsMultipleSelection = false
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
||||
|
||||
class Coordinator: NSObject, UIDocumentPickerDelegate {
|
||||
let onPick: (URL) -> Void
|
||||
|
||||
init(onPick: @escaping (URL) -> Void) {
|
||||
self.onPick = onPick
|
||||
}
|
||||
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
onPick(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
62
ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift
Normal file
62
ios_client 0.8.5/Kecalek/Views/Chat/PinnedMessagesView.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PinnedMessagesView: View {
|
||||
let messages: [Message]
|
||||
var onScrollTo: ((String) -> Void)?
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if messages.isEmpty {
|
||||
Text("No pinned messages")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List(messages) { message in
|
||||
Button {
|
||||
dismiss()
|
||||
onScrollTo?(message.id)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Image(systemName: "pin.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
Text(message.senderUsername)
|
||||
.font(.caption.bold())
|
||||
Spacer()
|
||||
Text(formatTime(message.createdAt))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(message.text ?? "")
|
||||
.font(.body)
|
||||
.lineLimit(3)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Pinned Messages")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatTime(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
if Calendar.current.isDateInToday(date) {
|
||||
formatter.dateFormat = "HH:mm"
|
||||
} else {
|
||||
formatter.dateFormat = "MMM d, HH:mm"
|
||||
}
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
46
ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift
Normal file
46
ios_client 0.8.5/Kecalek/Views/Chat/SearchOverlayView.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchOverlayView: View {
|
||||
@Binding var query: String
|
||||
let matchCount: Int
|
||||
let currentIndex: Int
|
||||
let onSearch: (String) -> Void
|
||||
let onNext: () -> Void
|
||||
let onPrev: () -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("Search messages", text: $query)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: query) { _, newValue in
|
||||
onSearch(newValue)
|
||||
}
|
||||
|
||||
if matchCount > 0 {
|
||||
Text("\(currentIndex + 1)/\(matchCount)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize()
|
||||
|
||||
Button(action: onPrev) {
|
||||
Image(systemName: "chevron.up")
|
||||
}
|
||||
Button(action: onNext) {
|
||||
Image(systemName: "chevron.down")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import SwiftUI
|
||||
|
||||
struct CircularAvatarView: View {
|
||||
let name: String
|
||||
var imageData: Data?
|
||||
var size: CGFloat = 32
|
||||
var isGroup: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if let imageData = imageData, let uiImage = UIImage(data: imageData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
// Default: colored circle with initial letter
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(avatarColor)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
Text(initial)
|
||||
.font(.system(size: size * 0.4, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var initial: String {
|
||||
String(name.prefix(1)).uppercased()
|
||||
}
|
||||
|
||||
/// Deterministic color from name hash (matching Python gui_client behavior)
|
||||
private var avatarColor: Color {
|
||||
let colors: [Color] = [
|
||||
.red, .orange, .yellow, .green, .mint,
|
||||
.teal, .cyan, .blue, .indigo, .purple, .pink
|
||||
]
|
||||
var hash = 0
|
||||
for char in name.unicodeScalars {
|
||||
hash = hash &* 31 &+ Int(char.value)
|
||||
}
|
||||
return colors[abs(hash) % colors.count]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionIndicator: View {
|
||||
let status: ConnectionStatus
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(statusColor)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
if status != .connected {
|
||||
Text(statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case .connected: return .green
|
||||
case .connecting, .reconnecting: return .orange
|
||||
case .disconnected: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String {
|
||||
switch status {
|
||||
case .connected: return ""
|
||||
case .connecting: return "Connecting..."
|
||||
case .reconnecting: return "Reconnecting..."
|
||||
case .disconnected: return "Disconnected"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OnlineDotOverlay: View {
|
||||
var size: CGFloat = 12
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationListView: View {
|
||||
var appState: AppState
|
||||
@Bindable var viewModel: ConversationListVM
|
||||
@State private var showNewConversation = false
|
||||
@State private var showProfile = false
|
||||
@State private var selectedConversation: Conversation?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Invitations section
|
||||
if !viewModel.invitations.isEmpty {
|
||||
Section {
|
||||
ForEach(viewModel.invitations) { invitation in
|
||||
InvitationBanner(
|
||||
invitation: invitation,
|
||||
onAccept: {
|
||||
Task {
|
||||
let (success, _) = await appState.chatClient.acceptInvitation(convId: invitation.conversationId)
|
||||
if success {
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDecline: {
|
||||
Task {
|
||||
_ = await appState.chatClient.declineInvitation(convId: invitation.conversationId)
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
} header: {
|
||||
Text("Invitations")
|
||||
}
|
||||
}
|
||||
|
||||
// Conversations section
|
||||
Section {
|
||||
ForEach(viewModel.conversations) { conversation in
|
||||
NavigationLink(value: conversation) {
|
||||
ConversationRowView(
|
||||
conversation: conversation,
|
||||
currentUserId: appState.currentUser?.id ?? "",
|
||||
isOnline: conversation.dmPartnerId(currentUserId: appState.currentUser?.id ?? "")
|
||||
.map { viewModel.onlineUsers.contains($0) } ?? false,
|
||||
unreadCount: viewModel.unreadCounts[conversation.id] ?? 0,
|
||||
avatarData: viewModel.avatarCache[conversation.id]
|
||||
)
|
||||
}
|
||||
.contextMenu {
|
||||
Button(conversation.isFavorite ? "Remove from Favorites" : "Add to Favorites") {
|
||||
viewModel.toggleFavorite(convId: conversation.id, email: appState.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Chats")
|
||||
.navigationDestination(for: Conversation.self) { conversation in
|
||||
ChatView(
|
||||
conversation: conversation,
|
||||
appState: appState,
|
||||
conversationListVM: viewModel
|
||||
)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
ConnectionIndicator(status: appState.connectionStatus)
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
HStack {
|
||||
Button(action: { showProfile = true }) {
|
||||
Image(systemName: "person.circle")
|
||||
}
|
||||
Button(action: { showNewConversation = true }) {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
.sheet(isPresented: $showNewConversation) {
|
||||
NewConversationSheet(appState: appState) { convId in
|
||||
showNewConversation = false
|
||||
await viewModel.refresh(chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProfile) {
|
||||
ProfileView(appState: appState, isOwnProfile: true)
|
||||
}
|
||||
.task {
|
||||
await viewModel.load(chatClient: appState.chatClient, email: appState.email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationRowView: View {
|
||||
let conversation: Conversation
|
||||
let currentUserId: String
|
||||
let isOnline: Bool
|
||||
let unreadCount: Int
|
||||
var avatarData: Data?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Avatar
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
CircularAvatarView(
|
||||
name: conversation.displayName(currentUserId: currentUserId),
|
||||
imageData: avatarData,
|
||||
size: 44,
|
||||
isGroup: conversation.isGroup
|
||||
)
|
||||
|
||||
if isOnline && !conversation.isGroup {
|
||||
OnlineDotOverlay(size: 12)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
if conversation.isFavorite {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.yellow)
|
||||
}
|
||||
|
||||
Text(conversation.displayName(currentUserId: currentUserId))
|
||||
.font(unreadCount > 0 ? .body.bold() : .body)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if conversation.isGroup {
|
||||
Text("\(conversation.members.count) members")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if unreadCount > 0 {
|
||||
Text("\(unreadCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.blue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NewConversationSheet: View {
|
||||
var appState: AppState
|
||||
var onCreated: (String) async -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var groupName = ""
|
||||
@State private var isGroup = false
|
||||
@State private var memberEmails: [String] = [""]
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Toggle("Create Group", isOn: $isGroup)
|
||||
|
||||
if isGroup {
|
||||
TextField("Group Name", text: $groupName)
|
||||
}
|
||||
}
|
||||
|
||||
Section(isGroup ? "Members" : "Recipient") {
|
||||
if isGroup {
|
||||
ForEach(memberEmails.indices, id: \.self) { index in
|
||||
TextField("Email", text: $memberEmails[index])
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
Button("Add Member") {
|
||||
memberEmails.append("")
|
||||
}
|
||||
} else {
|
||||
TextField("Email", text: $email)
|
||||
.textContentType(.emailAddress)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocapitalization(.none)
|
||||
}
|
||||
}
|
||||
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Conversation")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Create") {
|
||||
Task { await create() }
|
||||
}
|
||||
.disabled(isLoading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func create() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
let emails: [String]
|
||||
if isGroup {
|
||||
emails = memberEmails.map { $0.trimmed }.filter { !$0.isEmpty }
|
||||
guard !emails.isEmpty else {
|
||||
errorMessage = "Add at least one member"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
} else {
|
||||
guard !email.trimmed.isEmpty else {
|
||||
errorMessage = "Enter an email address"
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
emails = [email.trimmed]
|
||||
}
|
||||
|
||||
let name = isGroup && !groupName.trimmed.isEmpty ? groupName.trimmed : nil
|
||||
let (convId, message) = await appState.chatClient.createConversation(emails: emails, name: name)
|
||||
|
||||
isLoading = false
|
||||
|
||||
if let convId = convId {
|
||||
await onCreated(convId)
|
||||
} else {
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Group creation is handled within NewConversationSheet via the isGroup toggle.
|
||||
// This file exists for potential future separation.
|
||||
301
ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift
Normal file
301
ios_client 0.8.5/Kecalek/Views/Groups/GroupInfoView.swift
Normal file
@@ -0,0 +1,301 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
|
||||
struct GroupInfoView: View {
|
||||
@Binding var conversation: Conversation
|
||||
var appState: AppState
|
||||
var conversationListVM: ConversationListVM?
|
||||
@State private var showRenameSheet = false
|
||||
@State private var showLeaveConfirm = false
|
||||
@State private var showAddMember = false
|
||||
@State private var showRemoveConfirm = false
|
||||
@State private var showAvatarPicker = false
|
||||
@State private var newName = ""
|
||||
@State private var addMemberEmail = ""
|
||||
@State private var memberToRemove: ConversationMember?
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
@State private var isUploadingAvatar = false
|
||||
@State private var groupAvatarData: Data?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var isCreator: Bool {
|
||||
conversation.createdBy == appState.currentUser?.id
|
||||
}
|
||||
|
||||
private func refreshConversation() async {
|
||||
let convs = await appState.chatClient.listConversations()
|
||||
if let updated = convs.first(where: { $0.id == conversation.id }) {
|
||||
conversation = updated
|
||||
}
|
||||
await conversationListVM?.forceRefresh(chatClient: appState.chatClient)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Avatar section
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
CircularAvatarView(
|
||||
name: conversation.name ?? "Group",
|
||||
imageData: groupAvatarData ?? conversationListVM?.avatarCache[conversation.id],
|
||||
size: 64,
|
||||
isGroup: true
|
||||
)
|
||||
|
||||
Text(conversation.name ?? "Group")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("\(conversation.members.count) members")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// Actions
|
||||
if isCreator {
|
||||
Section {
|
||||
Button("Add Member") {
|
||||
addMemberEmail = ""
|
||||
showAddMember = true
|
||||
}
|
||||
|
||||
Button("Rename Group") {
|
||||
newName = conversation.name ?? ""
|
||||
showRenameSheet = true
|
||||
}
|
||||
|
||||
Button {
|
||||
showAvatarPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Change Avatar")
|
||||
if isUploadingAvatar {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(isUploadingAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
// Members
|
||||
Section("Members") {
|
||||
ForEach(conversation.members) { member in
|
||||
HStack {
|
||||
CircularAvatarView(name: member.username, size: 32, isGroup: false)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(member.username)
|
||||
.font(.body)
|
||||
Text(member.email)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if member.userId == conversation.createdBy {
|
||||
Text("Admin")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
if isCreator && member.userId != appState.currentUser?.id {
|
||||
Button("Remove from Group", role: .destructive) {
|
||||
memberToRemove = member
|
||||
showRemoveConfirm = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Leave / Delete
|
||||
Section {
|
||||
Button("Leave Group", role: .destructive) {
|
||||
showLeaveConfirm = true
|
||||
}
|
||||
|
||||
if isCreator {
|
||||
Button("Delete Group", role: .destructive) {
|
||||
Task {
|
||||
_ = await appState.chatClient.deleteConversation(convId: conversation.id)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Group Info")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.alert("Leave Group?", isPresented: $showLeaveConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Leave", role: .destructive) {
|
||||
Task {
|
||||
_ = await appState.chatClient.leaveGroup(convId: conversation.id)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Remove Member?", isPresented: $showRemoveConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Remove", role: .destructive) {
|
||||
if let member = memberToRemove {
|
||||
Task {
|
||||
let (success, msg) = await appState.chatClient.removeMember(
|
||||
convId: conversation.id, userId: member.userId
|
||||
)
|
||||
if success {
|
||||
await refreshConversation()
|
||||
} else {
|
||||
errorMessage = msg
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
if let member = memberToRemove {
|
||||
Text("Remove \(member.username) from the group?")
|
||||
}
|
||||
}
|
||||
.alert("Add Member", isPresented: $showAddMember) {
|
||||
TextField("Email", text: $addMemberEmail)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.emailAddress)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Add") {
|
||||
let email = addMemberEmail.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !email.isEmpty else { return }
|
||||
Task {
|
||||
let (success, msg) = await appState.chatClient.addMember(
|
||||
convId: conversation.id, email: email
|
||||
)
|
||||
if success {
|
||||
await refreshConversation()
|
||||
} else {
|
||||
errorMessage = msg
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Rename Group", isPresented: $showRenameSheet) {
|
||||
TextField("Group Name", text: $newName)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Rename") {
|
||||
let trimmedName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedName.isEmpty else { return }
|
||||
// Optimistic update - immediately reflect in UI
|
||||
conversation.name = trimmedName
|
||||
Task {
|
||||
let (success, _) = await appState.chatClient.renameConversation(convId: conversation.id, name: trimmedName)
|
||||
if success {
|
||||
await refreshConversation()
|
||||
} else {
|
||||
// Revert on failure
|
||||
await refreshConversation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: $showError) {
|
||||
Button("OK") {}
|
||||
} message: {
|
||||
Text(errorMessage ?? "")
|
||||
}
|
||||
.sheet(isPresented: $showAvatarPicker) {
|
||||
AvatarPickerView { imageData in
|
||||
isUploadingAvatar = true
|
||||
Task {
|
||||
let success = await appState.chatClient.updateGroupAvatar(
|
||||
convId: conversation.id, imageData: imageData
|
||||
)
|
||||
isUploadingAvatar = false
|
||||
if success {
|
||||
// Update local avatar cache (memory + disk)
|
||||
groupAvatarData = imageData
|
||||
conversationListVM?.updateAvatar(convId: conversation.id, data: imageData)
|
||||
await refreshConversation()
|
||||
} else {
|
||||
errorMessage = "Failed to update avatar"
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Load current group avatar
|
||||
if groupAvatarData == nil, let cached = conversationListVM?.avatarCache[conversation.id] {
|
||||
groupAvatarData = cached
|
||||
} else if groupAvatarData == nil {
|
||||
groupAvatarData = await appState.chatClient.getGroupAvatar(convId: conversation.id)
|
||||
}
|
||||
await refreshConversation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Avatar Picker (PHPicker wrapper for avatar selection)
|
||||
|
||||
private struct AvatarPickerView: UIViewControllerRepresentable {
|
||||
let onImagePicked: (Data) -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onImagePicked: onImagePicked)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> PHPickerViewController {
|
||||
var config = PHPickerConfiguration()
|
||||
config.filter = .images
|
||||
config.selectionLimit = 1
|
||||
let picker = PHPickerViewController(configuration: config)
|
||||
picker.delegate = context.coordinator
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
|
||||
|
||||
class Coordinator: NSObject, PHPickerViewControllerDelegate {
|
||||
let onImagePicked: (Data) -> Void
|
||||
|
||||
init(onImagePicked: @escaping (Data) -> Void) {
|
||||
self.onImagePicked = onImagePicked
|
||||
}
|
||||
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
guard let provider = results.first?.itemProvider,
|
||||
provider.canLoadObject(ofClass: UIImage.self) else {
|
||||
picker.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] image, _ in
|
||||
guard let uiImage = image as? UIImage,
|
||||
let data = uiImage.jpegData(compressionQuality: 0.8) else {
|
||||
DispatchQueue.main.async { picker.dismiss(animated: true) }
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self?.onImagePicked(data)
|
||||
picker.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift
Normal file
41
ios_client 0.8.5/Kecalek/Views/Groups/InvitationBanner.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InvitationBanner: View {
|
||||
let invitation: Invitation
|
||||
let onAccept: () -> Void
|
||||
let onDecline: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "envelope.badge")
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(invitation.conversationName)
|
||||
.font(.body.bold())
|
||||
Text("Invited by \(invitation.invitedByUsername)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Accept") {
|
||||
onAccept()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
|
||||
Button("Decline") {
|
||||
onDecline()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import SwiftUI
|
||||
|
||||
// Profile editing is handled within ProfileView when isOwnProfile = true.
|
||||
// This file exists for potential future separation.
|
||||
277
ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift
Normal file
277
ios_client 0.8.5/Kecalek/Views/Profile/ProfileView.swift
Normal file
@@ -0,0 +1,277 @@
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import UIKit
|
||||
|
||||
struct ProfileView: View {
|
||||
var appState: AppState
|
||||
var isOwnProfile: Bool
|
||||
var userId: String?
|
||||
@State private var viewModel = ProfileViewModel()
|
||||
@State private var showLogoutConfirm = false
|
||||
@State private var showAvatarPicker = false
|
||||
@State private var showAuthorizeDevice = false
|
||||
@State private var showRotateKeys = false
|
||||
@State private var rotatePassword = ""
|
||||
@State private var isRotating = false
|
||||
@State private var rotateMessage: String?
|
||||
@State private var rotateIsError = false
|
||||
@State private var showChangeUsername = false
|
||||
@State private var newUsername = ""
|
||||
@State private var showChangePassword = false
|
||||
@State private var oldPassword = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var confirmNewPassword = ""
|
||||
@State private var showVerification = false
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Avatar
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
if let avatarData = viewModel.avatarData,
|
||||
let uiImage = UIImage(data: avatarData) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
CircularAvatarView(
|
||||
name: viewModel.profile?.username ?? "?",
|
||||
size: 80,
|
||||
isGroup: false
|
||||
)
|
||||
}
|
||||
|
||||
if isOwnProfile {
|
||||
Button("Change Photo") {
|
||||
showAvatarPicker = true
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
// Info
|
||||
Section("Info") {
|
||||
if let username = viewModel.profile?.username {
|
||||
LabeledContent("Username", value: username)
|
||||
}
|
||||
if let email = viewModel.profile?.email {
|
||||
LabeledContent("Email", value: email)
|
||||
}
|
||||
}
|
||||
|
||||
if isOwnProfile {
|
||||
// Editable fields
|
||||
Section("Contact") {
|
||||
TextField("Phone", text: $viewModel.phone)
|
||||
.keyboardType(.phonePad)
|
||||
Toggle("Phone visible to contacts", isOn: $viewModel.phoneVisible)
|
||||
|
||||
TextField("Location", text: $viewModel.location)
|
||||
Toggle("Location visible to contacts", isOn: $viewModel.locationVisible)
|
||||
}
|
||||
} else {
|
||||
// Read-only view
|
||||
if let phone = viewModel.profile?.phone, viewModel.profile?.phoneVisible == true {
|
||||
Section("Contact") {
|
||||
LabeledContent("Phone", value: phone)
|
||||
}
|
||||
}
|
||||
if let location = viewModel.profile?.location, viewModel.profile?.locationVisible == true {
|
||||
Section("Location") {
|
||||
LabeledContent("Location", value: location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !isOwnProfile, let uid = userId {
|
||||
Section("Security") {
|
||||
NavigationLink {
|
||||
SafetyNumberView(
|
||||
peerUserId: uid,
|
||||
peerUsername: viewModel.profile?.username ?? "User",
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
} label: {
|
||||
Label("Verify Identity", systemImage: "checkmark.shield")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
if isOwnProfile {
|
||||
Section("Account") {
|
||||
Button {
|
||||
newUsername = viewModel.profile?.username ?? ""
|
||||
showChangeUsername = true
|
||||
} label: {
|
||||
Label("Change Username", systemImage: "person.text.rectangle")
|
||||
}
|
||||
|
||||
Button {
|
||||
showChangePassword = true
|
||||
} label: {
|
||||
Label("Change Password", systemImage: "key")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Security") {
|
||||
Button {
|
||||
showAuthorizeDevice = true
|
||||
} label: {
|
||||
Label("Authorize New Device", systemImage: "iphone.badge.checkmark")
|
||||
}
|
||||
|
||||
Button {
|
||||
showRotateKeys = true
|
||||
} label: {
|
||||
Label("Rotate Keys", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
showLogoutConfirm = true
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Logout")
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(isOwnProfile ? "My Profile" : "Profile")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if isOwnProfile {
|
||||
Button("Save") {
|
||||
Task {
|
||||
let success = await viewModel.saveProfile(chatClient: appState.chatClient)
|
||||
if success {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isSaving)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if !isOwnProfile {
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Logout", isPresented: $showLogoutConfirm) {
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Logout", role: .destructive) {
|
||||
Task {
|
||||
await appState.logout()
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to logout?")
|
||||
}
|
||||
.sheet(isPresented: $showAvatarPicker) {
|
||||
ImagePickerView { data in
|
||||
Task {
|
||||
await viewModel.uploadAvatar(imageData: data, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAuthorizeDevice) {
|
||||
AuthorizeDeviceView(appState: appState)
|
||||
}
|
||||
.alert("Rotate Keys", isPresented: $showRotateKeys) {
|
||||
SecureField("Password", text: $rotatePassword)
|
||||
Button("Cancel", role: .cancel) { rotatePassword = "" }
|
||||
Button("Rotate") {
|
||||
Task {
|
||||
isRotating = true
|
||||
let (success, msg) = await appState.chatClient.rotateKeys(password: rotatePassword)
|
||||
rotatePassword = ""
|
||||
isRotating = false
|
||||
rotateMessage = msg
|
||||
rotateIsError = !success
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Enter your password to generate new keys. All other devices will be disconnected.")
|
||||
}
|
||||
.alert(rotateIsError ? "Error" : "Success", isPresented: Binding(
|
||||
get: { rotateMessage != nil },
|
||||
set: { if !$0 { rotateMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { rotateMessage = nil }
|
||||
} message: {
|
||||
Text(rotateMessage ?? "")
|
||||
}
|
||||
.alert("Change Username", isPresented: $showChangeUsername) {
|
||||
TextField("New username", text: $newUsername)
|
||||
Button("Cancel", role: .cancel) { newUsername = "" }
|
||||
Button("Change") {
|
||||
Task {
|
||||
let success = await viewModel.changeUsername(newUsername: newUsername, chatClient: appState.chatClient)
|
||||
if success {
|
||||
await viewModel.loadProfile(chatClient: appState.chatClient)
|
||||
}
|
||||
newUsername = ""
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Enter a new display name.")
|
||||
}
|
||||
.alert("Change Password", isPresented: $showChangePassword) {
|
||||
SecureField("Current password", text: $oldPassword)
|
||||
SecureField("New password", text: $newPassword)
|
||||
SecureField("Confirm new password", text: $confirmNewPassword)
|
||||
Button("Cancel", role: .cancel) {
|
||||
oldPassword = ""
|
||||
newPassword = ""
|
||||
confirmNewPassword = ""
|
||||
}
|
||||
Button("Change") {
|
||||
Task {
|
||||
guard newPassword == confirmNewPassword else {
|
||||
viewModel.errorMessage = "Passwords don't match"
|
||||
return
|
||||
}
|
||||
_ = await viewModel.changePassword(
|
||||
oldPassword: oldPassword, newPassword: newPassword,
|
||||
chatClient: appState.chatClient
|
||||
)
|
||||
oldPassword = ""
|
||||
newPassword = ""
|
||||
confirmNewPassword = ""
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Enter your current password and a new password.")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadProfile(userId: userId, chatClient: appState.chatClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
struct QRCodeScannerView: View {
|
||||
let onScan: (Data) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var cameraPermission: CameraPermission = .unknown
|
||||
|
||||
enum CameraPermission {
|
||||
case unknown, granted, denied
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
switch cameraPermission {
|
||||
case .unknown:
|
||||
ProgressView("Requesting camera access...")
|
||||
case .denied:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Camera access is required to scan QR codes.")
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
case .granted:
|
||||
ScannerRepresentable(onScan: { data in
|
||||
onScan(data)
|
||||
dismiss()
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await checkCameraPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private func checkCameraPermission() async {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
switch status {
|
||||
case .authorized:
|
||||
cameraPermission = .granted
|
||||
case .notDetermined:
|
||||
let granted = await AVCaptureDevice.requestAccess(for: .video)
|
||||
cameraPermission = granted ? .granted : .denied
|
||||
default:
|
||||
cameraPermission = .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scanner UIKit wrapper
|
||||
|
||||
private struct ScannerRepresentable: UIViewControllerRepresentable {
|
||||
let onScan: (Data) -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> ScannerViewController {
|
||||
let vc = ScannerViewController()
|
||||
vc.onScan = onScan
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {}
|
||||
}
|
||||
|
||||
final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||
var onScan: ((Data) -> Void)?
|
||||
private var captureSession: AVCaptureSession?
|
||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
private var hasScanned = false
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
setupCamera()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
captureSession?.stopRunning()
|
||||
}
|
||||
|
||||
private func setupCamera() {
|
||||
let session = AVCaptureSession()
|
||||
captureSession = session
|
||||
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else {
|
||||
return
|
||||
}
|
||||
session.addInput(input)
|
||||
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||
output.metadataObjectTypes = [.qr]
|
||||
|
||||
let layer = AVCaptureVideoPreviewLayer(session: session)
|
||||
layer.videoGravity = .resizeAspectFill
|
||||
layer.frame = view.bounds
|
||||
view.layer.addSublayer(layer)
|
||||
previewLayer = layer
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
session.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection) {
|
||||
guard !hasScanned,
|
||||
let object = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
|
||||
object.type == .qr else { return }
|
||||
|
||||
hasScanned = true
|
||||
captureSession?.stopRunning()
|
||||
|
||||
// QR codes contain base64-encoded binary data (matching Python client)
|
||||
if let stringValue = object.stringValue,
|
||||
let data = Data(base64Encoded: stringValue) {
|
||||
onScan?(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import SwiftUI
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
struct SafetyNumberView: View {
|
||||
let peerUserId: String
|
||||
let peerUsername: String
|
||||
var chatClient: ChatClient
|
||||
@State private var vm = VerificationVM()
|
||||
@State private var showQRScanner = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Verification status badge
|
||||
VerificationStatusView(status: vm.verificationStatus)
|
||||
.padding(.top)
|
||||
|
||||
// Safety number
|
||||
if let safetyNumber = vm.safetyNumber {
|
||||
VStack(spacing: 8) {
|
||||
Text("Safety Number")
|
||||
.font(.headline)
|
||||
|
||||
Text("If both you and \(peerUsername) see the same number, your communication is secure.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Text(safetyNumber)
|
||||
.font(.system(.title2, design: .monospaced))
|
||||
.padding()
|
||||
.background(.quaternary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// QR Code
|
||||
if let qrData = vm.qrCodeData {
|
||||
VStack(spacing: 8) {
|
||||
Text("Your QR Code")
|
||||
.font(.headline)
|
||||
|
||||
if let qrImage = generateQRCode(from: qrData) {
|
||||
Image(uiImage: qrImage)
|
||||
.interpolation(.none)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 200, height: 200)
|
||||
.padding()
|
||||
.background(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fingerprints
|
||||
VStack(spacing: 12) {
|
||||
if let myFP = vm.myFingerprint {
|
||||
VStack(spacing: 4) {
|
||||
Text("Your Fingerprint")
|
||||
.font(.subheadline.bold())
|
||||
Text(myFP)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
if let peerFP = vm.peerFingerprint {
|
||||
VStack(spacing: 4) {
|
||||
Text("\(peerUsername)'s Fingerprint")
|
||||
.font(.subheadline.bold())
|
||||
Text(peerFP)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
VStack(spacing: 12) {
|
||||
if vm.verificationStatus != "verified" {
|
||||
Button {
|
||||
Task { await vm.verifyContact(peerUserId: peerUserId, chatClient: chatClient) }
|
||||
} label: {
|
||||
Label("Mark as Verified", systemImage: "checkmark.shield.fill")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
showQRScanner = true
|
||||
} label: {
|
||||
Label("Scan QR Code", systemImage: "qrcode.viewfinder")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button(role: .destructive) {
|
||||
Task { await vm.unverifyContact(peerUserId: peerUserId, chatClient: chatClient) }
|
||||
} label: {
|
||||
Label("Remove Verification", systemImage: "xmark.shield")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Scan result
|
||||
if let result = vm.scanResult {
|
||||
Text(result)
|
||||
.font(.callout)
|
||||
.foregroundStyle(vm.scanSuccess == true ? .green : .red)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Verify \(peerUsername)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.sheet(isPresented: $showQRScanner) {
|
||||
QRCodeScannerView { scannedData in
|
||||
showQRScanner = false
|
||||
Task { await vm.verifyQRCode(data: scannedData, chatClient: chatClient) }
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await vm.loadVerification(peerUserId: peerUserId, chatClient: chatClient)
|
||||
}
|
||||
}
|
||||
|
||||
private func generateQRCode(from data: Data) -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
// Base64-encode binary data — raw binary gets corrupted by QR readers (UTF-8 re-encoding)
|
||||
let b64String = data.base64EncodedString()
|
||||
filter.setValue(b64String.data(using: .ascii), forKey: "inputMessage")
|
||||
filter.setValue("M", forKey: "inputCorrectionLevel")
|
||||
guard let outputImage = filter.outputImage else { return nil }
|
||||
let scale = 200.0 / outputImage.extent.width
|
||||
let scaledImage = outputImage.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||||
guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else { return nil }
|
||||
return UIImage(cgImage: cgImage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VerificationStatusView: View {
|
||||
let status: String // "verified", "trusted", "unverified"
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(iconColor)
|
||||
Text(displayText)
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(iconColor.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch status {
|
||||
case "verified": return "checkmark.shield.fill"
|
||||
case "trusted": return "shield.fill"
|
||||
default: return "shield.slash"
|
||||
}
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
switch status {
|
||||
case "verified": return .green
|
||||
case "trusted": return .blue
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
switch status {
|
||||
case "verified": return "Verified"
|
||||
case "trusted": return "Trusted"
|
||||
default: return "Unverified"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user