Compare commits

..

No commits in common. "cc32d7acad64a79abcbf8c53cb5a7a0b669f2f01" and "7dc78edb02a0ed814feaed238924b598e4456cca" have entirely different histories.

11 changed files with 26 additions and 464 deletions

View File

@ -28,12 +28,12 @@ struct TopBarView: View {
}
private var statusMessage: String? {
if viewModel.socketState != .connected {
return NSLocalizedString("Подключение", comment: "")
}
if viewModel.chatLoadingState == .loading {
return NSLocalizedString("Загрузка чатов", comment: "")
}
if viewModel.socketState == .connecting {
return NSLocalizedString("Подключение", comment: "")
}
return nil
}

View File

@ -1,233 +0,0 @@
import Foundation
import CoreData
final class ChatCacheWriter {
static let shared = ChatCacheWriter()
private let persistenceController: PersistenceController
private let userDefaults: UserDefaults
private let keychainService: KeychainService
init(
persistenceController: PersistenceController = .shared,
userDefaults: UserDefaults = .standard,
keychainService: KeychainService = .shared
) {
self.persistenceController = persistenceController
self.userDefaults = userDefaults
self.keychainService = keychainService
}
func storePrivateChatList(_ data: PrivateChatListData) {
guard !AppConfig.DISABLE_DB else { return }
guard let accountUserId = resolveCurrentAccountUserId() else { return }
let context = persistenceController.newBackgroundContext()
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
context.perform { [weak self] in
guard let self else { return }
do {
try self.sync(chatList: data, accountUserId: accountUserId, context: context)
if context.hasChanges {
try context.save()
}
} catch {
if AppConfig.DEBUG {
print("[ChatCacheWriter] Failed to store private chat list: \(error)")
}
}
}
}
private func resolveCurrentAccountUserId() -> String? {
guard let login = userDefaults.string(forKey: "currentUser") else {
return nil
}
return keychainService.get(forKey: "userId", service: login)
}
private func sync(chatList: PrivateChatListData, accountUserId: String, context: NSManagedObjectContext) throws {
var profileCache: [String: CDProfile] = [:]
var chatCache: [String: CDPrivateChat] = [:]
var messageCache: [String: CDMessage] = [:]
for item in chatList.items {
let chat = try upsertChat(item, accountUserId: accountUserId, context: context, cache: &chatCache)
let previousLastMessage = chat.lastMessage
if let profileData = item.chatData {
let profile = try upsertProfile(profileData, accountUserId: accountUserId, context: context, cache: &profileCache)
chat.chatData = profile
} else {
chat.chatData = nil
}
if let lastMessageData = item.lastMessage {
let message = try upsertMessage(
lastMessageData,
accountUserId: accountUserId,
chat: chat,
context: context,
profileCache: &profileCache,
messageCache: &messageCache
)
if let previous = previousLastMessage, previous.objectID != message.objectID {
previous.chatAsLastMessage = nil
}
chat.lastMessage = message
} else {
previousLastMessage?.chatAsLastMessage = nil
chat.lastMessage = nil
}
}
}
private func upsertChat(
_ chatItem: PrivateChatListItem,
accountUserId: String,
context: NSManagedObjectContext,
cache: inout [String: CDPrivateChat]
) throws -> CDPrivateChat {
if let cached = cache[chatItem.chatId] {
apply(chatItem: chatItem, to: cached, accountUserId: accountUserId)
return cached
}
let chat = try fetchChat(chatId: chatItem.chatId, accountUserId: accountUserId, context: context) ?? CDPrivateChat(context: context)
apply(chatItem: chatItem, to: chat, accountUserId: accountUserId)
cache[chatItem.chatId] = chat
return chat
}
private func apply(chatItem: PrivateChatListItem, to chat: CDPrivateChat, accountUserId: String) {
chat.accountUserId = accountUserId
chat.chatId = chatItem.chatId
chat.chatType = chatItem.chatType.rawValue
chat.createdAt = chatItem.createdAt
chat.unreadCount = Int32(chatItem.unreadCount)
}
private func upsertProfile(
_ profileData: ChatProfile,
accountUserId: String,
context: NSManagedObjectContext,
cache: inout [String: CDProfile]
) throws -> CDProfile {
if let cached = cache[profileData.userId] {
apply(profile: profileData, to: cached, accountUserId: accountUserId)
return cached
}
let profile = try fetchProfile(userId: profileData.userId, accountUserId: accountUserId, context: context) ?? CDProfile(context: context)
apply(profile: profileData, to: profile, accountUserId: accountUserId)
cache[profileData.userId] = profile
return profile
}
private func apply(profile profileData: ChatProfile, to profile: CDProfile, accountUserId: String) {
profile.accountUserId = accountUserId
profile.userId = profileData.userId
profile.login = profileData.login
profile.fullName = profileData.fullName
profile.customName = profileData.customName
profile.bio = profileData.bio
if let lastSeen = profileData.lastSeen {
profile.setValue(NSNumber(value: lastSeen), forKey: "lastSeen")
} else {
profile.setValue(nil, forKey: "lastSeen")
}
profile.createdAt = profileData.createdAt
profile.isOfficial = profileData.isOfficial
}
private func upsertMessage(
_ messageData: MessageItem,
accountUserId: String,
chat: CDPrivateChat,
context: NSManagedObjectContext,
profileCache: inout [String: CDProfile],
messageCache: inout [String: CDMessage]
) throws -> CDMessage {
if let cached = messageCache[messageData.messageId] {
apply(message: messageData, to: cached, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache)
return cached
}
let message = try fetchMessage(messageId: messageData.messageId, accountUserId: accountUserId, context: context) ?? CDMessage(context: context)
apply(message: messageData, to: message, accountUserId: accountUserId, chat: chat, context: context, profileCache: &profileCache)
messageCache[messageData.messageId] = message
return message
}
private func apply(
message messageData: MessageItem,
to message: CDMessage,
accountUserId: String,
chat: CDPrivateChat,
context: NSManagedObjectContext,
profileCache: inout [String: CDProfile]
) {
message.accountUserId = accountUserId
message.messageId = messageData.messageId
message.chatId = messageData.chatId
message.messageType = messageData.messageType
message.content = messageData.content
message.mediaLink = messageData.mediaLink
message.createdAt = messageData.createdAt
message.updatedAt = messageData.updatedAt
message.senderId = messageData.senderId
message.chat = chat
message.chatAsLastMessage = chat
if let isViewed = messageData.isViewed {
message.setValue(NSNumber(value: isViewed), forKey: "isViewed")
} else {
message.setValue(nil, forKey: "isViewed")
}
if let forward = messageData.forwardMetadata {
message.forwardType = forward.forwardType
message.forwardSenderId = forward.forwardSenderId
message.forwardMessageId = forward.forwardMessageId
} else {
message.forwardType = nil
message.forwardSenderId = nil
message.forwardMessageId = nil
}
if let senderProfileData = messageData.senderData {
if let profile = try? upsertProfile(
senderProfileData,
accountUserId: accountUserId,
context: context,
cache: &profileCache
) {
message.sender = profile
}
} else {
message.sender = nil
}
}
private func fetchChat(chatId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDPrivateChat? {
let request = NSFetchRequest<CDPrivateChat>(entityName: "CDPrivateChat")
request.fetchLimit = 1
request.predicate = NSPredicate(format: "accountUserId == %@ AND chatId == %@", accountUserId, chatId)
return try context.fetch(request).first
}
private func fetchProfile(userId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDProfile? {
let request = NSFetchRequest<CDProfile>(entityName: "CDProfile")
request.fetchLimit = 1
request.predicate = NSPredicate(format: "accountUserId == %@ AND userId == %@", accountUserId, userId)
return try context.fetch(request).first
}
private func fetchMessage(messageId: String, accountUserId: String, context: NSManagedObjectContext) throws -> CDMessage? {
let request = NSFetchRequest<CDMessage>(entityName: "CDMessage")
request.fetchLimit = 1
request.predicate = NSPredicate(format: "accountUserId == %@ AND messageId == %@", accountUserId, messageId)
return try context.fetch(request).first
}
}

View File

@ -1,101 +0,0 @@
import CoreData
enum PersistenceControllerError: Error {
case encryptionKeyUnavailable
case persistentStoreMissing
case rekeyingUnavailable
}
final class PersistenceController {
static let shared: PersistenceController = {
do {
return try PersistenceController()
} catch {
fatalError("Failed to initialize PersistenceController: \(error)")
}
}()
let container: NSPersistentContainer
private let keyManager: DatabaseEncryptionKeyManager
var viewContext: NSManagedObjectContext {
container.viewContext
}
init(
inMemory: Bool = false,
keyManager: DatabaseEncryptionKeyManager = .shared,
fileManager: FileManager = .default
) throws {
self.keyManager = keyManager
let modelName = "YobbleDataModel"
guard let modelURL = Bundle.main.url(forResource: modelName, withExtension: "momd"),
let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else {
fatalError("Unable to load Core Data model \(modelName)")
}
container = NSPersistentContainer(name: modelName, managedObjectModel: managedObjectModel)
let description = container.persistentStoreDescriptions.first ?? NSPersistentStoreDescription()
description.type = NSSQLiteStoreType
if inMemory {
description.url = URL(fileURLWithPath: "/dev/null")
} else {
description.url = try Self.makeStoreURL(fileManager: fileManager, fileName: "\(modelName).sqlite")
}
description.shouldInferMappingModelAutomatically = true
description.shouldMigrateStoreAutomatically = true
let key: String
do {
key = try keyManager.currentKey()
} catch {
throw PersistenceControllerError.encryptionKeyUnavailable
}
let pragmas: [String: String] = [
"journal_mode": "WAL",
"cipher_page_size": "4096",
"key": key
]
description.setOption(pragmas as NSDictionary, forKey: NSSQLitePragmasOption)
description.setOption(FileProtectionType.completeUntilFirstUserAuthentication as NSObject, forKey: NSPersistentStoreFileProtectionKey)
container.persistentStoreDescriptions = [description]
container.loadPersistentStores { _, error in
if let error {
fatalError("Unresolved error \(error)")
}
}
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
container.viewContext.automaticallyMergesChangesFromParent = true
}
func newBackgroundContext() -> NSManagedObjectContext {
container.newBackgroundContext()
}
/// Placeholder for a future rekey flow once a password-based key is available.
///
/// On iOS 16 with SQLCipher you typically re-encrypt by running `PRAGMA rekey` via a raw
/// SQLite handle and then persisting the new key to the key manager. This helper keeps
/// the signature in place for when that flow is implemented.
func rekeyStore(to newKey: String) throws {
throw PersistenceControllerError.rekeyingUnavailable
}
private static func makeStoreURL(fileManager: FileManager, fileName: String) throws -> URL {
guard let baseURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
fatalError("Unable to resolve Application Support directory")
}
if !fileManager.fileExists(atPath: baseURL.path) {
try fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true)
}
return baseURL.appendingPathComponent(fileName)
}
}

View File

@ -19,14 +19,12 @@ enum ChatServiceError: LocalizedError {
final class ChatService {
private let client: NetworkClient
private let decoder: JSONDecoder
private let cacheWriter: ChatCacheWriter
init(client: NetworkClient = .shared, cacheWriter: ChatCacheWriter = .shared) {
init(client: NetworkClient = .shared) {
self.client = client
self.decoder = JSONDecoder()
self.decoder.keyDecodingStrategy = .convertFromSnakeCase
self.decoder.dateDecodingStrategy = .custom(Self.decodeDate)
self.cacheWriter = cacheWriter
}
func fetchPrivateChats(
@ -44,7 +42,7 @@ final class ChatService {
method: .get,
query: query,
requiresAuth: true
) { [decoder, weak self] result in
) { [decoder] result in
switch result {
case .success(let response):
do {
@ -54,9 +52,7 @@ final class ChatService {
completion(.failure(ChatServiceError.unexpectedStatus(message)))
return
}
let chatData = apiResponse.data
self?.cacheWriter.storePrivateChatList(chatData)
completion(.success(chatData))
completion(.success(apiResponse.data))
} catch {
let debugMessage = Self.describeDecodingError(error: error, data: response.data)
if AppConfig.DEBUG {

View File

@ -1,45 +0,0 @@
import Foundation
enum DatabaseEncryptionKeyError: Error {
case keyNotAvailable
}
final class DatabaseEncryptionKeyManager {
static let shared = DatabaseEncryptionKeyManager()
private let keychainService: KeychainService
private let serviceName = "yobble.database.encryption"
private let accountName = "sqlcipher_key"
/// Hardcoded dev key used until the user saves their own password-derived key.
private let fallbackKey: String
init(
keychainService: KeychainService = .shared,
fallbackKey: String = AppConfig.DEFAULT_DATABASE_ENCRYPTION_KEY
) {
self.keychainService = keychainService
self.fallbackKey = fallbackKey
}
func currentKey() throws -> String {
if let key = keychainService.get(forKey: accountName, service: serviceName), !key.isEmpty {
return key
}
guard !fallbackKey.isEmpty else {
throw DatabaseEncryptionKeyError.keyNotAvailable
}
return fallbackKey
}
func persistKey(_ key: String) {
keychainService.save(key, forKey: accountName, service: serviceName)
}
func clearPersistedKey() {
keychainService.delete(forKey: accountName, service: serviceName)
}
func hasPersistedKey() -> Bool {
keychainService.get(forKey: accountName, service: serviceName) != nil
}
}

View File

@ -41,7 +41,6 @@ final class SocketService {
private var heartbeatAckInFlight = false
private var lastHeartbeatSentAt: Date?
private var consecutiveHeartbeatMisses = 0
private var reconnectWorkItem: DispatchWorkItem?
#endif
private init() {}
@ -127,7 +126,6 @@ final class SocketService {
currentToken = token
currentAuthPayload = ["token": token]
setupSocket(with: token)
cancelScheduledReconnect()
updateConnectionState(.connecting)
socket?.connect(withPayload: currentAuthPayload)
startHeartbeat()
@ -204,13 +202,11 @@ final class SocketService {
socket.on(clientEvent: .disconnect) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Disconnected: \(data)") }
self.updateConnectionState(.disconnected)
self.scheduleReconnect()
}
socket.on(clientEvent: .error) { data, _ in
if AppConfig.DEBUG { print("[SocketService] Error: \(data)") }
self.updateConnectionState(.disconnected)
self.scheduleReconnect()
}
socket.on("pong") { [weak self] _, _ in
@ -227,7 +223,6 @@ final class SocketService {
private func disconnectInternal() {
stopHeartbeat()
cancelScheduledReconnect()
socket?.disconnect()
manager?.disconnect()
socket = nil
@ -326,7 +321,6 @@ final class SocketService {
consecutiveHeartbeatMisses = 0
heartbeatAckInFlight = false
lastHeartbeatSentAt = nil
cancelScheduledReconnect()
updateConnectionState(.connected)
}
@ -345,28 +339,6 @@ final class SocketService {
socket.disconnect()
socket.connect(withPayload: currentAuthPayload)
}
private func scheduleReconnect(after delay: TimeInterval = 5) {
cancelScheduledReconnect()
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
if self.refreshAuthTokenIfNeeded(disconnectIfMissing: true) { return }
if let socket = self.socket {
self.updateConnectionState(.connecting)
socket.connect(withPayload: self.currentAuthPayload)
} else {
self.connectForCurrentUser()
}
}
reconnectWorkItem = workItem
syncQueue.asyncAfter(deadline: .now() + delay, execute: workItem)
}
private func cancelScheduledReconnect() {
reconnectWorkItem?.cancel()
reconnectWorkItem = nil
}
#else
private func disconnectInternal() { }
#endif

View File

@ -11,7 +11,7 @@ import UIKit
#endif
struct ChatsTab: View {
@ObservedObject private var loginViewModel: LoginViewModel
var currentUserId: String?
@Binding var searchRevealProgress: CGFloat
@Binding var searchText: String
private let searchService = SearchService()
@ -33,13 +33,8 @@ struct ChatsTab: View {
private let searchRevealDistance: CGFloat = 90
private var currentUserId: String? {
let userId = loginViewModel.userId
return userId.isEmpty ? nil : userId
}
init(loginViewModel: LoginViewModel, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
self._loginViewModel = ObservedObject(wrappedValue: loginViewModel)
init(currentUserId: String? = nil, searchRevealProgress: Binding<CGFloat>, searchText: Binding<String>) {
self.currentUserId = currentUserId
self._searchRevealProgress = searchRevealProgress
self._searchText = searchText
}
@ -116,11 +111,11 @@ struct ChatsTab: View {
Text(message)
.font(.subheadline)
.foregroundColor(.orange)
// Spacer(minLength: 0)
// Button(action: triggerChatsReload) {
// Text(NSLocalizedString("Обновить", comment: ""))
// .font(.subheadline)
// }
Spacer(minLength: 0)
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Обновить", comment: ""))
.font(.subheadline)
}
}
.padding(.vertical, 4)
}
@ -144,10 +139,9 @@ struct ChatsTab: View {
globalSearchContent
}
} else {
// if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
// errorState(message: message)
// } else
if viewModel.chats.isEmpty {
if let message = viewModel.errorMessage, viewModel.chats.isEmpty {
errorState(message: message)
} else if viewModel.chats.isEmpty {
emptyState
} else {
@ -302,11 +296,9 @@ struct ChatsTab: View {
private var globalSearchContent: some View {
if isGlobalSearchLoading {
globalSearchLoadingRow
} else
// if let error = globalSearchError {
// globalSearchErrorRow(message: error)
// } else
if globalSearchResults.isEmpty {
} else if let error = globalSearchError {
globalSearchErrorRow(message: error)
} else if globalSearchResults.isEmpty {
globalSearchEmptyRow
} else {
ForEach(globalSearchResults) { user in
@ -349,7 +341,7 @@ struct ChatsTab: View {
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.primary)
Button(action: triggerChatsReload) {
Button(action: { viewModel.loadInitialChats(force: true) }) {
Text(NSLocalizedString("Повторить", comment: ""))
.font(.headline)
}
@ -367,7 +359,7 @@ struct ChatsTab: View {
Text(NSLocalizedString("Пока что у вас нет чатов", comment: ""))
.font(.body)
.foregroundColor(.secondary)
Button(action: triggerChatsReload) {
Button(action: { viewModel.refresh() }) {
Text(NSLocalizedString("Обновить", comment: ""))
}
.buttonStyle(.bordered)
@ -389,13 +381,6 @@ struct ChatsTab: View {
.listRowSeparator(.hidden)
}
private func triggerChatsReload() {
if loginViewModel.chatLoadingState != .loading {
loginViewModel.chatLoadingState = .loading
}
viewModel.loadInitialChats(force: true)
}
@ViewBuilder
private func chatRowItem(for chat: PrivateChatListItem) -> some View {
Button {
@ -1150,14 +1135,9 @@ struct ChatsTab_Previews: PreviewProvider {
struct Wrapper: View {
@State private var progress: CGFloat = 1
@State private var searchText: String = ""
@StateObject private var loginViewModel = LoginViewModel()
var body: some View {
ChatsTab(
loginViewModel: loginViewModel,
searchRevealProgress: $progress,
searchText: $searchText
)
ChatsTab(searchRevealProgress: $progress, searchText: $searchText)
.environmentObject(ThemeManager())
}
}

View File

@ -53,7 +53,7 @@ struct MainView: View {
.opacity(selectedTab == 1 ? 1 : 0)
ChatsTab(
loginViewModel: viewModel,
currentUserId: viewModel.userId.isEmpty ? nil : viewModel.userId,
searchRevealProgress: $chatSearchRevealProgress,
searchText: $chatSearchText
)

View File

@ -11,10 +11,6 @@ struct AppConfig {
static let USER_AGENT = "yobble ios"
static let APP_BUILD = "appstore" // appstore / freestore
static let APP_VERSION = "0.1"
static let DISABLE_DB = false
/// Fallback SQLCipher key used until the user sets an application password.
static let DEFAULT_DATABASE_ENCRYPTION_KEY = "yobble_dev_change_me"
}
struct AppInfo {

View File

@ -6,13 +6,11 @@
//
import SwiftUI
import CoreData
@main
struct yobbleApp: App {
@StateObject private var themeManager = ThemeManager()
@StateObject private var viewModel = LoginViewModel()
private let persistenceController = PersistenceController.shared
var body: some Scene {
WindowGroup {
@ -27,7 +25,6 @@ struct yobbleApp: App {
}
.environmentObject(themeManager)
.preferredColorScheme(themeManager.theme.colorScheme)
.environment(\.managedObjectContext, persistenceController.viewContext)
}
}
}