Fix connection leaks causing crash on Android 9

- Added SafeSardineWrapper to properly close HTTP responses
- Prevents resource exhaustion after extended use (30-45 min)
- Added preemptive authentication to reduce 401 round-trips
- Added ProGuard rule for TextInclusionStrategy warnings
- Updated version to 1.7.1

Refs: #15
This commit is contained in:
inventory69
2026-01-30 13:35:37 +01:00
parent 614650e37d
commit 68e8490db8
7 changed files with 146 additions and 15 deletions

View File

@@ -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 <a href="https://square.github.io/okhttp/4.x/okhttp/okhttp3/-response-body/">OkHttp Response Body Docs</a>
*/
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<DavResource> {
Logger.d(TAG, "list($url)")
return delegate.list(url)
}
/**
* ✅ Wrapper um list(url, depth) mit Logging
*/
override fun list(url: String, depth: Int): List<DavResource> {
Logger.d(TAG, "list($url, depth=$depth)")
return delegate.list(url, depth)
}
// Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet
}

View File

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