fix: timeout increase (1s→10s) and locale hardcoded strings

## 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.
This commit is contained in:
inventory69
2026-02-02 17:14:23 +01:00
parent cf9695844c
commit 0b143e5f0d
6 changed files with 111 additions and 176 deletions

View File

@@ -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, "╚═══════════════════════════════════════════════════")
}
}

View File

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

View File

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

View File

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

View File

@@ -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<Application>().getString(R.string.toast_already_synced)
SyncStateManager.markCompleted(message)
loadNotes()
return@launch
}

View File

@@ -780,10 +780,42 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
serverUrl != "https://"
}
private fun getString(resId: Int): String = getApplication<android.app.Application>().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<Application>().resources.configuration)
config.setLocale(appLocales.get(0))
getApplication<Application>().createConfigurationContext(config)
} else {
// Use system locale (default)
getApplication<Application>()
}
return context.getString(resId)
}
private fun getString(resId: Int, vararg formatArgs: Any): String =
getApplication<android.app.Application>().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<Application>().resources.configuration)
config.setLocale(appLocales.get(0))
getApplication<Application>().createConfigurationContext(config)
} else {
// Use system locale (default)
getApplication<Application>()
}
return context.getString(resId, *formatArgs)
}
private suspend fun emitToast(message: String) {
_showToast.emit(message)