@@ -104,8 +104,45 @@ class WebDavSyncService(private val context: Context) {
104104 return sessionWifiAddress
105105 }
106106
107+ /* *
108+ * 🔒 v1.7.1: Checks if any VPN/Wireguard interface is active.
109+ *
110+ * Wireguard VPNs run as separate network interfaces (tun*, wg*, *-wg-*),
111+ * and are NOT detected via NetworkCapabilities.TRANSPORT_VPN!
112+ *
113+ * @return true if VPN interface is detected
114+ */
115+ private fun isVpnInterfaceActive (): Boolean {
116+ try {
117+ val interfaces = NetworkInterface .getNetworkInterfaces() ? : return false
118+ while (interfaces.hasMoreElements()) {
119+ val iface = interfaces.nextElement()
120+ if (! iface.isUp) continue
121+
122+ val name = iface.name.lowercase()
123+ // Check for VPN/Wireguard interface patterns:
124+ // - tun0, tun1, etc. (OpenVPN, generic VPN)
125+ // - wg0, wg1, etc. (Wireguard)
126+ // - *-wg-* (Mullvad, ProtonVPN style: se-sto-wg-202)
127+ if (name.startsWith(" tun" ) ||
128+ name.startsWith(" wg" ) ||
129+ name.contains(" -wg-" ) ||
130+ name.startsWith(" ppp" )) {
131+ Logger .d(TAG , " 🔒 VPN interface detected: ${iface.name} " )
132+ return true
133+ }
134+ }
135+ } catch (e: Exception ) {
136+ Logger .w(TAG , " ⚠️ Failed to check VPN interfaces: ${e.message} " )
137+ }
138+ return false
139+ }
140+
107141 /* *
108142 * Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
143+ *
144+ * 🔒 v1.7.1 Fix: Now detects Wireguard VPN interfaces and skips WiFi binding
145+ * when VPN is active, so traffic routes through VPN tunnel correctly.
109146 */
110147 @Suppress(" ReturnCount" ) // Early returns for network validation checks
111148 private fun getWiFiInetAddressInternal (): InetAddress ? {
@@ -129,10 +166,17 @@ class WebDavSyncService(private val context: Context) {
129166 return null
130167 }
131168
132- // 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active
169+ // 🔒 v1.7.0: VPN-Detection via NetworkCapabilities (standard Android VPN)
133170 // When VPN is active, traffic should route through VPN, not directly via WiFi
134171 if (capabilities.hasTransport(NetworkCapabilities .TRANSPORT_VPN )) {
135- Logger .d(TAG , " 🔒 VPN detected - using default routing (traffic will go through VPN)" )
172+ Logger .d(TAG , " 🔒 VPN detected (TRANSPORT_VPN) - using default routing" )
173+ return null
174+ }
175+
176+ // 🔒 v1.7.1: VPN-Detection via interface names (Wireguard, OpenVPN, etc.)
177+ // Wireguard VPNs are NOT detected via TRANSPORT_VPN, they run as separate interfaces!
178+ if (isVpnInterfaceActive()) {
179+ Logger .d(TAG , " 🔒 VPN interface detected - skip WiFi binding, use default routing" )
136180 return null
137181 }
138182
@@ -142,7 +186,7 @@ class WebDavSyncService(private val context: Context) {
142186 return null
143187 }
144188
145- Logger .d(TAG , " ✅ Network is WiFi, searching for interface..." )
189+ Logger .d(TAG , " ✅ Network is WiFi (no VPN) , searching for interface..." )
146190
147191 @Suppress(" LoopWithTooManyJumpStatements" ) // Network interface filtering requires multiple conditions
148192 // Finde WiFi Interface
@@ -649,19 +693,19 @@ class WebDavSyncService(private val context: Context) {
649693 SyncResult (
650694 isSuccess = false ,
651695 errorMessage = when (e) {
652- is java.net.UnknownHostException -> " Server nicht erreichbar "
653- is java.net.SocketTimeoutException -> " Verbindungs-Timeout "
654- is javax.net.ssl.SSLException -> " SSL-Fehler "
696+ is java.net.UnknownHostException -> context.getString( R .string.snackbar_server_unreachable)
697+ is java.net.SocketTimeoutException -> context.getString( R .string.snackbar_connection_timeout)
698+ is javax.net.ssl.SSLException -> context.getString( R .string.sync_error_ssl)
655699 is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
656700 when (e.statusCode) {
657- 401 -> " Authentifizierung fehlgeschlagen "
658- 403 -> " Zugriff verweigert "
659- 404 -> " Server-Pfad nicht gefunden "
660- 500 -> " Server-Fehler "
661- else -> " HTTP-Fehler: ${ e.statusCode} "
701+ 401 -> context.getString( R .string.sync_error_auth_failed)
702+ 403 -> context.getString( R .string.sync_error_access_denied)
703+ 404 -> context.getString( R .string.sync_error_path_not_found)
704+ 500 -> context.getString( R .string.sync_error_server)
705+ else -> context.getString( R .string.sync_error_http, e.statusCode)
662706 }
663707 }
664- else -> e.message ? : " Unbekannter Fehler "
708+ else -> e.message ? : context.getString( R .string.sync_error_unknown)
665709 }
666710 )
667711 }
@@ -824,19 +868,19 @@ class WebDavSyncService(private val context: Context) {
824868 SyncResult (
825869 isSuccess = false ,
826870 errorMessage = when (e) {
827- is java.net.UnknownHostException -> " Server nicht erreichbar : ${e.message} "
828- is java.net.SocketTimeoutException -> " Verbindungs-Timeout : ${e.message} "
829- is javax.net.ssl.SSLException -> " SSL-Fehler "
871+ is java.net.UnknownHostException -> " ${context.getString( R .string.snackbar_server_unreachable)} : ${e.message} "
872+ is java.net.SocketTimeoutException -> " ${context.getString( R .string.snackbar_connection_timeout)} : ${e.message} "
873+ is javax.net.ssl.SSLException -> context.getString( R .string.sync_error_ssl)
830874 is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
831875 when (e.statusCode) {
832- 401 -> " Authentifizierung fehlgeschlagen "
833- 403 -> " Zugriff verweigert "
834- 404 -> " Server-Pfad nicht gefunden "
835- 500 -> " Server-Fehler "
836- else -> " HTTP-Fehler: ${ e.statusCode} "
876+ 401 -> context.getString( R .string.sync_error_auth_failed)
877+ 403 -> context.getString( R .string.sync_error_access_denied)
878+ 404 -> context.getString( R .string.sync_error_path_not_found)
879+ 500 -> context.getString( R .string.sync_error_server)
880+ else -> context.getString( R .string.sync_error_http, e.statusCode)
837881 }
838882 }
839- else -> e.message ? : " Unbekannter Fehler "
883+ else -> e.message ? : context.getString( R .string.sync_error_unknown)
840884 }
841885 )
842886 }
@@ -1145,9 +1189,32 @@ class WebDavSyncService(private val context: Context) {
11451189 " modified=$serverModified lastSync=$lastSyncTime "
11461190 )
11471191
1192+ // FIRST: Check deletion tracker - if locally deleted, skip unless re-created on server
1193+ if (deletionTracker.isDeleted(noteId)) {
1194+ val deletedAt = deletionTracker.getDeletionTimestamp(noteId)
1195+
1196+ // Smart check: Was note re-created on server after deletion?
1197+ if (deletedAt != null && serverModified > deletedAt) {
1198+ Logger .d(TAG , " 📝 Note re-created on server after deletion: $noteId " )
1199+ deletionTracker.removeDeletion(noteId)
1200+ trackerModified = true
1201+ // Continue with download below
1202+ } else {
1203+ Logger .d(TAG , " ⏭️ Skipping deleted note: $noteId " )
1204+ skippedDeleted++
1205+ processedIds.add(noteId)
1206+ continue
1207+ }
1208+ }
1209+
1210+ // Check if file exists locally
1211+ val localNote = storage.loadNote(noteId)
1212+ val fileExistsLocally = localNote != null
1213+
11481214 // PRIMARY: Timestamp check (works on first sync!)
11491215 // Same logic as Markdown sync - skip if not modified since last sync
1150- if (! forceOverwrite && lastSyncTime > 0 && serverModified <= lastSyncTime) {
1216+ // BUT: Always download if file doesn't exist locally!
1217+ if (! forceOverwrite && fileExistsLocally && lastSyncTime > 0 && serverModified <= lastSyncTime) {
11511218 skippedUnchanged++
11521219 Logger .d(TAG , " ⏭️ Skipping $noteId : Not modified since last sync (timestamp)" )
11531220 processedIds.add(noteId)
@@ -1156,13 +1223,19 @@ class WebDavSyncService(private val context: Context) {
11561223
11571224 // SECONDARY: E-Tag check (for performance after first sync)
11581225 // Catches cases where file was re-uploaded with same content
1159- if (! forceOverwrite && serverETag != null && serverETag == cachedETag) {
1226+ // BUT: Always download if file doesn't exist locally!
1227+ if (! forceOverwrite && fileExistsLocally && serverETag != null && serverETag == cachedETag) {
11601228 skippedUnchanged++
11611229 Logger .d(TAG , " ⏭️ Skipping $noteId : E-Tag match (content unchanged)" )
11621230 processedIds.add(noteId)
11631231 continue
11641232 }
11651233
1234+ // If file doesn't exist locally, always download
1235+ if (! fileExistsLocally) {
1236+ Logger .d(TAG , " 📥 File missing locally - forcing download" )
1237+ }
1238+
11661239 // 🐛 DEBUG: Log download reason
11671240 val downloadReason = when {
11681241 lastSyncTime == 0L -> " First sync ever"
@@ -1179,28 +1252,9 @@ class WebDavSyncService(private val context: Context) {
11791252 val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
11801253 val remoteNote = Note .fromJson(jsonContent) ? : continue
11811254
1182- // NEW: Check if note was deleted locally
1183- if (deletionTracker.isDeleted(remoteNote.id)) {
1184- val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id)
1185-
1186- // Smart check: Was note re-created on server after deletion?
1187- if (deletedAt != null && remoteNote.updatedAt > deletedAt) {
1188- Logger .d(TAG , " 📝 Note re-created on server after deletion: ${remoteNote.id} " )
1189- deletionTracker.removeDeletion(remoteNote.id)
1190- trackerModified = true
1191- // Continue with download below
1192- } else {
1193- Logger .d(TAG , " ⏭️ Skipping deleted note: ${remoteNote.id} " )
1194- skippedDeleted++
1195- processedIds.add(remoteNote.id)
1196- continue
1197- }
1198- }
1199-
12001255 processedIds.add(remoteNote.id) // 🆕 Mark as processed
12011256
1202- val localNote = storage.loadNote(remoteNote.id)
1203-
1257+ // Note: localNote was already loaded above for existence check
12041258 when {
12051259 localNote == null -> {
12061260 // New note from server
0 commit comments