diff --git a/.github/workflows/build-production-apk.yml b/.github/workflows/build-production-apk.yml
index 9b5b474..e8917dc 100644
--- a/.github/workflows/build-production-apk.yml
+++ b/.github/workflows/build-production-apk.yml
@@ -102,24 +102,24 @@ jobs:
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ env.VERSION_TAG }}
- name: "đ Simple Notes Sync v${{ env.VERSION_NAME }} (Production)"
+ name: "đ Simple Notes Sync v${{ env.VERSION_NAME }} (Produktions-Release)"
files: apk-output/*.apk
draft: false
prerelease: false
generate_release_notes: false
body: |
- # đ Production Release: Simple Notes Sync v${{ env.VERSION_NAME }}
+ # đ Produktions-Release: Simple Notes Sync v${{ env.VERSION_NAME }}
- ## Build Information
+ ## Build-Informationen
- **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }}
- - **Build Date:** ${{ env.COMMIT_DATE }}
+ - **Build-Datum:** ${{ env.COMMIT_DATE }}
- **Commit:** ${{ env.SHORT_SHA }}
- - **Environment:** đ˘ **PRODUCTION**
+ - **Umgebung:** đ˘ **PRODUKTION**
---
- ## đ Changes
+ ## đ Ănderungen
${{ env.COMMIT_MSG }}
@@ -127,69 +127,69 @@ jobs:
## đŚ Download & Installation
- ### Which APK should I download?
+ ### Welche APK soll ich herunterladen?
- | Your Device | Download This APK | Size | Compatibility |
- |-------------|------------------|------|---------------|
- | 𤡠Not sure? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~5 MB | Works on all devices |
- | Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~3 MB | Faster, smaller |
- | Older devices | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~3 MB | Older ARM chips |
+ | Dein Gerät | Lade diese APK herunter | GrĂśĂe | Kompatibilität |
+ |------------|------------------------|-------|----------------|
+ | 𤡠Nicht sicher? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~5 MB | Funktioniert auf allen Geräten |
+ | Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~3 MB | Schneller, kleiner |
+ | Ăltere Geräte | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~3 MB | Ăltere ARM-Chips |
- ### Installation Steps
- 1. Download the appropriate APK from the assets below
- 2. Enable "Install from unknown sources" in Android settings
- 3. Open the downloaded APK file
- 4. Follow the installation prompts
- 5. Configure WebDAV settings in the app
+ ### Installationsschritte
+ 1. Lade die passende APK aus den Assets unten herunter
+ 2. Aktiviere "Installation aus unbekannten Quellen" in den Android-Einstellungen
+ 3. Ăffne die heruntergeladene APK-Datei
+ 4. Folge den Installationsanweisungen
+ 5. Konfiguriere die WebDAV-Einstellungen in der App
---
- ## âď¸ Features
+ ## âď¸ Funktionen
- - â
Automatic WebDAV sync every 30 minutes (~0.4% battery/day)
- - â
Smart gateway detection (home network auto-detection)
- - â
Material Design 3 UI
- - â
Privacy-focused (no tracking, no analytics)
- - â
Offline-first architecture
+ - â
Automatische WebDAV-Synchronisation alle 30 Minuten (~0,4% Akku/Tag)
+ - â
Intelligente Gateway-Erkennung (automatische Heimnetzwerk-Erkennung)
+ - â
Material Design 3 Oberfläche
+ - â
Datenschutzorientiert (kein Tracking, keine Analysen)
+ - â
Offline-First Architektur
---
- ## đ Updating from Previous Version
+ ## đ Update von vorheriger Version
- Simply install this APK over the existing installation - all data and settings will be preserved.
+ Installiere diese APK einfach Ăźber die bestehende Installation - alle Daten und Einstellungen bleiben erhalten.
---
## đą Obtanium - Auto-Update App
- Get automatic updates with [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest).
+ Erhalte automatische Updates mit [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest).
- **Setup:**
- 1. Install Obtanium from the link above
- 2. Add app with this URL: `https://github.com/dettmersLiq/simple-notes-sync`
- 3. Enable auto-updates
+ **Einrichtung:**
+ 1. Installiere Obtanium Ăźber den Link oben
+ 2. FĂźge die App mit dieser URL hinzu: `https://github.com/dettmersLiq/simple-notes-sync`
+ 3. Aktiviere Auto-Updates
---
## đ Support
- For issues or questions, please open an issue on GitHub.
+ Bei Problemen oder Fragen Ăśffne bitte ein Issue auf GitHub.
---
- ## đ Privacy & Security
+ ## đ Datenschutz & Sicherheit
- - All data synced via your own WebDAV server
- - No third-party analytics or tracking
- - No internet permissions except for WebDAV sync
- - All sync operations encrypted (HTTPS)
- - Open source - audit the code yourself
+ - Alle Daten werden Ăźber deinen eigenen WebDAV-Server synchronisiert
+ - Keine Drittanbieter-Analysen oder Tracking
+ - Keine Internet-Berechtigungen auĂer fĂźr WebDAV-Sync
+ - Alle Synchronisationsvorgänge verschlßsselt (HTTPS)
+ - Open Source - prĂźfe den Code selbst
---
- ## đ ď¸ Built With
+ ## đ ď¸ Erstellt mit
- - **Language:** Kotlin
+ - **Sprache:** Kotlin
- **UI:** Material Design 3
- **Sync:** WorkManager + WebDAV
- **Target SDK:** Android 16 (API 36)
diff --git a/IMPROVEMENT_PLAN.md b/IMPROVEMENT_PLAN.md
new file mode 100644
index 0000000..f5b053a
--- /dev/null
+++ b/IMPROVEMENT_PLAN.md
@@ -0,0 +1,2338 @@
+# đŻ Simple Notes Sync - Verbesserungsplan
+
+**Erstellt am:** 21. Dezember 2025
+**Ziel:** UX-Verbesserungen, Material Design 3, Deutsche Lokalisierung, F-Droid Release
+
+---
+
+## đ Ăbersicht der Probleme & LĂśsungen
+
+---
+
+## đ NEU: Server-Backup Wiederherstellung
+
+### â Neue Anforderung: Notizen vom Server wiederherstellen
+
+**Problem:**
+- User kann keine vollständige Wiederherstellung vom Server machen
+- Wenn lokale Daten verloren gehen, keine einfache Recovery
+- Nßtzlich bei Gerätewechsel oder nach App-Neuinstallation
+
+**LĂśsung:**
+
+#### UI-Komponente (Settings)
+```kotlin
+// SettingsActivity.kt - Button hinzufĂźgen
+
+
+// Click Handler
+buttonRestoreFromServer.setOnClickListener {
+ showRestoreConfirmationDialog()
+}
+
+private fun showRestoreConfirmationDialog() {
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Vom Server wiederherstellen?")
+ .setMessage(
+ "â ď¸ WARNUNG:\n\n" +
+ "⢠Alle lokalen Notizen werden gelÜscht\n" +
+ "⢠Alle Notizen vom Server werden heruntergeladen\n" +
+ "⢠Diese Aktion kann nicht rßckgängig gemacht werden\n\n" +
+ "Fortfahren?"
+ )
+ .setIcon(R.drawable.ic_warning)
+ .setPositiveButton("Wiederherstellen") { _, _ ->
+ restoreFromServer()
+ }
+ .setNegativeButton("Abbrechen", null)
+ .show()
+}
+
+private fun restoreFromServer() {
+ lifecycleScope.launch {
+ try {
+ // Show progress dialog
+ val progressDialog = MaterialAlertDialogBuilder(this@SettingsActivity)
+ .setTitle("Wiederherstelle...")
+ .setMessage("Lade Notizen vom Server...")
+ .setCancelable(false)
+ .create()
+ progressDialog.show()
+
+ val syncService = WebDavSyncService(this@SettingsActivity)
+ val result = syncService.restoreFromServer()
+
+ progressDialog.dismiss()
+
+ if (result.isSuccess) {
+ MaterialAlertDialogBuilder(this@SettingsActivity)
+ .setTitle("â
Wiederherstellung erfolgreich")
+ .setMessage("${result.restoredCount} Notizen vom Server wiederhergestellt")
+ .setPositiveButton("OK") { _, _ ->
+ // Trigger MainActivity refresh
+ val intent = Intent("dev.dettmer.simplenotes.NOTES_CHANGED")
+ LocalBroadcastManager.getInstance(this@SettingsActivity)
+ .sendBroadcast(intent)
+ }
+ .show()
+ } else {
+ showErrorDialog(result.errorMessage ?: "Unbekannter Fehler")
+ }
+ } catch (e: Exception) {
+ showErrorDialog(e.message ?: "Wiederherstellung fehlgeschlagen")
+ }
+ }
+}
+```
+
+#### Backend-Logik (WebDavSyncService)
+```kotlin
+// WebDavSyncService.kt
+data class RestoreResult(
+ val isSuccess: Boolean,
+ val restoredCount: Int = 0,
+ val errorMessage: String? = null
+)
+
+suspend fun restoreFromServer(): RestoreResult = withContext(Dispatchers.IO) {
+ try {
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
+ val username = prefs.getString(Constants.KEY_USERNAME, null)
+ val password = prefs.getString(Constants.KEY_PASSWORD, null)
+
+ if (serverUrl.isNullOrEmpty() || username.isNullOrEmpty() || password.isNullOrEmpty()) {
+ return@withContext RestoreResult(
+ isSuccess = false,
+ errorMessage = "Server nicht konfiguriert"
+ )
+ }
+
+ // List all remote files
+ val sardine = Sardine()
+ sardine.setCredentials(username, password)
+
+ val remoteFiles = sardine.list(serverUrl)
+ .filter { it.name.endsWith(".json") && !it.isDirectory }
+
+ if (remoteFiles.isEmpty()) {
+ return@withContext RestoreResult(
+ isSuccess = false,
+ errorMessage = "Keine Notizen auf dem Server gefunden"
+ )
+ }
+
+ val restoredNotes = mutableListOf()
+
+ // Download each note
+ for (file in remoteFiles) {
+ try {
+ val content = sardine.get(file.href).toString(Charsets.UTF_8)
+ val note = Note.fromJson(content)
+ restoredNotes.add(note)
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to parse ${file.name}: ${e.message}")
+ // Continue with other files
+ }
+ }
+
+ if (restoredNotes.isEmpty()) {
+ return@withContext RestoreResult(
+ isSuccess = false,
+ errorMessage = "Keine gĂźltigen Notizen gefunden"
+ )
+ }
+
+ // Clear local storage and save all notes
+ withContext(Dispatchers.Main) {
+ storage.clearAll()
+ restoredNotes.forEach { note ->
+ storage.saveNote(note.copy(syncStatus = SyncStatus.SYNCED))
+ }
+ }
+
+ RestoreResult(
+ isSuccess = true,
+ restoredCount = restoredNotes.size
+ )
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Restore failed", e)
+ RestoreResult(
+ isSuccess = false,
+ errorMessage = e.message ?: "Verbindungsfehler"
+ )
+ }
+}
+```
+
+#### Storage Update
+```kotlin
+// NotesStorage.kt - Methode hinzufĂźgen
+fun clearAll() {
+ val file = File(context.filesDir, NOTES_FILE)
+ if (file.exists()) {
+ file.delete()
+ }
+ // Create empty notes list
+ saveAllNotes(emptyList())
+}
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt`
+- `android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt`
+- `android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt`
+- `android/app/src/main/res/layout/activity_settings.xml`
+- `android/app/src/main/res/values/strings.xml`
+- `android/app/src/main/res/drawable/ic_cloud_download.xml` (neu)
+
+**Zeitaufwand:** 2-3 Stunden
+
+**Strings hinzufĂźgen:**
+```xml
+Vom Server wiederherstellen
+Vom Server wiederherstellen?
+â ď¸ WARNUNG:\n\n⢠Alle lokalen Notizen werden gelĂśscht\n⢠Alle Notizen vom Server werden heruntergeladen\n⢠Diese Aktion kann nicht rĂźckgängig gemacht werden\n\nFortfahren?
+Wiederherstelle...
+Lade Notizen vom Server...
+â
Wiederherstellung erfolgreich
+%d Notizen vom Server wiederhergestellt
+Server nicht konfiguriert
+Keine Notizen auf dem Server gefunden
+Keine gĂźltigen Notizen gefunden
+```
+
+---
+
+### 1ď¸âŁ Server-Status Aktualisierung â ď¸ HOCH
+**Problem:**
+- Server-Status wird nicht sofort nach erfolgreichem Verbindungstest grĂźn
+- User muss App neu Üffnen oder Focus ändern
+
+**LĂśsung:**
+```kotlin
+// In SettingsActivity.kt nach testConnection()
+private fun testConnection() {
+ lifecycleScope.launch {
+ try {
+ showToast("Teste Verbindung...")
+ val syncService = WebDavSyncService(this@SettingsActivity)
+ val result = syncService.testConnection()
+
+ if (result.isSuccess) {
+ showToast("Verbindung erfolgreich!")
+ checkServerStatus() // â
HIER HINZUFĂGEN
+ } else {
+ showToast("Verbindung fehlgeschlagen: ${result.errorMessage}")
+ checkServerStatus() // â
Auch bei Fehler aktualisieren
+ }
+ } catch (e: Exception) {
+ showToast("Fehler: ${e.message}")
+ checkServerStatus() // â
Auch bei Exception
+ }
+ }
+}
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt`
+
+**Zeitaufwand:** 15 Minuten
+
+---
+
+### 2ď¸âŁ Auto-Save Indikator im Editor â ď¸ HOCH
+**Problem:**
+- User erkennt nicht, dass automatisch gespeichert wird
+- Save-Button fehlt â Verwirrung
+- Keine visuelle RĂźckmeldung Ăźber Speicher-Status
+
+**LĂśsung A: Auto-Save mit Indikator (Empfohlen)**
+```kotlin
+// NoteEditorActivity.kt
+private var autoSaveJob: Job? = null
+private lateinit var saveStatusTextView: TextView
+
+private fun setupAutoSave() {
+ val textWatcher = object : TextWatcher {
+ override fun afterTextChanged(s: Editable?) {
+ // Cancel previous save job
+ autoSaveJob?.cancel()
+
+ // Show "Speichere..."
+ saveStatusTextView.text = "đž Speichere..."
+ saveStatusTextView.setTextColor(getColor(android.R.color.darker_gray))
+
+ // Debounce: Save after 2 seconds of no typing
+ autoSaveJob = lifecycleScope.launch {
+ delay(2000)
+ saveNoteQuietly()
+
+ // Show "Gespeichert â"
+ saveStatusTextView.text = "â Gespeichert"
+ saveStatusTextView.setTextColor(getColor(android.R.color.holo_green_dark))
+
+ // Hide after 2 seconds
+ delay(2000)
+ saveStatusTextView.text = ""
+ }
+ }
+ // ... beforeTextChanged, onTextChanged
+ }
+
+ editTextTitle.addTextChangedListener(textWatcher)
+ editTextContent.addTextChangedListener(textWatcher)
+}
+
+private fun saveNoteQuietly() {
+ val title = editTextTitle.text?.toString()?.trim() ?: ""
+ val content = editTextContent.text?.toString()?.trim() ?: ""
+
+ if (title.isEmpty() && content.isEmpty()) return
+
+ val note = if (existingNote != null) {
+ existingNote!!.copy(
+ title = title,
+ content = content,
+ updatedAt = System.currentTimeMillis(),
+ syncStatus = SyncStatus.PENDING
+ )
+ } else {
+ Note(
+ title = title,
+ content = content,
+ deviceId = DeviceIdGenerator.getDeviceId(this),
+ syncStatus = SyncStatus.LOCAL_ONLY
+ ).also { existingNote = it }
+ }
+
+ storage.saveNote(note)
+}
+```
+
+**Layout Update:**
+```xml
+
+
+```
+
+**Alternative B: Save-Button behalten + Auto-Save**
+- Button zeigt "Gespeichert â" nach Auto-Save
+- Button disabled wenn keine Ănderungen
+
+**Betroffene Dateien:**
+- `android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt`
+- `android/app/src/main/res/layout/activity_editor.xml`
+- `android/app/src/main/res/values/strings.xml`
+
+**Zeitaufwand:** 1-2 Stunden
+
+---
+
+### 3ď¸âŁ GitHub Releases auf Deutsch â ď¸ MITTEL
+**Problem:**
+- Release Notes sind auf Englisch
+- Asset-Namen teilweise englisch
+- Zielgruppe ist deutsch
+
+**LĂśsung:**
+```yaml
+# .github/workflows/build-production-apk.yml
+
+# Asset-Namen schon auf Deutsch â
+
+# Release Body Ăźbersetzen:
+body: |
+ # đ Produktions-Release: Simple Notes Sync v${{ env.VERSION_NAME }}
+
+ ## đ Build-Informationen
+
+ - **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }}
+ - **Build-Datum:** ${{ env.COMMIT_DATE }}
+ - **Commit:** ${{ env.SHORT_SHA }}
+ - **Umgebung:** đ˘ **PRODUKTION**
+
+ ---
+
+ ## đ Ănderungen
+
+ ${{ env.COMMIT_MSG }}
+
+ ---
+
+ ## đŚ Download & Installation
+
+ ### Welche APK sollte ich herunterladen?
+
+ | Dein Gerät | Diese APK herunterladen | GrĂśĂe | Kompatibilität |
+ |------------|-------------------------|-------|----------------|
+ | 𤡠Unsicher? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~3 MB | Funktioniert auf allen Geräten |
+ | Modern (ab 2018) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~2 MB | Schneller, kleiner |
+ | Ăltere Geräte | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~2 MB | Ăltere ARM-Chips |
+
+ ### đ˛ Installationsschritte
+ 1. Lade die passende APK aus den Assets herunter
+ 2. Aktiviere "Aus unbekannten Quellen installieren" in den Android-Einstellungen
+ 3. Ăffne die heruntergeladene APK-Datei
+ 4. Folge den Installationsanweisungen
+ 5. Konfiguriere die WebDAV-Einstellungen in der App
+
+ ---
+
+ ## âď¸ Funktionen
+
+ - â
Automatische WebDAV-Synchronisation alle 30 Minuten (~0,4% Akku/Tag)
+ - â
Intelligente Gateway-Erkennung (automatische Heimnetzwerk-Erkennung)
+ - â
Material Design 3 Benutzeroberfläche
+ - â
Privatsphäre-fokussiert (kein Tracking, keine Analytics)
+ - â
Offline-First Architektur
+
+ ---
+
+ ## đ Aktualisierung von vorheriger Version
+
+ Installiere diese APK einfach Ăźber die bestehende Installation - alle Daten und Einstellungen bleiben erhalten.
+
+ ---
+
+ ## đą Obtanium - Automatische Updates
+
+ Erhalte automatische Updates mit [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest).
+
+ **Einrichtung:**
+ 1. Installiere Obtanium Ăźber den obigen Link
+ 2. FĂźge die App mit dieser URL hinzu: `https://github.com/inventory69/simple-notes-sync`
+ 3. Aktiviere automatische Updates
+
+ ---
+
+ ## đ Support
+
+ Bei Problemen oder Fragen bitte ein Issue auf GitHub Ăśffnen.
+
+ ---
+
+ ## đ Datenschutz & Sicherheit
+
+ - Alle Daten werden Ăźber deinen eigenen WebDAV-Server synchronisiert
+ - Keine Analytics oder Tracking von Drittanbietern
+ - Keine Internet-Berechtigungen auĂer fĂźr WebDAV-Sync
+ - Alle Sync-Vorgänge verschlßsselt (HTTPS)
+ - Open Source - prĂźfe den Code selbst
+
+ ---
+
+ ## đ ď¸ Technische Details
+
+ - **Sprache:** Kotlin
+ - **UI:** Material Design 3
+ - **Sync:** WorkManager + WebDAV
+ - **Target SDK:** Android 16 (API 36)
+ - **Min SDK:** Android 8.0 (API 26)
+```
+
+**Betroffene Dateien:**
+- `.github/workflows/build-production-apk.yml`
+
+**Zeitaufwand:** 30 Minuten
+
+---
+
+### 4ď¸âŁ Material Design 3 - Vollständige Migration â ď¸ HOCH
+
+**Basierend auf:** `MATERIAL_DESIGN_3_MIGRATION.md` Plan
+
+**Problem:**
+- Aktuelles Design ist Material Design 2
+- Keine Dynamic Colors (Material You)
+- Veraltete Komponenten und Farb-Palette
+- Keine Android 12+ Features
+
+**Ziel:**
+- ⨠Dynamische Farben aus Wallpaper (Material You)
+- đ¨ Modern Design Language
+- đ˛ GrĂśĂere Corner Radius (16dp)
+- đą Material Symbols Icons
+- đ Material 3 Typography
+- đ Perfekter Dark Mode
+- âż Bessere Accessibility
+
+---
+
+#### Phase 4.1: Theme & Dynamic Colors (15 Min)
+
+**themes.xml:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**colors.xml (Material 3 Baseline - GrĂźn/Natur Theme):**
+```xml
+
+
+ #006C4C
+ #FFFFFF
+ #89F8C7
+ #002114
+
+ #4D6357
+ #FFFFFF
+ #CFE9D9
+ #0A1F16
+
+ #3D6373
+ #FFFFFF
+ #C1E8FB
+ #001F29
+
+ #BA1A1A
+ #FFFFFF
+ #FFDAD6
+ #410002
+
+ #FBFDF9
+ #191C1A
+
+ #FBFDF9
+ #191C1A
+ #DCE5DD
+ #404943
+
+ #707973
+ #BFC9C2
+
+```
+
+**values-night/colors.xml (neu erstellen):**
+```xml
+
+
+ #6DDBAC
+ #003826
+ #005138
+ #89F8C7
+
+ #B3CCBD
+ #1F352A
+ #354B40
+ #CFE9D9
+
+ #A5CCE0
+ #073543
+ #254B5B
+ #C1E8FB
+
+ #FFB4AB
+ #690005
+ #93000A
+ #FFDAD6
+
+ #191C1A
+ #E1E3DF
+
+ #191C1A
+ #E1E3DF
+ #404943
+ #BFC9C2
+
+ #8A938C
+ #404943
+
+```
+
+**MainActivity.kt - Dynamic Colors aktivieren:**
+```kotlin
+override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Enable Dynamic Colors (Android 12+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ DynamicColors.applyToActivityIfAvailable(this)
+ }
+
+ setContentView(R.layout.activity_main)
+ // ...
+}
+
+// Import hinzufĂźgen:
+import com.google.android.material.color.DynamicColors
+import android.os.Build
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/values/themes.xml`
+- `android/app/src/main/res/values/colors.xml`
+- `android/app/src/main/res/values-night/colors.xml` (neu)
+- `android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt`
+
+**Zeitaufwand:** 15 Minuten
+
+---
+
+#### Phase 4.2: MainActivity Layout (10 Min)
+
+**activity_main.xml - Material 3 Update:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/layout/activity_main.xml`
+- `android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt`
+
+**Zeitaufwand:** 10 Minuten
+
+---
+
+#### Phase 4.3: Note Item Card (10 Min)
+
+**item_note.xml - Material 3 Card:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/layout/item_note.xml`
+
+**Zeitaufwand:** 10 Minuten
+
+---
+
+#### Phase 4.4: Editor Layout (10 Min)
+
+**activity_editor.xml - Material 3 TextInputLayouts:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/layout/activity_editor.xml`
+
+**Zeitaufwand:** 10 Minuten
+
+---
+
+#### Phase 4.5: Material Symbols Icons (15 Min)
+
+**Icons erstellen in `res/drawable/`:**
+
+1. **ic_add_24.xml**
+```xml
+
+
+
+```
+
+2. **ic_sync_24.xml**
+3. **ic_settings_24.xml**
+4. **ic_cloud_done_24.xml**
+5. **ic_cloud_sync_24.xml**
+6. **ic_warning_24.xml**
+7. **ic_server_24.xml**
+8. **ic_person_24.xml**
+9. **ic_lock_24.xml**
+10. **ic_cloud_download_24.xml**
+11. **ic_check_24.xml**
+
+Tool: https://fonts.google.com/icons
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/drawable/` (11 neue Icons)
+
+**Zeitaufwand:** 15 Minuten
+
+---
+
+#### Phase 4.6: Splash Screen (30 Min)
+
+**themes.xml - Splash Screen hinzufĂźgen:**
+```xml
+
+```
+
+**AndroidManifest.xml:**
+```xml
+
+
+
+```
+
+**MainActivity.kt:**
+```kotlin
+override fun onCreate(savedInstanceState: Bundle?) {
+ // Handle splash screen
+ installSplashScreen()
+
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+}
+
+// Import:
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+```
+
+**build.gradle.kts:**
+```kotlin
+dependencies {
+ implementation("androidx.core:core-splashscreen:1.0.1")
+}
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/values/themes.xml`
+- `android/app/src/main/AndroidManifest.xml`
+- `android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt`
+- `android/app/build.gradle.kts`
+
+**Zeitaufwand:** 30 Minuten
+
+---
+
+### Material 3 Gesamtaufwand: ~90 Minuten
+
+---
+
+### 5ď¸âŁ F-Droid Release Vorbereitung â ď¸ MITTEL
+**Problem:**
+- Keine F-Droid Metadata vorhanden
+- Keine Build-Variante ohne Google-Dependencies
+
+**LĂśsung - Verzeichnisstruktur:**
+```
+simple-notes-sync/
+âââ metadata/
+â âââ de-DE/
+â âââ full_description.txt
+â âââ short_description.txt
+â âââ title.txt
+â âââ changelogs/
+â âââ 1.txt
+â âââ 2.txt
+â âââ 3.txt
+âââ fastlane/
+ âââ metadata/
+ âââ android/
+ âââ de-DE/
+ âââ images/
+ â âââ icon.png
+ â âââ featureGraphic.png
+ â âââ phoneScreenshots/
+ â âââ 1.png
+ â âââ 2.png
+ â âââ 3.png
+ âââ full_description.txt
+ âââ short_description.txt
+ âââ title.txt
+```
+
+**metadata/de-DE/full_description.txt:**
+```
+Simple Notes Sync - Deine privaten Notizen, selbst gehostet
+
+Eine minimalistische, datenschutzfreundliche Notizen-App mit automatischer WebDAV-Synchronisation.
+
+HAUPTMERKMALE:
+
+đ Datenschutz
+⢠Alle Daten auf deinem eigenen Server
+⢠Keine Cloud-Dienste von Drittanbietern
+⢠Kein Tracking oder Analytics
+⢠Open Source - transparent und ßberprßfbar
+
+âď¸ Automatische Synchronisation
+⢠WebDAV-Sync alle 30 Minuten
+⢠Intelligente Netzwerkerkennung
+⢠Nur im WLAN (konfigurierbar)
+⢠Minimaler Akkuverbrauch (~0,4%/Tag)
+
+⨠Einfach & Schnell
+⢠Klare, aufgeräumte Benutzeroberfläche
+⢠Blitzschnelle Notiz-Erfassung
+⢠Offline-First Design
+⢠Material Design 3
+
+đ§ Flexibel
+⢠Funktioniert mit jedem WebDAV-Server
+⢠Nextcloud, ownCloud, Apache, etc.
+⢠Docker-Setup verfßgbar
+⢠Konflikterkennung und -lÜsung
+
+TECHNISCHE DETAILS:
+
+⢠Keine Google Services benÜtigt
+⢠Keine unnÜtigen Berechtigungen
+⢠Minimale App-GrĂśĂe (~2-3 MB)
+⢠Android 8.0+ kompatibel
+⢠Kotlin + Material Design 3
+
+PERFECT FĂR:
+
+⢠Schnelle Notizen und Ideen
+⢠Einkaufslisten
+⢠Todo-Listen
+⢠PersÜnliche Gedanken
+⢠Alle, die Wert auf Datenschutz legen
+
+Der Quellcode ist verfĂźgbar auf: https://github.com/inventory69/simple-notes-sync
+```
+
+**metadata/de-DE/short_description.txt:**
+```
+Minimalistische Notizen-App mit selbst-gehosteter WebDAV-Synchronisation
+```
+
+**metadata/de-DE/title.txt:**
+```
+Simple Notes Sync
+```
+
+**Build-Flavor ohne Google:**
+```kotlin
+// build.gradle.kts
+android {
+ flavorDimensions += "version"
+ productFlavors {
+ create("fdroid") {
+ dimension = "version"
+ // Keine Google/Firebase Dependencies
+ }
+ create("playstore") {
+ dimension = "version"
+ // Optional: Google Services fĂźr Analytics etc.
+ }
+ }
+}
+
+dependencies {
+ // Base dependencies (alle Flavors)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.material)
+
+ // PlayStore specific (optional)
+ "playstoreImplementation"("com.google.firebase:firebase-analytics:21.5.0")
+}
+```
+
+**Betroffene Dateien:**
+- `metadata/` (neu)
+- `fastlane/` (neu)
+- `android/app/build.gradle.kts` (Flavors hinzufĂźgen)
+- Screenshots erstellen (phone + tablet)
+
+**Zeitaufwand:** 3-4 Stunden (inkl. Screenshots)
+
+---
+
+### 5ď¸âŁ Material Design 3 Theme â ď¸ HOCH
+**Problem:**
+- Aktuelles Theme ist Material Design 2
+- Keine Dynamic Colors (Material You)
+- Veraltete Farb-Palette
+
+**LĂśsung - themes.xml:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**colors.xml (Material 3 Baseline):**
+```xml
+
+
+
+ #006C4C
+ #FFFFFF
+ #89F8C7
+ #002114
+
+ #4D6357
+ #FFFFFF
+ #CFE9D9
+ #0A1F16
+
+ #3D6373
+ #FFFFFF
+ #C1E8FB
+ #001F29
+
+ #BA1A1A
+ #FFFFFF
+ #FFDAD6
+ #410002
+
+ #FBFDF9
+ #191C1A
+
+ #FBFDF9
+ #191C1A
+ #DCE5DD
+ #404943
+
+ #707973
+ #BFC9C2
+
+```
+
+**Dynamic Colors aktivieren (MainActivity.kt):**
+```kotlin
+override fun onCreate(savedInstanceState: Bundle?) {
+ // Enable dynamic colors (Android 12+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ DynamicColors.applyToActivityIfAvailable(this)
+ }
+
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+ // ...
+}
+```
+
+**Dependency hinzufĂźgen:**
+```kotlin
+// build.gradle.kts
+dependencies {
+ implementation("com.google.android.material:material:1.11.0")
+}
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/values/themes.xml`
+- `android/app/src/main/res/values/colors.xml`
+- `android/app/src/main/res/values-night/colors.xml` (neu)
+- `android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt`
+- `android/app/build.gradle.kts`
+
+**Zeitaufwand:** 2-3 Stunden
+
+---
+
+### 6ď¸âŁ Settings UI mit Material 3 â ď¸ MITTEL
+**Problem:**
+- Plain TextInputLayouts ohne Icons
+- Keine visuellen Gruppierungen
+- Server-Status Wechsel nicht animiert
+
+**LĂśsung - activity_settings.xml:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Animierter Server-Status (SettingsActivity.kt):**
+```kotlin
+private fun updateServerStatus(status: ServerStatus) {
+ val chip = findViewById(R.id.chipServerStatus)
+
+ // Animate transition
+ chip.animate()
+ .alpha(0f)
+ .setDuration(150)
+ .withEndAction {
+ when (status) {
+ ServerStatus.CHECKING -> {
+ chip.text = "đ PrĂźfe Server..."
+ chip.chipBackgroundColor = ColorStateList.valueOf(
+ getColor(R.color.md_theme_surfaceVariant)
+ )
+ chip.setChipIconResource(R.drawable.ic_sync)
+ }
+ ServerStatus.REACHABLE -> {
+ chip.text = "â
Server erreichbar"
+ chip.chipBackgroundColor = ColorStateList.valueOf(
+ getColor(R.color.md_theme_primaryContainer)
+ )
+ chip.setChipIconResource(R.drawable.ic_check_circle)
+ }
+ ServerStatus.UNREACHABLE -> {
+ chip.text = "â Nicht erreichbar"
+ chip.chipBackgroundColor = ColorStateList.valueOf(
+ getColor(R.color.md_theme_errorContainer)
+ )
+ chip.setChipIconResource(R.drawable.ic_error)
+ }
+ ServerStatus.NOT_CONFIGURED -> {
+ chip.text = "â ď¸ Nicht konfiguriert"
+ chip.chipBackgroundColor = ColorStateList.valueOf(
+ getColor(R.color.md_theme_surfaceVariant)
+ )
+ chip.setChipIconResource(R.drawable.ic_warning)
+ }
+ }
+
+ chip.animate()
+ .alpha(1f)
+ .setDuration(150)
+ .start()
+ }
+ .start()
+}
+
+enum class ServerStatus {
+ CHECKING,
+ REACHABLE,
+ UNREACHABLE,
+ NOT_CONFIGURED
+}
+```
+
+**Icons benĂśtigt (drawable/):**
+- `ic_server.xml`
+- `ic_person.xml`
+- `ic_lock.xml`
+- `ic_check_circle.xml`
+- `ic_sync.xml`
+- `ic_error.xml`
+- `ic_warning.xml`
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/layout/activity_settings.xml`
+- `android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt`
+- `android/app/src/main/res/drawable/` (Icons)
+
+**Zeitaufwand:** 3-4 Stunden
+
+---
+
+### 7ď¸âŁ Main Activity mit Material 3 Cards â ď¸ HOCH
+**Problem:**
+- Notizen in einfachen ListItems
+- Keine Elevation/Shadow
+- Swipe-to-Delete fehlt
+
+**LĂśsung - item_note.xml:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Swipe-to-Delete (MainActivity.kt):**
+```kotlin
+private fun setupRecyclerView() {
+ recyclerView = findViewById(R.id.recyclerView)
+ adapter = NotesAdapter { note ->
+ openNoteEditor(note.id)
+ }
+
+ recyclerView.adapter = adapter
+ recyclerView.layoutManager = LinearLayoutManager(this)
+
+ // Swipe-to-Delete
+ val swipeHandler = object : ItemTouchHelper.SimpleCallback(
+ 0,
+ ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
+ ) {
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean = false
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ val position = viewHolder.adapterPosition
+ val note = adapter.notes[position]
+
+ // Delete with undo
+ adapter.removeNote(position)
+ storage.deleteNote(note.id)
+
+ Snackbar.make(
+ findViewById(R.id.coordinator),
+ "Notiz gelĂśscht",
+ Snackbar.LENGTH_LONG
+ ).setAction("RĂCKGĂNGIG") {
+ adapter.addNote(position, note)
+ storage.saveNote(note)
+ }.show()
+ }
+
+ override fun onChildDraw(
+ c: Canvas,
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ dX: Float,
+ dY: Float,
+ actionState: Int,
+ isCurrentlyActive: Boolean
+ ) {
+ val itemView = viewHolder.itemView
+
+ val paint = Paint()
+ paint.color = getColor(R.color.md_theme_errorContainer)
+
+ // Draw background
+ if (dX > 0) {
+ c.drawRect(
+ itemView.left.toFloat(),
+ itemView.top.toFloat(),
+ dX,
+ itemView.bottom.toFloat(),
+ paint
+ )
+ } else {
+ c.drawRect(
+ itemView.right.toFloat() + dX,
+ itemView.top.toFloat(),
+ itemView.right.toFloat(),
+ itemView.bottom.toFloat(),
+ paint
+ )
+ }
+
+ super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
+ }
+ }
+
+ ItemTouchHelper(swipeHandler).attachToRecyclerView(recyclerView)
+}
+```
+
+**Empty State (activity_main.xml):**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Extended FAB (activity_main.xml):**
+```xml
+
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/layout/activity_main.xml`
+- `android/app/src/main/res/layout/item_note.xml`
+- `android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt`
+- `android/app/src/main/java/dev/dettmer/simplenotes/adapters/NotesAdapter.kt`
+
+**Zeitaufwand:** 4-5 Stunden
+
+---
+
+### 8ď¸âŁ Editor mit Material 3 â ď¸ MITTEL
+**Problem:**
+- Einfache EditText-Felder
+- Kein Character Counter
+- Keine visuelle Trennung
+
+**LĂśsung - activity_editor.xml:**
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/layout/activity_editor.xml`
+- `android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt`
+
+**Zeitaufwand:** 2 Stunden
+
+---
+
+### 9ď¸âŁ Splash Screen mit Material 3 â ď¸ NIEDRIG
+**Problem:**
+- Kein moderner Splash Screen
+
+**LĂśsung:**
+```xml
+
+
+```
+
+```xml
+
+
+
+
+```
+
+```kotlin
+// MainActivity.kt
+override fun onCreate(savedInstanceState: Bundle?) {
+ // Handle the splash screen transition
+ installSplashScreen()
+
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_main)
+}
+```
+
+**Dependency:**
+```kotlin
+implementation("androidx.core:core-splashscreen:1.0.1")
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/values/themes.xml`
+- `android/app/src/main/AndroidManifest.xml`
+- `android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt`
+- `android/app/build.gradle.kts`
+
+**Zeitaufwand:** 30 Minuten
+
+---
+
+### đ Deutsche Lokalisierung â ď¸ MITTEL
+**Problem:**
+- Einige Strings noch auf Englisch
+- Release Notes englisch
+- Error Messages englisch
+
+**LÜsung - strings.xml vervollständigen:**
+```xml
+
+
+ Simple Notes
+
+
+ Noch keine Notizen.\nTippe + um eine zu erstellen.
+ Notiz hinzufĂźgen
+ Synchronisieren
+ Einstellungen
+
+
+ Notiz bearbeiten
+ Neue Notiz
+ Titel
+ Inhalt
+ Speichern
+ LĂśschen
+ SpeichereâŚ
+ â Gespeichert
+ Ănderungen werden automatisch gespeichert
+
+
+ Server-Einstellungen
+ Server URL
+ z.B. https://cloud.example.com/remote.php/dav/files/username/notes
+ Benutzername
+ Passwort
+ WLAN-Einstellungen
+ Heim-WLAN SSID
+ Auto-Sync aktiviert
+ Synchronisiert alle 30 Minuten
+ Auto-Sync funktioniert nur im selben WLAN-Netzwerk wie dein Server. Minimaler Akkuverbrauch (~0.4%/Tag).
+ Verbindung testen
+ Jetzt synchronisieren
+ Sync-Status
+
+
+ đ PrĂźfe ServerâŚ
+ â
Server erreichbar
+ â Nicht erreichbar
+ â ď¸ Nicht konfiguriert
+
+
+ Teste VerbindungâŚ
+ Verbindung erfolgreich!
+ Verbindung fehlgeschlagen: %s
+ SynchronisiereâŚ
+ Erfolgreich! %d Notizen synchronisiert
+ Sync fehlgeschlagen: %s
+ Sync abgeschlossen. %d Konflikte erkannt!
+ Notiz gespeichert
+ Notiz gelĂśscht
+ RĂCKGĂNGIG
+
+
+ Notiz lĂśschen?
+ Diese Aktion kann nicht rßckgängig gemacht werden.
+ Abbrechen
+ Hintergrund-Synchronisation
+ Damit die App im Hintergrund synchronisieren kann, muss die Akku-Optimierung deaktiviert werden.\n\nBitte wähle \'Nicht optimieren\' fßr Simple Notes.
+ Einstellungen Ăśffnen
+ Später
+
+
+ Titel oder Inhalt darf nicht leer sein
+ Netzwerkfehler: %s
+ Server-Fehler: %s
+ Authentifizierung fehlgeschlagen
+ Unbekannter Fehler: %s
+
+
+ Notizen Synchronisierung
+ Benachrichtigungen Ăźber Sync-Status
+ Sync erfolgreich
+ %d Notizen synchronisiert
+ Sync fehlgeschlagen
+ %s
+
+```
+
+**Betroffene Dateien:**
+- `android/app/src/main/res/values/strings.xml`
+- `.github/workflows/build-production-apk.yml`
+- Alle `.kt` Dateien mit hardcoded strings
+
+**Zeitaufwand:** 2 Stunden
+
+---
+
+## đ Zusammenfassung & Prioritäten
+
+### Phase 1: Kritische UX-Fixes (Sofort) âĄ
+**Zeitaufwand: ~3-4 Stunden**
+
+1. â
Server-Status Aktualisierung (15 min)
+2. â
Auto-Save Indikator (1-2 h)
+3. â
GitHub Releases auf Deutsch (30 min)
+4. â
Server-Backup Wiederherstellung (2-3 h)
+
+### Phase 2: Material Design 3 Migration (1 Tag) đ¨
+**Zeitaufwand: ~90 Minuten**
+
+5. â
Theme & Dynamic Colors (15 min)
+6. â
MainActivity Layout (10 min)
+7. â
Note Item Card (10 min)
+8. â
Editor Layout (10 min)
+9. â
Settings Layout (10 min) - aus Phase 3 vorgezogen
+10. â
Material Icons (15 min)
+11. â
Splash Screen (30 min)
+
+### Phase 3: Advanced UI Features (2-3 Tage) đ
+**Zeitaufwand: ~4-5 Stunden**
+
+12. â
Swipe-to-Delete (1 h)
+13. â
Empty State (30 min)
+14. â
Animierte Server-Status Ănderung (1 h)
+15. â
Deutsche Lokalisierung vervollständigen (1-2 h)
+
+### Phase 4: F-Droid Release (1 Tag) đŚ
+**Zeitaufwand: ~4 Stunden**
+
+16. â
F-Droid Metadata (3-4 h)
+17. â
F-Droid Build-Flavor (30 min)
+
+---
+
+## đŻ Empfohlene Reihenfolge
+
+### Woche 1: Fundament & Kritische Fixes
+**Tag 1 (3-4h):** Phase 1 - Kritische UX-Fixes
+- Server-Status sofort grĂźn nach Test
+- Auto-Save mit visuellem Feedback
+- Deutsche Release Notes
+- **Server-Backup Funktion** â NEU & WICHTIG
+
+**Tag 2 (1.5h):** Phase 2 Start - Material Design 3 Foundation
+- Theme & Dynamic Colors aktivieren
+- MainActivity Layout modernisieren
+- Note Item Cards verbessern
+
+**Tag 3 (1.5h):** Phase 2 FortfĂźhrung - Material Design 3
+- Editor Layout upgraden
+- Settings Layout modernisieren
+- Material Icons erstellen
+- Splash Screen implementieren
+
+### Woche 2: Polish & Release
+**Tag 4 (2-3h):** Phase 3 - Advanced Features
+- Swipe-to-Delete mit Animation
+- Empty State mit Illustration
+- Server-Status Animationen
+- Deutsche Strings vervollständigen
+
+**Tag 5 (4h):** Phase 4 - F-Droid Vorbereitung
+- Metadata erstellen
+- Screenshots machen
+- Build-Flavor konfigurieren
+
+---
+
+## đ Neue Features Zusammenfassung
+
+### Server-Backup Wiederherstellung
+**Warum wichtig:**
+- â
Gerätewechsel einfach
+- â
Recovery nach App-Neuinstallation
+- â
Datensicherheit erhĂśht
+- â
User-Vertrauen gestärkt
+
+**Wo in der UI:**
+- Settings Activity â neuer Button "Vom Server wiederherstellen"
+- Warn-Dialog vor AusfĂźhrung
+- Progress-Dialog während Download
+- Success-Dialog mit Anzahl wiederhergestellter Notizen
+
+**Backend:**
+- `WebDavSyncService.restoreFromServer()`
+- `NotesStorage.clearAll()`
+- Vollständiger Download aller Server-Notizen
+- Ăberschreibt lokale Daten komplett
+
+---
+
+## đ Material Design 3 - Schnellreferenz
+
+### Umgesetzt wird:
+â
**Dynamic Colors** - Farben aus Wallpaper (Android 12+)
+â
**Material 3 Components** - Cards, Buttons, TextInputs
+â
**16dp Corner Radius** - Modernere abgerundete Ecken
+â
**Material Symbols** - Neue Icon-Familie
+â
**Typography Scale** - Material 3 Text-Styles
+â
**Dark Mode** - Perfekt abgestimmte Nacht-Farben
+â
**Splash Screen API** - Android 12+ Native Splash
+
+### Design-Token:
+- **Primary:** GrĂźn (#006C4C) - Natur, Notizen, Wachstum
+- **Secondary:** Grau-GrĂźn - Subtil, harmonisch
+- **Surface:** Hell/Dunkel - Abhängig von Theme
+- **Shapes:** Small 12dp, Medium 16dp, Large 24dp
+
+---
+
+## đ Checkliste vor Start
+
+- [ ] Branch erstellen: `git checkout -b feature/ux-improvements`
+- [ ] Backup vom aktuellen Stand
+- [ ] Material 3 Dependency prĂźfen: `com.google.android.material:material:1.11.0`
+- [ ] Android Studio aktualisiert
+- [ ] Testgerät mit Android 12+ fßr Dynamic Colors
+
+---
+
+## đ§Ş Testing nach Abschluss
+
+### Manuell:
+- [ ] Alle Layouts auf Smartphone (Phone)
+- [ ] Alle Layouts auf Tablet
+- [ ] Dark Mode Ăźberall
+- [ ] Light Mode Ăźberall
+- [ ] Dynamic Colors (Android 12+)
+- [ ] Server-Backup: Restore funktioniert
+- [ ] Server-Backup: Dialog-Texte korrekt
+- [ ] Auto-Save: Indikator erscheint
+- [ ] Auto-Save: Speichert nach 2s
+- [ ] Server-Status: Wird sofort aktualisiert
+- [ ] Swipe-to-Delete: Animation smooth
+- [ ] Empty State: Zeigt sich bei 0 Notizen
+- [ ] Splash Screen: Erscheint beim Start
+- [ ] Alle Icons: Richtige Farbe (Tint)
+- [ ] Alle Buttons: Funktionieren
+- [ ] Deutsch: Keine englischen Strings mehr
+
+### Automatisch:
+- [ ] Build erfolgreich (Debug)
+- [ ] Build erfolgreich (Release)
+- [ ] APK Size akzeptabel (<5 MB)
+- [ ] Keine Lint-Errors
+- [ ] ProGuard-Regeln funktionieren
+
+---
+
+## đ Referenzen & Tools
+
+### Material Design 3:
+- [Material Design 3 Guidelines](https://m3.material.io/)
+- [Material Theme Builder](https://material-foundation.github.io/material-theme-builder/)
+- [Material Symbols Icons](https://fonts.google.com/icons)
+
+### Android:
+- [Splash Screen API](https://developer.android.com/develop/ui/views/launch/splash-screen)
+- [Dynamic Colors](https://developer.android.com/develop/ui/views/theming/dynamic-colors)
+
+---
+
+## đ Nächste Schritte
+
+Soll ich mit **Phase 1** (kritische UX-Fixes + Server-Backup) beginnen?
+
+### Was ich jetzt machen wĂźrde:
+
+1. **Server-Backup implementieren** (2-3h)
+ - HÜchste Priorität: User-requested Feature
+ - Kritisch fĂźr Datensicherheit
+
+2. **Server-Status sofort aktualisieren** (15 min)
+ - Schneller Win
+ - Verbessert UX sofort
+
+3. **Auto-Save Indikator** (1-2h)
+ - Eliminiert Verwirrung
+ - Modernes Pattern
+
+4. **Material 3 Foundation** (90 min)
+ - Theme & Colors
+ - Basis fĂźr alles weitere
+
+Diese 4 Tasks wĂźrden den grĂśĂten Impact haben und sind in ~4-6 Stunden machbar! đ
diff --git a/README.md b/README.md
index c59ddd9..a61f006 100644
--- a/README.md
+++ b/README.md
@@ -1,143 +1,128 @@
# Simple Notes Sync đ
-> Minimalistische Android-App fĂźr Offline-Notizen mit automatischer WLAN-Synchronisierung
+> **Minimalistische Android Notiz-App mit automatischer WLAN-Synchronisierung**
-Eine schlanke Notiz-App ohne Schnickschnack - perfekt fĂźr schnelle Gedanken, die automatisch zu Hause synchronisiert werden.
+[](https://www.android.com/)
+[](https://kotlinlang.org/)
+[](https://m3.material.io/)
+[](LICENSE)
----
+Schlanke Offline-Notizen ohne Schnickschnack - deine Daten bleiben bei dir. Automatische Synchronisierung zu deinem eigenen WebDAV-Server, kein Google, kein Microsoft, keine Cloud.
## ⨠Features
-- đ **Offline-first** - Notizen werden lokal gespeichert und sind immer verfĂźgbar
-- đ **Auto-Sync** - Automatische Synchronisierung wenn du im Heimnetzwerk bist
-- đ **WebDAV Server** - Deine Daten bleiben bei dir (Docker-Container)
-- đ **Akkuschonend** - Nur ~0.4% Akkuverbrauch pro Tag
-- đŤ **Keine Cloud** - Keine Google, keine Microsoft, keine Drittanbieter
-- đ **Privacy** - Keine Tracking, keine Analytics, keine Standort-Berechtigungen
+- đ **Offline-First** - Notizen lokal gespeichert, immer verfĂźgbar
+- đ **Auto-Sync** - Konfigurierbare Intervalle (15/30/60 Min.) mit ~0.2-0.8% Akku/Tag
+- đ **Self-Hosted** - Deine Daten auf deinem Server (WebDAV)
+- đ¨ **Material Design 3** - Modern & Dynamic Theming
+- đ **Akkuschonend** - Optimiert fĂźr Hintergrund-Synchronisierung
+- đ **Privacy-First** - Kein Tracking, keine Analytics, keine Cloud
+- đŤ **Keine Berechtigungen** - Nur Internet fĂźr WebDAV Sync
----
+## đĽ Quick Download
-## đĽ Installation
+**Android APK:** [đą Neueste Version herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
-### Android App
-
-**Option 1: APK herunterladen**
-
-1. Neueste [Release](../../releases/latest) Ăśffnen
-2. `app-debug.apk` herunterladen
-3. APK auf dem Handy installieren
-
-**Option 2: Selbst bauen**
-
-```bash
-cd android
-./gradlew assembleDebug
-# APK: android/app/build/outputs/apk/debug/app-debug.apk
-```
-
-### WebDAV Server
-
-Der Server läuft als Docker-Container und speichert deine Notizen.
-
-```bash
-cd server
-cp .env.example .env
-nano .env # Passwort anpassen!
-docker-compose up -d
-```
-
-**Server testen:**
-```bash
-curl -u noteuser:dein_passwort http://192.168.0.XXX:8080/
-```
+đĄ **Tipp:** Nutze [Obtainium](https://github.com/ImranR98/Obtainium) fĂźr automatische Updates!
---
## đ Schnellstart
-1. **Server starten** (siehe oben)
-2. **App installieren** und Ăśffnen
-3. **Einstellungen Ăśffnen** (âď¸ Symbol oben rechts)
-4. **Server konfigurieren:**
- - Server-URL: `http://192.168.0.XXX:8080/notes`
+### 1ď¸âŁ WebDAV Server starten
+
+```fish
+cd server
+cp .env.example .env
+# Passwort in .env anpassen
+docker compose up -d
+```
+
+### 2ď¸âŁ App installieren & konfigurieren
+
+1. APK herunterladen und installieren
+2. App Ăśffnen â **Einstellungen** (âď¸)
+3. Server konfigurieren:
+ - URL: `http://192.168.0.XXX:8080/notes`
- Benutzername: `noteuser`
- - Passwort: (aus `.env` Datei)
- - Auto-Sync: **AN**
-5. **Fertig!** Notizen werden jetzt automatisch synchronisiert
+ - Passwort: (aus `.env`)
+4. **Auto-Sync aktivieren**
+5. **Sync-Intervall wählen** (15/30/60 Min.)
+
+**Fertig!** Notizen werden automatisch synchronisiert đ
---
-## đĄ Wie funktioniert Auto-Sync?
+## âď¸ Sync-Intervalle
-Die App prĂźft **alle 30 Minuten**, ob:
-- â
WLAN verbunden ist
-- â
Server im gleichen Netzwerk erreichbar ist
-- â
Neue Notizen vorhanden sind
+| Intervall | Akku/Tag | Anwendungsfall |
+|-----------|----------|----------------|
+| **15 Min** | ~0.8% (~23 mAh) | ⥠Maximale Aktualität |
+| **30 Min** | ~0.4% (~12 mAh) | â Empfohlen - Ausgewogen |
+| **60 Min** | ~0.2% (~6 mAh) | đ Maximale Akkulaufzeit |
-Wenn alle Bedingungen erfĂźllt â **Automatische Synchronisierung**
-
-**Wichtig:** Funktioniert nur im selben Netzwerk wie der Server (kein Internet-Zugriff nĂśtig!)
+đĄ **Hinweis:** Android Doze Mode kann Sync im Standby auf ~60 Min. verzĂśgern (betrifft alle Apps).
---
-## đ Akkuverbrauch
+## ďż˝ Neue Features in v1.1.0
-| Komponente | Verbrauch/Tag |
-|------------|---------------|
-| WorkManager (alle 30 Min) | ~0.3% |
-| Netzwerk-Checks | ~0.1% |
-| **Total** | **~0.4%** |
+### Konfigurierbare Sync-Intervalle
+- âąď¸ Wählbare Intervalle: 15/30/60 Minuten
+- đ Transparente Akkuverbrauchs-Anzeige
+- ďż˝ Sofortige Anwendung ohne App-Neustart
-Bei einem 3000 mAh Akku entspricht das ~12 mAh pro Tag.
+### Ăber-Sektion
+- ďż˝ App-Version & Build-Datum
+- đ Links zu GitHub Repo & Entwickler
+- âď¸ Lizenz-Information
+
+### Verbesserungen
+- đŻ Benutzerfreundliche Doze-Mode Erklärung
+- đ Keine stĂśrenden Sync-Fehler Toasts im Hintergrund
+- đ Erweiterte Debug-Logs fĂźr Troubleshooting
---
-## đą Screenshots
+## đ ď¸ Selbst bauen
-_TODO: Screenshots hinzufĂźgen_
-
----
-
-## đ ď¸ Technische Details
-
-Mehr Infos zur Architektur und Implementierung findest du in der [technischen Dokumentation](DOCS.md).
-
-**Stack:**
-- **Android:** Kotlin, Material Design 3, WorkManager
-- **Server:** Docker, WebDAV (bytemark/webdav)
-- **Sync:** Sardine Android (WebDAV Client)
+```fish
+cd android
+./gradlew assembleStandardRelease
+# APK: android/app/build/outputs/apk/standard/release/
+```
---
## đ Troubleshooting
+### Auto-Sync funktioniert nicht
+
+1. **Akku-Optimierung deaktivieren**
+ - Einstellungen â Apps â Simple Notes â Akku â Nicht optimieren
+2. **WLAN-Verbindung prĂźfen**
+ - Funktioniert nur im selben Netzwerk wie Server
+3. **Server-Status checken**
+ - Settings â "Verbindung testen"
+
### Server nicht erreichbar
-```bash
-# Server Status prĂźfen
-docker-compose ps
+```fish
+# Status prĂźfen
+docker compose ps
# Logs ansehen
-docker-compose logs -f
+docker compose logs -f
# IP-Adresse finden
ip addr show | grep "inet " | grep -v 127.0.0.1
```
-### Auto-Sync funktioniert nicht
-
-1. **Akku-Optimierung deaktivieren**
- - Einstellungen â Apps â Simple Notes â Akku â Nicht optimieren
-2. **WLAN Verbindung prĂźfen**
- - App funktioniert nur im selben Netzwerk wie der Server
-3. **Server-Status in App prĂźfen**
- - Settings â Server-Status sollte "Erreichbar" zeigen
-
-Mehr Details in der [Dokumentation](DOCS.md).
+Mehr Details: [đ Dokumentation](DOCS.md)
---
-## đ¤ Beitragen
+## đ¤ Contributing
Contributions sind willkommen! Bitte Ăśffne ein Issue oder Pull Request.
@@ -149,5 +134,4 @@ MIT License - siehe [LICENSE](LICENSE)
---
-**Projekt Start:** 19. Dezember 2025
-**Status:** â
Funktional & Produktiv
+**Version:** 1.1.0 ¡ **Status:** â
Produktiv ¡ **Gebaut mit:** Kotlin + Material Design 3
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 530b9bf..2f4174f 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -5,6 +5,9 @@ plugins {
import java.util.Properties
import java.io.FileInputStream
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
android {
namespace = "dev.dettmer.simplenotes"
@@ -14,10 +17,13 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
- versionCode = 1
- versionName = "1.0"
+ versionCode = 2 // đĽ F-Droid Release v1.1.0
+ versionName = "1.1.0" // đĽ Configurable Sync Interval + About Section
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ // đĽ NEU: Build Date fĂźr About Screen
+ buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
}
// Enable multiple APKs per ABI for smaller downloads
@@ -29,6 +35,21 @@ android {
isUniversalApk = true // Also generate universal APK
}
}
+
+ // Product Flavors for F-Droid and standard builds
+ flavorDimensions += "distribution"
+ productFlavors {
+ create("fdroid") {
+ dimension = "distribution"
+ // F-Droid builds have no proprietary dependencies
+ // All dependencies in this project are already FOSS-compatible
+ }
+
+ create("standard") {
+ dimension = "distribution"
+ // Standard builds can include Play Services in the future if needed
+ }
+ }
// Signing configuration for release builds
signingConfigs {
@@ -86,6 +107,9 @@ dependencies {
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
+ // Splash Screen API (Android 12+)
+ implementation("androidx.core:core-splashscreen:1.0.1")
+
// Unsere Dependencies (DIREKT mit Versionen - viel einfacher!)
implementation("com.github.thegrizzlylabs:sardine-android:0.8") {
exclude(group = "xpp3", module = "xpp3")
@@ -104,4 +128,10 @@ dependencies {
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
+}
+
+// đĽ NEU: Helper function fĂźr Build Date
+fun getBuildDate(): String {
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ return dateFormat.format(Date())
}
\ No newline at end of file
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b033a8d..2eef493 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -31,7 +31,8 @@
tools:targetApi="31">
+ android:exported="true"
+ android:theme="@style/Theme.SimpleNotes.Splash">
@@ -60,6 +61,17 @@
+
+
+
+
+
\ 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 8313312..8ccf242 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt
@@ -13,39 +13,52 @@ import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
+import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.card.MaterialCardView
import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast
+import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView
- private lateinit var textViewEmpty: TextView
+ private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) }
+ private val prefs by lazy {
+ getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
+ }
+
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_NOTIFICATION_PERMISSION = 1001
+ private const val REQUEST_SETTINGS = 1002
+ private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
+ private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
}
/**
- * BroadcastReceiver fĂźr Background-Sync Completion
+ * BroadcastReceiver fĂźr Background-Sync Completion (Periodic Sync)
*/
private val syncCompletedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -63,9 +76,21 @@ class MainActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
+ // Install Splash Screen (Android 12+)
+ installSplashScreen()
+
super.onCreate(savedInstanceState)
+
+ // Apply Dynamic Colors for Android 12+ (Material You)
+ DynamicColors.applyToActivityIfAvailable(this)
+
setContentView(R.layout.activity_main)
+ // File Logging aktivieren wenn eingestellt
+ if (prefs.getBoolean("file_logging_enabled", false)) {
+ Logger.enableFileLogging(this)
+ }
+
// Permission fĂźr Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission()
@@ -82,14 +107,87 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
+ Logger.d(TAG, "đą MainActivity.onResume() - Registering receivers")
+
// Register BroadcastReceiver fĂźr Background-Sync
LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
)
- Logger.d(TAG, "đĄ BroadcastReceiver registered")
+ Logger.d(TAG, "đĄ BroadcastReceiver registered (sync-completed)")
+
+ // Reload notes
loadNotes()
+
+ // Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
+ triggerAutoSync("onResume")
+ }
+
+ /**
+ * Automatischer Sync (onResume)
+ * - Nutzt WiFi-gebundenen Socket (VPN Fix!)
+ * - Nur Success-Toast (kein "Auto-Sync..." Toast)
+ *
+ * NOTE: WiFi-Connect Sync nutzt WorkManager (auch wenn App geschlossen!)
+ */
+ private fun triggerAutoSync(source: String = "unknown") {
+ // Throttling: Max 1 Sync pro Minute
+ if (!canTriggerAutoSync()) {
+ return
+ }
+
+ Logger.d(TAG, "đ Auto-sync triggered ($source)")
+
+ // Update last sync timestamp
+ prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
+
+ // GLEICHER Sync-Code wie manueller Sync (funktioniert!)
+ lifecycleScope.launch {
+ try {
+ val syncService = WebDavSyncService(this@MainActivity)
+ val result = withContext(Dispatchers.IO) {
+ syncService.syncNotes()
+ }
+
+ // Feedback abhängig von Source
+ if (result.isSuccess && result.syncedCount > 0) {
+ Logger.d(TAG, "â
Auto-sync successful ($source): ${result.syncedCount} notes")
+
+ // onResume: Nur Success-Toast
+ showToast("â
Gesynct: ${result.syncedCount} Notizen")
+ loadNotes()
+
+ } else if (result.isSuccess) {
+ Logger.d(TAG, "âšď¸ Auto-sync ($source): No changes")
+
+ } else {
+ Logger.e(TAG, "â Auto-sync failed ($source): ${result.errorMessage}")
+ // Kein Toast - App ist im Hintergrund
+ }
+
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ Auto-sync exception ($source): ${e.message}")
+ // Kein Toast - App ist im Hintergrund
+ }
+ }
+ }
+
+ /**
+ * PrĂźft ob Auto-Sync getriggert werden darf (Throttling)
+ */
+ private fun canTriggerAutoSync(): Boolean {
+ val lastSyncTime = prefs.getLong(PREF_LAST_AUTO_SYNC_TIME, 0)
+ val now = System.currentTimeMillis()
+ val timeSinceLastSync = now - lastSyncTime
+
+ if (timeSinceLastSync < MIN_AUTO_SYNC_INTERVAL_MS) {
+ val remainingSeconds = (MIN_AUTO_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
+ Logger.d(TAG, "âł Auto-sync throttled - wait ${remainingSeconds}s")
+ return false
+ }
+
+ return true
}
override fun onPause() {
@@ -102,7 +200,7 @@ class MainActivity : AppCompatActivity() {
private fun findViews() {
recyclerViewNotes = findViewById(R.id.recyclerViewNotes)
- textViewEmpty = findViewById(R.id.textViewEmpty)
+ emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar)
}
@@ -117,6 +215,57 @@ class MainActivity : AppCompatActivity() {
}
recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
+
+ // Setup Swipe-to-Delete
+ setupSwipeToDelete()
+ }
+
+ private fun setupSwipeToDelete() {
+ val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
+ 0, // No drag
+ ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT // Swipe left or right
+ ) {
+ override fun onMove(
+ recyclerView: RecyclerView,
+ viewHolder: RecyclerView.ViewHolder,
+ target: RecyclerView.ViewHolder
+ ): Boolean = false
+
+ override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
+ val position = viewHolder.adapterPosition
+ val note = adapter.currentList[position]
+ val notesCopy = adapter.currentList.toMutableList()
+
+ // Remove from list immediately for visual feedback
+ notesCopy.removeAt(position)
+ adapter.submitList(notesCopy)
+
+ // Show Snackbar with UNDO
+ Snackbar.make(
+ recyclerViewNotes,
+ "Notiz gelĂśscht",
+ Snackbar.LENGTH_LONG
+ ).setAction("RĂCKGĂNGIG") {
+ // UNDO: Restore note in list
+ loadNotes()
+ }.addCallback(object : Snackbar.Callback() {
+ override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
+ if (event != DISMISS_EVENT_ACTION) {
+ // Snackbar dismissed without UNDO â Actually delete the note
+ storage.deleteNote(note.id)
+ loadNotes()
+ }
+ }
+ }).show()
+ }
+
+ override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
+ // Require 80% swipe to trigger
+ return 0.8f
+ }
+ })
+
+ itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
}
private fun setupFab() {
@@ -129,8 +278,8 @@ class MainActivity : AppCompatActivity() {
val notes = storage.loadAllNotes()
adapter.submitList(notes)
- // Empty state
- textViewEmpty.visibility = if (notes.isEmpty()) {
+ // Material 3 Empty State Card
+ emptyStateCard.visibility = if (notes.isEmpty()) {
android.view.View.VISIBLE
} else {
android.view.View.GONE
@@ -146,7 +295,9 @@ class MainActivity : AppCompatActivity() {
}
private fun openSettings() {
- startActivity(Intent(this, SettingsActivity::class.java))
+ val intent = Intent(this, SettingsActivity::class.java)
+ @Suppress("DEPRECATION")
+ startActivityForResult(intent, REQUEST_SETTINGS)
}
private fun triggerManualSync() {
@@ -205,6 +356,16 @@ class MainActivity : AppCompatActivity() {
}
}
+ @Deprecated("Deprecated in Java")
+ override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+ super.onActivityResult(requestCode, resultCode, data)
+
+ if (requestCode == REQUEST_SETTINGS && resultCode == RESULT_OK) {
+ // Restore was successful, reload notes
+ loadNotes()
+ }
+ }
+
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array,
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt
index c48d7cf..dadaeb9 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/NoteEditorActivity.kt
@@ -6,6 +6,7 @@ import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.color.DynamicColors
import com.google.android.material.textfield.TextInputEditText
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
@@ -27,6 +28,10 @@ class NoteEditorActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ // Apply Dynamic Colors for Android 12+ (Material You)
+ DynamicColors.applyToActivityIfAvailable(this)
+
setContentView(R.layout.activity_editor)
storage = NotesStorage(this)
@@ -89,7 +94,7 @@ class NoteEditorActivity : AppCompatActivity() {
val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) {
- showToast("Titel oder Inhalt darf nicht leer sein")
+ showToast("Notiz ist leer")
return
}
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 fc45636..64d3a05 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
@@ -10,25 +10,43 @@ import android.util.Log
import android.view.MenuItem
import android.widget.Button
import android.widget.EditText
+import android.widget.RadioGroup
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat
+import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
+import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.appbar.MaterialToolbar
-import dev.dettmer.simplenotes.sync.WebDavSyncService
-import dev.dettmer.simplenotes.utils.Constants
-import dev.dettmer.simplenotes.utils.showToast
+import com.google.android.material.card.MaterialCardView
+import com.google.android.material.chip.Chip
+import com.google.android.material.color.DynamicColors
+import com.google.android.material.switchmaterial.SwitchMaterial
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import dev.dettmer.simplenotes.sync.WebDavSyncService
+import dev.dettmer.simplenotes.sync.NetworkMonitor
+import dev.dettmer.simplenotes.utils.Constants
+import dev.dettmer.simplenotes.utils.Logger
+import dev.dettmer.simplenotes.utils.showToast
+import java.io.File
import java.net.HttpURLConnection
import java.net.URL
+import java.text.SimpleDateFormat
+import java.util.Locale
class SettingsActivity : AppCompatActivity() {
companion object {
private const val TAG = "SettingsActivity"
+ private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync"
+ private const val GITHUB_PROFILE_URL = "https://github.com/inventory69"
+ private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
}
private lateinit var editTextServerUrl: EditText
@@ -37,7 +55,20 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var switchAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button
+ private lateinit var buttonRestoreFromServer: Button
private lateinit var textViewServerStatus: TextView
+ private lateinit var chipAutoSaveStatus: Chip
+
+ // Sync Interval UI
+ private lateinit var radioGroupSyncInterval: RadioGroup
+
+ // About Section UI
+ private lateinit var textViewAppVersion: TextView
+ private lateinit var cardGitHubRepo: MaterialCardView
+ private lateinit var cardDeveloperProfile: MaterialCardView
+ private lateinit var cardLicense: MaterialCardView
+
+ private var autoSaveIndicatorJob: Job? = null
private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
@@ -45,6 +76,10 @@ class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ // Apply Dynamic Colors for Android 12+ (Material You)
+ DynamicColors.applyToActivityIfAvailable(this)
+
setContentView(R.layout.activity_settings)
// Setup toolbar
@@ -58,6 +93,8 @@ class SettingsActivity : AppCompatActivity() {
findViews()
loadSettings()
setupListeners()
+ setupSyncIntervalPicker()
+ setupAboutSection()
}
private fun findViews() {
@@ -67,7 +104,18 @@ class SettingsActivity : AppCompatActivity() {
switchAutoSync = findViewById(R.id.switchAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow)
+ buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
textViewServerStatus = findViewById(R.id.textViewServerStatus)
+ chipAutoSaveStatus = findViewById(R.id.chipAutoSaveStatus)
+
+ // Sync Interval UI
+ radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval)
+
+ // About Section UI
+ textViewAppVersion = findViewById(R.id.textViewAppVersion)
+ cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
+ cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
+ cardLicense = findViewById(R.id.cardLicense)
}
private fun loadSettings() {
@@ -91,16 +139,122 @@ class SettingsActivity : AppCompatActivity() {
syncNow()
}
+ buttonRestoreFromServer.setOnClickListener {
+ saveSettings()
+ showRestoreConfirmation()
+ }
+
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked)
+ showAutoSaveIndicator()
}
// Server Status Check bei Settings-Ănderung
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) {
checkServerStatus()
+ showAutoSaveIndicator()
}
}
+
+ editTextUsername.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) showAutoSaveIndicator()
+ }
+
+ editTextPassword.setOnFocusChangeListener { _, hasFocus ->
+ if (!hasFocus) showAutoSaveIndicator()
+ }
+ }
+
+ /**
+ * Setup sync interval picker with radio buttons
+ */
+ private fun setupSyncIntervalPicker() {
+ // Load current interval from preferences
+ val currentInterval = prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES)
+
+ // Set checked radio button based on current interval
+ val checkedId = when (currentInterval) {
+ 15L -> R.id.radioInterval15
+ 30L -> R.id.radioInterval30
+ 60L -> R.id.radioInterval60
+ else -> R.id.radioInterval30 // Default
+ }
+ radioGroupSyncInterval.check(checkedId)
+
+ // Listen for interval changes
+ radioGroupSyncInterval.setOnCheckedChangeListener { _, checkedId ->
+ val newInterval = when (checkedId) {
+ R.id.radioInterval15 -> 15L
+ R.id.radioInterval60 -> 60L
+ else -> 30L // R.id.radioInterval30 or fallback
+ }
+
+ // Save new interval to preferences
+ prefs.edit().putLong(Constants.PREF_SYNC_INTERVAL_MINUTES, newInterval).apply()
+
+ // Restart periodic sync with new interval (only if auto-sync is enabled)
+ if (prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)) {
+ val networkMonitor = NetworkMonitor(this)
+ networkMonitor.startMonitoring()
+
+ val intervalText = when (newInterval) {
+ 15L -> "15 Minuten"
+ 30L -> "30 Minuten"
+ 60L -> "60 Minuten"
+ else -> "$newInterval Minuten"
+ }
+ showToast("âąď¸ Sync-Intervall auf $intervalText geändert")
+ Logger.i(TAG, "Sync interval changed to $newInterval minutes, restarted periodic sync")
+ } else {
+ showToast("âąď¸ Sync-Intervall gespeichert (Auto-Sync ist deaktiviert)")
+ }
+ }
+ }
+
+ /**
+ * Setup about section with version info and clickable cards
+ */
+ private fun setupAboutSection() {
+ // Display app version with build date
+ try {
+ val versionName = BuildConfig.VERSION_NAME
+ val versionCode = BuildConfig.VERSION_CODE
+ val buildDate = BuildConfig.BUILD_DATE
+
+ textViewAppVersion.text = "Version $versionName ($versionCode)\nErstellt am: $buildDate"
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to load version info", e)
+ textViewAppVersion.text = "Version nicht verfĂźgbar"
+ }
+
+ // GitHub Repository Card
+ cardGitHubRepo.setOnClickListener {
+ openUrl(GITHUB_REPO_URL)
+ }
+
+ // Developer Profile Card
+ cardDeveloperProfile.setOnClickListener {
+ openUrl(GITHUB_PROFILE_URL)
+ }
+
+ // License Card
+ cardLicense.setOnClickListener {
+ openUrl(LICENSE_URL)
+ }
+ }
+
+ /**
+ * Opens URL in browser
+ */
+ private fun openUrl(url: String) {
+ try {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+ startActivity(intent)
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to open URL: $url", e)
+ showToast("â Fehler beim Ăffnen des Links")
+ }
}
private fun saveSettings() {
@@ -122,11 +276,14 @@ class SettingsActivity : AppCompatActivity() {
if (result.isSuccess) {
showToast("Verbindung erfolgreich!")
+ checkServerStatus() // â
Server-Status sofort aktualisieren
} else {
showToast("Verbindung fehlgeschlagen: ${result.errorMessage}")
+ checkServerStatus() // â
Auch bei Fehler aktualisieren
}
} catch (e: Exception) {
showToast("Fehler: ${e.message}")
+ checkServerStatus() // â
Auch bei Exception aktualisieren
}
}
}
@@ -144,11 +301,14 @@ class SettingsActivity : AppCompatActivity() {
} else {
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
}
+ checkServerStatus() // â
Server-Status nach Sync aktualisieren
} else {
showToast("Sync fehlgeschlagen: ${result.errorMessage}")
+ checkServerStatus() // â
Auch bei Fehler aktualisieren
}
} catch (e: Exception) {
showToast("Fehler: ${e.message}")
+ checkServerStatus() // â
Auch bei Exception aktualisieren
}
}
}
@@ -260,6 +420,75 @@ class SettingsActivity : AppCompatActivity() {
}
}
+ private fun showAutoSaveIndicator() {
+ // Cancel previous job if still running
+ autoSaveIndicatorJob?.cancel()
+
+ // Show saving indicator
+ chipAutoSaveStatus.apply {
+ visibility = android.view.View.VISIBLE
+ text = "đž Speichere..."
+ setChipBackgroundColorResource(android.R.color.darker_gray)
+ }
+
+ // Save settings
+ saveSettings()
+
+ // Show saved confirmation after short delay
+ autoSaveIndicatorJob = lifecycleScope.launch {
+ delay(300) // Short delay to show "Speichere..."
+ chipAutoSaveStatus.apply {
+ text = "â Gespeichert"
+ setChipBackgroundColorResource(android.R.color.holo_green_light)
+ }
+ delay(2000) // Show for 2 seconds
+ chipAutoSaveStatus.visibility = android.view.View.GONE
+ }
+ }
+
+ private fun showRestoreConfirmation() {
+ android.app.AlertDialog.Builder(this)
+ .setTitle(R.string.restore_confirmation_title)
+ .setMessage(R.string.restore_confirmation_message)
+ .setPositiveButton(R.string.restore_button) { _, _ ->
+ performRestore()
+ }
+ .setNegativeButton(R.string.cancel, null)
+ .show()
+ }
+
+ private fun performRestore() {
+ val progressDialog = android.app.ProgressDialog(this).apply {
+ setMessage(getString(R.string.restore_progress))
+ setCancelable(false)
+ show()
+ }
+
+ CoroutineScope(Dispatchers.Main).launch {
+ try {
+ val webdavService = WebDavSyncService(this@SettingsActivity)
+ val result = withContext(Dispatchers.IO) {
+ webdavService.restoreFromServer()
+ }
+
+ progressDialog.dismiss()
+
+ if (result.isSuccess) {
+ showToast(getString(R.string.restore_success, result.restoredCount))
+ // Refresh MainActivity's note list
+ setResult(RESULT_OK)
+ } else {
+ showToast(getString(R.string.restore_error, result.errorMessage))
+ }
+ checkServerStatus()
+ } catch (e: Exception) {
+ progressDialog.dismiss()
+ showToast(getString(R.string.restore_error, e.message))
+ checkServerStatus()
+ }
+ }
+ }
+
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
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 44051be..71401f5 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/SimpleNotesApplication.kt
@@ -1,9 +1,11 @@
package dev.dettmer.simplenotes
import android.app.Application
+import android.content.Context
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper
+import dev.dettmer.simplenotes.utils.Constants
class SimpleNotesApplication : Application() {
@@ -16,6 +18,13 @@ class SimpleNotesApplication : Application() {
override fun onCreate() {
super.onCreate()
+ // File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
+ val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
+ if (prefs.getBoolean("file_logging_enabled", false)) {
+ Logger.enableFileLogging(this)
+ Logger.d(TAG, "đ File logging enabled at Application startup")
+ }
+
Logger.d(TAG, "đ Application onCreate()")
// Initialize notification channel
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
index 17dfcd0..2797a1c 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
@@ -37,5 +37,16 @@ class NotesStorage(private val context: Context) {
return file.delete()
}
+ fun deleteAllNotes(): Boolean {
+ return try {
+ notesDir.listFiles()
+ ?.filter { it.extension == "json" }
+ ?.forEach { it.delete() }
+ true
+ } catch (e: Exception) {
+ false
+ }
+ }
+
fun getNotesDir(): File = notesDir
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt
index c57c2c8..0327949 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/NetworkMonitor.kt
@@ -1,15 +1,19 @@
package dev.dettmer.simplenotes.sync
import android.content.Context
-import android.net.wifi.WifiManager
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
import androidx.work.*
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import java.util.concurrent.TimeUnit
/**
- * NetworkMonitor: Verwaltet WorkManager-basiertes Auto-Sync
- * WICHTIG: Kein NetworkCallback mehr - WorkManager macht das fĂźr uns!
+ * NetworkMonitor: Verwaltet Auto-Sync
+ * - Periodic WorkManager fĂźr Auto-Sync alle 30min
+ * - NetworkCallback fĂźr WiFi-Connect Detection â WorkManager OneTime Sync
*/
class NetworkMonitor(private val context: Context) {
@@ -22,30 +26,145 @@ class NetworkMonitor(private val context: Context) {
context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
}
+ private val connectivityManager by lazy {
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ }
+
+ // đĽ Track last connected network ID to detect network changes (SSID wechsel, WiFi an/aus)
+ // null = kein Netzwerk, sonst Network.toString() als eindeutiger Identifier
+ private var lastConnectedNetworkId: String? = null
+
/**
- * Startet WorkManager mit Network Constraints
- * WorkManager kĂźmmert sich automatisch um WiFi-Erkennung!
+ * NetworkCallback: Erkennt WiFi-Verbindung und triggert WorkManager
+ * WorkManager funktioniert auch wenn App geschlossen ist!
+ */
+ private val networkCallback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ super.onAvailable(network)
+
+ Logger.d(TAG, "đ NetworkCallback.onAvailable() triggered")
+
+ val capabilities = connectivityManager.getNetworkCapabilities(network)
+ Logger.d(TAG, " Network capabilities: $capabilities")
+
+ val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
+ Logger.d(TAG, " Is WiFi: $isWifi")
+
+ if (isWifi) {
+ val currentNetworkId = network.toString()
+ Logger.d(TAG, "đś WiFi network connected: $currentNetworkId")
+
+ // đĽ Trigger bei:
+ // 1. WiFi aus -> WiFi an (lastConnectedNetworkId == null)
+ // 2. SSID-Wechsel (lastConnectedNetworkId != currentNetworkId)
+ // NICHT triggern bei: App-Restart mit gleichem WiFi
+
+ if (lastConnectedNetworkId != currentNetworkId) {
+ if (lastConnectedNetworkId == null) {
+ Logger.d(TAG, " đŻ WiFi state changed: OFF -> ON (network: $currentNetworkId)")
+ } else {
+ Logger.d(TAG, " đŻ WiFi network changed: $lastConnectedNetworkId -> $currentNetworkId")
+ }
+
+ lastConnectedNetworkId = currentNetworkId
+
+ // Auto-Sync check
+ val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
+ Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
+
+ if (autoSyncEnabled) {
+ Logger.d(TAG, " â
Triggering WorkManager...")
+ triggerWifiConnectSync()
+ } else {
+ Logger.d(TAG, " â Auto-sync disabled - not triggering")
+ }
+ } else {
+ Logger.d(TAG, " â ď¸ Same WiFi network as before - ignoring (no network change)")
+ }
+ } else {
+ Logger.d(TAG, " â ď¸ Not WiFi - ignoring")
+ }
+ }
+
+ override fun onLost(network: Network) {
+ super.onLost(network)
+
+ val lostNetworkId = network.toString()
+ Logger.d(TAG, "đ´ NetworkCallback.onLost() - Network disconnected: $lostNetworkId")
+
+ if (lastConnectedNetworkId == lostNetworkId) {
+ Logger.d(TAG, " Last WiFi network lost - resetting state")
+ lastConnectedNetworkId = null
+ }
+ }
+ }
+
+ /**
+ * Triggert WiFi-Connect Sync via WorkManager
+ * WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
+ */
+ private fun triggerWifiConnectSync() {
+ Logger.d(TAG, "đĄ Scheduling WiFi-Connect sync via WorkManager")
+
+ // đĽ WICHTIG: NetworkType.UNMETERED constraint!
+ // Ohne Constraint kĂśnnte WorkManager den Job auf Cellular ausfĂźhren
+ // (z.B. wenn WiFi disconnected bevor Job startet)
+ val constraints = Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only!
+ .build()
+
+ val syncRequest = OneTimeWorkRequestBuilder()
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .setConstraints(constraints) // đĽ Constraints hinzugefĂźgt
+ .addTag(Constants.SYNC_WORK_TAG)
+ .addTag("wifi-connect")
+ .build()
+
+ WorkManager.getInstance(context).enqueue(syncRequest)
+ Logger.d(TAG, "â
WiFi-Connect sync scheduled (WIFI ONLY, WorkManager will wake app if needed)")
+ }
+
+ /**
+ * Startet WorkManager mit Network Constraints + NetworkCallback
*/
fun startMonitoring() {
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
if (!autoSyncEnabled) {
- Logger.d(TAG, "Auto-sync disabled - stopping periodic work")
+ Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
stopMonitoring()
return
}
- Logger.d(TAG, "đ Starting WorkManager-based auto-sync")
+ Logger.d(TAG, "đ Starting NetworkMonitor (WorkManager + WiFi Callback)")
+
+ // 1. WorkManager fĂźr periodic sync
+ startPeriodicSync()
+
+ // 2. NetworkCallback fĂźr WiFi-Connect Detection
+ startWifiMonitoring()
+ }
+
+ /**
+ * Startet WorkManager periodic sync
+ * đĽ Interval aus SharedPrefs konfigurierbar (15/30/60 min)
+ */
+ private fun startPeriodicSync() {
+ // đĽ Interval aus SharedPrefs lesen
+ val intervalMinutes = prefs.getLong(
+ Constants.PREF_SYNC_INTERVAL_MINUTES,
+ Constants.DEFAULT_SYNC_INTERVAL_MINUTES
+ )
+
+ Logger.d(TAG, "đ
Configuring periodic sync: ${intervalMinutes}min interval")
- // Constraints: Nur wenn WiFi connected
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.build()
- // Periodic Work Request - prĂźft alle 30 Minuten (Battery optimized)
val syncRequest = PeriodicWorkRequestBuilder(
- 30, TimeUnit.MINUTES, // Optimiert: 30 Min statt 15 Min
- 10, TimeUnit.MINUTES // Flex interval
+ intervalMinutes, TimeUnit.MINUTES, // đĽ Dynamisch!
+ 5, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.addTag(Constants.SYNC_WORK_TAG)
@@ -53,107 +172,103 @@ class NetworkMonitor(private val context: Context) {
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
AUTO_SYNC_WORK_NAME,
- ExistingPeriodicWorkPolicy.UPDATE, // UPDATE statt KEEP fĂźr immediate trigger
+ ExistingPeriodicWorkPolicy.UPDATE, // đĽ Update bei Interval-Ănderung
syncRequest
)
- Logger.d(TAG, "â
Periodic auto-sync scheduled (every 30min when on WiFi)")
-
- // Trigger sofortigen Sync wenn WiFi bereits connected
- triggerImmediateSync()
+ Logger.d(TAG, "â
Periodic sync scheduled (every ${intervalMinutes}min)")
}
/**
- * Stoppt WorkManager Auto-Sync
+ * Startet NetworkCallback fĂźr WiFi-Connect Detection
+ */
+ private fun startWifiMonitoring() {
+ try {
+ Logger.d(TAG, "đ Starting WiFi monitoring...")
+
+ val request = NetworkRequest.Builder()
+ .addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+
+ Logger.d(TAG, " NetworkRequest built: WIFI + INTERNET capability")
+
+ connectivityManager.registerNetworkCallback(request, networkCallback)
+ Logger.d(TAG, "â
â
â
WiFi NetworkCallback registered successfully")
+ Logger.d(TAG, " Callback will trigger on WiFi connect/disconnect")
+
+ // đĽ FIX: Initialisiere wasWifiConnected State beim Start
+ // onAvailable() wird nur bei NEUEN Verbindungen getriggert!
+ initializeWifiState()
+
+ } catch (e: Exception) {
+ Logger.e(TAG, "âââ Failed to register NetworkCallback", e)
+ }
+ }
+
+ /**
+ * Initialisiert lastConnectedNetworkId beim App-Start
+ * Wichtig damit wir echte Netzwerk-Wechsel von App-Restarts unterscheiden kĂśnnen
+ */
+ private fun initializeWifiState() {
+ try {
+ Logger.d(TAG, "đ Initializing WiFi state...")
+
+ val activeNetwork = connectivityManager.activeNetwork
+ if (activeNetwork == null) {
+ Logger.d(TAG, " â No active network - lastConnectedNetworkId = null")
+ lastConnectedNetworkId = null
+ return
+ }
+
+ val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
+ val isWifi = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true
+
+ if (isWifi) {
+ lastConnectedNetworkId = activeNetwork.toString()
+ Logger.d(TAG, " â
Initial WiFi network: $lastConnectedNetworkId")
+ Logger.d(TAG, " đĄ WiFi already connected at startup - onAvailable() will only trigger on network change")
+ } else {
+ lastConnectedNetworkId = null
+ Logger.d(TAG, " â ď¸ Not on WiFi at startup")
+ }
+
+ } catch (e: Exception) {
+ Logger.e(TAG, "â Error initializing WiFi state", e)
+ lastConnectedNetworkId = null
+ }
+ }
+
+ /**
+ * PrĂźft ob WiFi aktuell verbunden ist
+ * @return true wenn WiFi verbunden, false sonst (Cellular, offline, etc.)
+ */
+ fun isWiFiConnected(): Boolean {
+ return try {
+ val activeNetwork = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false
+ capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
+ } catch (e: Exception) {
+ Logger.e(TAG, "Error checking WiFi status", e)
+ false
+ }
+ }
+
+ /**
+ * Stoppt WorkManager Auto-Sync + NetworkCallback
*/
fun stopMonitoring() {
Logger.d(TAG, "đ Stopping auto-sync")
+
+ // Stop WorkManager
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
- }
-
- /**
- * Trigger sofortigen Sync (z.B. nach Settings-Ănderung)
- */
- private fun triggerImmediateSync() {
- if (!isConnectedToHomeWifi()) {
- Logger.d(TAG, "Not on home WiFi - skipping immediate sync")
- return
- }
- Logger.d(TAG, "ďż˝ Triggering immediate sync...")
-
- val syncRequest = OneTimeWorkRequestBuilder()
- .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
- .addTag(Constants.SYNC_WORK_TAG)
- .build()
-
- WorkManager.getInstance(context).enqueue(syncRequest)
- }
-
- /**
- * PrĂźft ob connected zu Home WiFi via Gateway IP Check
- */
- private fun isConnectedToHomeWifi(): Boolean {
- val gatewayIP = getGatewayIP() ?: return false
-
- val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
- if (serverUrl.isNullOrEmpty()) return false
-
- val serverIP = extractIPFromUrl(serverUrl)
- if (serverIP == null) return false
-
- val sameNetwork = isSameNetwork(gatewayIP, serverIP)
- Logger.d(TAG, "Gateway: $gatewayIP, Server: $serverIP â Same network: $sameNetwork")
-
- return sameNetwork
- }
-
- private fun getGatewayIP(): String? {
- return try {
- val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
- as WifiManager
- val dhcpInfo = wifiManager.dhcpInfo
- val gateway = dhcpInfo.gateway
-
- val ip = String.format(
- "%d.%d.%d.%d",
- gateway and 0xFF,
- (gateway shr 8) and 0xFF,
- (gateway shr 16) and 0xFF,
- (gateway shr 24) and 0xFF
- )
- ip
+ // Unregister NetworkCallback
+ try {
+ connectivityManager.unregisterNetworkCallback(networkCallback)
+ Logger.d(TAG, "â
WiFi monitoring stopped")
} catch (e: Exception) {
- Logger.e(TAG, "Failed to get gateway IP: ${e.message}")
- null
+ // Already unregistered
}
}
-
- private fun extractIPFromUrl(url: String): String? {
- return try {
- val urlObj = java.net.URL(url)
- val host = urlObj.host
-
- if (host.matches(Regex("\\d+\\.\\d+\\.\\d+\\.\\d+"))) {
- host
- } else {
- val addr = java.net.InetAddress.getByName(host)
- addr.hostAddress
- }
- } catch (e: Exception) {
- Logger.e(TAG, "Failed to extract IP: ${e.message}")
- null
- }
- }
-
- private fun isSameNetwork(ip1: String, ip2: String): Boolean {
- val parts1 = ip1.split(".")
- val parts2 = ip2.split(".")
-
- if (parts1.size != 4 || parts2.size != 4) return false
-
- return parts1[0] == parts2[0] &&
- parts1[1] == parts2[1] &&
- parts1[2] == parts2[2]
- }
}
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 01adb9e..1233778 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,6 +5,7 @@ import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
+import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.Dispatchers
@@ -21,25 +22,72 @@ class SyncWorker(
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
- Logger.d(TAG, "đ SyncWorker started")
- Logger.d(TAG, "Context: ${applicationContext.javaClass.simpleName}")
- Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ Logger.d(TAG, "đ SyncWorker.doWork() ENTRY")
+ Logger.d(TAG, "Context: ${applicationContext.javaClass.simpleName}")
+ Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
+ Logger.d(TAG, "RunAttempt: $runAttemptCount")
+ }
return@withContext try {
- // Start sync (kein "in progress" notification mehr)
- val syncService = WebDavSyncService(applicationContext)
- Logger.d(TAG, "đ Starting sync...")
- Logger.d(TAG, "đ Attempt: ${runAttemptCount}")
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "đ Step 1: Before WebDavSyncService creation")
+ }
- val result = syncService.syncNotes()
+ // Try-catch um Service-Creation
+ val syncService = try {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " Creating WebDavSyncService with applicationContext...")
+ }
+ WebDavSyncService(applicationContext).also {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " â
WebDavSyncService created successfully")
+ }
+ }
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH in WebDavSyncService constructor!", e)
+ Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
+ throw e
+ }
- Logger.d(TAG, "đŚ Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "đ Step 2: Before syncNotes() call")
+ Logger.d(TAG, " SyncService: $syncService")
+ }
+
+ // Try-catch um syncNotes
+ val result = try {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " Calling syncService.syncNotes()...")
+ }
+ syncService.syncNotes().also {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " â
syncNotes() returned")
+ }
+ }
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH in syncNotes()!", e)
+ Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
+ throw e
+ }
+
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "đ Step 3: Processing result")
+ Logger.d(TAG, "đŚ Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
+ }
if (result.isSuccess) {
- Logger.d(TAG, "â
Sync successful: ${result.syncedCount} notes")
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "đ Step 4: Success path")
+ }
+ Logger.i(TAG, "â
Sync successful: ${result.syncedCount} notes")
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
if (result.syncedCount > 0) {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " Showing success notification...")
+ }
NotificationHelper.showSyncSuccess(
applicationContext,
result.syncedCount
@@ -49,10 +97,20 @@ class SyncWorker(
}
// **UI REFRESH**: Broadcast fĂźr MainActivity
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " Broadcasting sync completed...")
+ }
broadcastSyncCompleted(true, result.syncedCount)
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "â
SyncWorker.doWork() SUCCESS")
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ }
Result.success()
} else {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "đ Step 4: Failure path")
+ }
Logger.e(TAG, "â Sync failed: ${result.errorMessage}")
NotificationHelper.showSyncError(
applicationContext,
@@ -62,19 +120,39 @@ class SyncWorker(
// Broadcast auch bei Fehler (damit UI refresht)
broadcastSyncCompleted(false, 0)
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "â SyncWorker.doWork() FAILURE")
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ }
Result.failure()
}
} catch (e: Exception) {
- Logger.e(TAG, "đĽ Sync exception: ${e.message}", e)
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ }
+ Logger.e(TAG, "đĽđĽđĽ FATAL EXCEPTION in doWork() đĽđĽđĽ")
Logger.e(TAG, "Exception type: ${e.javaClass.name}")
+ Logger.e(TAG, "Exception message: ${e.message}")
Logger.e(TAG, "Stack trace:", e)
- NotificationHelper.showSyncError(
- applicationContext,
- e.message ?: "Unknown error"
- )
- broadcastSyncCompleted(false, 0)
+ try {
+ NotificationHelper.showSyncError(
+ applicationContext,
+ e.message ?: "Unknown error"
+ )
+ } catch (notifError: Exception) {
+ Logger.e(TAG, "Failed to show error notification", notifError)
+ }
+ try {
+ broadcastSyncCompleted(false, 0)
+ } catch (broadcastError: Exception) {
+ Logger.e(TAG, "Failed to broadcast", broadcastError)
+ }
+
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ }
Result.failure()
}
}
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 33b6987..1269296 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
@@ -1,8 +1,11 @@
package dev.dettmer.simplenotes.sync
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.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
@@ -10,6 +13,14 @@ import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import java.net.Inet4Address
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.NetworkInterface
+import java.net.Proxy
+import java.net.Socket
+import javax.net.SocketFactory
class WebDavSyncService(private val context: Context) {
@@ -17,17 +28,158 @@ class WebDavSyncService(private val context: Context) {
private const val TAG = "WebDavSyncService"
}
- private val storage = NotesStorage(context)
+ private val storage: NotesStorage
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
+ init {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ Logger.d(TAG, "đď¸ WebDavSyncService INIT")
+ Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
+ Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
+ }
+
+ try {
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " Creating NotesStorage...")
+ }
+ storage = NotesStorage(context)
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " â
NotesStorage created successfully")
+ Logger.d(TAG, " Notes dir: ${storage.getNotesDir()}")
+ }
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH in NotesStorage creation!", e)
+ Logger.e(TAG, "Exception: ${e.javaClass.name}: ${e.message}")
+ throw e
+ }
+
+ if (BuildConfig.DEBUG) {
+ Logger.d(TAG, " SharedPreferences: $prefs")
+ Logger.d(TAG, "â
WebDavSyncService INIT complete")
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ }
+ }
+
+ /**
+ * Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
+ */
+ private fun getWiFiInetAddress(): InetAddress? {
+ try {
+ Logger.d(TAG, "đ getWiFiInetAddress() called")
+
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork
+ Logger.d(TAG, " Active network: $network")
+
+ if (network == null) {
+ Logger.d(TAG, "â No active network")
+ return null
+ }
+
+ val capabilities = connectivityManager.getNetworkCapabilities(network)
+ Logger.d(TAG, " Network capabilities: $capabilities")
+
+ if (capabilities == null) {
+ Logger.d(TAG, "â No network capabilities")
+ return null
+ }
+
+ // Nur wenn WiFi aktiv
+ 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...")
+
+ // Finde WiFi Interface
+ val interfaces = NetworkInterface.getNetworkInterfaces()
+ while (interfaces.hasMoreElements()) {
+ val iface = interfaces.nextElement()
+
+ Logger.d(TAG, " Checking interface: ${iface.name}, isUp=${iface.isUp}")
+
+ // WiFi Interfaces: wlan0, wlan1, etc.
+ if (!iface.name.startsWith("wlan")) continue
+ if (!iface.isUp) continue
+
+ val addresses = iface.inetAddresses
+ while (addresses.hasMoreElements()) {
+ val addr = addresses.nextElement()
+
+ Logger.d(TAG, " Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}")
+
+ // Nur IPv4, nicht loopback, nicht link-local
+ if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
+ Logger.d(TAG, "â
Found WiFi IP: ${addr.hostAddress} on ${iface.name}")
+ return addr
+ }
+ }
+ }
+
+ Logger.w(TAG, "â ď¸ No WiFi interface found, using default routing")
+ return null
+
+ } catch (e: Exception) {
+ Logger.e(TAG, "â Failed to get WiFi interface", e)
+ return null
+ }
+ }
+
+ /**
+ * Custom SocketFactory die an WiFi-IP bindet (VPN Fix)
+ */
+ private inner class WiFiSocketFactory(private val wifiAddress: InetAddress) : SocketFactory() {
+ override fun createSocket(): Socket {
+ val socket = Socket()
+ socket.bind(InetSocketAddress(wifiAddress, 0))
+ Logger.d(TAG, "đ Socket bound to WiFi IP: ${wifiAddress.hostAddress}")
+ return socket
+ }
+
+ override fun createSocket(host: String, port: Int): Socket {
+ val socket = createSocket()
+ socket.connect(InetSocketAddress(host, port))
+ return socket
+ }
+
+ override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
+ return createSocket(host, port)
+ }
+
+ override fun createSocket(host: InetAddress, port: Int): Socket {
+ val socket = createSocket()
+ socket.connect(InetSocketAddress(host, port))
+ return socket
+ }
+
+ override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
+ return createSocket(address, port)
+ }
+ }
+
private fun getSardine(): Sardine? {
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
- // Einfach standard OkHttpSardine - funktioniert im manuellen Sync!
- android.util.Log.d(TAG, "đ§ Creating OkHttpSardine")
+ Logger.d(TAG, "đ§ Creating OkHttpSardine with WiFi binding")
+ Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
- return OkHttpSardine().apply {
+ // Versuche WiFi-IP zu finden
+ val wifiAddress = getWiFiInetAddress()
+
+ 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)
}
}
@@ -83,58 +235,102 @@ class WebDavSyncService(private val context: Context) {
}
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
- android.util.Log.d(TAG, "đ syncNotes() called")
- android.util.Log.d(TAG, "Context: ${context.javaClass.simpleName}")
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ Logger.d(TAG, "đ syncNotes() ENTRY")
+ Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
+ Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
return@withContext try {
- val sardine = getSardine()
+ Logger.d(TAG, "đ Step 1: Getting Sardine client")
+
+ val sardine = try {
+ getSardine()
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH in getSardine()!", e)
+ e.printStackTrace()
+ throw e
+ }
+
if (sardine == null) {
- android.util.Log.e(TAG, "â Sardine is null - credentials missing")
+ Logger.e(TAG, "â Sardine is null - credentials missing")
return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
)
}
+ Logger.d(TAG, " â
Sardine client created")
+ Logger.d(TAG, "đ Step 2: Getting server URL")
val serverUrl = getServerUrl()
if (serverUrl == null) {
- android.util.Log.e(TAG, "â Server URL is null")
+ Logger.e(TAG, "â Server URL is null")
return@withContext SyncResult(
isSuccess = false,
errorMessage = "Server-URL nicht konfiguriert"
)
}
- android.util.Log.d(TAG, "đĄ Server URL: $serverUrl")
- android.util.Log.d(TAG, "đ Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}")
+ Logger.d(TAG, "đĄ Server URL: $serverUrl")
+ Logger.d(TAG, "đ Credentials configured: ${prefs.getString(Constants.KEY_USERNAME, null) != null}")
var syncedCount = 0
var conflictCount = 0
+ Logger.d(TAG, "đ Step 3: Checking server directory")
// Ensure server directory exists
- android.util.Log.d(TAG, "đ Checking if server directory exists...")
- if (!sardine.exists(serverUrl)) {
- android.util.Log.d(TAG, "đ Creating server directory...")
- sardine.createDirectory(serverUrl)
+ try {
+ Logger.d(TAG, "đ Checking if server directory exists...")
+ if (!sardine.exists(serverUrl)) {
+ Logger.d(TAG, "đ Creating server directory...")
+ sardine.createDirectory(serverUrl)
+ }
+ Logger.d(TAG, " â
Server directory ready")
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH checking/creating server directory!", e)
+ e.printStackTrace()
+ throw e
}
+ Logger.d(TAG, "đ Step 4: Uploading local notes")
// Upload local notes
- android.util.Log.d(TAG, "âŹď¸ Uploading local notes...")
- val uploadedCount = uploadLocalNotes(sardine, serverUrl)
- syncedCount += uploadedCount
- android.util.Log.d(TAG, "â
Uploaded: $uploadedCount notes")
+ try {
+ Logger.d(TAG, "âŹď¸ Uploading local notes...")
+ val uploadedCount = uploadLocalNotes(sardine, serverUrl)
+ syncedCount += uploadedCount
+ Logger.d(TAG, "â
Uploaded: $uploadedCount notes")
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH in uploadLocalNotes()!", e)
+ e.printStackTrace()
+ throw e
+ }
+ Logger.d(TAG, "đ Step 5: Downloading remote notes")
// Download remote notes
- android.util.Log.d(TAG, "âŹď¸ Downloading remote notes...")
- val downloadResult = downloadRemoteNotes(sardine, serverUrl)
- syncedCount += downloadResult.downloadedCount
- conflictCount += downloadResult.conflictCount
- android.util.Log.d(TAG, "â
Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
+ try {
+ Logger.d(TAG, "âŹď¸ Downloading remote notes...")
+ val downloadResult = downloadRemoteNotes(sardine, serverUrl)
+ syncedCount += downloadResult.downloadedCount
+ conflictCount += downloadResult.conflictCount
+ Logger.d(TAG, "â
Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH in downloadRemoteNotes()!", e)
+ e.printStackTrace()
+ throw e
+ }
+ Logger.d(TAG, "đ Step 6: Saving sync timestamp")
// Update last sync timestamp
- saveLastSyncTimestamp()
+ try {
+ saveLastSyncTimestamp()
+ Logger.d(TAG, " â
Timestamp saved")
+ } catch (e: Exception) {
+ Logger.e(TAG, "đĽ CRASH saving timestamp!", e)
+ e.printStackTrace()
+ // Non-fatal, continue
+ }
- android.util.Log.d(TAG, "đ Sync completed successfully - Total synced: $syncedCount")
+ Logger.d(TAG, "đ Sync completed successfully - Total synced: $syncedCount")
+ Logger.d(TAG, "âââââââââââââââââââââââââââââââââââââââ")
SyncResult(
isSuccess = true,
@@ -143,8 +339,13 @@ class WebDavSyncService(private val context: Context) {
)
} catch (e: Exception) {
- android.util.Log.e(TAG, "đĽ Sync exception: ${e.message}", e)
- android.util.Log.e(TAG, "Exception type: ${e.javaClass.name}")
+ Logger.e(TAG, "âââââââââââââââââââââââââââââââââââââââ")
+ Logger.e(TAG, "đĽđĽđĽ FATAL EXCEPTION in syncNotes() đĽđĽđĽ")
+ Logger.e(TAG, "Exception type: ${e.javaClass.name}")
+ Logger.e(TAG, "Exception message: ${e.message}")
+ Logger.e(TAG, "Stack trace:")
+ e.printStackTrace()
+ Logger.e(TAG, "âââââââââââââââââââââââââââââââââââââââ")
SyncResult(
isSuccess = false,
@@ -253,4 +454,95 @@ class WebDavSyncService(private val context: Context) {
fun getLastSyncTimestamp(): Long {
return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
}
+
+ /**
+ * Restore all notes from server - overwrites local storage
+ * @return RestoreResult with count of restored notes
+ */
+ suspend fun restoreFromServer(): RestoreResult = withContext(Dispatchers.IO) {
+ return@withContext try {
+ val sardine = getSardine() ?: return@withContext RestoreResult(
+ isSuccess = false,
+ errorMessage = "Server-Zugangsdaten nicht konfiguriert",
+ restoredCount = 0
+ )
+
+ val serverUrl = getServerUrl() ?: return@withContext RestoreResult(
+ isSuccess = false,
+ errorMessage = "Server-URL nicht konfiguriert",
+ restoredCount = 0
+ )
+
+ Logger.d(TAG, "đ Starting restore from server...")
+
+ // List all files on server
+ val resources = sardine.list(serverUrl)
+ val jsonFiles = resources.filter {
+ !it.isDirectory && it.name.endsWith(".json")
+ }
+
+ Logger.d(TAG, "đ Found ${jsonFiles.size} files on server")
+
+ val restoredNotes = mutableListOf()
+
+ // Download and parse each file
+ for (resource in jsonFiles) {
+ try {
+ val fileUrl = serverUrl.trimEnd('/') + "/" + resource.name
+ val content = sardine.get(fileUrl).bufferedReader().use { it.readText() }
+
+ val note = Note.fromJson(content)
+ if (note != null) {
+ restoredNotes.add(note)
+ Logger.d(TAG, "â
Downloaded: ${note.title}")
+ } else {
+ Logger.e(TAG, "â Failed to parse ${resource.name}: Note.fromJson returned null")
+ }
+ } catch (e: Exception) {
+ Logger.e(TAG, "â Failed to download ${resource.name}", e)
+ // Continue with other files
+ }
+ }
+
+ if (restoredNotes.isEmpty()) {
+ return@withContext RestoreResult(
+ isSuccess = false,
+ errorMessage = "Keine Notizen auf Server gefunden",
+ restoredCount = 0
+ )
+ }
+
+ // Clear local storage
+ Logger.d(TAG, "đď¸ Clearing local storage...")
+ storage.deleteAllNotes()
+
+ // Save all restored notes
+ Logger.d(TAG, "đž Saving ${restoredNotes.size} notes...")
+ restoredNotes.forEach { note ->
+ storage.saveNote(note.copy(syncStatus = SyncStatus.SYNCED))
+ }
+
+ Logger.d(TAG, "â
Restore completed: ${restoredNotes.size} notes")
+
+ RestoreResult(
+ isSuccess = true,
+ errorMessage = null,
+ restoredCount = restoredNotes.size
+ )
+
+ } catch (e: Exception) {
+ Logger.e(TAG, "â Restore failed", e)
+ RestoreResult(
+ isSuccess = false,
+ errorMessage = e.message ?: "Unbekannter Fehler",
+ restoredCount = 0
+ )
+ }
+ }
}
+
+data class RestoreResult(
+ val isSuccess: Boolean,
+ val errorMessage: String?,
+ val restoredCount: Int
+)
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
index b827225..4d1dc32 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
@@ -10,6 +10,10 @@ object Constants {
const val KEY_AUTO_SYNC = "auto_sync_enabled"
const val KEY_LAST_SYNC = "last_sync_timestamp"
+ // đĽ NEU: Sync Interval Configuration
+ const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
+ const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
+
// WorkManager
const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt
index 87ebbfc..7d6d5ae 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Logger.kt
@@ -1,30 +1,122 @@
package dev.dettmer.simplenotes.utils
+import android.content.Context
import android.util.Log
import dev.dettmer.simplenotes.BuildConfig
+import java.io.File
+import java.io.FileWriter
+import java.io.PrintWriter
+import java.text.SimpleDateFormat
+import java.util.*
/**
- * Logger: Debug logs nur bei DEBUG builds
+ * Logger: Debug logs nur bei DEBUG builds + File Logging
* Release builds zeigen nur Errors/Warnings
*/
object Logger {
+ private var fileLoggingEnabled = false
+ private var logFile: File? = null
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
+ private val maxLogEntries = 500 // Nur letzte 500 Einträge
+
+ /**
+ * Aktiviert File-Logging fĂźr Debugging
+ */
+ fun enableFileLogging(context: Context) {
+ try {
+ logFile = File(context.filesDir, "simplenotes_debug.log")
+ fileLoggingEnabled = true
+
+ // Clear old log
+ logFile?.writeText("")
+
+ i("Logger", "đ File logging enabled: ${logFile?.absolutePath}")
+ } catch (e: Exception) {
+ Log.e("Logger", "Failed to enable file logging", e)
+ }
+ }
+
+ /**
+ * Deaktiviert File-Logging
+ */
+ fun disableFileLogging() {
+ fileLoggingEnabled = false
+ i("Logger", "đ File logging disabled")
+ }
+
+ /**
+ * Gibt Log-Datei zurĂźck
+ */
+ fun getLogFile(): File? = logFile
+
+ /**
+ * Schreibt Log-Eintrag in Datei
+ */
+ private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
+ if (!fileLoggingEnabled || logFile == null) return
+
+ try {
+ val timestamp = dateFormat.format(Date())
+ val logEntry = buildString {
+ append("$timestamp [$level] $tag: $message\n")
+ throwable?.let {
+ append(" Exception: ${it.message}\n")
+ append(" ${it.stackTraceToString()}\n")
+ }
+ }
+
+ // Append to file
+ FileWriter(logFile, true).use { writer ->
+ writer.write(logEntry)
+ }
+
+ // Trim file if too large
+ trimLogFile()
+
+ } catch (e: Exception) {
+ Log.e("Logger", "Failed to write to log file", e)
+ }
+ }
+
+ /**
+ * Begrenzt Log-Datei auf maxLogEntries
+ */
+ private fun trimLogFile() {
+ try {
+ val lines = logFile?.readLines() ?: return
+ if (lines.size > maxLogEntries) {
+ val trimmed = lines.takeLast(maxLogEntries)
+ logFile?.writeText(trimmed.joinToString("\n") + "\n")
+ }
+ } catch (e: Exception) {
+ Log.e("Logger", "Failed to trim log file", e)
+ }
+ }
+
fun d(tag: String, message: String) {
+ // Logcat nur in DEBUG builds
if (BuildConfig.DEBUG) {
Log.d(tag, message)
}
+ // File-Logging IMMER (wenn enabled)
+ writeToFile("DEBUG", tag, message)
}
fun v(tag: String, message: String) {
+ // Logcat nur in DEBUG builds
if (BuildConfig.DEBUG) {
Log.v(tag, message)
}
+ // File-Logging IMMER (wenn enabled)
+ writeToFile("VERBOSE", tag, message)
}
fun i(tag: String, message: String) {
- if (BuildConfig.DEBUG) {
- Log.i(tag, message)
- }
+ // INFO logs IMMER zeigen (auch in Release) - wichtige Events
+ Log.i(tag, message)
+ // File-Logging IMMER (wenn enabled)
+ writeToFile("INFO", tag, message)
}
// Errors und Warnings IMMER zeigen (auch in Release)
@@ -34,9 +126,11 @@ object Logger {
} else {
Log.e(tag, message)
}
+ writeToFile("ERROR", tag, message, throwable)
}
fun w(tag: String, message: String) {
Log.w(tag, message)
+ writeToFile("WARN", tag, message)
}
}
diff --git a/android/app/src/main/res/drawable/ic_splash_icon.xml b/android/app/src/main/res/drawable/ic_splash_icon.xml
new file mode 100644
index 0000000..815408d
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_splash_icon.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_editor.xml b/android/app/src/main/res/layout/activity_editor.xml
index 58fb72e..9ce0a6e 100644
--- a/android/app/src/main/res/layout/activity_editor.xml
+++ b/android/app/src/main/res/layout/activity_editor.xml
@@ -5,31 +5,46 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
+ android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
+
+ app:title="@string/edit_note"
+ app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
+
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
+ app:boxCornerRadiusTopStart="16dp"
+ app:boxCornerRadiusTopEnd="16dp"
+ app:boxCornerRadiusBottomStart="16dp"
+ app:boxCornerRadiusBottomEnd="16dp"
+ app:endIconMode="clear_text"
+ app:counterEnabled="true"
+ app:counterMaxLength="100">
+ android:maxLines="2"
+ android:maxLength="100"
+ android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
+
+ style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
+ app:boxCornerRadiusTopStart="16dp"
+ app:boxCornerRadiusTopEnd="16dp"
+ app:boxCornerRadiusBottomStart="16dp"
+ app:boxCornerRadiusBottomEnd="16dp"
+ app:endIconMode="clear_text"
+ app:counterEnabled="true"
+ app:counterMaxLength="10000">
+ android:scrollbars="vertical"
+ android:maxLength="10000"
+ android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index 4831276..a2cdf4c 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -4,39 +4,83 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
+
+ android:layout_height="wrap_content"
+ app:elevation="0dp"
+ android:background="?attr/colorSurface">
+ android:elevation="0dp"
+ app:title="@string/app_name"
+ app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
+
-
+
+ android:layout_marginStart="32dp"
+ android:layout_marginEnd="32dp"
+ android:visibility="gone"
+ app:cardElevation="0dp"
+ app:cardCornerRadius="16dp"
+ style="@style/Widget.Material3.CardView.Filled">
+
+
+
+
+
+
+
+
+
+
+
+
+
+ app:srcCompat="@android:drawable/ic_input_add"
+ app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Large" />
diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml
index f7674a0..30be952 100644
--- a/android/app/src/main/res/layout/activity_settings.xml
+++ b/android/app/src/main/res/layout/activity_settings.xml
@@ -5,19 +5,24 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
- android:fitsSystemWindows="true">
+ android:fitsSystemWindows="true"
+ android:background="?attr/colorSurface">
+
+ android:layout_weight="1"
+ android:fillViewport="true">
-
-
+
+ android:layout_gravity="center_horizontal"
+ android:layout_marginBottom="12dp"
+ android:visibility="gone"
+ android:textSize="12sp"
+ app:chipIconEnabled="false" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ style="@style/Widget.Material3.CardView.Elevated"
+ app:cardCornerRadius="16dp">
-
-
-
+ android:orientation="vertical"
+ android:padding="20dp">
-
+
+
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_marginBottom="16dp"
+ style="@style/Widget.Material3.CardView.Elevated"
+ app:cardCornerRadius="16dp">
-