7 Commits

Author SHA1 Message Date
inventory69
0b143e5f0d 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.
2026-02-02 17:14:23 +01:00
inventory69
cf9695844c chore: Add SystemForegroundService to manifest and Feature Requests link to issue template
- AndroidManifest.xml: Added WorkManager SystemForegroundService declaration
  with dataSync foregroundServiceType to fix lint error for Expedited Work
- .github/ISSUE_TEMPLATE/config.yml: Added Feature Requests & Ideas link
  pointing to GitHub Discussions for non-bug feature discussions
2026-02-02 13:45:16 +01:00
inventory69
24ea7ec59a fix: Android 9 crash - Implement getForegroundInfo() for WorkManager Expedited Work (Issue #15)
This commit fixes the critical crash on Android 9 (API 28) that occurred when using
WorkManager Expedited Work for background sync operations.

## Root Cause
When setExpedited() is used in WorkManager, the CoroutineWorker must implement
getForegroundInfo() to return a ForegroundInfo object with 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
- Returns ForegroundInfo with sync progress notification
- Android 10+: Sets FOREGROUND_SERVICE_TYPE_DATA_SYNC for proper service typing
- Added required Foreground Service permissions to AndroidManifest.xml

## Technical Changes
- SyncWorker.kt: Added getForegroundInfo() override
- NotificationHelper.kt: Added createSyncProgressNotification() factory method
- strings.xml: Added sync_in_progress UI strings (EN + DE)
- AndroidManifest.xml: Added FOREGROUND_SERVICE permissions
- Version updated to 1.7.1 (versionCode 18)

## Previously Fixed (in this release)
- Kernel-VPN compatibility (Wireguard interface detection)
- HTTP connection lifecycle optimization (SafeSardineWrapper)
- Stability improvements for sync sessions

## Testing
- Tested on Android 9 (API 28) - No crash on second app start
- Tested on Android 15 (API 35) - No regressions
- WiFi-connect sync working correctly
- Expedited work notifications display properly

Fixes #15
Thanks to @roughnecks for detailed bug report and testing!
2026-02-02 13:09:12 +01:00
inventory69
df4ee4bed0 v1.7.1: Fix Android 9 crash and Kernel-VPN compatibility
- Fix connection leak on Android 9 (close() in finally block)
- Fix VPN detection for Kernel Wireguard (interface name patterns)
- Fix missing files after app data clear (local existence check)
- Update changelogs for v1.7.1 (versionCode 18)

Refs: #15
2026-01-30 16:21:04 +01:00
inventory69
68e8490db8 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
2026-01-30 13:37:52 +01:00
inventory69
614650e37d delete: remove feature request issue template [skip ci] 2026-01-28 16:14:10 +01:00
Fabian Dettmer
785a6c011a Add feature requests section to README [skip ci]
Added a section for feature requests and ideas with guidelines.
2026-01-28 15:24:17 +01:00
21 changed files with 508 additions and 297 deletions

View File

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

View File

@@ -1,84 +0,0 @@
name: "💡 Feature Request / Feature-Wunsch"
description: Schlage eine neue Funktion vor / Suggest a new feature
title: "[FEATURE] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Danke für deinen Feature-Vorschlag! / Thanks for your feature suggestion!
- type: textarea
id: feature-description
attributes:
label: "💡 Feature-Beschreibung / Feature Description"
description: Was möchtest du hinzugefügt haben? / What would you like to be added?
placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting"
validations:
required: true
- type: textarea
id: problem
attributes:
label: "🎯 Problem / Motivation"
description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve?
placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes"
validations:
required: true
- type: textarea
id: solution
attributes:
label: "📝 Vorgeschlagene Lösung / Proposed Solution"
description: Wie könnte das Feature funktionieren? / How could the feature work?
placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview"
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: "🔄 Alternativen / Alternatives"
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
validations:
required: false
- type: dropdown
id: platform
attributes:
label: "📱 Plattform / Platform"
description: Für welche Komponente ist das Feature? / For which component is the feature?
options:
- Android App
- WebDAV Server
- Dokumentation / Documentation
- Andere / Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: "⭐ Priorität (aus deiner Sicht) / Priority (from your perspective)"
options:
- Nice to have
- Wichtig / Important
- Sehr wichtig / Very important
validations:
required: false
- type: checkboxes
id: willing-to-contribute
attributes:
label: "🤝 Beitragen / Contribute"
options:
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
required: false
- type: textarea
id: additional
attributes:
label: "🔧 Zusätzliche Informationen / Additional Context"
description: Screenshots, Mockups, Links, ähnliche Apps, etc.
validations:
required: false

View File

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

View File

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

View File

@@ -125,6 +125,18 @@ cd android
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
## 💡 Feature Requests & Ideas
Have an idea for a new feature or improvement? We'd love to hear it!
➡️ **How to suggest features:**
1. Check [existing discussions](https://github.com/inventory69/simple-notes-sync/discussions) to see if someone already suggested it
2. If not, start a new discussion in the "Feature Requests / Ideas" category
3. Upvote (👍) features you'd like to see
Features with enough community support will be considered for implementation. Please keep in mind that this app is designed to stay simple and user-friendly.
## 🤝 Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)

View File

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

View File

@@ -60,4 +60,8 @@
-keep class * implements com.google.gson.JsonDeserializer
# Keep your app's data classes
-keep class dev.dettmer.simplenotes.** { *; }
-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

View File

@@ -12,6 +12,11 @@
<!-- Battery Optimization (for WorkManager background sync) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- v1.7.1: Foreground Service Type for Android 10+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -91,6 +96,12 @@
android:resource="@xml/file_paths" />
</provider>
<!-- v1.7.1: WorkManager SystemForegroundService for Expedited Work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>

View File

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

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

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

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

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

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

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)

View File

@@ -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
*/

View File

@@ -93,9 +93,21 @@
<string name="snackbar_server_error">Server-Fehler: %s</string>
<string name="snackbar_already_synced">Bereits synchronisiert</string>
<string name="snackbar_server_unreachable">Server nicht erreichbar</string>
<string name="snackbar_connection_timeout">Verbindungs-Timeout</string>
<string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string>
<string name="snackbar_nothing_to_sync"> Nichts zu syncen</string>
<!-- ============================= -->
<!-- SYNC ERROR MESSAGES -->
<!-- ============================= -->
<string name="sync_error_ssl">SSL-Fehler</string>
<string name="sync_error_auth_failed">Authentifizierung fehlgeschlagen</string>
<string name="sync_error_access_denied">Zugriff verweigert</string>
<string name="sync_error_path_not_found">Server-Pfad nicht gefunden</string>
<string name="sync_error_server">Server-Fehler</string>
<string name="sync_error_http">HTTP-Fehler: %d</string>
<string name="sync_error_unknown">Unbekannter Fehler</string>
<!-- ============================= -->
<!-- URL VALIDATION ERRORS -->
<!-- ============================= -->
@@ -426,6 +438,8 @@
<!-- ============================= -->
<string name="notification_channel_name">Notizen Synchronisierung</string>
<string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string>
<string name="sync_in_progress">Synchronisierung läuft</string>
<string name="sync_in_progress_text">Notizen werden synchronisiert…</string>
<string name="notification_sync_success_title">Sync erfolgreich</string>
<string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string>
<string name="notification_sync_failed_title">Sync fehlgeschlagen</string>

View File

@@ -93,9 +93,21 @@
<string name="snackbar_server_error">Server error: %s</string>
<string name="snackbar_already_synced">Already synced</string>
<string name="snackbar_server_unreachable">Server not reachable</string>
<string name="snackbar_connection_timeout">Connection timeout</string>
<string name="snackbar_synced_count">✅ Synced: %d notes</string>
<string name="snackbar_nothing_to_sync"> Nothing to sync</string>
<!-- ============================= -->
<!-- SYNC ERROR MESSAGES -->
<!-- ============================= -->
<string name="sync_error_ssl">SSL error</string>
<string name="sync_error_auth_failed">Authentication failed</string>
<string name="sync_error_access_denied">Access denied</string>
<string name="sync_error_path_not_found">Server path not found</string>
<string name="sync_error_server">Server error</string>
<string name="sync_error_http">HTTP error: %d</string>
<string name="sync_error_unknown">Unknown error</string>
<!-- ============================= -->
<!-- URL VALIDATION ERRORS -->
<!-- ============================= -->
@@ -426,6 +438,8 @@
<!-- ============================= -->
<string name="notification_channel_name">Notes Synchronization</string>
<string name="notification_channel_desc">Notifications about sync status</string>
<string name="sync_in_progress">Syncing</string>
<string name="sync_in_progress_text">Syncing notes…</string>
<string name="notification_sync_success_title">Sync successful</string>
<string name="notification_sync_success_message">%d note(s) synchronized</string>
<string name="notification_sync_failed_title">Sync failed</string>

View File

@@ -0,0 +1,6 @@
• Behoben: App-Absturz auf Android 9 (Issue #15) - Danke an @roughnecks
- WorkManager Expedited Work Kompatibilität (getForegroundInfo)
- Kernel-VPN-Kompatibilität (Wireguard tun/wg Interfaces)
• Verbessert: Stabilität und Verbindungsverwaltung
• Technisch: Optimierter HTTP-Connection-Lebenszyklus

View File

@@ -0,0 +1,5 @@
• Fixed: App crash on Android 9 (Issue #15) - Thanks to @roughnecks
- WorkManager expedited work compatibility (getForegroundInfo)
- Kernel-VPN compatibility (Wireguard tun/wg interfaces)
• Improved: Stability and connection management
• Technical: Optimized HTTP connection lifecycle