diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 9f0ad54..c770057 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -9,3 +9,6 @@ contact_links: - name: "🐛 Troubleshooting" url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting about: Häufige Probleme und Lösungen / Common issues and solutions + - name: "✨ Feature Requests & Ideas" + url: https://github.com/inventory69/simple-notes-sync/discussions/categories/ideas + about: Diskutiere neue Features in Discussions / Discuss new features in Discussions diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index da297a4..c0e0723 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -8,6 +8,46 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.7.1] - 2026-02-02 + +### 🐛 Kritische Fehlerbehebungen + +#### Android 9 App-Absturz Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15)) + +**Problem:** App stürzte auf Android 9 (API 28) ab wenn WorkManager Expedited Work für Hintergrund-Sync verwendet wurde. + +**Root Cause:** Wenn `setExpedited()` in WorkManager verwendet wird, muss die `CoroutineWorker` die Methode `getForegroundInfo()` implementieren um eine Foreground Service Notification zurückzugeben. Auf Android 9-11 ruft WorkManager diese Methode auf, aber die Standard-Implementierung wirft `IllegalStateException: Not implemented`. + +**Lösung:** `getForegroundInfo()` in `SyncWorker` implementiert um eine korrekte `ForegroundInfo` mit Sync-Progress-Notification zurückzugeben. + +**Details:** +- `ForegroundInfo` mit Sync-Progress-Notification für Android 9-11 hinzugefügt +- Android 10+: Setzt `FOREGROUND_SERVICE_TYPE_DATA_SYNC` für korrekte Service-Typisierung +- Foreground Service Permissions in AndroidManifest.xml hinzugefügt +- Notification zeigt Sync-Progress mit indeterminiertem Progress Bar +- Danke an [@roughnecks](https://github.com/roughnecks) für das detaillierte Debugging! + +#### VPN-Kompatibilitäts-Fix ([#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 +- Foreground Service Erkennung und Notification-System für Hintergrund-Sync-Tasks + +### 🌍 Lokalisierung + +- Hardcodierte deutsche Fehlermeldungen behoben - jetzt String-Resources für korrekte Lokalisierung +- Deutsche und englische Strings für Sync-Progress-Notifications hinzugefügt + +--- + ## [1.7.0] - 2026-01-26 ### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung diff --git a/CHANGELOG.md b/CHANGELOG.md index 6000ba3..759db93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). --- +## [1.7.1] - 2026-02-02 + +### 🐛 Critical Bug Fixes + +#### Android 9 App Crash Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15)) + +**Problem:** App crashed on Android 9 (API 28) when using WorkManager Expedited Work for background sync. + +**Root Cause:** When `setExpedited()` is used in WorkManager, the `CoroutineWorker` must implement `getForegroundInfo()` to return a Foreground Service notification. On Android 9-11, WorkManager calls this method, but the default implementation throws `IllegalStateException: Not implemented`. + +**Solution:** Implemented `getForegroundInfo()` in `SyncWorker` to return a proper `ForegroundInfo` with sync progress notification. + +**Details:** +- Added `ForegroundInfo` with sync progress notification for Android 9-11 +- Android 10+: Sets `FOREGROUND_SERVICE_TYPE_DATA_SYNC` for proper service typing +- Added Foreground Service permissions to AndroidManifest.xml +- Notification shows sync progress with indeterminate progress bar +- Thanks to [@roughnecks](https://github.com/roughnecks) for the detailed debugging! + +#### VPN Compatibility Fix ([#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 +- Foreground Service detection and notification system for background sync tasks + +### 🌍 Localization + +- Fixed hardcoded German error messages - now uses string resources for proper localization +- Added German and English strings for sync progress notifications + +--- + ## [1.7.0] - 2026-01-26 ### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 82d4c85..e315feb 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "dev.dettmer.simplenotes" minSdk = 24 targetSdk = 36 - versionCode = 17 // 🎨 v1.7.0: Grid Layout + Backup Encryption - versionName = "1.7.0" // 🎨 v1.7.0: Grid Layout + Backup Encryption + versionCode = 18 // 🔧 v1.7.1: Android 9 getForegroundInfo Fix (Issue #15) + versionName = "1.7.1" // 🔧 v1.7.1: Android 9 getForegroundInfo Fix testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 1dde533..87335da 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -60,4 +60,8 @@ -keep class * implements com.google.gson.JsonDeserializer # Keep your app's data classes --keep class dev.dettmer.simplenotes.** { *; } \ No newline at end of file +-keep class dev.dettmer.simplenotes.** { *; } + +# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions +# This class only exists on API 35+ but Compose handles the fallback gracefully +-dontwarn android.text.Layout$TextInclusionStrategy diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bc7094a..c348297 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,11 @@ + + + + + @@ -91,6 +96,12 @@ android:resource="@xml/file_paths" /> + + + \ No newline at end of file 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..0b2bfbb 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt @@ -133,6 +133,9 @@ class MainActivity : AppCompatActivity() { requestNotificationPermission() } + // 🌍 v1.7.2: Debug Locale für Fehlersuche + logLocaleInfo() + findViews() setupToolbar() setupRecyclerView() @@ -392,13 +395,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 +409,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) @@ -672,7 +675,8 @@ class MainActivity : AppCompatActivity() { // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping") - SyncStateManager.markCompleted("Bereits synchronisiert") + val message = getString(R.string.toast_already_synced) + SyncStateManager.markCompleted(message) return@launch } @@ -683,7 +687,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 } @@ -814,4 +818,39 @@ class MainActivity : AppCompatActivity() { } } } + + /** + * 🌍 v1.7.2: Debug-Logging für Locale-Problem + * Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden + */ + private fun logLocaleInfo() { + if (!BuildConfig.DEBUG) return + + Logger.d(TAG, "╔═══════════════════════════════════════════════════") + Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO") + Logger.d(TAG, "╠═══════════════════════════════════════════════════") + + // System Locale + val systemLocale = java.util.Locale.getDefault() + Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale") + + // Resources Locale + val resourcesLocale = resources.configuration.locales[0] + Logger.d(TAG, "║ Resources Locale: $resourcesLocale") + + // Context Locale (API 24+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val contextLocales = resources.configuration.locales + Logger.d(TAG, "║ Context Locales (all): $contextLocales") + } + + // Test String Loading + val testString = getString(R.string.toast_already_synced) + Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)") + Logger.d(TAG, "║ Result: '$testString'") + Logger.d(TAG, "║ Expected EN: '✅ Already synced'") + Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}") + + Logger.d(TAG, "╚═══════════════════════════════════════════════════") + } } 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..71b04b6 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt @@ -599,7 +599,7 @@ class SettingsActivity : AppCompatActivity() { // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) if (!syncService.hasUnsyncedChanges()) { - showToast("✅ Bereits synchronisiert") + showToast(getString(R.string.toast_already_synced)) SyncStateManager.markCompleted() return@launch } @@ -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/SimpleNotesApplication.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt index 239f1c7..facba27 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt @@ -2,6 +2,7 @@ package dev.dettmer.simplenotes import android.app.Application import android.content.Context +import androidx.appcompat.app.AppCompatDelegate import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.utils.NotificationHelper @@ -15,6 +16,18 @@ class SimpleNotesApplication : Application() { lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity + /** + * 🌍 v1.7.1: Apply app locale to Application Context + * + * This ensures ViewModels and other components using Application Context + * get the correct locale-specific strings. + */ + override fun attachBaseContext(base: Context) { + // Apply the app locale before calling super + // This is handled by AppCompatDelegate which reads from system storage + super.attachBaseContext(base) + } + override fun onCreate() { super.onCreate() diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SafeSardineWrapper.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SafeSardineWrapper.kt new file mode 100644 index 0000000..cc8923d --- /dev/null +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SafeSardineWrapper.kt @@ -0,0 +1,105 @@ +package dev.dettmer.simplenotes.sync + +import com.thegrizzlylabs.sardineandroid.DavResource +import com.thegrizzlylabs.sardineandroid.Sardine +import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine +import dev.dettmer.simplenotes.utils.Logger +import okhttp3.Credentials +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.InputStream + +/** + * 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert + * + * Hintergrund: + * - OkHttpSardine.exists() schließt den Response-Body nicht + * - Dies führt zu "connection leaked" Warnungen im Log + * - Kann bei vielen Requests zu Socket-Exhaustion führen + * + * Lösung: + * - Eigene exists()-Implementation mit korrektem Response-Cleanup + * - Preemptive Authentication um 401-Round-Trips zu vermeiden + * + * @see OkHttp Response Body Docs + */ +class SafeSardineWrapper private constructor( + private val delegate: OkHttpSardine, + private val okHttpClient: OkHttpClient, + private val authHeader: String +) : Sardine by delegate { + + companion object { + private const val TAG = "SafeSardine" + + /** + * Factory-Methode für SafeSardineWrapper + */ + fun create( + okHttpClient: OkHttpClient, + username: String, + password: String + ): SafeSardineWrapper { + val delegate = OkHttpSardine(okHttpClient).apply { + setCredentials(username, password) + } + val authHeader = Credentials.basic(username, password) + return SafeSardineWrapper(delegate, okHttpClient, authHeader) + } + } + + /** + * ✅ Sichere exists()-Implementation mit Response Cleanup + * + * Im Gegensatz zu OkHttpSardine.exists() wird hier: + * 1. Preemptive Auth-Header gesendet (kein 401 Round-Trip) + * 2. Response.use{} für garantiertes Cleanup verwendet + */ + override fun exists(url: String): Boolean { + val request = Request.Builder() + .url(url) + .head() + .header("Authorization", authHeader) + .build() + + return try { + okHttpClient.newCall(request).execute().use { response -> + val isSuccess = response.isSuccessful + Logger.d(TAG, "exists($url) → $isSuccess (${response.code})") + isSuccess + } + } catch (e: Exception) { + Logger.d(TAG, "exists($url) failed: ${e.message}") + false + } + } + + /** + * ✅ Wrapper um get() mit Logging + * + * WICHTIG: Der zurückgegebene InputStream MUSS vom Caller geschlossen werden! + * Empfohlen: inputStream.bufferedReader().use { it.readText() } + */ + override fun get(url: String): InputStream { + Logger.d(TAG, "get($url)") + return delegate.get(url) + } + + /** + * ✅ Wrapper um list() mit Logging + */ + override fun list(url: String): List { + Logger.d(TAG, "list($url)") + return delegate.list(url) + } + + /** + * ✅ Wrapper um list(url, depth) mit Logging + */ + override fun list(url: String, depth: Int): List { + Logger.d(TAG, "list($url, depth=$depth)") + return delegate.list(url, depth) + } + + // Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet +} diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt index 692db55..43ee154 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncWorker.kt @@ -5,8 +5,11 @@ package dev.dettmer.simplenotes.sync import android.app.ActivityManager import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo import androidx.work.WorkerParameters import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.utils.Constants @@ -26,6 +29,35 @@ class SyncWorker( const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED" } + /** + * 🔧 v1.7.2: Required for expedited work on Android 9-11 + * + * WorkManager ruft diese Methode auf um die Foreground-Notification zu erstellen + * wenn der Worker als Expedited Work gestartet wird. + * + * Ab Android 12+ wird diese Methode NICHT aufgerufen (neue Expedited API). + * Auf Android 9-11 MUSS diese Methode implementiert sein! + * + * @see https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#foregroundinfo + */ + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = NotificationHelper.createSyncProgressNotification(applicationContext) + + // Android 10+ benötigt foregroundServiceType + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } else { + ForegroundInfo( + NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID, + notification + ) + } + } + /** * Prüft ob die App im Vordergrund ist. * Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt. 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 5d8dbdb..c69ea7e 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 @@ -4,7 +4,6 @@ import android.content.Context import android.net.ConnectivityManager import android.net.NetworkCapabilities import com.thegrizzlylabs.sardineandroid.Sardine -import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.models.DeletionTracker @@ -41,7 +40,7 @@ class WebDavSyncService(private val context: Context) { companion object { private const val TAG = "WebDavSyncService" - private const val SOCKET_TIMEOUT_MS = 1000 // 🆕 v1.7.0: Reduziert von 2s auf 1s + private const val SOCKET_TIMEOUT_MS = 10000 // 🔧 v1.7.2: 10s für stabile Verbindungen (1s war zu kurz) private const val MAX_FILENAME_LENGTH = 200 private const val ETAG_PREVIEW_LENGTH = 8 private const val CONTENT_PREVIEW_LENGTH = 50 @@ -56,9 +55,7 @@ class WebDavSyncService(private val context: Context) { private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz // ⚡ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert) - private var sessionSardine: Sardine? = null - private var sessionWifiAddress: InetAddress? = null - private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgeführt + private var sessionSardine: SafeSardineWrapper? = null init { if (BuildConfig.DEBUG) { @@ -91,129 +88,37 @@ class WebDavSyncService(private val context: Context) { } /** - * ⚡ v1.3.1: Gecachte WiFi-Adresse zurückgeben oder berechnen + * 🔒 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 getOrCacheWiFiAddress(): InetAddress? { - // Return cached if already checked this session - if (sessionWifiAddressChecked) { - return sessionWifiAddress - } - - // Calculate and cache - sessionWifiAddress = getWiFiInetAddressInternal() - sessionWifiAddressChecked = true - return sessionWifiAddress - } - - /** - * Findet WiFi Interface IP-Adresse (um VPN zu umgehen) - */ - @Suppress("ReturnCount") // Early returns for network validation checks - private fun getWiFiInetAddressInternal(): InetAddress? { + private fun isVpnInterfaceActive(): Boolean { try { - Logger.d(TAG, "🔍 getWiFiInetAddress() called") - - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val network = connectivityManager.activeNetwork - Logger.d(TAG, " Active network: $network") - - if (network == null) { - Logger.d(TAG, "❌ No active network") - return null - } - - val capabilities = connectivityManager.getNetworkCapabilities(network) - Logger.d(TAG, " Network capabilities: $capabilities") - - if (capabilities == null) { - Logger.d(TAG, "❌ No network capabilities") - return null - } - - // 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active - // 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)") - return null - } - - // Nur wenn WiFi aktiv (und kein VPN) - if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - Logger.d(TAG, "⚠️ Not on WiFi, using default routing") - return null - } - - Logger.d(TAG, "✅ Network is WiFi, searching for interface...") - - @Suppress("LoopWithTooManyJumpStatements") // Network interface filtering requires multiple conditions - // Finde WiFi Interface - val interfaces = NetworkInterface.getNetworkInterfaces() + val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false while (interfaces.hasMoreElements()) { val iface = interfaces.nextElement() - - Logger.d(TAG, " Checking interface: ${iface.name}, isUp=${iface.isUp}") - - // WiFi Interfaces: wlan0, wlan1, etc. - if (!iface.name.startsWith("wlan")) continue if (!iface.isUp) continue - val addresses = iface.inetAddresses - while (addresses.hasMoreElements()) { - val addr = addresses.nextElement() - - Logger.d( - TAG, - " Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, " + - "loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}" - ) - - // Nur IPv4, nicht loopback, nicht link-local - if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) { - Logger.d(TAG, "✅ Found WiFi IP: ${addr.hostAddress} on ${iface.name}") - return addr - } + 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 } } - - Logger.w(TAG, "⚠️ No WiFi interface found, using default routing") - return null - } catch (e: Exception) { - Logger.e(TAG, "❌ Failed to get WiFi interface", e) - return null - } - } - - /** - * Custom SocketFactory die an WiFi-IP bindet (VPN Fix) - */ - private inner class WiFiSocketFactory(private val wifiAddress: InetAddress) : SocketFactory() { - override fun createSocket(): Socket { - val socket = Socket() - socket.bind(InetSocketAddress(wifiAddress, 0)) - Logger.d(TAG, "🔌 Socket bound to WiFi IP: ${wifiAddress.hostAddress}") - return socket - } - - override fun createSocket(host: String, port: Int): Socket { - val socket = createSocket() - socket.connect(InetSocketAddress(host, port)) - return socket - } - - override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket { - return createSocket(host, port) - } - - override fun createSocket(host: InetAddress, port: Int): Socket { - val socket = createSocket() - socket.connect(InetSocketAddress(host, port)) - return socket - } - - override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket { - return createSocket(address, port) + Logger.w(TAG, "⚠️ Failed to check VPN interfaces: ${e.message}") } + return false } /** @@ -235,30 +140,26 @@ class WebDavSyncService(private val context: Context) { /** * Erstellt einen neuen Sardine-Client (intern) + * + * 🆕 v1.7.2: Intelligentes Routing basierend auf Ziel-Adresse + * - Lokale Server: WiFi-Binding (bypass VPN) + * - Externe Server: Default-Routing (nutzt VPN wenn aktiv) + * + * 🔧 v1.7.1: Verwendet SafeSardineWrapper statt OkHttpSardine + * - Verhindert Connection Leaks durch proper Response-Cleanup + * - Preemptive Authentication für weniger 401-Round-Trips */ - private fun createSardineClient(): Sardine? { + private fun createSardineClient(): SafeSardineWrapper? { val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null - Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding") - Logger.d(TAG, " Context: ${context.javaClass.simpleName}") + Logger.d(TAG, "🔧 Creating SafeSardineWrapper") - // ⚡ v1.3.1: Verwende gecachte WiFi-Adresse - val wifiAddress = getOrCacheWiFiAddress() + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS) + .build() - val okHttpClient = if (wifiAddress != null) { - Logger.d(TAG, "✅ Using WiFi-bound socket factory") - OkHttpClient.Builder() - .socketFactory(WiFiSocketFactory(wifiAddress)) - .build() - } else { - Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)") - OkHttpClient.Builder().build() - } - - return OkHttpSardine(okHttpClient).apply { - setCredentials(username, password) - } + return SafeSardineWrapper.create(okHttpClient, username, password) } /** @@ -266,8 +167,6 @@ class WebDavSyncService(private val context: Context) { */ private fun clearSessionCache() { sessionSardine = null - sessionWifiAddress = null - sessionWifiAddressChecked = false notesDirEnsured = false markdownDirEnsured = false Logger.d(TAG, "🧹 Session caches cleared") @@ -394,8 +293,10 @@ class WebDavSyncService(private val context: Context) { } val notesUrl = getNotesUrl(serverUrl) + // 🔧 v1.7.2: Exception wird NICHT gefangen - muss nach oben propagieren! + // Wenn sardine.exists() timeout hat, soll hasUnsyncedChanges() das behandeln if (!sardine.exists(notesUrl)) { - Logger.d(TAG, "📁 /notes/ doesn't exist - no server changes") + Logger.d(TAG, "📁 /notes/ doesn't exist - assuming no server changes") return false } @@ -524,8 +425,11 @@ class WebDavSyncService(private val context: Context) { hasServerChanges } catch (e: Exception) { - Logger.e(TAG, "Failed to check for unsynced changes", e) - true // Safe default + // 🔧 v1.7.2 KRITISCH: Bei Server-Fehler (Timeout, etc.) return TRUE! + // Grund: Besser fälschlich synchen als "Already synced" zeigen obwohl Server nicht erreichbar + Logger.e(TAG, "❌ Failed to check server for changes: ${e.message}") + Logger.d(TAG, "⚠️ Returning TRUE (will attempt sync) - server check failed") + true // Sicherheitshalber TRUE → Sync wird versucht und gibt dann echte Fehlermeldung } } @@ -648,19 +552,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) } ) } @@ -823,19 +727,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) } ) } @@ -1017,22 +921,11 @@ class WebDavSyncService(private val context: Context) { ): Int = withContext(Dispatchers.IO) { Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...") - // ⚡ v1.3.1: Use cached WiFi address - val wifiAddress = getOrCacheWiFiAddress() + val okHttpClient = OkHttpClient.Builder() + .connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS) + .build() - val okHttpClient = if (wifiAddress != null) { - Logger.d(TAG, "✅ Using WiFi-bound socket factory") - OkHttpClient.Builder() - .socketFactory(WiFiSocketFactory(wifiAddress)) - .build() - } else { - Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)") - OkHttpClient.Builder().build() - } - - val sardine = OkHttpSardine(okHttpClient).apply { - setCredentials(username, password) - } + val sardine = SafeSardineWrapper.create(okHttpClient, username, password) val mdUrl = getMarkdownUrl(serverUrl) @@ -1146,9 +1039,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) @@ -1157,13 +1073,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" @@ -1180,28 +1102,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 @@ -1544,8 +1447,8 @@ class WebDavSyncService(private val context: Context) { return@withContext try { Logger.d(TAG, "📝 Starting Markdown sync...") - val sardine = OkHttpSardine() - sardine.setCredentials(username, password) + val okHttpClient = OkHttpClient.Builder().build() + val sardine = SafeSardineWrapper.create(okHttpClient, username, password) val mdUrl = getMarkdownUrl(serverUrl) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt index 024ba3e..49a0899 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt @@ -536,7 +536,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { // Check for unsynced changes if (!syncService.hasUnsyncedChanges()) { Logger.d(TAG, "⏭️ $source Sync: No unsynced changes") - SyncStateManager.markCompleted("Bereits synchronisiert") + val message = getApplication().getString(R.string.toast_already_synced) + SyncStateManager.markCompleted(message) loadNotes() return@launch } diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt index fbed556..a714c81 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/ui/settings/SettingsViewModel.kt @@ -780,10 +780,42 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application serverUrl != "https://" } - private fun getString(resId: Int): String = getApplication().getString(resId) + /** + * 🌍 v1.7.1: Get string resources with correct app locale + * + * AndroidViewModel uses Application context which may not have the correct locale + * applied when using per-app language settings. We need to get a Context that + * respects AppCompatDelegate.getApplicationLocales(). + */ + private fun getString(resId: Int): String { + // Get context with correct locale configuration from AppCompatDelegate + val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales() + val context = if (!appLocales.isEmpty) { + // Create configuration with app locale + val config = android.content.res.Configuration(getApplication().resources.configuration) + config.setLocale(appLocales.get(0)) + getApplication().createConfigurationContext(config) + } else { + // Use system locale (default) + getApplication() + } + return context.getString(resId) + } - private fun getString(resId: Int, vararg formatArgs: Any): String = - getApplication().getString(resId, *formatArgs) + private fun getString(resId: Int, vararg formatArgs: Any): String { + // Get context with correct locale configuration from AppCompatDelegate + val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales() + val context = if (!appLocales.isEmpty) { + // Create configuration with app locale + val config = android.content.res.Configuration(getApplication().resources.configuration) + config.setLocale(appLocales.get(0)) + getApplication().createConfigurationContext(config) + } else { + // Use system locale (default) + getApplication() + } + return context.getString(resId, *formatArgs) + } private suspend fun emitToast(message: String) { _showToast.emit(message) diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt index 62d2eda..e4909e5 100644 --- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt +++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/NotificationHelper.kt @@ -19,6 +19,7 @@ object NotificationHelper { private const val CHANNEL_ID = "notes_sync_channel" private const val NOTIFICATION_ID = 1001 private const val SYNC_NOTIFICATION_ID = 2 + const val SYNC_PROGRESS_NOTIFICATION_ID = 1003 // v1.7.2: For expedited work foreground notification private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L /** @@ -54,6 +55,26 @@ object NotificationHelper { Logger.d(TAG, "🗑️ Cleared old sync notifications") } + /** + * 🔧 v1.7.2: Erstellt Notification für Sync-Progress (Expedited Work) + * + * Wird von SyncWorker.getForegroundInfo() aufgerufen auf Android 9-11. + * Muss eine gültige, sichtbare Notification zurückgeben. + * + * @return Notification (nicht anzeigen, nur erstellen) + */ + fun createSyncProgressNotification(context: Context): android.app.Notification { + return NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setContentTitle(context.getString(R.string.sync_in_progress)) + .setContentText(context.getString(R.string.sync_in_progress_text)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .setProgress(0, 0, true) // Indeterminate progress + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .build() + } + /** * Zeigt Erfolgs-Notification nach Sync */ diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 81a188d..f773ecd 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 + @@ -426,6 +438,8 @@ Notizen Synchronisierung Benachrichtigungen über Sync-Status + Synchronisierung läuft + Notizen werden synchronisiert… Sync erfolgreich %d Notiz(en) synchronisiert Sync fehlgeschlagen diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 414911d..e51bbf0 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 + @@ -426,6 +438,8 @@ Notes Synchronization Notifications about sync status + Syncing + Syncing notes… Sync successful %d note(s) synchronized Sync failed diff --git a/fastlane/metadata/android/de-DE/changelogs/18.txt b/fastlane/metadata/android/de-DE/changelogs/18.txt new file mode 100644 index 0000000..10a7619 --- /dev/null +++ b/fastlane/metadata/android/de-DE/changelogs/18.txt @@ -0,0 +1,5 @@ +• Behoben: App-Absturz auf Android 9 - Thanks to @roughnecks +• Behoben: Deutsche Texte trotz englischer App-Sprache +• Verbessert: Sync-Verbindungsstabilität +• Verbessert: Code-Qualität und Zuverlässigkeit + diff --git a/fastlane/metadata/android/en-US/changelogs/18.txt b/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 0000000..1b0e424 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/18.txt @@ -0,0 +1,4 @@ +• Fixed: App crash on Android 9 - Thanks to @roughnecks +• Fixed: German text appearing despite English language setting +• Improved: Sync connection stability (longer timeout) +• Improved: Code quality and reliability