v1.7.1: Fix Android 9 crash and Kernel-VPN compatibility

- Fix connection leak on Android 9 (close() in finally block)
- Fix VPN detection for Kernel Wireguard (interface name patterns)
- Fix missing files after app data clear (local existence check)
- Update changelogs for v1.7.1 (versionCode 18)

Refs: #15
This commit is contained in:
inventory69
2026-01-30 16:21:04 +01:00
parent 68e8490db8
commit df4ee4bed0
9 changed files with 166 additions and 49 deletions

View File

@@ -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

View File

@@ -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
---

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -93,9 +93,21 @@
<string name="snackbar_server_error">Server-Fehler: %s</string>
<string name="snackbar_already_synced">Bereits synchronisiert</string>
<string name="snackbar_server_unreachable">Server nicht erreichbar</string>
<string name="snackbar_connection_timeout">Verbindungs-Timeout</string>
<string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string>
<string name="snackbar_nothing_to_sync"> Nichts zu syncen</string>
<!-- ============================= -->
<!-- SYNC ERROR MESSAGES -->
<!-- ============================= -->
<string name="sync_error_ssl">SSL-Fehler</string>
<string name="sync_error_auth_failed">Authentifizierung fehlgeschlagen</string>
<string name="sync_error_access_denied">Zugriff verweigert</string>
<string name="sync_error_path_not_found">Server-Pfad nicht gefunden</string>
<string name="sync_error_server">Server-Fehler</string>
<string name="sync_error_http">HTTP-Fehler: %d</string>
<string name="sync_error_unknown">Unbekannter Fehler</string>
<!-- ============================= -->
<!-- URL VALIDATION ERRORS -->
<!-- ============================= -->

View File

@@ -93,9 +93,21 @@
<string name="snackbar_server_error">Server error: %s</string>
<string name="snackbar_already_synced">Already synced</string>
<string name="snackbar_server_unreachable">Server not reachable</string>
<string name="snackbar_connection_timeout">Connection timeout</string>
<string name="snackbar_synced_count">✅ Synced: %d notes</string>
<string name="snackbar_nothing_to_sync"> Nothing to sync</string>
<!-- ============================= -->
<!-- SYNC ERROR MESSAGES -->
<!-- ============================= -->
<string name="sync_error_ssl">SSL error</string>
<string name="sync_error_auth_failed">Authentication failed</string>
<string name="sync_error_access_denied">Access denied</string>
<string name="sync_error_path_not_found">Server path not found</string>
<string name="sync_error_server">Server error</string>
<string name="sync_error_http">HTTP error: %d</string>
<string name="sync_error_unknown">Unknown error</string>
<!-- ============================= -->
<!-- URL VALIDATION ERRORS -->
<!-- ============================= -->

View File

@@ -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

View File

@@ -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