From c55b64dab3e8d0095a8b3432b3d82ddf495f8ba2 Mon Sep 17 00:00:00 2001
From: Inventory69 <50034034+inventory69@users.noreply.github.com>
Date: Mon, 22 Dec 2025 00:49:24 +0100
Subject: [PATCH] =?UTF-8?q?feat:=20Konfigurierbare=20Sync-Intervalle=20+?=
=?UTF-8?q?=20=C3=9Cber-Sektion=20(v1.1.0)=20(#1)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: WiFi-Connect Auto-Sync + Debug Logging [skip ci]
- WiFi-Connect Auto-Sync via NetworkCallback + Broadcast (statt WorkManager)
- onResume Auto-Sync mit Toast-Feedback (nur Success)
- File-Logging Feature für Debugging (letzte 500 Einträge)
- Settings: Debug/Logs Section mit Test-Button
- FileProvider für Log-Sharing
- Extensive Debug-Logs für NetworkMonitor + MainActivity
- Material Design 3 Migration (alle 17 Tasks)
- Bug-Fixes: Input underlines, section rename, swipe-to-delete, flat cards
PROBLEM: WiFi-Connect sendet Broadcast aber MainActivity empfängt nicht
→ Benötigt logcat debugging auf anderem Gerät
* 🐛 fix: Remove WiFi-Connect related code and UI elements to streamline sync process
* feat: Konfigurierbare Sync-Intervalle + Über-Sektion (v1.1.0)
## Neue Features
### Konfigurierbare Sync-Intervalle
- Wählbare Intervalle: 15/30/60 Minuten in Settings
- Transparente Akkuverbrauchs-Anzeige (0.2-0.8% pro Tag)
- Sofortige Anwendung ohne App-Neustart
- NetworkMonitor liest Intervall dynamisch aus SharedPreferences
### Über-Sektion
- App-Version & Build-Datum Anzeige
- Klickbare Links zu GitHub Repository & Entwickler-Profil
- Lizenz-Information (MIT License)
- Ersetzt alte Debug/Logs Sektion
## Verbesserungen
- Benutzerfreundliche Doze-Mode Erklärung in Settings
- Keine störenden Sync-Fehler Toasts mehr im Hintergrund
- Modernisierte README mit Badges und kompakter Struktur
- F-Droid Metadaten aktualisiert (changelogs + descriptions)
## Technische Änderungen
- Version Bump: 1.0 → 1.1.0 (versionCode: 1 → 2)
- BUILD_DATE buildConfigField hinzugefügt
- PREF_SYNC_INTERVAL_MINUTES Konstante in Constants.kt
- NetworkMonitor.startPeriodicSync() nutzt konfigurierbare Intervalle
- SettingsActivity: setupSyncIntervalPicker() + setupAboutSection()
- activity_settings.xml: RadioGroup für Intervalle + About Cards
---
.github/workflows/build-production-apk.yml | 80 +-
IMPROVEMENT_PLAN.md | 2338 +++++++++++++++++
README.md | 174 +-
android/app/build.gradle.kts | 34 +-
android/app/src/main/AndroidManifest.xml | 14 +-
.../dev/dettmer/simplenotes/MainActivity.kt | 175 +-
.../dettmer/simplenotes/NoteEditorActivity.kt | 7 +-
.../dettmer/simplenotes/SettingsActivity.kt | 235 +-
.../simplenotes/SimpleNotesApplication.kt | 9 +
.../simplenotes/storage/NotesStorage.kt | 11 +
.../simplenotes/sync/NetworkMonitor.kt | 315 ++-
.../dettmer/simplenotes/sync/SyncWorker.kt | 110 +-
.../simplenotes/sync/WebDavSyncService.kt | 348 ++-
.../dettmer/simplenotes/utils/Constants.kt | 4 +
.../dev/dettmer/simplenotes/utils/Logger.kt | 102 +-
.../src/main/res/drawable/ic_splash_icon.xml | 10 +
.../src/main/res/layout/activity_editor.xml | 34 +-
.../app/src/main/res/layout/activity_main.xml | 69 +-
.../src/main/res/layout/activity_settings.xml | 666 ++++-
.../res/layout/dialog_delete_confirmation.xml | 65 +
android/app/src/main/res/layout/item_note.xml | 45 +-
.../app/src/main/res/values-night/colors.xml | 33 +
.../app/src/main/res/values-night/themes.xml | 32 +-
android/app/src/main/res/values/colors.xml | 63 +
android/app/src/main/res/values/strings.xml | 43 +-
android/app/src/main/res/values/themes.xml | 42 +-
android/app/src/main/res/xml/file_paths.xml | 5 +
android/fastlane/README.md | 38 +
.../metadata/android/de-DE/changelogs/1.txt | 8 +
.../metadata/android/de-DE/changelogs/2.txt | 5 +
.../android/de-DE/full_description.txt | 37 +
.../android/de-DE/short_description.txt | 1 +
.../fastlane/metadata/android/de-DE/title.txt | 1 +
33 files changed, 4687 insertions(+), 466 deletions(-)
create mode 100644 IMPROVEMENT_PLAN.md
create mode 100644 android/app/src/main/res/drawable/ic_splash_icon.xml
create mode 100644 android/app/src/main/res/layout/dialog_delete_confirmation.xml
create mode 100644 android/app/src/main/res/values-night/colors.xml
create mode 100644 android/app/src/main/res/xml/file_paths.xml
create mode 100644 android/fastlane/README.md
create mode 100644 android/fastlane/metadata/android/de-DE/changelogs/1.txt
create mode 100644 android/fastlane/metadata/android/de-DE/changelogs/2.txt
create mode 100644 android/fastlane/metadata/android/de-DE/full_description.txt
create mode 100644 android/fastlane/metadata/android/de-DE/short_description.txt
create mode 100644 android/fastlane/metadata/android/de-DE/title.txt
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">
-