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