diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index da297a4..2e157d1 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -8,6 +8,33 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.7.1] - 2026-01-30 + +### 🐛 Kritische Fehlerbehebungen + +- **App-Absturz auf Android 9 nach längerer Nutzung behoben** ([ref #15](https://github.com/inventory69/simple-notes-sync/issues/15)) + - Ressourcenerschöpfung durch nicht geschlossene HTTP-Verbindungen behoben + - App konnte nach ~30-45 Minuten Nutzung durch angesammelte Connection-Leaks abstürzen + - Danke an [@roughnecks] für den detaillierten Fehlerbericht! + +- **VPN-Kompatibilitäts-Regression behoben** ([ref #11](https://github.com/inventory69/simple-notes-sync/issues/11)) + - WiFi Socket-Binding erkennt jetzt korrekt Wireguard VPN-Interfaces (tun*, wg*, *-wg-*) + - Traffic wird korrekt durch VPN-Tunnel geleitet statt direkt über WiFi + - Behebt "Verbindungs-Timeout" beim Sync zu externen Servern über VPN + +### 🔧 Technische Änderungen + +- Neue `SafeSardineWrapper` Klasse stellt korrektes HTTP-Connection-Cleanup sicher +- Weniger unnötige 401-Authentifizierungs-Challenges durch preemptive Auth-Header +- ProGuard-Regel hinzugefügt um harmlose TextInclusionStrategy-Warnungen zu unterdrücken +- VPN-Interface-Erkennung via `NetworkInterface.getNetworkInterfaces()` Pattern-Matching + +### 🌍 Lokalisierung + +- Hardcodierte deutsche Fehlermeldungen behoben - jetzt String-Resources für korrekte Lokalisierung + +--- + ## [1.7.0] - 2026-01-26 ### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2554d..3a63608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - App could crash after ~30-45 minutes of use due to accumulated connection leaks - Thanks to [@roughnecks] for the detailed bug report! +- **Fixed VPN compatibility regression** ([ref #11](https://github.com/inventory69/simple-notes-sync/issues/11)) + - WiFi socket binding now correctly detects Wireguard VPN interfaces (tun*, wg*, *-wg-*) + - Traffic routes through VPN tunnel instead of bypassing it directly to WiFi + - Fixes "Connection timeout" when syncing to external servers via VPN + ### 🔧 Technical Changes - New `SafeSardineWrapper` class ensures proper HTTP connection cleanup - Reduced unnecessary 401 authentication challenges with preemptive auth headers - Added ProGuard rule to suppress harmless TextInclusionStrategy warnings on older Android versions +- VPN interface detection via `NetworkInterface.getNetworkInterfaces()` pattern matching + +### 🌍 Localization + +- Fixed hardcoded German error messages - now uses string resources for proper localization --- diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt index f6eb0cc..72153be 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt @@ -392,13 +392,13 @@ class MainActivity : AppCompatActivity() { // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check") - SyncStateManager.markCompleted("Bereits synchronisiert") + SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced)) return@launch } // Check if server is reachable if (!syncService.isServerReachable()) { - SyncStateManager.markError("Server nicht erreichbar") + SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) return@launch } @@ -406,7 +406,7 @@ class MainActivity : AppCompatActivity() { val result = syncService.syncNotes() if (result.isSuccess) { - SyncStateManager.markCompleted("${result.syncedCount} Notizen") + SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount)) loadNotes() } else { SyncStateManager.markError(result.errorMessage) @@ -683,7 +683,7 @@ class MainActivity : AppCompatActivity() { if (!isReachable) { Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting") - SyncStateManager.markError("Server nicht erreichbar") + SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) return@launch } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt index 60816bb..f688db5 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -608,8 +608,8 @@ class SettingsActivity : AppCompatActivity() { // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern) if (!syncService.isServerReachable()) { - showToast("⚠️ Server nicht erreichbar") - SyncStateManager.markError("Server nicht erreichbar") + showToast("⚠️ ${getString(R.string.snackbar_server_unreachable)}") + SyncStateManager.markError(getString(R.string.snackbar_server_unreachable)) checkServerStatus() // Server-Status aktualisieren return@launch } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index 3d2ccff..3fa95d6 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt @@ -104,8 +104,45 @@ class WebDavSyncService(private val context: Context) { return sessionWifiAddress } + /** + * 🔒 v1.7.1: Checks if any VPN/Wireguard interface is active. + * + * Wireguard VPNs run as separate network interfaces (tun*, wg*, *-wg-*), + * and are NOT detected via NetworkCapabilities.TRANSPORT_VPN! + * + * @return true if VPN interface is detected + */ + private fun isVpnInterfaceActive(): Boolean { + try { + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (!iface.isUp) continue + + val name = iface.name.lowercase() + // Check for VPN/Wireguard interface patterns: + // - tun0, tun1, etc. (OpenVPN, generic VPN) + // - wg0, wg1, etc. (Wireguard) + // - *-wg-* (Mullvad, ProtonVPN style: se-sto-wg-202) + if (name.startsWith("tun") || + name.startsWith("wg") || + name.contains("-wg-") || + name.startsWith("ppp")) { + Logger.d(TAG, "🔒 VPN interface detected: ${iface.name}") + return true + } + } + } catch (e: Exception) { + Logger.w(TAG, "⚠️ Failed to check VPN interfaces: ${e.message}") + } + return false + } + /** * Findet WiFi Interface IP-Adresse (um VPN zu umgehen) + * + * 🔒 v1.7.1 Fix: Now detects Wireguard VPN interfaces and skips WiFi binding + * when VPN is active, so traffic routes through VPN tunnel correctly. */ @Suppress("ReturnCount") // Early returns for network validation checks private fun getWiFiInetAddressInternal(): InetAddress? { @@ -129,10 +166,17 @@ class WebDavSyncService(private val context: Context) { return null } - // 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active + // 🔒 v1.7.0: VPN-Detection via NetworkCapabilities (standard Android VPN) // When VPN is active, traffic should route through VPN, not directly via WiFi if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { - Logger.d(TAG, "🔒 VPN detected - using default routing (traffic will go through VPN)") + Logger.d(TAG, "🔒 VPN detected (TRANSPORT_VPN) - using default routing") + return null + } + + // 🔒 v1.7.1: VPN-Detection via interface names (Wireguard, OpenVPN, etc.) + // Wireguard VPNs are NOT detected via TRANSPORT_VPN, they run as separate interfaces! + if (isVpnInterfaceActive()) { + Logger.d(TAG, "🔒 VPN interface detected - skip WiFi binding, use default routing") return null } @@ -142,7 +186,7 @@ class WebDavSyncService(private val context: Context) { return null } - Logger.d(TAG, "✅ Network is WiFi, searching for interface...") + Logger.d(TAG, "✅ Network is WiFi (no VPN), searching for interface...") @Suppress("LoopWithTooManyJumpStatements") // Network interface filtering requires multiple conditions // Finde WiFi Interface @@ -649,19 +693,19 @@ class WebDavSyncService(private val context: Context) { SyncResult( isSuccess = false, errorMessage = when (e) { - is java.net.UnknownHostException -> "Server nicht erreichbar" - is java.net.SocketTimeoutException -> "Verbindungs-Timeout" - is javax.net.ssl.SSLException -> "SSL-Fehler" + is java.net.UnknownHostException -> context.getString(R.string.snackbar_server_unreachable) + is java.net.SocketTimeoutException -> context.getString(R.string.snackbar_connection_timeout) + is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl) is com.thegrizzlylabs.sardineandroid.impl.SardineException -> { when (e.statusCode) { - 401 -> "Authentifizierung fehlgeschlagen" - 403 -> "Zugriff verweigert" - 404 -> "Server-Pfad nicht gefunden" - 500 -> "Server-Fehler" - else -> "HTTP-Fehler: ${e.statusCode}" + 401 -> context.getString(R.string.sync_error_auth_failed) + 403 -> context.getString(R.string.sync_error_access_denied) + 404 -> context.getString(R.string.sync_error_path_not_found) + 500 -> context.getString(R.string.sync_error_server) + else -> context.getString(R.string.sync_error_http, e.statusCode) } } - else -> e.message ?: "Unbekannter Fehler" + else -> e.message ?: context.getString(R.string.sync_error_unknown) } ) } @@ -824,19 +868,19 @@ class WebDavSyncService(private val context: Context) { SyncResult( isSuccess = false, errorMessage = when (e) { - is java.net.UnknownHostException -> "Server nicht erreichbar: ${e.message}" - is java.net.SocketTimeoutException -> "Verbindungs-Timeout: ${e.message}" - is javax.net.ssl.SSLException -> "SSL-Fehler" + is java.net.UnknownHostException -> "${context.getString(R.string.snackbar_server_unreachable)}: ${e.message}" + is java.net.SocketTimeoutException -> "${context.getString(R.string.snackbar_connection_timeout)}: ${e.message}" + is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl) is com.thegrizzlylabs.sardineandroid.impl.SardineException -> { when (e.statusCode) { - 401 -> "Authentifizierung fehlgeschlagen" - 403 -> "Zugriff verweigert" - 404 -> "Server-Pfad nicht gefunden" - 500 -> "Server-Fehler" - else -> "HTTP-Fehler: ${e.statusCode}" + 401 -> context.getString(R.string.sync_error_auth_failed) + 403 -> context.getString(R.string.sync_error_access_denied) + 404 -> context.getString(R.string.sync_error_path_not_found) + 500 -> context.getString(R.string.sync_error_server) + else -> context.getString(R.string.sync_error_http, e.statusCode) } } - else -> e.message ?: "Unbekannter Fehler" + else -> e.message ?: context.getString(R.string.sync_error_unknown) } ) } @@ -1145,9 +1189,32 @@ class WebDavSyncService(private val context: Context) { "modified=$serverModified lastSync=$lastSyncTime" ) + // FIRST: Check deletion tracker - if locally deleted, skip unless re-created on server + if (deletionTracker.isDeleted(noteId)) { + val deletedAt = deletionTracker.getDeletionTimestamp(noteId) + + // Smart check: Was note re-created on server after deletion? + if (deletedAt != null && serverModified > deletedAt) { + Logger.d(TAG, " 📝 Note re-created on server after deletion: $noteId") + deletionTracker.removeDeletion(noteId) + trackerModified = true + // Continue with download below + } else { + Logger.d(TAG, " ⏭️ Skipping deleted note: $noteId") + skippedDeleted++ + processedIds.add(noteId) + continue + } + } + + // Check if file exists locally + val localNote = storage.loadNote(noteId) + val fileExistsLocally = localNote != null + // PRIMARY: Timestamp check (works on first sync!) // Same logic as Markdown sync - skip if not modified since last sync - if (!forceOverwrite && lastSyncTime > 0 && serverModified <= lastSyncTime) { + // BUT: Always download if file doesn't exist locally! + if (!forceOverwrite && fileExistsLocally && lastSyncTime > 0 && serverModified <= lastSyncTime) { skippedUnchanged++ Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)") processedIds.add(noteId) @@ -1156,13 +1223,19 @@ class WebDavSyncService(private val context: Context) { // SECONDARY: E-Tag check (for performance after first sync) // Catches cases where file was re-uploaded with same content - if (!forceOverwrite && serverETag != null && serverETag == cachedETag) { + // BUT: Always download if file doesn't exist locally! + if (!forceOverwrite && fileExistsLocally && serverETag != null && serverETag == cachedETag) { skippedUnchanged++ Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)") processedIds.add(noteId) continue } + // If file doesn't exist locally, always download + if (!fileExistsLocally) { + Logger.d(TAG, " 📥 File missing locally - forcing download") + } + // 🐛 DEBUG: Log download reason val downloadReason = when { lastSyncTime == 0L -> "First sync ever" @@ -1179,28 +1252,9 @@ class WebDavSyncService(private val context: Context) { val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } val remoteNote = Note.fromJson(jsonContent) ?: continue - // NEW: Check if note was deleted locally - if (deletionTracker.isDeleted(remoteNote.id)) { - val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id) - - // Smart check: Was note re-created on server after deletion? - if (deletedAt != null && remoteNote.updatedAt > deletedAt) { - Logger.d(TAG, " 📝 Note re-created on server after deletion: ${remoteNote.id}") - deletionTracker.removeDeletion(remoteNote.id) - trackerModified = true - // Continue with download below - } else { - Logger.d(TAG, " ⏭️ Skipping deleted note: ${remoteNote.id}") - skippedDeleted++ - processedIds.add(remoteNote.id) - continue - } - } - processedIds.add(remoteNote.id) // 🆕 Mark as processed - val localNote = storage.loadNote(remoteNote.id) - + // Note: localNote was already loaded above for existence check when { localNote == null -> { // New note from server diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 81a188d..eeff55f 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -93,9 +93,21 @@ Server-Fehler: %s Bereits synchronisiert Server nicht erreichbar + Verbindungs-Timeout ✅ Gesynct: %d Notizen ℹ️ Nichts zu syncen + + + + SSL-Fehler + Authentifizierung fehlgeschlagen + Zugriff verweigert + Server-Pfad nicht gefunden + Server-Fehler + HTTP-Fehler: %d + Unbekannter Fehler + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 414911d..cd9d711 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -93,9 +93,21 @@ Server error: %s Already synced Server not reachable + Connection timeout ✅ Synced: %d notes ℹ️ Nothing to sync + + + + SSL error + Authentication failed + Access denied + Server path not found + Server error + HTTP error: %d + Unknown error + diff --git a/fastlane/metadata/android/de-DE/changelogs/18.txt b/fastlane/metadata/android/de-DE/changelogs/18.txt index 7d64b33..3896169 100644 --- a/fastlane/metadata/android/de-DE/changelogs/18.txt +++ b/fastlane/metadata/android/de-DE/changelogs/18.txt @@ -1,3 +1,4 @@ • Behoben: App-Absturz auf Android 9 - Danke an @roughnecks +• Behoben: Kernel-VPN-Kompatibilität (Wireguard) • Verbessert: Stabilität der Sync-Sessions • Technisch: Optimierte Verbindungsverwaltung diff --git a/fastlane/metadata/android/en-US/changelogs/18.txt b/fastlane/metadata/android/en-US/changelogs/18.txt index 914c3b4..e0fabd8 100644 --- a/fastlane/metadata/android/en-US/changelogs/18.txt +++ b/fastlane/metadata/android/en-US/changelogs/18.txt @@ -1,3 +1,4 @@ • Fixed: App crash on Android 9 - Thanks to @roughnecks +• Fixed: Kernel-VPN compatibility (Wireguard) • Improved: Stability at sync sessions • Technical: Optimized connection management