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. +[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/) +[![Kotlin](https://img.shields.io/badge/Kotlin-1.9%2B-blue.svg)](https://kotlinlang.org/) +[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/) +[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](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"> - + + - + + - -