From 0b143e5f0d1ed9e240948601408b6aad00c3a355 Mon Sep 17 00:00:00 2001 From: inventory69 Date: Mon, 2 Feb 2026 17:14:23 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20timeout=20increase=20(1s=E2=86=9210s)=20?= =?UTF-8?q?and=20locale=20hardcoded=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes: ### Timeout Fix (v1.7.2) - SOCKET_TIMEOUT_MS: 1000ms β†’ 10000ms for more stable connections - Better error handling in hasUnsyncedChanges(): returns TRUE on error ### Locale Fix (v1.7.2) - Replaced hardcoded German strings with getString(R.string.*) - MainActivity, SettingsActivity, MainViewModel: 'Bereits synchronisiert' β†’ getString() - SettingsViewModel: Enhanced getString() with AppCompatDelegate locale support - Added locale debug logging in MainActivity ### Code Cleanup - Removed non-working VPN bypass code: - WiFiSocketFactory class - getWiFiInetAddressInternal() function - getOrCacheWiFiAddress() function - sessionWifiAddress cache variables - WiFi-binding logic in createSardineClient() - Kept isVpnInterfaceActive() for logging/debugging Note: VPN users should configure their VPN to exclude private IPs (e.g., 192.168.x.x) for local server connectivity. App-level VPN bypass is not reliable on Android. --- .../dev/dettmer/simplenotes/MainActivity.kt | 41 +++- .../dettmer/simplenotes/SettingsActivity.kt | 2 +- .../simplenotes/SimpleNotesApplication.kt | 13 ++ .../simplenotes/sync/WebDavSyncService.kt | 190 ++---------------- .../simplenotes/ui/main/MainViewModel.kt | 3 +- .../ui/settings/SettingsViewModel.kt | 38 +++- 6 files changed, 111 insertions(+), 176 deletions(-) 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 72153be..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() @@ -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 } @@ -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 f688db5..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 } 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/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt index 3fa95d6..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 @@ -40,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,8 +56,6 @@ class WebDavSyncService(private val context: Context) { // ⚑ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert) private var sessionSardine: SafeSardineWrapper? = null - private var sessionWifiAddress: InetAddress? = null - private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgefΓΌhrt init { if (BuildConfig.DEBUG) { @@ -89,21 +87,6 @@ class WebDavSyncService(private val context: Context) { } } - /** - * ⚑ v1.3.1: Gecachte WiFi-Adresse zurΓΌckgeben oder berechnen - */ - private fun getOrCacheWiFiAddress(): InetAddress? { - // Return cached if already checked this session - if (sessionWifiAddressChecked) { - return sessionWifiAddress - } - - // Calculate and cache - sessionWifiAddress = getWiFiInetAddressInternal() - sessionWifiAddressChecked = true - return sessionWifiAddress - } - /** * πŸ”’ v1.7.1: Checks if any VPN/Wireguard interface is active. * @@ -138,127 +121,6 @@ class WebDavSyncService(private val context: Context) { 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? { - 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 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 (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 - } - - // 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 (no VPN), searching for interface...") - - @Suppress("LoopWithTooManyJumpStatements") // Network interface filtering requires multiple conditions - // Finde WiFi Interface - val interfaces = NetworkInterface.getNetworkInterfaces() - 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 - } - } - } - - 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) - } - } - /** * ⚑ v1.3.1: Gecachten Sardine-Client zurΓΌckgeben oder erstellen * Spart ~100ms pro Aufruf durch Wiederverwendung @@ -279,6 +141,10 @@ 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 @@ -287,21 +153,11 @@ class WebDavSyncService(private val context: Context) { val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null - Logger.d(TAG, "πŸ”§ Creating SafeSardineWrapper 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 = 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 okHttpClient = OkHttpClient.Builder() + .connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS) + .build() return SafeSardineWrapper.create(okHttpClient, username, password) } @@ -311,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") @@ -439,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 } @@ -569,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 } } @@ -1062,18 +921,9 @@ 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 = 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 okHttpClient = OkHttpClient.Builder() + .connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS) + .build() val sardine = SafeSardineWrapper.create(okHttpClient, username, password) 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)