ReadKeep/readeck/Domain/UseCase/OfflineCacheSyncUseCase.swift
Ilyas Hallak e4657aa281 Fix offline reading bugs and improve network monitoring (Phase 5)
Bugfixes:
- Add toggle for offline mode simulation (DEBUG only)
- Fix VPN false-positives with interface count check
- Add detailed error logging for download failures
- Fix last sync timestamp display
- Translate all strings to English

Network Monitoring:
- Add NetworkMonitorRepository with NWPathMonitor
- Check path.status AND availableInterfaces for reliability
- Add manual reportConnectionFailure/Success methods
- Auto-load cached bookmarks when offline
- Visual debug banner (green=online, red=offline)

Architecture:
- Clean architecture with Repository → UseCase → ViewModel
- Network status in AppSettings for global access
- Combine publishers for reactive updates
2025-11-21 21:37:24 +01:00

168 lines
5.9 KiB
Swift

//
// OfflineCacheSyncUseCase.swift
// readeck
//
// Created by Claude on 17.11.25.
//
import Foundation
import Combine
// MARK: - Protocol
protocol POfflineCacheSyncUseCase {
var isSyncing: AnyPublisher<Bool, Never> { get }
var syncProgress: AnyPublisher<String?, Never> { get }
func syncOfflineArticles(settings: OfflineSettings) async
func getCachedArticlesCount() -> Int
func getCacheSize() -> String
}
// MARK: - Implementation
final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
// MARK: - Dependencies
private let offlineCacheRepository: POfflineCacheRepository
private let bookmarksRepository: PBookmarksRepository
private let settingsRepository: PSettingsRepository
// MARK: - Published State
private let _isSyncingSubject = CurrentValueSubject<Bool, Never>(false)
private let _syncProgressSubject = CurrentValueSubject<String?, Never>(nil)
var isSyncing: AnyPublisher<Bool, Never> {
_isSyncingSubject.eraseToAnyPublisher()
}
var syncProgress: AnyPublisher<String?, Never> {
_syncProgressSubject.eraseToAnyPublisher()
}
// MARK: - Initialization
init(
offlineCacheRepository: POfflineCacheRepository,
bookmarksRepository: PBookmarksRepository,
settingsRepository: PSettingsRepository
) {
self.offlineCacheRepository = offlineCacheRepository
self.bookmarksRepository = bookmarksRepository
self.settingsRepository = settingsRepository
}
// MARK: - Public Methods
@MainActor
func syncOfflineArticles(settings: OfflineSettings) async {
guard settings.enabled else {
Logger.sync.info("Offline sync skipped: disabled in settings")
return
}
_isSyncingSubject.send(true)
Logger.sync.info("🔄 Starting offline sync (max: \(settings.maxUnreadArticlesInt) articles, images: \(settings.saveImages))")
do {
// Fetch unread bookmarks from API
let page = try await bookmarksRepository.fetchBookmarks(
state: .unread,
limit: settings.maxUnreadArticlesInt,
offset: 0,
search: nil,
type: nil,
tag: nil
)
let bookmarks = page.bookmarks
Logger.sync.info("📚 Fetched \(bookmarks.count) unread bookmarks")
var successCount = 0
var skippedCount = 0
var errorCount = 0
// Process each bookmark
for (index, bookmark) in bookmarks.enumerated() {
let progress = "\(index + 1)/\(bookmarks.count)"
// Check cache status
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
skippedCount += 1
_syncProgressSubject.send("⏭️ Article \(progress) already cached...")
continue
}
// Update progress
let imagesSuffix = settings.saveImages ? " + images" : ""
_syncProgressSubject.send("📥 Article \(progress)\(imagesSuffix)...")
Logger.sync.info("📥 Caching '\(bookmark.title)'")
do {
// Fetch article HTML from API
let html = try await bookmarksRepository.fetchBookmarkArticle(id: bookmark.id)
// Cache with metadata
try await offlineCacheRepository.cacheBookmarkWithMetadata(
bookmark: bookmark,
html: html,
saveImages: settings.saveImages
)
successCount += 1
Logger.sync.info("✅ Cached '\(bookmark.title)'")
} catch {
errorCount += 1
// Detailed error logging
if let urlError = error as? URLError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Network error: \(urlError.code.rawValue) (\(urlError.localizedDescription))")
} else if let decodingError = error as? DecodingError {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Decoding error: \(decodingError)")
} else {
Logger.sync.error("❌ Failed to cache '\(bookmark.title)' - Error: \(error.localizedDescription) (Type: \(type(of: error)))")
}
}
}
// Cleanup old articles (FIFO)
try await offlineCacheRepository.cleanupOldestCachedArticles(keepCount: settings.maxUnreadArticlesInt)
// Update last sync date in settings
var updatedSettings = settings
updatedSettings.lastSyncDate = Date()
try await settingsRepository.saveOfflineSettings(updatedSettings)
// Final status
let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)"
Logger.sync.info(statusMessage)
_syncProgressSubject.send(statusMessage)
// Clear progress message after 3 seconds
try? await Task.sleep(nanoseconds: 3_000_000_000)
_syncProgressSubject.send(nil)
} catch {
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
_syncProgressSubject.send("❌ Sync failed")
// Clear error message after 5 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000)
_syncProgressSubject.send(nil)
}
_isSyncingSubject.send(false)
}
func getCachedArticlesCount() -> Int {
offlineCacheRepository.getCachedArticlesCount()
}
func getCacheSize() -> String {
offlineCacheRepository.getCacheSize()
}
}