From ffb41347af75581fca14edfd644cb5d720fd448f Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Mon, 18 Aug 2025 22:58:42 +0200 Subject: [PATCH] refactor: Implement state machine architecture for offline sync - Replace multiple boolean properties with single OfflineBookmarkSyncState enum - Add Use Case pattern for OfflineSyncManager with dependency injection - Simplify LocalBookmarksSyncView using state-driven UI with external bindings - Convert OfflineBookmarksViewModel to use @Observable instead of ObservableObject - Move credentials from Core Data to Keychain for better persistence - Implement comprehensive database migration for App Group containers - Add structured logging throughout sync operations and API calls Architecture improvements follow MVVM principles with clean separation of concerns. --- Localizable.xcstrings | 64 ++++- URLShare/ShareBookmarkViewModel.swift | 64 ++++- URLShare/SimpleAPI.swift | 21 ++ readeck/Data/API/API.swift | 96 ++++++-- readeck/Data/CoreData/CoreDataManager.swift | 146 ++++++++++- readeck/Data/KeychainHelper.swift | 27 ++ .../Data/Repository/SettingsRepository.swift | 233 ++++++------------ readeck/Data/TokenProvider.swift | 54 +--- .../Model/OfflineBookmarkSyncState.swift | 62 +++++ .../UseCase/OfflineBookmarkSyncUseCase.swift | 30 +++ .../UseCase/SaveServerSettingsUseCase.swift | 3 - .../Domain/UseCase/SaveSettingsUseCase.swift | 32 --- readeck/Logger.swift | 12 + .../Components/LocalBookmarksSyncView.swift | 105 -------- .../UI/Factory/DefaultUseCaseFactory.swift | 9 +- readeck/UI/Factory/MockUseCaseFactory.swift | 23 ++ readeck/UI/Menu/LocalBookmarksSyncView.swift | 130 ++++++++++ .../UI/Menu/OfflineBookmarksViewModel.swift | 131 ++++++++++ readeck/UI/Menu/PadSidebarView.swift | 15 ++ readeck/UI/Menu/PhoneTabView.swift | 35 +-- .../Settings/LoggingConfigurationView.swift | 131 ++++++++++ .../UI/Settings/SettingsContainerView.swift | 51 ++++ readeck/UI/Settings/SettingsServerView.swift | 11 - .../readeck.xcdatamodel/contents | 5 +- 24 files changed, 1071 insertions(+), 419 deletions(-) create mode 100644 readeck/Domain/Model/OfflineBookmarkSyncState.swift create mode 100644 readeck/Domain/UseCase/OfflineBookmarkSyncUseCase.swift delete mode 100644 readeck/UI/Components/LocalBookmarksSyncView.swift create mode 100644 readeck/UI/Menu/LocalBookmarksSyncView.swift create mode 100644 readeck/UI/Menu/OfflineBookmarksViewModel.swift create mode 100644 readeck/UI/Settings/LoggingConfigurationView.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index b05065f..68238a3 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -114,15 +114,36 @@ }, "Cancel" : { + }, + "Category-specific Levels" : { + + }, + "Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages)." : { + }, "Clear cache" : { }, "Close" : { + }, + "Configure log levels and categories" : { + + }, + "Critical" : { + }, "Data Management" : { + }, + "Debug" : { + + }, + "DEBUG BUILD" : { + + }, + "Debug Settings" : { + }, "Delete" : { @@ -171,30 +192,54 @@ }, "General" : { + }, + "Global Level" : { + + }, + "Global Minimum Level" : { + + }, + "Global Settings" : { + }, "https://example.com" : { }, "https://readeck.example.com" : { + }, + "Include Source Location" : { + + }, + "Info" : { + }, "Jump to last read position (%lld%%)" : { }, "Key" : { "extractionState" : "manual" + }, + "Level for %@" : { + }, "Loading %@" : { }, "Loading article..." : { + }, + "Logging Configuration" : { + }, "Login & Save" : { }, "Logout" : { + }, + "Logs below this level will be filtered out globally" : { + }, "Manage Labels" : { @@ -222,6 +267,9 @@ }, "No results" : { + }, + "Notice" : { + }, "OK" : { @@ -277,9 +325,15 @@ }, "Remove" : { + }, + "Reset" : { + }, "Reset settings" : { + }, + "Reset to Defaults" : { + }, "Restore" : { @@ -329,10 +383,13 @@ "Settings" : { }, - "Speed" : { + "Show Performance Logs" : { }, - "Successfully logged in" : { + "Show Timestamps" : { + + }, + "Speed" : { }, "Sync interval" : { @@ -361,6 +418,9 @@ }, "Version %@" : { + }, + "Warning" : { + }, "Your current server connection and login credentials." : { diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index 4e0e113..d6c0221 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -14,6 +14,8 @@ class ShareBookmarkViewModel: ObservableObject { @Published var isServerReachable: Bool = true let extensionContext: NSExtensionContext? + private let logger = Logger.viewModel + // Computed properties for pagination var availableLabels: [BookmarkLabelDto] { return labels.filter { !selectedLabels.contains($0.name) } @@ -43,20 +45,29 @@ class ShareBookmarkViewModel: ObservableObject { init(extensionContext: NSExtensionContext?) { self.extensionContext = extensionContext + logger.info("ShareBookmarkViewModel initialized with extension context: \(extensionContext != nil)") extractSharedContent() } func onAppear() { + logger.debug("ShareBookmarkViewModel appeared") checkServerReachability() loadLabels() } private func checkServerReachability() { + let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger) isServerReachable = ServerConnectivity.isServerReachableSync() + logger.info("Server reachability checked: \(isServerReachable)") + measurement.end() } private func extractSharedContent() { - guard let extensionContext = extensionContext else { return } + logger.debug("Starting to extract shared content") + guard let extensionContext = extensionContext else { + logger.warning("No extension context available for content extraction") + return + } for item in extensionContext.inputItems { guard let inputItem = item as? NSExtensionItem else { continue } for attachment in inputItem.attachments ?? [] { @@ -65,6 +76,9 @@ class ShareBookmarkViewModel: ObservableObject { DispatchQueue.main.async { if let url = url as? URL { self?.url = url.absoluteString + self?.logger.info("Extracted URL from shared content: \(url.absoluteString)") + } else if let error = error { + self?.logger.error("Failed to extract URL: \(error.localizedDescription)") } } } @@ -74,6 +88,9 @@ class ShareBookmarkViewModel: ObservableObject { DispatchQueue.main.async { if let text = text as? String, let url = URL(string: text) { self?.url = url.absoluteString + self?.logger.info("Extracted URL from shared text: \(url.absoluteString)") + } else if let error = error { + self?.logger.error("Failed to extract text: \(error.localizedDescription)") } } } @@ -83,10 +100,12 @@ class ShareBookmarkViewModel: ObservableObject { } func loadLabels() { + let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger) + logger.debug("Starting to load labels") Task { // Check if server is reachable let serverReachable = ServerConnectivity.isServerReachableSync() - print("DEBUG: Server reachable: \(serverReachable)") + logger.debug("Server reachable for labels: \(serverReachable)") if serverReachable { // Load from API @@ -96,7 +115,8 @@ class ShareBookmarkViewModel: ObservableObject { let sorted = loaded.sorted { $0.count > $1.count } await MainActor.run { self.labels = Array(sorted) - print("DEBUG: Loaded \(loaded.count) labels from API") + self.logger.info("Loaded \(loaded.count) labels from API") + measurement.end() } } else { // Load from local database @@ -107,51 +127,83 @@ class ShareBookmarkViewModel: ObservableObject { .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } await MainActor.run { self.labels = localLabels + self.logger.info("Loaded \(localLabels.count) labels from local database") + measurement.end() } } } } func save() { + logger.info("Starting to save bookmark with title: '\(title)', URL: '\(url ?? "nil")', labels: \(selectedLabels.count)") guard let url = url, !url.isEmpty else { + logger.warning("Save attempted without valid URL") statusMessage = ("No URL found.", true, "❌") return } isSaving = true + logger.debug("Set saving state to true") // Check server connectivity - if ServerConnectivity.isServerReachableSync() { + let serverReachable = ServerConnectivity.isServerReachableSync() + logger.debug("Server connectivity for save: \(serverReachable)") + if serverReachable { // Online - try to save via API + logger.info("Attempting to save bookmark via API") Task { await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in + self?.logger.info("API save completed - Success: \(!error), Message: \(message)") self?.statusMessage = (message, error, error ? "❌" : "✅") self?.isSaving = false if !error { + self?.logger.debug("Bookmark saved successfully, completing extension request") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + self?.completeExtensionRequest() } + } else { + self?.logger.error("Failed to save bookmark via API: \(message)") } } } } else { // Server not reachable - save locally + logger.info("Server not reachable, attempting local save") let success = OfflineBookmarkManager.shared.saveOfflineBookmark( url: url, title: title, tags: Array(selectedLabels) ) + logger.info("Local save result: \(success)") DispatchQueue.main.async { self.isSaving = false if success { + self.logger.info("Bookmark saved locally successfully") self.statusMessage = ("Server not reachable. Saved locally and will sync later.", false, "🏠") DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil) + self.completeExtensionRequest() } } else { + self.logger.error("Failed to save bookmark locally") self.statusMessage = ("Failed to save locally.", true, "❌") } } } } + + private func completeExtensionRequest() { + logger.debug("Completing extension request") + guard let context = extensionContext else { + logger.warning("Extension context not available for completion") + return + } + + context.completeRequest(returningItems: []) { [weak self] error in + if error { + self?.logger.error("Extension completion failed: \(error)") + } else { + self?.logger.info("Extension request completed successfully") + } + } + } } diff --git a/URLShare/SimpleAPI.swift b/URLShare/SimpleAPI.swift index a11e6eb..5a0dd3a 100644 --- a/URLShare/SimpleAPI.swift +++ b/URLShare/SimpleAPI.swift @@ -1,8 +1,11 @@ import Foundation class SimpleAPI { + private static let logger = Logger.network + // MARK: - API Methods static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async { + logger.info("Adding bookmark: \(url)") guard let token = KeychainHelper.shared.loadToken() else { showStatus("No token found. Please log in via the main app.", true) return @@ -28,25 +31,35 @@ class SimpleAPI { do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid server response for bookmark creation") showStatus("Invalid server response.", true) return } + + logger.logNetworkRequest(method: "POST", url: "/api/bookmarks", statusCode: httpResponse.statusCode) + guard 200...299 ~= httpResponse.statusCode else { let msg = String(data: data, encoding: .utf8) ?? "Unknown error" + logger.error("Server error \(httpResponse.statusCode): \(msg)") showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true) return } + if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) { + logger.info("Bookmark created successfully: \(resp.message)") showStatus("Saved: \(resp.message)", false) } else { + logger.info("Bookmark created successfully") showStatus("Bookmark saved!", false) } } catch { + logger.logNetworkError(method: "POST", url: "/api/bookmarks", error: error) showStatus("Network error: \(error.localizedDescription)", true) } } static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? { + logger.info("Fetching bookmark labels") guard let token = KeychainHelper.shared.loadToken() else { showStatus("No token found. Please log in via the main app.", true) return nil @@ -66,17 +79,25 @@ class SimpleAPI { do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid server response for labels request") showStatus("Invalid server response.", true) return nil } + + logger.logNetworkRequest(method: "GET", url: "/api/bookmarks/labels", statusCode: httpResponse.statusCode) + guard 200...299 ~= httpResponse.statusCode else { let msg = String(data: data, encoding: .utf8) ?? "Unknown error" + logger.error("Server error \(httpResponse.statusCode): \(msg)") showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true) return nil } + let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data) + logger.info("Successfully fetched \(labels.count) bookmark labels") return labels } catch { + logger.logNetworkError(method: "GET", url: "/api/bookmarks/labels", error: error) showStatus("Network error: \(error.localizedDescription)", true) return nil } diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 06de15c..1ff8466 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -23,8 +23,9 @@ protocol PAPI { class API: PAPI { let tokenProvider: TokenProvider private var cachedBaseURL: String? + private let logger = Logger.network - init(tokenProvider: TokenProvider = CoreDataTokenProvider()) { + init(tokenProvider: TokenProvider = KeychainTokenProvider()) { self.tokenProvider = tokenProvider } @@ -73,7 +74,6 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { - print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")") throw APIError.serverError(httpResponse.statusCode) } @@ -114,7 +114,6 @@ class API: PAPI { } guard 200...299 ~= httpResponse.statusCode else { - print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")") throw APIError.serverError(httpResponse.statusCode) } @@ -159,7 +158,11 @@ class API: PAPI { } func login(endpoint: String, username: String, password: String) async throws -> UserDto { - guard let url = URL(string: endpoint + "/api/auth") else { throw APIError.invalidURL } + logger.info("Attempting login for user: \(username) at endpoint: \(endpoint)") + guard let url = URL(string: endpoint + "/api/auth") else { + logger.error("Invalid URL for login endpoint: \(endpoint)") + throw APIError.invalidURL + } let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password) let requestData = try JSONEncoder().encode(loginRequest) @@ -168,20 +171,27 @@ class API: PAPI { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = requestData + logger.logNetworkRequest(method: "POST", url: url.absoluteString) + let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid HTTP response for login request") throw APIError.invalidResponse } guard 200...299 ~= httpResponse.statusCode else { + logger.logNetworkError(method: "POST", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) throw APIError.serverError(httpResponse.statusCode) } + logger.logNetworkRequest(method: "POST", url: url.absoluteString, statusCode: httpResponse.statusCode) + logger.info("Login successful for user: \(username)") return try JSONDecoder().decode(UserDto.self, from: data) } func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil, tag: String? = nil) async throws -> BookmarksPageDto { + logger.debug("Fetching bookmarks with state: \(state?.rawValue ?? "all"), limit: \(limit ?? 0), offset: \(offset ?? 0)") var endpoint = "/api/bookmarks" var queryItems: [URLQueryItem] = [] @@ -227,11 +237,16 @@ class API: PAPI { endpoint += "?\(queryString)" } + logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)")) + let (bookmarks, response) = try await makeJSONRequestWithHeaders( endpoint: endpoint, responseType: [BookmarkDto].self ) + logger.logNetworkRequest(method: "GET", url: await self.baseURL + (endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"), statusCode: response.statusCode) + logger.info("Fetched \(bookmarks.count) bookmarks") + // Header auslesen let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) } let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) } @@ -249,38 +264,60 @@ class API: PAPI { } func getBookmark(id: String) async throws -> BookmarkDetailDto { - return try await makeJSONRequest( - endpoint: "/api/bookmarks/\(id)", + logger.debug("Fetching bookmark: \(id)") + let endpoint = "/api/bookmarks/\(id)" + logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) + + let result = try await makeJSONRequest( + endpoint: endpoint, responseType: BookmarkDetailDto.self ) + + logger.info("Successfully fetched bookmark: \(id)") + return result } // Artikel als String laden statt als JSON func getBookmarkArticle(id: String) async throws -> String { - return try await makeStringRequest( - endpoint: "/api/bookmarks/\(id)/article" + logger.debug("Fetching article for bookmark: \(id)") + let endpoint = "/api/bookmarks/\(id)/article" + logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) + + let result = try await makeStringRequest( + endpoint: endpoint ) + + logger.info("Successfully fetched article for bookmark: \(id)") + return result } func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto { + logger.info("Creating bookmark for URL: \(createRequest.url)") let requestData = try JSONEncoder().encode(createRequest) + let endpoint = "/api/bookmarks" + logger.logNetworkRequest(method: "POST", url: await self.baseURL + endpoint) - return try await makeJSONRequest( - endpoint: "/api/bookmarks", + let result = try await makeJSONRequest( + endpoint: endpoint, method: .POST, body: requestData, responseType: CreateBookmarkResponseDto.self ) + + logger.info("Successfully created bookmark: \(result.status)") + return result } func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws { + logger.info("Updating bookmark: \(id)") let requestData = try JSONEncoder().encode(updateRequest) - // PATCH Request ohne Response-Body erwarten + // Use makeJSONRequest but ignore the response since PATCH returns no body let baseURL = await self.baseURL let fullEndpoint = "/api/bookmarks/\(id)" guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + logger.error("Invalid URL: \(baseURL)\(fullEndpoint)") throw APIError.invalidURL } @@ -295,24 +332,32 @@ class API: PAPI { request.httpBody = requestData + logger.logNetworkRequest(method: "PATCH", url: url.absoluteString) + let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid HTTP response for PATCH \(url.absoluteString)") throw APIError.invalidResponse } guard 200...299 ~= httpResponse.statusCode else { - print("Server Error: \(httpResponse.statusCode)") + logger.logNetworkError(method: "PATCH", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) throw APIError.serverError(httpResponse.statusCode) } + + logger.logNetworkRequest(method: "PATCH", url: url.absoluteString, statusCode: httpResponse.statusCode) + logger.info("Successfully updated bookmark: \(id)") } func deleteBookmark(id: String) async throws { - // DELETE Request ohne Response-Body erwarten + logger.info("Deleting bookmark: \(id)") + let baseURL = await self.baseURL let fullEndpoint = "/api/bookmarks/\(id)" guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else { + logger.error("Invalid URL: \(baseURL)\(fullEndpoint)") throw APIError.invalidURL } @@ -324,25 +369,37 @@ class API: PAPI { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + logger.logNetworkRequest(method: "DELETE", url: url.absoluteString) + let (_, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { + logger.error("Invalid HTTP response for DELETE \(url.absoluteString)") throw APIError.invalidResponse } guard 200...299 ~= httpResponse.statusCode else { - print("Server Error: \(httpResponse.statusCode)") + logger.logNetworkError(method: "DELETE", url: url.absoluteString, error: APIError.serverError(httpResponse.statusCode)) throw APIError.serverError(httpResponse.statusCode) } + + logger.logNetworkRequest(method: "DELETE", url: url.absoluteString, statusCode: httpResponse.statusCode) + logger.info("Successfully deleted bookmark: \(id)") } func searchBookmarks(search: String) async throws -> BookmarksPageDto { + logger.debug("Searching bookmarks with query: \(search)") let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) + let (bookmarks, response) = try await makeJSONRequestWithHeaders( endpoint: endpoint, responseType: [BookmarkDto].self ) + logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint, statusCode: response.statusCode) + logger.info("Found \(bookmarks.count) bookmarks matching search: \(search)") + let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) } let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) } let totalPages = response.value(forHTTPHeaderField: "Total-Pages").flatMap { Int($0) } @@ -359,10 +416,17 @@ class API: PAPI { } func getBookmarkLabels() async throws -> [BookmarkLabelDto] { - return try await makeJSONRequest( - endpoint: "/api/bookmarks/labels", + logger.debug("Fetching bookmark labels") + let endpoint = "/api/bookmarks/labels" + logger.logNetworkRequest(method: "GET", url: await self.baseURL + endpoint) + + let result = try await makeJSONRequest( + endpoint: endpoint, responseType: [BookmarkLabelDto].self ) + + logger.info("Successfully fetched \(result.count) bookmark labels") + return result } } diff --git a/readeck/Data/CoreData/CoreDataManager.swift b/readeck/Data/CoreData/CoreDataManager.swift index e9b991c..0eeb145 100644 --- a/readeck/Data/CoreData/CoreDataManager.swift +++ b/readeck/Data/CoreData/CoreDataManager.swift @@ -4,22 +4,43 @@ import Foundation class CoreDataManager { static let shared = CoreDataManager() + private var isInMemoryStore = false + private let logger = Logger.data + private init() {} lazy var persistentContainer: NSPersistentContainer = { - let container = NSPersistentContainer(name: "readeck") + // Try to find the model in the main bundle first, then in extension bundle + guard let modelURL = Bundle.main.url(forResource: "readeck", withExtension: "momd") ?? + Bundle(for: CoreDataManager.self).url(forResource: "readeck", withExtension: "momd") else { + logger.error("Could not find Core Data model file") + fatalError("Core Data model 'readeck.xcdatamodeld' not found in bundle") + } + + guard let managedObjectModel = NSManagedObjectModel(contentsOf: modelURL) else { + logger.error("Could not load Core Data model from URL: \(modelURL)") + fatalError("Failed to load Core Data model") + } + + let container = NSPersistentContainer(name: "readeck", managedObjectModel: managedObjectModel) // Use App Group container for shared access with extensions let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.readeck.app")?.appendingPathComponent("readeck.sqlite") if let storeURL = storeURL { + // Migrate existing database from app container to app group if needed + migrateStoreToAppGroupIfNeeded(targetURL: storeURL) + let storeDescription = NSPersistentStoreDescription(url: storeURL) container.persistentStoreDescriptions = [storeDescription] } - container.loadPersistentStores { _, error in + container.loadPersistentStores { [weak self] _, error in if let error = error { - fatalError("Core Data error: \(error)") + self?.logger.error("Core Data failed to load persistent store: \(error)", file: #file, function: #function, line: #line) + self?.setupInMemoryStore(container: container) + } else { + self?.logger.info("Core Data persistent store loaded successfully") } } return container @@ -33,9 +54,126 @@ class CoreDataManager { if context.hasChanges { do { try context.save() + logger.debug("Core Data context saved successfully") } catch { - print("Failed to save Core Data context: \(error)") + logger.error("Failed to save Core Data context: \(error.localizedDescription)") } } } + + private func setupInMemoryStore(container: NSPersistentContainer) { + logger.warning("Setting up in-memory Core Data store as fallback") + isInMemoryStore = true + + let inMemoryDescription = NSPersistentStoreDescription() + inMemoryDescription.type = NSInMemoryStoreType + container.persistentStoreDescriptions = [inMemoryDescription] + + container.loadPersistentStores { [weak self] _, error in + if let error = error { + self?.logger.error("Failed to setup in-memory store: \(error.localizedDescription)") + // Continue with empty container - app will work with reduced functionality + } else { + self?.logger.info("In-memory Core Data store setup successfully") + } + } + } + + private func migrateStoreToAppGroupIfNeeded(targetURL: URL) { + let fileManager = FileManager.default + + // Check if store already exists in app group + if fileManager.fileExists(atPath: targetURL.path) { + logger.info("Database already exists in app group container") + return + } + + // Try multiple possible old locations for database + var searchPaths: [URL] = [] + + // 1. App's documents directory (most common old location) + if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + searchPaths.append(documentsURL) + } + + // 2. App's library directory + if let libraryURL = fileManager.urls(for: .libraryDirectory, in: .userDomainMask).first { + searchPaths.append(libraryURL) + } + + // 3. App's support directory + if let supportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first { + searchPaths.append(supportURL) + } + + var foundOldStore = false + + for searchPath in searchPaths { + let oldStoreURL = searchPath.appendingPathComponent("readeck.sqlite") + + if fileManager.fileExists(atPath: oldStoreURL.path) { + logger.info("Found existing database at: \(oldStoreURL.path)") + foundOldStore = true + + if migrateFromPath(oldStoreURL: oldStoreURL, targetURL: targetURL) { + break // Successfully migrated, stop searching + } + } + } + + if !foundOldStore { + logger.info("No existing database found in any search location - starting fresh") + } + } + + private func migrateFromPath(oldStoreURL: URL, targetURL: URL) -> Bool { + let fileManager = FileManager.default + let oldStoreWAL = oldStoreURL.appendingPathExtension("wal") + let oldStoreSHM = oldStoreURL.appendingPathExtension("shm") + + logger.info("Migrating existing database from: \(oldStoreURL.path)") + logger.info("Migrating existing database to: \(targetURL.path)") + + do { + // Create app group directory if it doesn't exist + let appGroupDirectory = targetURL.deletingLastPathComponent() + try fileManager.createDirectory(at: appGroupDirectory, withIntermediateDirectories: true) + + // Copy main database file + try fileManager.copyItem(at: oldStoreURL, to: targetURL) + logger.info("Main database file migrated successfully") + + // Copy WAL file if it exists + if fileManager.fileExists(atPath: oldStoreWAL.path) { + let targetWAL = targetURL.appendingPathExtension("wal") + try fileManager.copyItem(at: oldStoreWAL, to: targetWAL) + logger.info("WAL file migrated successfully") + } + + // Copy SHM file if it exists + if fileManager.fileExists(atPath: oldStoreSHM.path) { + let targetSHM = targetURL.appendingPathExtension("shm") + try fileManager.copyItem(at: oldStoreSHM, to: targetSHM) + logger.info("SHM file migrated successfully") + } + + // Remove old files after successful migration + try fileManager.removeItem(at: oldStoreURL) + if fileManager.fileExists(atPath: oldStoreWAL.path) { + try fileManager.removeItem(at: oldStoreWAL) + } + if fileManager.fileExists(atPath: oldStoreSHM.path) { + try fileManager.removeItem(at: oldStoreSHM) + } + + logger.info("Database migration completed successfully") + return true + + } catch { + logger.error("Failed to migrate database from \(oldStoreURL.path): \(error.localizedDescription)") + return false + } + } + + } diff --git a/readeck/Data/KeychainHelper.swift b/readeck/Data/KeychainHelper.swift index 4e9fce2..0435149 100644 --- a/readeck/Data/KeychainHelper.swift +++ b/readeck/Data/KeychainHelper.swift @@ -25,6 +25,33 @@ class KeychainHelper { loadString(forKey: "readeck_endpoint") } + @discardableResult + func saveUsername(_ username: String) -> Bool { + saveString(username, forKey: "readeck_username") + } + + func loadUsername() -> String? { + loadString(forKey: "readeck_username") + } + + @discardableResult + func savePassword(_ password: String) -> Bool { + saveString(password, forKey: "readeck_password") + } + + func loadPassword() -> String? { + loadString(forKey: "readeck_password") + } + + @discardableResult + func clearCredentials() -> Bool { + let tokenCleared = saveString("", forKey: "readeck_token") + let endpointCleared = saveString("", forKey: "readeck_endpoint") + let usernameCleared = saveString("", forKey: "readeck_username") + let passwordCleared = saveString("", forKey: "readeck_password") + return tokenCleared && endpointCleared && usernameCleared && passwordCleared + } + // MARK: - Private generic helpers @discardableResult private func saveString(_ value: String, forKey key: String) -> Bool { diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index a026c86..a865d47 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -37,51 +37,49 @@ protocol PSettingsRepository { class SettingsRepository: PSettingsRepository { private let coreDataManager = CoreDataManager.shared private let userDefault = UserDefaults.standard + private let keychainHelper = KeychainHelper.shared func saveSettings(_ settings: Settings) async throws { + // Save credentials to keychain + if let endpoint = settings.endpoint, !endpoint.isEmpty { + keychainHelper.saveEndpoint(endpoint) + } + if let username = settings.username, !username.isEmpty { + keychainHelper.saveUsername(username) + } + if let password = settings.password, !password.isEmpty { + keychainHelper.savePassword(password) + } + if let token = settings.token, !token.isEmpty { + keychainHelper.saveToken(token) + } + + // Save UI preferences to Core Data let context = coreDataManager.context return try await withCheckedThrowingContinuation { continuation in context.perform { do { - // Vorhandene Einstellungen löschen let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() - if let existingSettings = try context.fetch(fetchRequest).first { - - if let endpoint = settings.endpoint, !endpoint.isEmpty { - existingSettings.endpoint = endpoint - } - - if let username = settings.username, !username.isEmpty { - existingSettings.username = username - } - - if let password = settings.password, !password.isEmpty { - existingSettings.password = password - } - - if let token = settings.token, !token.isEmpty { - existingSettings.token = token - } - - if let fontFamily = settings.fontFamily { - existingSettings.fontFamily = fontFamily.rawValue - } - - if let fontSize = settings.fontSize { - existingSettings.fontSize = fontSize.rawValue - } - if let enableTTS = settings.enableTTS { - existingSettings.enableTTS = enableTTS - } - - if let theme = settings.theme { - existingSettings.theme = theme.rawValue - } - - try context.save() + let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context) + + if let fontFamily = settings.fontFamily { + existingSettings.fontFamily = fontFamily.rawValue } + if let fontSize = settings.fontSize { + existingSettings.fontSize = fontSize.rawValue + } + + if let enableTTS = settings.enableTTS { + existingSettings.enableTTS = enableTTS + } + + if let theme = settings.theme { + existingSettings.theme = theme.rawValue + } + + try context.save() continuation.resume() } catch { continuation.resume(throwing: error) @@ -100,22 +98,26 @@ class SettingsRepository: PSettingsRepository { fetchRequest.fetchLimit = 1 let settingEntities = try context.fetch(fetchRequest) + let settingEntity = settingEntities.first - if let settingEntity = settingEntities.first { - let settings = Settings( - endpoint: settingEntity.endpoint ?? "", - username: settingEntity.username ?? "", - password: settingEntity.password ?? "", - token: settingEntity.token, - fontFamily: FontFamily(rawValue: settingEntity.fontFamily ?? FontFamily.system.rawValue), - fontSize: FontSize(rawValue: settingEntity.fontSize ?? FontSize.medium.rawValue), - enableTTS: settingEntity.enableTTS, - theme: Theme(rawValue: settingEntity.theme ?? Theme.system.rawValue) - ) - continuation.resume(returning: settings) - } else { - continuation.resume(returning: nil) - } + // Load credentials from keychain only + let endpoint = self.keychainHelper.loadEndpoint() + let username = self.keychainHelper.loadUsername() + let password = self.keychainHelper.loadPassword() + let token = self.keychainHelper.loadToken() + + // Load UI preferences from Core Data + let settings = Settings( + endpoint: endpoint, + username: username, + password: password, + token: token, + fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue), + fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue), + enableTTS: settingEntity?.enableTTS, + theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue) + ) + continuation.resume(returning: settings) } catch { continuation.resume(throwing: error) } @@ -124,6 +126,10 @@ class SettingsRepository: PSettingsRepository { } func clearSettings() async throws { + // Clear credentials from keychain + keychainHelper.clearCredentials() + + // Also clear from Core Data let context = coreDataManager.context return try await withCheckedThrowingContinuation { continuation in @@ -146,128 +152,39 @@ class SettingsRepository: PSettingsRepository { } func saveToken(_ token: String) async throws { - let context = coreDataManager.context + // Save to keychain only + keychainHelper.saveToken(token) - return try await withCheckedThrowingContinuation { continuation in - context.perform { - do { - let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() - fetchRequest.fetchLimit = 1 - - let settingEntities = try context.fetch(fetchRequest) - - if let settingEntity = settingEntities.first { - settingEntity.token = token - } else { - let settingEntity = SettingEntity(context: context) - settingEntity.token = token - } - - try context.save() - - // Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren - if !token.isEmpty { - self.hasFinishedSetup = true - // Notification senden, dass sich der Setup-Status geändert hat - DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) - } - } - - continuation.resume() - } catch { - continuation.resume(throwing: error) - } + // Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren + if !token.isEmpty { + self.hasFinishedSetup = true + // Notification senden, dass sich der Setup-Status geändert hat + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) } } } func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws { - let context = coreDataManager.context - return try await withCheckedThrowingContinuation { continuation in - context.perform { - do { - let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() - fetchRequest.fetchLimit = 1 - let settingEntities = try context.fetch(fetchRequest) - let settingEntity: SettingEntity - if let existing = settingEntities.first { - settingEntity = existing - } else { - settingEntity = SettingEntity(context: context) - } - settingEntity.endpoint = endpoint - settingEntity.username = username - settingEntity.password = password - settingEntity.token = token - try context.save() - // Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren - if !token.isEmpty { - self.hasFinishedSetup = true - DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) - } - } - continuation.resume() - } catch { - continuation.resume(throwing: error) - } + keychainHelper.saveEndpoint(endpoint) + keychainHelper.saveUsername(username) + keychainHelper.savePassword(password) + keychainHelper.saveToken(token) + + if !token.isEmpty { + self.hasFinishedSetup = true + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) } } } func saveUsername(_ username: String) async throws { - let context = coreDataManager.context - - return try await withCheckedThrowingContinuation { continuation in - context.perform { - do { - let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() - fetchRequest.fetchLimit = 1 - - let settingEntities = try context.fetch(fetchRequest) - - if let settingEntity = settingEntities.first { - settingEntity.username = username - } else { - let settingEntity = SettingEntity(context: context) - settingEntity.username = username - } - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } + keychainHelper.saveUsername(username) } func savePassword(_ password: String) async throws { - let context = coreDataManager.context - - return try await withCheckedThrowingContinuation { continuation in - context.perform { - do { - let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() - fetchRequest.fetchLimit = 1 - - let settingEntities = try context.fetch(fetchRequest) - - if let settingEntity = settingEntities.first { - settingEntity.password = password - } else { - let settingEntity = SettingEntity(context: context) - settingEntity.password = password - } - - try context.save() - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } + keychainHelper.savePassword(password) } func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws { diff --git a/readeck/Data/TokenProvider.swift b/readeck/Data/TokenProvider.swift index 1768054..9881db6 100644 --- a/readeck/Data/TokenProvider.swift +++ b/readeck/Data/TokenProvider.swift @@ -7,66 +7,22 @@ protocol TokenProvider { func clearToken() async } -class CoreDataTokenProvider: TokenProvider { - private let settingsRepository = SettingsRepository() - private var cachedSettings: Settings? - private var isLoaded = false +class KeychainTokenProvider: TokenProvider { private let keychainHelper = KeychainHelper.shared - private func loadSettingsIfNeeded() async { - guard isLoaded == false || cachedSettings == nil else { return } - - do { - cachedSettings = try await settingsRepository.loadSettings() - isLoaded = true - } catch { - print("Failed to load settings: \(error)") - cachedSettings = nil - } - } - func getToken() async -> String? { - await loadSettingsIfNeeded() - return cachedSettings?.token + return keychainHelper.loadToken() } func getEndpoint() async -> String? { - await loadSettingsIfNeeded() - // Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird - return cachedSettings?.endpoint + return keychainHelper.loadEndpoint() } func setToken(_ token: String) async { - await loadSettingsIfNeeded() - - do { - try await settingsRepository.saveToken(token) - saveTokenToKeychain(token: token) - if cachedSettings != nil { - cachedSettings!.token = token - } - } catch { - print("Failed to save token: \(error)") - } - } - - func clearToken() async { - do { - try await settingsRepository.clearSettings() - cachedSettings = nil - saveTokenToKeychain(token: "") - } catch { - print("Failed to clear settings: \(error)") - } - } - - // MARK: - Keychain Support - - func saveTokenToKeychain(token: String) { keychainHelper.saveToken(token) } - func loadTokenFromKeychain() -> String? { - keychainHelper.loadToken() + func clearToken() async { + keychainHelper.clearCredentials() } } diff --git a/readeck/Domain/Model/OfflineBookmarkSyncState.swift b/readeck/Domain/Model/OfflineBookmarkSyncState.swift new file mode 100644 index 0000000..7f8187f --- /dev/null +++ b/readeck/Domain/Model/OfflineBookmarkSyncState.swift @@ -0,0 +1,62 @@ +import Foundation + +enum OfflineBookmarkSyncState: Equatable { + case idle + case pending(count: Int) + case syncing(count: Int, status: String?) + case success(syncedCount: Int) + case error(String) + + var localBookmarkCount: Int { + switch self { + case .idle: + return 0 + case .pending(let count): + return count + case .syncing(let count, _): + return count + case .success: + return 0 + case .error: + return 0 + } + } + + var isSyncing: Bool { + switch self { + case .syncing: + return true + default: + return false + } + } + + var syncStatus: String? { + switch self { + case .syncing(_, let status): + return status + case .error(let message): + return message + default: + return nil + } + } + + var showSuccessMessage: Bool { + switch self { + case .success: + return true + default: + return false + } + } + + var syncedBookmarkCount: Int { + switch self { + case .success(let count): + return count + default: + return 0 + } + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/OfflineBookmarkSyncUseCase.swift b/readeck/Domain/UseCase/OfflineBookmarkSyncUseCase.swift new file mode 100644 index 0000000..042f831 --- /dev/null +++ b/readeck/Domain/UseCase/OfflineBookmarkSyncUseCase.swift @@ -0,0 +1,30 @@ +import Foundation +import Combine + +protocol POfflineBookmarkSyncUseCase { + var isSyncing: AnyPublisher { get } + var syncStatus: AnyPublisher { get } + + func getOfflineBookmarksCount() -> Int + func syncOfflineBookmarks() async +} + +class OfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase { + private let syncManager = OfflineSyncManager.shared + + var isSyncing: AnyPublisher { + syncManager.$isSyncing.eraseToAnyPublisher() + } + + var syncStatus: AnyPublisher { + syncManager.$syncStatus.eraseToAnyPublisher() + } + + func getOfflineBookmarksCount() -> Int { + return syncManager.getOfflineBookmarksCount() + } + + func syncOfflineBookmarks() async { + await syncManager.syncOfflineBookmarks() + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift b/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift index 9c7c837..cc37d3a 100644 --- a/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift +++ b/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift @@ -13,8 +13,5 @@ class SaveServerSettingsUseCase: PSaveServerSettingsUseCase { func execute(endpoint: String, username: String, password: String, token: String) async throws { try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token) - KeychainHelper.shared.saveToken(token) - KeychainHelper.shared.saveEndpoint(endpoint) - print("token saved", KeychainHelper.shared.loadToken()) } } diff --git a/readeck/Domain/UseCase/SaveSettingsUseCase.swift b/readeck/Domain/UseCase/SaveSettingsUseCase.swift index 75d5510..4217318 100644 --- a/readeck/Domain/UseCase/SaveSettingsUseCase.swift +++ b/readeck/Domain/UseCase/SaveSettingsUseCase.swift @@ -1,9 +1,6 @@ import Foundation protocol PSaveSettingsUseCase { - func execute(endpoint: String, username: String, password: String) async throws - func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws - func execute(token: String) async throws func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws func execute(enableTTS: Bool) async throws func execute(theme: Theme) async throws @@ -16,35 +13,6 @@ class SaveSettingsUseCase: PSaveSettingsUseCase { self.settingsRepository = settingsRepository } - func execute(endpoint: String, username: String, password: String) async throws { - try await settingsRepository.saveSettings( - .init( - endpoint: endpoint, - username: username, - password: password - ) - ) - } - - func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws { - try await settingsRepository.saveSettings( - .init( - endpoint: endpoint, - username: username, - password: password, - hasFinishedSetup: hasFinishedSetup - ) - ) - } - - func execute(token: String) async throws { - try await settingsRepository.saveSettings( - .init( - token: token - ) - ) - } - func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws { try await settingsRepository.saveSettings( .init( diff --git a/readeck/Logger.swift b/readeck/Logger.swift index bf0e08d..cc8f6c1 100644 --- a/readeck/Logger.swift +++ b/readeck/Logger.swift @@ -248,3 +248,15 @@ extension Dictionary { } } +// MARK: - Debug Build Detection + +extension Bundle { + var isDebugBuild: Bool { + #if DEBUG + return true + #else + return false + #endif + } +} + diff --git a/readeck/UI/Components/LocalBookmarksSyncView.swift b/readeck/UI/Components/LocalBookmarksSyncView.swift deleted file mode 100644 index 7d23e92..0000000 --- a/readeck/UI/Components/LocalBookmarksSyncView.swift +++ /dev/null @@ -1,105 +0,0 @@ -import SwiftUI - -struct LocalBookmarksSyncView: View { - @StateObject private var syncManager = OfflineSyncManager.shared - @StateObject private var serverConnectivity = ServerConnectivity.shared - @State private var showSuccessMessage = false - @State private var syncedBookmarkCount = 0 - - let localBookmarkCount: Int - - init(bookmarkCount: Int) { - self.localBookmarkCount = bookmarkCount - } - - var body: some View { - Group { - if showSuccessMessage { - VStack(spacing: 4) { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .imageScale(.small) - - Text("\(syncedBookmarkCount) bookmark\(syncedBookmarkCount == 1 ? "" : "s") synced successfully") - .font(.caption2) - .foregroundColor(.green) - - Spacer() - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - .padding(.horizontal) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - withAnimation { - showSuccessMessage = false - } - } - } - } else if localBookmarkCount > 0 || syncManager.isSyncing { - VStack(spacing: 4) { - HStack { - Image(systemName: syncManager.isSyncing ? "arrow.triangle.2.circlepath" : "externaldrive.badge.wifi") - .foregroundColor(syncManager.isSyncing ? .blue : .blue) - .imageScale(.medium) - - if syncManager.isSyncing { - Text("Syncing with server...") - .font(.subheadline) - .foregroundColor(.blue) - } else { - Text("\(localBookmarkCount) bookmark\(localBookmarkCount == 1 ? "" : "s") waiting for sync") - .font(.subheadline) - .foregroundColor(.primary) - } - - Spacer() - - if !syncManager.isSyncing && localBookmarkCount > 0 { - Button { - syncedBookmarkCount = localBookmarkCount // Store count before sync - Task { - await syncManager.syncOfflineBookmarks() - } - } label: { - Image(systemName: "icloud.and.arrow.up") - .foregroundColor(.blue) - } - } - } - - if let status = syncManager.syncStatus { - Text(status) - .font(.caption) - .foregroundColor(.secondary) - .multilineTextAlignment(.leading) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color(.secondarySystemBackground)) - .cornerRadius(8) - .padding(.horizontal) - .animation(.easeInOut, value: syncManager.isSyncing) - .animation(.easeInOut, value: syncManager.syncStatus) - } - } - .onChange(of: syncManager.isSyncing) { _ in - if !syncManager.isSyncing { - // Show success message if all bookmarks are synced - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - let currentCount = syncManager.getOfflineBookmarksCount() - if currentCount == 0 { - withAnimation { - showSuccessMessage = true - } - } - } - } - } - } -} diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index b3db7b2..f09011d 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -17,12 +17,13 @@ protocol UseCaseFactory { func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase func makeGetLabelsUseCase() -> PGetLabelsUseCase func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase + func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase } class DefaultUseCaseFactory: UseCaseFactory { - private let tokenProvider = CoreDataTokenProvider() + private let tokenProvider = KeychainTokenProvider() private lazy var api: PAPI = API(tokenProvider: tokenProvider) private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: settingsRepository) private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api) @@ -89,7 +90,7 @@ class DefaultUseCaseFactory: UseCaseFactory { } func makeGetLabelsUseCase() -> PGetLabelsUseCase { - let api = API(tokenProvider: CoreDataTokenProvider()) + let api = API(tokenProvider: KeychainTokenProvider()) let labelsRepository = LabelsRepository(api: api) return GetLabelsUseCase(labelsRepository: labelsRepository) } @@ -97,4 +98,8 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase { return AddTextToSpeechQueueUseCase() } + + func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase { + return OfflineBookmarkSyncUseCase() + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index a8b90ac..f2d6f4b 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -6,8 +6,13 @@ // import Foundation +import Combine class MockUseCaseFactory: UseCaseFactory { + func makeOfflineBookmarkSyncUseCase() -> any POfflineBookmarkSyncUseCase { + MockOfflineBookmarkSyncUseCase() + } + func makeLoginUseCase() -> any PLoginUseCase { MockLoginUserCase() } @@ -181,6 +186,24 @@ class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase { func execute(bookmarkDetail: BookmarkDetail) {} } +class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase { + var isSyncing: AnyPublisher { + Just(false).eraseToAnyPublisher() + } + + var syncStatus: AnyPublisher { + Just(nil).eraseToAnyPublisher() + } + + func getOfflineBookmarksCount() -> Int { + return 0 + } + + func syncOfflineBookmarks() async { + // Mock implementation - do nothing + } +} + extension Bookmark { static let mock: Bookmark = .init( id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil) diff --git a/readeck/UI/Menu/LocalBookmarksSyncView.swift b/readeck/UI/Menu/LocalBookmarksSyncView.swift new file mode 100644 index 0000000..6a74683 --- /dev/null +++ b/readeck/UI/Menu/LocalBookmarksSyncView.swift @@ -0,0 +1,130 @@ +import SwiftUI + +struct LocalBookmarksSyncView: View { + let state: OfflineBookmarkSyncState + let onSyncTapped: () async -> Void + + init(state: OfflineBookmarkSyncState, onSyncTapped: @escaping () async -> Void) { + self.state = state + self.onSyncTapped = onSyncTapped + } + + var body: some View { + Group { + switch state { + case .idle: + EmptyView() + + case .pending(let count): + pendingView(count: count) + + case .syncing(let count, let status): + syncingView(count: count, status: status) + + case .success(let syncedCount): + successView(syncedCount: syncedCount) + + case .error(let message): + errorView(message: message) + } + } + } + + @ViewBuilder + private func pendingView(count: Int) -> some View { + syncContainerView { + HStack { + Image(systemName: "externaldrive.badge.wifi") + .foregroundColor(.blue) + .imageScale(.medium) + + Text("\(count) bookmark\(count == 1 ? "" : "s") waiting for sync") + .font(.subheadline) + .foregroundColor(.primary) + + Spacer() + + Button { + Task { await onSyncTapped() } + } label: { + Image(systemName: "icloud.and.arrow.up") + .foregroundColor(.blue) + } + } + } + } + + @ViewBuilder + private func syncingView(count: Int, status: String?) -> some View { + syncContainerView { + VStack(spacing: 4) { + HStack { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundColor(.blue) + .imageScale(.medium) + + Text("Syncing with server...") + .font(.subheadline) + .foregroundColor(.blue) + + Spacer() + } + + if let status = status { + HStack { + Text(status) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + Spacer() + } + } + } + } + } + + @ViewBuilder + private func successView(syncedCount: Int) -> some View { + syncContainerView { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .imageScale(.small) + + Text("\(syncedCount) bookmark\(syncedCount == 1 ? "" : "s") synced successfully") + .font(.caption2) + .foregroundColor(.green) + + Spacer() + } + } + } + + @ViewBuilder + private func errorView(message: String) -> some View { + syncContainerView { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .imageScale(.small) + + Text(message) + .font(.caption2) + .foregroundColor(.orange) + + Spacer() + } + } + } + + @ViewBuilder + private func syncContainerView(@ViewBuilder content: () -> Content) -> some View { + content() + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(.secondarySystemBackground)) + .cornerRadius(8) + .padding(.horizontal) + .animation(.easeInOut, value: state) + } +} \ No newline at end of file diff --git a/readeck/UI/Menu/OfflineBookmarksViewModel.swift b/readeck/UI/Menu/OfflineBookmarksViewModel.swift new file mode 100644 index 0000000..011cf34 --- /dev/null +++ b/readeck/UI/Menu/OfflineBookmarksViewModel.swift @@ -0,0 +1,131 @@ +import Foundation +import SwiftUI +import Combine + +@Observable +class OfflineBookmarksViewModel { + var state: OfflineBookmarkSyncState = .idle + + private let syncUseCase: POfflineBookmarkSyncUseCase + private var cancellables = Set() + private var successTimer: Timer? + + init(syncUseCase: POfflineBookmarkSyncUseCase = OfflineBookmarkSyncUseCase()) { + self.syncUseCase = syncUseCase + setupBindings() + updateState() + } + + private func setupBindings() { + // Observe sync state changes + syncUseCase.isSyncing + .receive(on: DispatchQueue.main) + .sink { [weak self] isSyncing in + self?.handleSyncStateChange(isSyncing: isSyncing) + } + .store(in: &cancellables) + + // Observe sync status changes + syncUseCase.syncStatus + .receive(on: DispatchQueue.main) + .sink { [weak self] status in + self?.handleSyncStatusChange(status: status) + } + .store(in: &cancellables) + + // Update count on app lifecycle events + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateState() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateState() + } + .store(in: &cancellables) + } + + func updateState() { + let count = syncUseCase.getOfflineBookmarksCount() + + switch state { + case .idle: + if count > 0 { + state = .pending(count: count) + } + case .pending: + if count > 0 { + state = .pending(count: count) + } else { + state = .idle + } + case .syncing: + // Keep syncing state, will be updated by handleSyncStateChange + break + case .success: + // Success state is temporary, handled by timer + break + case .error: + // Update count even in error state + if count > 0 { + state = .pending(count: count) + } else { + state = .idle + } + } + } + + func syncOfflineBookmarks() async { + guard case .pending(let count) = state else { return } + + state = .syncing(count: count, status: nil) + await syncUseCase.syncOfflineBookmarks() + } + + private func handleSyncStateChange(isSyncing: Bool) { + if isSyncing { + // If we're not already in syncing state, transition to it + if case .pending(let count) = state { + state = .syncing(count: count, status: nil) + } + } else { + // Sync completed + Task { @MainActor in + // Small delay to ensure count is updated + try await Task.sleep(nanoseconds: 500_000_000) + + let currentCount = syncUseCase.getOfflineBookmarksCount() + + if case .syncing(let originalCount, _) = state { + if currentCount == 0 { + // Success - all bookmarks synced + state = .success(syncedCount: originalCount) + + // Auto-hide success message after 2 seconds + successTimer?.invalidate() + successTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + self?.state = .idle + } + } else { + // Some bookmarks remain + state = .pending(count: currentCount) + } + } + } + } + } + + private func handleSyncStatusChange(status: String?) { + if case .syncing(let count, _) = state { + state = .syncing(count: count, status: status) + } + } + + deinit { + successTimer?.invalidate() + } +} \ No newline at end of file diff --git a/readeck/UI/Menu/PadSidebarView.swift b/readeck/UI/Menu/PadSidebarView.swift index 3554a61..ba13e9a 100644 --- a/readeck/UI/Menu/PadSidebarView.swift +++ b/readeck/UI/Menu/PadSidebarView.swift @@ -13,6 +13,7 @@ struct PadSidebarView: View { @State private var selectedTag: BookmarkLabel? @EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var appSettings: AppSettings + @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase()) private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] @@ -37,6 +38,20 @@ struct PadSidebarView: View { .listRowBackground(Color(R.color.menu_sidebar_bg)) } } + + if case .idle = offlineBookmarksViewModel.state { + // Don't show anything for idle state + } else { + Section { + VStack { + LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) { + await offlineBookmarksViewModel.syncOfflineBookmarks() + } + } + .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets()) + } + } } .listRowBackground(Color(R.color.menu_sidebar_bg)) .background(Color(R.color.menu_sidebar_bg)) diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index 95fa233..99c23ff 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -13,8 +13,7 @@ struct PhoneTabView: View { @State private var selectedMoreTab: SidebarTab? = nil @State private var selectedTabIndex: Int = 1 - @StateObject private var syncManager = OfflineSyncManager.shared - @State private var phoneTabLocalBookmarkCount = 0 + @State private var offlineBookmarksViewModel = OfflineBookmarksViewModel(syncUseCase: DefaultUseCaseFactory.shared.makeOfflineBookmarkSyncUseCase()) @EnvironmentObject var appSettings: AppSettings @@ -25,31 +24,9 @@ struct PhoneTabView: View { moreTabContent } .accentColor(.accentColor) - .onAppear { - updateLocalBookmarkCount() - } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in - updateLocalBookmarkCount() - } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in - updateLocalBookmarkCount() - } - .onChange(of: syncManager.isSyncing) { - if !syncManager.isSyncing { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - updateLocalBookmarkCount() - } - } - } } } - private func updateLocalBookmarkCount() { - let count = syncManager.getOfflineBookmarksCount() - DispatchQueue.main.async { - self.phoneTabLocalBookmarkCount = count - } - } // MARK: - Tab Content @@ -78,7 +55,7 @@ struct PhoneTabView: View { .tabItem { Label("More", systemImage: "ellipsis") } - .badge(phoneTabLocalBookmarkCount > 0 ? phoneTabLocalBookmarkCount : 0) + .badge(offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0) .tag(mainTabs.count) .onAppear { if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { @@ -106,10 +83,14 @@ struct PhoneTabView: View { .listRowBackground(Color(R.color.bookmark_list_bg)) } - if phoneTabLocalBookmarkCount > 0 { + if case .idle = offlineBookmarksViewModel.state { + // Don't show anything for idle state + } else { Section { VStack { - LocalBookmarksSyncView(bookmarkCount: phoneTabLocalBookmarkCount) + LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) { + await offlineBookmarksViewModel.syncOfflineBookmarks() + } } .listRowBackground(Color.clear) .listRowInsets(EdgeInsets()) diff --git a/readeck/UI/Settings/LoggingConfigurationView.swift b/readeck/UI/Settings/LoggingConfigurationView.swift new file mode 100644 index 0000000..d9896e4 --- /dev/null +++ b/readeck/UI/Settings/LoggingConfigurationView.swift @@ -0,0 +1,131 @@ +// +// LoggingConfigurationView.swift +// readeck +// +// Created by Ilyas Hallak on 16.08.25. +// + + + +import SwiftUI +import os + +struct LoggingConfigurationView: View { + @StateObject private var logConfig = LogConfiguration.shared + private let logger = Logger.ui + + var body: some View { + NavigationView { + Form { + Section(header: Text("Global Settings")) { + VStack(alignment: .leading, spacing: 8) { + Text("Global Minimum Level") + .font(.headline) + + Picker("Global Level", selection: $logConfig.globalMinLevel) { + ForEach(LogLevel.allCases, id: \.self) { level in + HStack { + Text(level.emoji) + Text(level.rawValue == 0 ? "Debug" : + level.rawValue == 1 ? "Info" : + level.rawValue == 2 ? "Notice" : + level.rawValue == 3 ? "Warning" : + level.rawValue == 4 ? "Error" : "Critical") + } + .tag(level) + } + } + .pickerStyle(SegmentedPickerStyle()) + + Text("Logs below this level will be filtered out globally") + .font(.caption) + .foregroundColor(.secondary) + } + + Toggle("Show Performance Logs", isOn: $logConfig.showPerformanceLogs) + Toggle("Show Timestamps", isOn: $logConfig.showTimestamps) + Toggle("Include Source Location", isOn: $logConfig.includeSourceLocation) + } + + Section(header: Text("Category-specific Levels")) { + ForEach(LogCategory.allCases, id: \.self) { category in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(category.rawValue) + .font(.headline) + Spacer() + Text(levelName(for: logConfig.getLevel(for: category))) + .font(.caption) + .foregroundColor(.secondary) + } + + Picker("Level for \(category.rawValue)", selection: Binding( + get: { logConfig.getLevel(for: category) }, + set: { logConfig.setLevel($0, for: category) } + )) { + ForEach(LogLevel.allCases, id: \.self) { level in + HStack { + Text(level.emoji) + Text(levelName(for: level)) + } + .tag(level) + } + } + .pickerStyle(SegmentedPickerStyle()) + } + .padding(.vertical, 4) + } + } + + Section(header: Text("Reset")) { + Button("Reset to Defaults") { + resetToDefaults() + } + .foregroundColor(.orange) + } + + Section(footer: Text("Changes take effect immediately. Lower log levels include higher ones (Debug includes all, Critical includes only critical messages).")) { + EmptyView() + } + } + .navigationTitle("Logging Configuration") + .navigationBarTitleDisplayMode(.inline) + } + .onAppear { + logger.debug("Opened logging configuration view") + } + } + + private func levelName(for level: LogLevel) -> String { + switch level.rawValue { + case 0: return "Debug" + case 1: return "Info" + case 2: return "Notice" + case 3: return "Warning" + case 4: return "Error" + case 5: return "Critical" + default: return "Unknown" + } + } + + private func resetToDefaults() { + logger.info("Resetting logging configuration to defaults") + + // Reset all category levels (this will use globalMinLevel as fallback) + for category in LogCategory.allCases { + logConfig.setLevel(.debug, for: category) + } + + // Reset global settings + logConfig.globalMinLevel = .debug + logConfig.showPerformanceLogs = true + logConfig.showTimestamps = true + logConfig.includeSourceLocation = true + + logger.info("Logging configuration reset to defaults") + } +} + +#Preview { + LoggingConfigurationView() +} diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index 80b31af..469b84b 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -26,6 +26,11 @@ struct SettingsContainerView: View { SettingsServerView() .cardStyle() + + // Debug-only Logging Configuration + if Bundle.main.isDebugBuild { + debugSettingsSection + } } .padding() .background(Color(.systemGroupedBackground)) @@ -39,6 +44,52 @@ struct SettingsContainerView: View { .navigationBarTitleDisplayMode(.large) } + @ViewBuilder + private var debugSettingsSection: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "ant.fill") + .foregroundColor(.orange) + Text("Debug Settings") + .font(.headline) + .foregroundColor(.primary) + Spacer() + Text("DEBUG BUILD") + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange.opacity(0.2)) + .foregroundColor(.orange) + .clipShape(Capsule()) + } + + NavigationLink { + LoggingConfigurationView() + } label: { + HStack { + Image(systemName: "doc.text.magnifyingglass") + .foregroundColor(.blue) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text("Logging Configuration") + .foregroundColor(.primary) + Text("Configure log levels and categories") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + .cardStyle() + } + @ViewBuilder func AppInfo() -> some View { VStack(spacing: 4) { diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index 256d456..c48b8fb 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -92,17 +92,6 @@ struct SettingsServerView: View { } } - // Connection Status - if viewModel.isLoggedIn { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("Successfully logged in") - .foregroundColor(.green) - .font(.caption) - } - } - // Messages if let errorMessage = viewModel.errorMessage { HStack { diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index c7471dd..fcee5ec 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -52,13 +52,10 @@ - - -