diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6000ba3..fd2554d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
+## [1.7.1] - 2026-01-30
+
+### 🐛 Critical Bug Fixes
+
+- **Fixed app crash on Android 9 after extended use** ([ref #15](https://github.com/inventory69/simple-notes-sync/issues/15))
+ - Fixed resource exhaustion caused by unclosed HTTP connections
+ - App could crash after ~30-45 minutes of use due to accumulated connection leaks
+ - Thanks to [@roughnecks] for the detailed bug report!
+
+### 🔧 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
+
+---
+
## [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..e6d274b 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: Connection Leak Fix (Issue #15)
+ versionName = "1.7.1" // 🔧 v1.7.1: Connection Leak 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/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/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt
index 5d8dbdb..3d2ccff 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
@@ -56,7 +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 sessionSardine: SafeSardineWrapper? = null
private var sessionWifiAddress: InetAddress? = null
private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgeführt
@@ -235,12 +234,16 @@ class WebDavSyncService(private val context: Context) {
/**
* Erstellt einen neuen Sardine-Client (intern)
+ *
+ * 🔧 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, "🔧 Creating SafeSardineWrapper with WiFi binding")
Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
// ⚡ v1.3.1: Verwende gecachte WiFi-Adresse
@@ -256,9 +259,7 @@ class WebDavSyncService(private val context: Context) {
OkHttpClient.Builder().build()
}
- return OkHttpSardine(okHttpClient).apply {
- setCredentials(username, password)
- }
+ return SafeSardineWrapper.create(okHttpClient, username, password)
}
/**
@@ -1030,9 +1031,7 @@ class WebDavSyncService(private val context: Context) {
OkHttpClient.Builder().build()
}
- val sardine = OkHttpSardine(okHttpClient).apply {
- setCredentials(username, password)
- }
+ val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
val mdUrl = getMarkdownUrl(serverUrl)
@@ -1544,8 +1543,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/fastlane/metadata/android/de-DE/changelogs/18.txt b/fastlane/metadata/android/de-DE/changelogs/18.txt
new file mode 100644
index 0000000..7d64b33
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/18.txt
@@ -0,0 +1,3 @@
+• Behoben: App-Absturz auf Android 9 - Danke an @roughnecks
+• Verbessert: Stabilität der Sync-Sessions
+• Technisch: Optimierte Verbindungsverwaltung
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..914c3b4
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/18.txt
@@ -0,0 +1,3 @@
+• Fixed: App crash on Android 9 - Thanks to @roughnecks
+• Improved: Stability at sync sessions
+• Technical: Optimized connection management