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
This commit is contained in:
Ilyas Hallak 2025-11-21 21:37:24 +01:00
parent fdc6b3a6b6
commit e4657aa281
12 changed files with 349 additions and 55 deletions

View File

@ -0,0 +1,102 @@
//
// NetworkMonitorRepository.swift
// readeck
//
// Created by Claude on 18.11.25.
//
import Foundation
import Network
import Combine
// MARK: - Protocol
protocol PNetworkMonitorRepository {
var isConnected: AnyPublisher<Bool, Never> { get }
func startMonitoring()
func stopMonitoring()
func reportConnectionFailure()
func reportConnectionSuccess()
}
// MARK: - Implementation
final class NetworkMonitorRepository: PNetworkMonitorRepository {
// MARK: - Properties
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.readeck.networkmonitor")
private let _isConnectedSubject = CurrentValueSubject<Bool, Never>(true)
private var hasPathConnection = true
private var hasRealConnection = true
var isConnected: AnyPublisher<Bool, Never> {
_isConnectedSubject.eraseToAnyPublisher()
}
// MARK: - Initialization
init() {
// Repository just manages the monitor, doesn't start it automatically
}
deinit {
monitor.cancel()
}
// MARK: - Public Methods
func startMonitoring() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
// More sophisticated check: path must be satisfied AND have actual interfaces
let hasInterfaces = path.availableInterfaces.count > 0
let isConnected = path.status == .satisfied && hasInterfaces
self.hasPathConnection = isConnected
self.updateConnectionState()
// Log network changes with details
if path.status == .satisfied {
if hasInterfaces {
Logger.network.info("📡 Network path available (interfaces: \(path.availableInterfaces.count))")
} else {
Logger.network.warning("⚠️ Network path satisfied but no interfaces (VPN?)")
}
} else {
Logger.network.warning("📡 Network path unavailable")
}
}
monitor.start(queue: queue)
Logger.network.debug("Network monitoring started")
}
func stopMonitoring() {
monitor.cancel()
Logger.network.debug("Network monitoring stopped")
}
func reportConnectionFailure() {
hasRealConnection = false
updateConnectionState()
Logger.network.warning("⚠️ Real connection failure reported (VPN/unreachable server)")
}
func reportConnectionSuccess() {
hasRealConnection = true
updateConnectionState()
Logger.network.info("✅ Real connection success reported")
}
private func updateConnectionState() {
// Only connected if BOTH path is available AND real connection works
let isConnected = hasPathConnection && hasRealConnection
DispatchQueue.main.async {
self._isConnectedSubject.send(isConnected)
}
}
}

View File

@ -0,0 +1,58 @@
//
// NetworkMonitorUseCase.swift
// readeck
//
// Created by Claude on 18.11.25.
//
import Foundation
import Combine
// MARK: - Protocol
protocol PNetworkMonitorUseCase {
var isConnected: AnyPublisher<Bool, Never> { get }
func startMonitoring()
func stopMonitoring()
func reportConnectionFailure()
func reportConnectionSuccess()
}
// MARK: - Implementation
final class NetworkMonitorUseCase: PNetworkMonitorUseCase {
// MARK: - Dependencies
private let repository: PNetworkMonitorRepository
// MARK: - Properties
var isConnected: AnyPublisher<Bool, Never> {
repository.isConnected
}
// MARK: - Initialization
init(repository: PNetworkMonitorRepository) {
self.repository = repository
}
// MARK: - Public Methods
func startMonitoring() {
repository.startMonitoring()
}
func stopMonitoring() {
repository.stopMonitoring()
}
func reportConnectionFailure() {
repository.reportConnectionFailure()
}
func reportConnectionSuccess() {
repository.reportConnectionSuccess()
}
}

View File

@ -92,13 +92,13 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
if offlineCacheRepository.hasCachedArticle(id: bookmark.id) {
Logger.sync.debug("⏭️ Skipping '\(bookmark.title)' (already cached)")
skippedCount += 1
_syncProgressSubject.send("⏭️ Artikel \(progress) bereits gecacht...")
_syncProgressSubject.send("⏭️ Article \(progress) already cached...")
continue
}
// Update progress
let imagesSuffix = settings.saveImages ? " + Bilder" : ""
_syncProgressSubject.send("📥 Artikel \(progress)\(imagesSuffix)...")
let imagesSuffix = settings.saveImages ? " + images" : ""
_syncProgressSubject.send("📥 Article \(progress)\(imagesSuffix)...")
Logger.sync.info("📥 Caching '\(bookmark.title)'")
do {
@ -116,7 +116,15 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
Logger.sync.info("✅ Cached '\(bookmark.title)'")
} catch {
errorCount += 1
Logger.sync.error("❌ Failed to cache '\(bookmark.title)': \(error.localizedDescription)")
// 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)))")
}
}
}
@ -129,7 +137,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
try await settingsRepository.saveOfflineSettings(updatedSettings)
// Final status
let statusMessage = "✅ Synchronisiert: \(successCount), Übersprungen: \(skippedCount), Fehler: \(errorCount)"
let statusMessage = "✅ Synced: \(successCount), Skipped: \(skippedCount), Errors: \(errorCount)"
Logger.sync.info(statusMessage)
_syncProgressSubject.send(statusMessage)
@ -139,7 +147,7 @@ final class OfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
} catch {
Logger.sync.error("❌ Offline sync failed: \(error.localizedDescription)")
_syncProgressSubject.send("❌ Synchronisierung fehlgeschlagen")
_syncProgressSubject.send("❌ Sync failed")
// Clear error message after 5 seconds
try? await Task.sleep(nanoseconds: 5_000_000_000)

View File

@ -7,6 +7,7 @@
import Foundation
import SwiftUI
import Combine
@MainActor
@Observable
@ -14,17 +15,22 @@ class AppViewModel {
private let settingsRepository = SettingsRepository()
private let factory: UseCaseFactory
private let syncTagsUseCase: PSyncTagsUseCase
let networkMonitorUseCase: PNetworkMonitorUseCase
var hasFinishedSetup: Bool = true
var isServerReachable: Bool = false
var isNetworkConnected: Bool = true
private var lastAppStartTagSyncTime: Date?
private var cancellables = Set<AnyCancellable>()
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.factory = factory
self.syncTagsUseCase = factory.makeSyncTagsUseCase()
setupNotificationObservers()
self.networkMonitorUseCase = factory.makeNetworkMonitorUseCase()
setupNotificationObservers()
setupNetworkMonitoring()
loadSetupStatus()
}
@ -63,6 +69,28 @@ class AppViewModel {
}
}
private func setupNetworkMonitoring() {
// Start monitoring network status
networkMonitorUseCase.startMonitoring()
// Bind network status to our published property
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.assign(to: \.isNetworkConnected, on: self)
.store(in: &cancellables)
}
func bindNetworkStatus(to appSettings: AppSettings) {
// Bind network status to AppSettings for global access
networkMonitorUseCase.isConnected
.receive(on: DispatchQueue.main)
.sink { isConnected in
Logger.viewModel.info("🌐 Network status changed: \(isConnected ? "Connected" : "Disconnected")")
appSettings.isNetworkConnected = isConnected
}
.store(in: &cancellables)
}
private func loadSetupStatus() {
hasFinishedSetup = settingsRepository.hasFinishedSetup
}

View File

@ -5,22 +5,23 @@ import SwiftUI
struct BookmarksView: View {
// MARK: States
@State private var viewModel: BookmarksViewModel
@State private var showingAddBookmark = false
@State private var selectedBookmarkId: String?
@State private var showingAddBookmarkFromShare = false
@State private var shareURL = ""
@State private var shareTitle = ""
let state: BookmarkState
let type: [BookmarkType]
@Binding var selectedBookmark: Bookmark?
@EnvironmentObject var playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings
let tag: String?
// MARK: Environments
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass
@ -37,8 +38,13 @@ struct BookmarksView: View {
var body: some View {
ZStack {
VStack(spacing: 0) {
#if DEBUG
// Debug: Network status indicator
debugNetworkStatusBanner
#endif
// Offline banner
if viewModel.isNetworkError && (viewModel.bookmarks?.bookmarks.isEmpty == false) {
if !appSettings.isNetworkConnected && (viewModel.bookmarks?.bookmarks.isEmpty == false) {
offlineBanner
}
@ -86,7 +92,21 @@ struct BookmarksView: View {
Task {
// Wait a bit for the server to process the new bookmark
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
await viewModel.refreshBookmarks()
}
}
}
.onChange(of: appSettings.isNetworkConnected) { oldValue, newValue in
// Network status changed
if !newValue && oldValue {
// Lost network connection - load cached bookmarks
Task {
await viewModel.loadCachedBookmarksFromUI()
}
} else if newValue && !oldValue {
// Regained network connection - refresh from server
Task {
await viewModel.refreshBookmarks()
}
}
@ -285,6 +305,31 @@ struct BookmarksView: View {
}
}
@ViewBuilder
private var debugNetworkStatusBanner: some View {
HStack(spacing: 12) {
Image(systemName: appSettings.isNetworkConnected ? "wifi" : "wifi.slash")
.font(.body)
.foregroundColor(appSettings.isNetworkConnected ? .green : .red)
Text("DEBUG: Network \(appSettings.isNetworkConnected ? "Connected ✓" : "Disconnected ✗")")
.font(.caption)
.foregroundColor(appSettings.isNetworkConnected ? .green : .red)
.bold()
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(appSettings.isNetworkConnected ? Color.green.opacity(0.1) : Color.red.opacity(0.1))
.overlay(
Rectangle()
.frame(height: 1)
.foregroundColor(appSettings.isNetworkConnected ? Color.green : Color.red),
alignment: .bottom
)
}
@ViewBuilder
private var offlineBanner: some View {
HStack(spacing: 12) {
@ -292,7 +337,7 @@ struct BookmarksView: View {
.font(.body)
.foregroundColor(.secondary)
Text("Offline-Modus Zeige gespeicherte Artikel")
Text("Offline Mode Showing cached articles")
.font(.caption)
.foregroundColor(.secondary)

View File

@ -9,7 +9,7 @@ class BookmarksViewModel {
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase
private let loadCardLayoutUseCase: PLoadCardLayoutUseCase
private let offlineCacheRepository: POfflineCacheRepository
var bookmarks: BookmarksPage?
var isLoading = false
var isInitialLoading = true
@ -19,7 +19,7 @@ class BookmarksViewModel {
var currentType = [BookmarkType.article]
var currentTag: String? = nil
var cardLayoutStyle: CardLayoutStyle = .magazine
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
@ -29,8 +29,7 @@ class BookmarksViewModel {
// Prevent concurrent updates
private var isUpdating = false
private var cancellables = Set<AnyCancellable>()
private var limit = 50
private var offset = 0
@ -69,7 +68,7 @@ class BookmarksViewModel {
}
}
.store(in: &cancellables)
// Listen for
NotificationCenter.default
.publisher(for: .addBookmarkFromShare)
@ -179,6 +178,13 @@ class BookmarksViewModel {
Logger.viewModel.error("Failed to load cached bookmarks: \(error.localizedDescription)")
}
}
@MainActor
func loadCachedBookmarksFromUI() async {
isNetworkError = true
errorMessage = "No internet connection"
await loadCachedBookmarks()
}
@MainActor
func loadMoreBookmarks() async {

View File

@ -27,6 +27,7 @@ protocol UseCaseFactory {
func makeDeleteAnnotationUseCase() -> PDeleteAnnotationUseCase
func makeSettingsRepository() -> PSettingsRepository
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase
}
@ -41,6 +42,7 @@ class DefaultUseCaseFactory: UseCaseFactory {
private lazy var serverInfoRepository: PServerInfoRepository = ServerInfoRepository(apiClient: infoApiClient)
private lazy var annotationsRepository: PAnnotationsRepository = AnnotationsRepository(api: api)
private let offlineCacheRepository: POfflineCacheRepository = OfflineCacheRepository()
private let networkMonitorRepository: PNetworkMonitorRepository = NetworkMonitorRepository()
static let shared = DefaultUseCaseFactory()
@ -159,4 +161,8 @@ class DefaultUseCaseFactory: UseCaseFactory {
settingsRepository: settingsRepository
)
}
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase {
return NetworkMonitorUseCase(repository: networkMonitorRepository)
}
}

View File

@ -112,6 +112,10 @@ class MockUseCaseFactory: UseCaseFactory {
func makeOfflineCacheSyncUseCase() -> POfflineCacheSyncUseCase {
MockOfflineCacheSyncUseCase()
}
func makeNetworkMonitorUseCase() -> PNetworkMonitorUseCase {
MockNetworkMonitorUseCase()
}
}
@ -331,6 +335,45 @@ class MockOfflineCacheSyncUseCase: POfflineCacheSyncUseCase {
}
}
class MockNetworkMonitorRepository: PNetworkMonitorRepository {
var isConnected: AnyPublisher<Bool, Never> {
Just(true).eraseToAnyPublisher()
}
func startMonitoring() {}
func stopMonitoring() {}
func reportConnectionFailure() {}
func reportConnectionSuccess() {}
}
class MockNetworkMonitorUseCase: PNetworkMonitorUseCase {
private let repository: PNetworkMonitorRepository
init(repository: PNetworkMonitorRepository = MockNetworkMonitorRepository()) {
self.repository = repository
}
var isConnected: AnyPublisher<Bool, Never> {
repository.isConnected
}
func startMonitoring() {
repository.startMonitoring()
}
func stopMonitoring() {
repository.stopMonitoring()
}
func reportConnectionFailure() {
repository.reportConnectionFailure()
}
func reportConnectionSuccess() {
repository.reportConnectionSuccess()
}
}
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)

View File

@ -18,6 +18,7 @@ import Combine
class AppSettings: ObservableObject {
@Published var settings: Settings?
@Published var isNetworkConnected: Bool = true
var enableTTS: Bool {
settings?.enableTTS ?? false

View File

@ -9,19 +9,20 @@ import SwiftUI
struct OfflineSettingsView: View {
@State private var viewModel = OfflineSettingsViewModel()
@EnvironmentObject var appSettings: AppSettings
var body: some View {
Group {
Section {
VStack(alignment: .leading, spacing: 4) {
Toggle("Offline-Reading aktivieren", isOn: $viewModel.offlineSettings.enabled)
Toggle("Enable Offline Reading", isOn: $viewModel.offlineSettings.enabled)
.onChange(of: viewModel.offlineSettings.enabled) {
Task {
await viewModel.saveSettings()
}
}
Text("Lade automatisch Artikel für die Offline-Nutzung herunter.")
Text("Automatically download articles for offline use.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
@ -31,7 +32,7 @@ struct OfflineSettingsView: View {
// Max articles slider
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Maximale Artikel")
Text("Maximum Articles")
Spacer()
Text("\(viewModel.offlineSettings.maxUnreadArticlesInt)")
.font(.caption)
@ -43,7 +44,7 @@ struct OfflineSettingsView: View {
in: 0...100,
step: 10
) {
Text("Max. Artikel offline")
Text("Max. Articles Offline")
}
.onChange(of: viewModel.offlineSettings.maxUnreadArticles) {
Task {
@ -54,14 +55,14 @@ struct OfflineSettingsView: View {
// Save images toggle
VStack(alignment: .leading, spacing: 4) {
Toggle("Bilder speichern", isOn: $viewModel.offlineSettings.saveImages)
Toggle("Save Images", isOn: $viewModel.offlineSettings.saveImages)
.onChange(of: viewModel.offlineSettings.saveImages) {
Task {
await viewModel.saveSettings()
}
}
Text("Lädt auch Bilder für die Offline-Nutzung herunter.")
Text("Also download images for offline use.")
.font(.caption)
.foregroundColor(.secondary)
.padding(.top, 2)
@ -83,7 +84,7 @@ struct OfflineSettingsView: View {
}
VStack(alignment: .leading, spacing: 2) {
Text("Jetzt synchronisieren")
Text("Sync Now")
.foregroundColor(viewModel.isSyncing ? .secondary : .blue)
if let progress = viewModel.syncProgress {
@ -91,7 +92,7 @@ struct OfflineSettingsView: View {
.font(.caption)
.foregroundColor(.secondary)
} else if let lastSync = viewModel.offlineSettings.lastSyncDate {
Text("Zuletzt: \(lastSync.formatted(.relative(presentation: .named)))")
Text("Last synced: \(lastSync.formatted(.relative(presentation: .named)))")
.font(.caption)
.foregroundColor(.secondary)
}
@ -106,8 +107,8 @@ struct OfflineSettingsView: View {
if viewModel.cachedArticlesCount > 0 {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Gespeicherte Artikel")
Text("\(viewModel.cachedArticlesCount) Artikel (\(viewModel.cacheSize))")
Text("Cached Articles")
Text("\(viewModel.cachedArticlesCount) articles (\(viewModel.cacheSize))")
.font(.caption)
.foregroundColor(.secondary)
}
@ -116,43 +117,36 @@ struct OfflineSettingsView: View {
}
#if DEBUG
// Debug: Force offline mode
Button(action: {
simulateOfflineMode()
}) {
HStack {
Image(systemName: "airplane")
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Offline-Modus simulieren")
.foregroundColor(.orange)
Text("DEBUG: Netzwerk temporär deaktivieren")
.font(.caption)
.foregroundColor(.secondary)
// Debug: Toggle offline mode simulation
VStack(alignment: .leading, spacing: 4) {
Toggle(isOn: Binding(
get: { !appSettings.isNetworkConnected },
set: { isOffline in
appSettings.isNetworkConnected = !isOffline
}
)) {
HStack {
Image(systemName: "airplane")
.foregroundColor(.orange)
Spacer()
VStack(alignment: .leading, spacing: 2) {
Text("Simulate Offline Mode")
.foregroundColor(.orange)
Text("DEBUG: Toggle network status")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
#endif
}
} header: {
Text("Offline-Reading")
Text("Offline Reading")
}
}
.task {
await viewModel.loadSettings()
}
}
#if DEBUG
private func simulateOfflineMode() {
// Post notification to simulate offline mode
NotificationCenter.default.post(
name: Notification.Name("SimulateOfflineMode"),
object: nil
)
}
#endif
}

View File

@ -78,6 +78,8 @@ class OfflineSettingsViewModel {
func syncNow() async {
Logger.viewModel.info("Manual sync triggered")
await offlineCacheSyncUseCase.syncOfflineArticles(settings: offlineSettings)
// Reload settings to get updated lastSyncDate
await loadSettings()
updateCacheStats()
}

View File

@ -34,6 +34,7 @@ struct readeckApp: App {
Task {
await loadAppSettings()
}
appViewModel.bindNetworkStatus(to: appSettings)
}
.onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in
Task {