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