14 Commits

Author SHA1 Message Date
inventory69
dee85233b6 fix: Remove dynamic build date for Reproducible Builds
Fixes #7 - Thanks @IzzySoft for reporting and investigating!

Removed:
- getBuildDate() function from build.gradle.kts
- BUILD_DATE buildConfigField
- Build date display in SettingsActivity

Sorry for the hour of work this caused - will be more careful about RB in the future.
2026-01-10 23:47:57 +01:00
inventory69
fbcca3807d Merge feature/v1.4.0-checklists: Checklists + WiFi permission cleanup 2026-01-10 23:40:10 +01:00
inventory69
e3e64b83e2 feat(v1.4.0): Checklists feature + WiFi permission cleanup
Features:
- Interactive checklists with tap-to-check, drag & drop sorting
- GitHub-flavored Markdown export (- [ ] / - [x])
- FAB menu for note type selection

Fixes:
- Improved Markdown parsing (robust line-based content extraction)
- Better duplicate filename handling (ID suffix)
- Foreground notification suppression

Privacy:
- Removed ACCESS_WIFI_STATE and CHANGE_WIFI_STATE permissions
  (SSID binding was never used, app only checks connectivity state)

Code Quality:
- Fixed 7 Detekt warnings (SwallowedException, MaxLineLength, MagicNumber)
2026-01-10 23:37:22 +01:00
inventory69
2324743f43 Update IzzyOnDroid metadata to v1.3.2 [skip ci] 2026-01-10 08:26:47 +01:00
inventory69
0e96757fab Merge feature/v1.3.2-lint-cleanup into main 2026-01-10 00:57:46 +01:00
inventory69
547c0a1011 v1.3.2: Lint Cleanup & Code Quality
- Complete lint cleanup (Phase 1-7)
- Replace magic numbers with constants
- Remove unused imports/members
- Add Logger.w() for swallowed exceptions
- Custom SyncException for better error handling
- ConstructorParameterNaming with @SerializedName
- ReturnCount & Destructuring with @Suppress
- F-Droid: Add privacy notice for file logging
- Update docs (FEATURES.md, README.md)
- Add fastlane changelogs for versionCode 10
2026-01-10 00:57:28 +01:00
inventory69
b79c0d25e6 [skip ci] fix: simplify workflow for single universal APK per flavor
- Remove APK splits logic from workflow
- Build only universal APKs for both standard and fdroid flavors
- Simplifies release process and fixes F-Droid compatibility
2026-01-09 13:38:43 +01:00
inventory69
cf1142afa2 [skip ci] fix: disable APK splits for F-Droid build
- F-Droid expects single universal APK, not multiple architecture-specific APKs
- Remove splits.abi configuration to fix build error in F-Droid CI
- Pipeline failed with: 'More than one resulting apks found' (armeabi-v7a, arm64-v8a, universal)
- Resolves F-Droid MR #31695 build failure
2026-01-09 13:19:47 +01:00
inventory69
359325bf64 [skip ci] chore: update author information in metadata 2026-01-09 11:33:18 +01:00
inventory69
c7d0f899e7 [skip ci] feat: new app icon with monochrome support & updated descriptions
🎨 New App Icon:
- Fresh adaptive icon design with warm background (#f9e9c8)
- Monochrome icon support for Android 13+ themed icons
- PNG format replacing WebP for better compatibility
- All densities: mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi

📝 Updated Descriptions (EN/DE):
- Added Multi-Device Sync feature
- Added Markdown export for Obsidian/desktop editors
- Added deletion tracking (zombie notes prevention)
- Added E-Tag caching (20x faster checks)
- Added optimized performance (~2-3s sync time)
- Added live sync status indicator
- Added Server-Restore modes (Merge/Replace/Overwrite)

📦 F-Droid Metadata:
- Updated build entries for v1.2.1, v1.2.2, v1.3.0, v1.3.1
- CurrentVersion now 1.3.1 (versionCode 9)
- Prepared for F-Droid merge request submission
2026-01-09 10:55:39 +01:00
inventory69
5121a7b2b8 Fix F-Droid changelogs: Remove emojis and bullet points [skip ci] 2026-01-08 23:18:29 +01:00
inventory69
04664c8920 v1.3.1 - Multi-Device Sync Fix + Performance + Restore Bug Fix
🔧 Fixed:
- Multi-device JSON sync now works (thanks Thomas!)
- Restore from Server skipped files (timestamp bug)
- No duplicate downloads
- First MD sync after export now fast

 Performance:
- JSON sync: 12-14s → 2-3s
- Hybrid timestamp + E-Tag optimization
- Matches Markdown sync speed

 New:
- Sync status UI in MainActivity
- Content-based MD import
- Debug logging improvements
- SyncStateManager for sync coordination

🔧 Technical:
- Clear lastSyncTimestamp on restore
- Clear E-Tag caches on restore
- E-Tag refresh after upload
- Fixed timestamp update after MD export
2026-01-08 23:09:59 +01:00
Inventory69
2a56dd8128 Merge pull request #4 from inventory69/release/v1.3.0
Release v1.3.0: Multi-Device Sync
2026-01-07 12:28:31 +01:00
inventory69
63af7d30dc Release v1.3.0: Multi-Device Sync with Deletion Tracking
New Features:
- Multi-Device Sync with deletion tracking (prevents zombie notes)
- Server deletion via swipe gesture with confirmation dialog
- E-Tag performance optimization (~150ms vs 3s for no-change syncs)
- Markdown Auto-Sync toggle (unified Export + Auto-Import)
- Manual Markdown sync button for performance control
- Server-Restore modes (Merge/Replace/Overwrite)

Technical Implementation:
- DeletionTracker model with JSON persistence
- Intelligent server checks with E-Tag caching
- Deletion-aware download logic
- Two-stage swipe deletion with Material Design dialog
- Automatic Markdown import during sync
- YAML frontmatter scanning for robust file deletion

Thanks to Thomas from Bielefeld for reporting the multi-device sync issue!

Compatible with: v1.2.0-v1.3.0
2026-01-07 12:27:27 +01:00
105 changed files with 3912 additions and 615 deletions

View File

@@ -61,33 +61,15 @@ jobs:
run: | run: |
mkdir -p apk-output mkdir -p apk-output
# === Standard Flavor (mit Google Services) === # Standard Flavor - Universal APK
# Universal APK (funktioniert auf allen Geraeten) cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
cp android/app/build/outputs/apk/standard/release/app-standard-universal-release.apk \ apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk
# ARM64 APK (moderne Geräte 2018+) # F-Droid Flavor - Universal APK
cp android/app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release.apk \ cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
# ARMv7 APK (ältere Geräte) echo "✅ APK-Dateien vorbereitet:"
cp android/app/build/outputs/apk/standard/release/app-standard-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk
# === F-Droid Flavor (ohne Google Services) ===
# Universal APK
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-universal-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk
# ARM64 APK
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk
# ARMv7 APK
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk
echo "✅ APK-Dateien vorbereitet (Standard + F-Droid):"
ls -lh apk-output/ ls -lh apk-output/
- name: APK-Artefakte hochladen - name: APK-Artefakte hochladen
@@ -138,14 +120,8 @@ jobs:
| Variante | Datei | Info | | Variante | Datei | Info |
|----------|-------|------| |----------|-------|------|
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk` | Funktioniert auf allen Android-Geraeten | | **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk` | Kleinere Dateigröße fuer 64-bit Geräte | | F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
| Aelter (<2018) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk` | Fuer 32-bit ARM Geräte |
| F-Droid Universal | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk` | Fuer F-Droid Store |
| F-Droid ARM64 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk` | F-Droid 64-bit |
| F-Droid ARMv7 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk` | F-Droid 32-bit |
💡 **Nicht sicher?** → Nimm die **Universal** APK!
--- ---

View File

@@ -6,7 +6,241 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [1.2.2] - TBD ## [1.4.0] - 2026-01-10
### 🎉 New Feature: Checklists
- **✅ Checklist Notes**
- New note type: Checklists with tap-to-toggle items
- Add items via dedicated input field with "+" button
- Drag & drop reordering (long-press to activate)
- Swipe-to-delete items
- Visual distinction: Checked items get strikethrough styling
- Type selector when creating new notes (Text or Checklist)
- **📝 Markdown Integration**
- Checklists export as GitHub-style task lists (`- [ ]` / `- [x]`)
- Compatible with Obsidian, Notion, and other Markdown editors
- Full round-trip: Edit in Obsidian → Sync back to app
- YAML frontmatter includes `type: checklist` for identification
### Fixed
- **<2A> Markdown Parsing Robustness**
- Fixed content extraction after title (was returning empty for some formats)
- Now handles single newline after title (was requiring double newline)
- Protection: Skips import if parsed content is empty but local has content
- **📂 Duplicate Filename Handling**
- Notes with identical titles now get unique Markdown filenames
- Format: `title_shortid.md` (e.g., `test_71540ca9.md`)
- Prevents data loss from filename collisions
- **🔔 Notification UX**
- No sync notifications when app is in foreground
- User sees changes directly in UI - no redundant notification
- Background syncs still show notifications as expected
### Privacy Improvements
- **🔒 WiFi Permissions Removed**
- Removed `ACCESS_WIFI_STATE` permission
- Removed `CHANGE_WIFI_STATE` permission
- WiFi binding now works via IP detection instead of SSID matching
- Cleaned up all SSID-related code from codebase and documentation
### Technical Improvements
- **📦 New Data Model**
- `NoteType` enum: `TEXT`, `CHECKLIST`
- `ChecklistItem` data class with id, text, isChecked, order
- `Note.kt` extended with `noteType` and `checklistItems` fields
- **🔄 Sync Protocol v1.4.0**
- JSON format updated to include checklist fields
- Full backward compatibility with v1.3.x notes
- Robust JSON parsing with manual field extraction
---
## [1.3.2] - 2026-01-10
### Changed
- **🧹 Code-Qualität: "Clean Slate" Release**
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
- Unused Imports und Members entfernt
- Magic Numbers durch benannte Konstanten ersetzt
- SwallowedExceptions mit Logger.w() versehen
- MaxLineLength-Verstöße reformatiert
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
### Added
- **📝 F-Droid Privacy Notice**
- Datenschutz-Hinweis für die Datei-Logging-Funktion
- Erklärt dass Logs nur lokal gespeichert werden
- Erfüllt F-Droid Opt-in Consent-Anforderungen
### Technical Improvements
- **⚡ Neue Konstanten für bessere Wartbarkeit**
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
- RFC 1918 IP-Range Konstanten (UrlValidator)
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
- **🔒 @Suppress Annotations für legitime Patterns**
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
### Notes
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
---
## [1.3.1] - 2026-01-08
### Fixed
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
- Funktioniert auch ohne aktiviertes Markdown
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
### Performance Improvements
- **⚡ JSON Sync Performance-Parität**
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
- **⚡ Session-Caching für WebDAV**
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
- **📝 Content-basierte Markdown-Erkennung**
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
### Added
- **🔄 Sync-Status-Anzeige (UI)**
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
### Fixed
- **🔧 Sync-Mutex verhindert doppelte Syncs**
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
- Concurrent Sync-Requests werden korrekt blockiert
- **🐛 Lint-Fehler behoben**
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
### Added
- **🔍 detekt Code-Analyse**
- Statische Code-Analyse mit detekt 1.23.4 integriert
- Pragmatische Konfiguration für Sync-intensive Codebasis
- 91 Issues identifiziert (als Baseline für v1.4.0)
- **🏗️ Debug Build mit separatem Package**
- Debug-APK kann parallel zur Release-Version installiert werden
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
- **📊 Debug-Logging UI**
- Neuer "Debug Log" Button in Einstellungen → Erweitert
- Zeigt letzte Sync-Logs mit Zeitstempeln
- Export-Funktion für Fehlerberichte
### Technical
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
- `ensureNotesDirectoryExists()`: Cached Directory-Check
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
---
## [1.3.0] - 2026-01-07
### Added
- **🚀 Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
- Automatic download of new notes from other devices
- Deletion tracking prevents "zombie notes" (deleted notes don't come back)
- Smart cleanup: Re-created notes (newer timestamp) are downloaded
- Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
- **🗑️ Server Deletion via Swipe Gesture**
- Swipe left on notes to delete from server (requires confirmation)
- Prevents duplicate notes on other devices
- Works with deletion tracking system
- Material Design confirmation dialog
- **⚡ E-Tag Performance Optimization**
- Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
- 20x faster when server has no updates
- E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
- Battery-friendly with minimal server requests
- **📥 Markdown Auto-Sync Toggle**
- NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
- When enabled: Notes export to Markdown AND import changes automatically
- When disabled: Manual sync button appears for on-demand synchronization
- Performance: Auto-Sync OFF = 0ms overhead
- **🔘 Manual Markdown Sync Button**
- Manual sync button for performance-conscious users
- Shows import/export counts after completion
- Only visible when Auto-Sync is disabled
- On-demand synchronization (~150-200ms only when triggered)
- **⚙️ Server-Restore Modes**
- MERGE: Keep local notes + add server notes
- REPLACE: Delete all local + download from server
- OVERWRITE: Update duplicates, keep non-duplicates
- Restore modes now work correctly for WebDAV restore
### Technical
- New `DeletionTracker` model with JSON persistence
- `NotesStorage`: Added deletion tracking methods
- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
- `WebDavSyncService.restoreFromServer()`: Support for restore modes
- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
- E-Tag caching in SharedPreferences for performance
---
## [1.2.2] - 2026-01-06
### Fixed ### Fixed
- **Backward Compatibility for v1.2.0 Users (Critical)** - **Backward Compatibility for v1.2.0 Users (Critical)**

View File

@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` | | **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
| **Username** | `noteuser` | | **Username** | `noteuser` |
| **Password** | (your password from `.env`) | | **Password** | (your password from `.env`) |
| **Gateway SSID** | Name of your WiFi network |
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export. > **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
@@ -158,9 +157,8 @@ For reliable auto-sync:
# Should show "Up" # Should show "Up"
``` ```
2. **Same WiFi?** 2. **Same network?**
- Smartphone and server must be on same network - Smartphone and server must be on same network
- Check SSID in app settings
3. **IP address correct?** 3. **IP address correct?**
```bash ```bash
@@ -193,9 +191,9 @@ For reliable auto-sync:
2. **Battery optimization disabled?** 2. **Battery optimization disabled?**
- See [Disable Battery Optimization](#-disable-battery-optimization) - See [Disable Battery Optimization](#-disable-battery-optimization)
3. **On correct WiFi?** 3. **Connected to WiFi?**
- Sync only works when SSID = Gateway SSID - Auto-sync triggers on any WiFi connection
- Check current SSID in Android settings - Check if you're connected to a WiFi network
4. **Test manually:** 4. **Test manually:**
- ⚙️ Settings → "Sync now" - ⚙️ Settings → "Sync now"

View File

@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` | | **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
| **Benutzername** | `noteuser` | | **Benutzername** | `noteuser` |
| **Passwort** | (dein Passwort aus `.env`) | | **Passwort** | (dein Passwort aus `.env`) |
| **Gateway SSID** | Name deines WLAN-Netzwerks |
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export. > **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
@@ -158,9 +157,8 @@ Für zuverlässigen Auto-Sync:
# Sollte "Up" zeigen # Sollte "Up" zeigen
``` ```
2. **Gleiche WLAN?** 2. **Gleiches Netzwerk?**
- Smartphone und Server müssen im selben Netzwerk sein - Smartphone und Server müssen im selben Netzwerk sein
- Prüfe SSID in App-Einstellungen
3. **IP-Adresse korrekt?** 3. **IP-Adresse korrekt?**
```bash ```bash
@@ -193,9 +191,9 @@ Für zuverlässigen Auto-Sync:
2. **Akku-Optimierung deaktiviert?** 2. **Akku-Optimierung deaktiviert?**
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren) - Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
3. **Im richtigen WLAN?** 3. **Mit WiFi verbunden?**
- Sync funktioniert nur wenn SSID = Gateway SSID - Auto-Sync triggert bei jeder WiFi-Verbindung
- Prüfe aktuelle SSID in Android-Einstellungen - Prüfe, ob du mit einem WLAN verbunden bist
4. **Manuell testen:** 4. **Manuell testen:**
- ⚙️ Einstellungen → "Jetzt synchronisieren" - ⚙️ Einstellungen → "Jetzt synchronisieren"

View File

@@ -6,6 +6,8 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/) [![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) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)** **📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
**🌍 Languages:** [Deutsch](README.md) · **English** **🌍 Languages:** [Deutsch](README.md) · **English**
@@ -24,11 +26,12 @@
## ✨ Highlights ## ✨ Highlights
-**NEW: Checklists** - Tap-to-check, drag & drop, swipe-to-delete
- 📝 **Offline-first** - Works without internet - 📝 **Offline-first** - Works without internet
- 🔄 **Auto-sync** - Home WiFi only (15/30/60 min) - 🔄 **Auto-sync** - On WiFi connection (15/30/60 min)
- 🔒 **Self-hosted** - Your data stays with you (WebDAV) - 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file - 💾 **Local backup** - Export/Import as JSON file
- 🖥️ **Desktop integration** - Markdown export for VS Code, Typora, etc. - 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
- 🔋 **Battery-friendly** - ~0.2-0.8% per day - 🔋 **Battery-friendly** - ~0.2-0.8% per day
- 🎨 **Material Design 3** - Dark mode & dynamic colors - 🎨 **Material Design 3** - Dark mode & dynamic colors
@@ -83,7 +86,7 @@ cd android
./gradlew assembleStandardRelease ./gradlew assembleStandardRelease
``` ```
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md) ➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
--- ---
@@ -99,4 +102,4 @@ MIT License - see [LICENSE](LICENSE)
--- ---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3 **v1.4.0** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -6,6 +6,8 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/) [![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) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)** **📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
**🌍 Sprachen:** **Deutsch** · [English](README.en.md) **🌍 Sprachen:** **Deutsch** · [English](README.en.md)
@@ -24,11 +26,12 @@
## ✨ Highlights ## ✨ Highlights
-**NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete
- 📝 **Offline-First** - Funktioniert ohne Internet - 📝 **Offline-First** - Funktioniert ohne Internet
- 🔄 **Auto-Sync** - Nur im Heim-WLAN (15/30/60 Min) - 🔄 **Auto-Sync** - Bei WiFi-Verbindung (15/30/60 Min)
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV) - 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** - Export/Import als JSON-Datei - 💾 **Lokales Backup** - Export/Import als JSON-Datei
- 🖥️ **Desktop-Integration** - Markdown-Export für VS Code, Typora, etc. - 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag - 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors - 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
@@ -86,7 +89,7 @@ cd android
./gradlew assembleStandardRelease ./gradlew assembleStandardRelease
``` ```
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md) ➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
--- ---
@@ -102,4 +105,4 @@ MIT License - siehe [LICENSE](LICENSE)
--- ---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3 **v1.4.0** · Built with ❤️ using Kotlin + Material Design 3

61
android/.editorconfig Normal file
View File

@@ -0,0 +1,61 @@
# ⚡ v1.3.1: EditorConfig for ktlint
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
trim_trailing_whitespace = true
[*.{kt,kts}]
# ktlint rules
ktlint_code_style = android_studio
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_package-name = enabled
ktlint_standard_filename = enabled
ktlint_standard_class-naming = enabled
ktlint_standard_function-naming = enabled
ktlint_standard_property-naming = enabled
ktlint_standard_backing-property-naming = enabled
ktlint_standard_enum-entry-name-case = enabled
ktlint_standard_multiline-if-else = enabled
ktlint_standard_no-empty-class-body = enabled
ktlint_standard_no-empty-first-line-in-class-body = enabled
ktlint_standard_blank-line-before-declaration = enabled
ktlint_standard_context-receiver-wrapping = enabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-literal = enabled
ktlint_standard_function-type-modifier-spacing = enabled
ktlint_standard_kdoc-wrapping = enabled
ktlint_standard_modifier-list-spacing = enabled
ktlint_standard_no-blank-line-in-list = enabled
ktlint_standard_no-consecutive-comments = disabled
ktlint_standard_no-single-line-block-comment = enabled
ktlint_standard_parameter-list-spacing = enabled
ktlint_standard_parameter-list-wrapping = enabled
ktlint_standard_property-wrapping = enabled
ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled
ktlint_standard_statement-wrapping = enabled
ktlint_standard_string-template-indent = disabled
ktlint_standard_try-catch-finally-spacing = enabled
ktlint_standard_type-argument-list-spacing = enabled
ktlint_standard_type-parameter-list-spacing = enabled
ktlint_standard_value-argument-comment = enabled
ktlint_standard_value-parameter-comment = enabled
[*.md]
trim_trailing_whitespace = false
[*.{xml,json}]
indent_size = 2
[*.yml]
indent_size = 2
[Makefile]
indent_style = tab

View File

@@ -1,6 +1,9 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
// alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
} }
import java.util.Properties import java.util.Properties
@@ -17,13 +20,10 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 7 // 🔧 v1.2.2: Backward compatibility for v1.2.0 migration versionCode = 11 // 🚀 v1.4.0: Checklists Feature
versionName = "1.2.2" // 🔧 v1.2.2: Dual-mode download (Root + /notes/) versionName = "1.4.0" // 🚀 v1.4.0: Checklists, Multi-Device Sync Fixes, UX Improvements
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 🔥 NEU: Build Date für About Screen
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
} }
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility // Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
@@ -32,23 +32,15 @@ android {
includeInBundle = false // Also disable for AAB (Google Play) includeInBundle = false // Also disable for AAB (Google Play)
} }
// Enable multiple APKs per ABI for smaller downloads
splits {
abi {
isEnable = true
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true // Also generate universal APK
}
}
// Product Flavors for F-Droid and standard builds // Product Flavors for F-Droid and standard builds
// Note: APK splits are disabled to ensure single APK output
flavorDimensions += "distribution" flavorDimensions += "distribution"
productFlavors { productFlavors {
create("fdroid") { create("fdroid") {
dimension = "distribution" dimension = "distribution"
// F-Droid builds have no proprietary dependencies // F-Droid builds have no proprietary dependencies
// All dependencies in this project are already FOSS-compatible // All dependencies in this project are already FOSS-compatible
// No APK splits - F-Droid expects single universal APK
} }
create("standard") { create("standard") {
@@ -75,6 +67,16 @@ android {
} }
buildTypes { buildTypes {
debug {
// ⚡ v1.3.1: Debug-Builds können parallel zur Release-App installiert werden
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isDebuggable = true
// Optionales separates Icon-Label für Debug-Builds
resValue("string", "app_name_debug", "Simple Notes (Debug)")
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -139,8 +141,26 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }
// 🔥 NEU: Helper function für Build Date // ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
fun getBuildDate(): String { // Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) // ktlint {
return dateFormat.format(Date()) // android = true
// outputToConsole = true
// ignoreFailures = true
// enableExperimentalRules = false
// filter {
// exclude("**/generated/**")
// exclude("**/build/**")
// }
// }
// ⚡ v1.3.1: detekt-Konfiguration
detekt {
buildUponDefaultConfig = true
allRules = false
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml")
// Parallel-Verarbeitung für schnellere Checks
parallel = true
} }

View File

@@ -5,8 +5,6 @@
<!-- Network & Sync Permissions --> <!-- Network & Sync Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Notifications --> <!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -21,20 +21,32 @@ import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.dettmer.simplenotes.adapters.NotesAdapter import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView import android.widget.TextView
import android.widget.CheckBox
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View
import android.widget.LinearLayout
import android.view.Gravity
import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -44,9 +56,16 @@ class MainActivity : AppCompatActivity() {
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout private lateinit var swipeRefreshLayout: SwipeRefreshLayout
// 🔄 v1.3.1: Sync Status Banner
private lateinit var syncStatusBanner: LinearLayout
private lateinit var syncStatusText: TextView
private lateinit var adapter: NotesAdapter private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) } private val storage by lazy { NotesStorage(this) }
// Menu reference for sync button state
private var optionsMenu: Menu? = null
// Track pending deletions to prevent flicker when notes reload // Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>() private val pendingDeletions = mutableSetOf<String>()
@@ -60,6 +79,8 @@ class MainActivity : AppCompatActivity() {
private const val REQUEST_SETTINGS = 1002 private const val REQUEST_SETTINGS = 1002
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp" private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
private const val SYNC_COMPLETED_DELAY_MS = 1500L
private const val ERROR_DISPLAY_DELAY_MS = 3000L
} }
/** /**
@@ -91,9 +112,10 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// File Logging aktivieren wenn eingestellt // Logger initialisieren und File-Logging aktivieren wenn eingestellt
if (prefs.getBoolean("file_logging_enabled", false)) { Logger.init(this)
Logger.enableFileLogging(this) if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
} }
// Alte Sync-Notifications beim App-Start löschen // Alte Sync-Notifications beim App-Start löschen
@@ -110,6 +132,65 @@ class MainActivity : AppCompatActivity() {
setupFab() setupFab()
loadNotes() loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates
setupSyncStateObserver()
}
/**
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
*/
private fun setupSyncStateObserver() {
SyncStateManager.syncStatus.observe(this) { status ->
when (status.state) {
SyncStateManager.SyncState.SYNCING -> {
// Disable sync controls
setSyncControlsEnabled(false)
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
syncStatusText.text = getString(R.string.sync_status_syncing)
syncStatusBanner.visibility = View.VISIBLE
}
SyncStateManager.SyncState.COMPLETED -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show completed briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
lifecycleScope.launch {
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.ERROR -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show error briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
lifecycleScope.launch {
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.IDLE -> {
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE
}
}
}
}
/**
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
*/
private fun setSyncControlsEnabled(enabled: Boolean) {
// Menu Sync-Button
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
// SwipeRefresh
swipeRefreshLayout.isEnabled = enabled
} }
override fun onResume() { override fun onResume() {
@@ -145,6 +226,12 @@ class MainActivity : AppCompatActivity() {
return return
} }
// 🔄 v1.3.1: Check if sync already running
if (!SyncStateManager.tryStartSync("auto-$source")) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)") Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp // Update last sync timestamp
@@ -157,6 +244,7 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping") Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
SyncStateManager.reset()
return@launch return@launch
} }
@@ -167,6 +255,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently") Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch return@launch
} }
@@ -178,6 +267,7 @@ class MainActivity : AppCompatActivity() {
// Feedback abhängig von Source // Feedback abhängig von Source
if (result.isSuccess && result.syncedCount > 0) { if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
// onResume: Nur Success-Toast // onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen") showToast("✅ Gesynct: ${result.syncedCount} Notizen")
@@ -185,14 +275,17 @@ class MainActivity : AppCompatActivity() {
} else if (result.isSuccess) { } else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes") Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted()
} else { } else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
// Kein Toast - App ist im Hintergrund // Kein Toast - App ist im Hintergrund
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}") Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message)
// Kein Toast - App ist im Hintergrund // Kein Toast - App ist im Hintergrund
} }
} }
@@ -229,6 +322,10 @@ class MainActivity : AppCompatActivity() {
fabAddNote = findViewById(R.id.fabAddNote) fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout) swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 🔄 v1.3.1: Sync Status Banner
syncStatusBanner = findViewById(R.id.syncStatusBanner)
syncStatusText = findViewById(R.id.syncStatusText)
} }
private fun setupToolbar() { private fun setupToolbar() {
@@ -256,6 +353,12 @@ class MainActivity : AppCompatActivity() {
swipeRefreshLayout.setOnRefreshListener { swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync") Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
swipeRefreshLayout.isRefreshing = false
return@setOnRefreshListener
}
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
@@ -263,7 +366,7 @@ class MainActivity : AppCompatActivity() {
if (serverUrl.isNullOrEmpty()) { if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert") showToast("⚠️ Server noch nicht konfiguriert")
swipeRefreshLayout.isRefreshing = false SyncStateManager.reset()
return@launch return@launch
} }
@@ -272,15 +375,13 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check") Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
showToast("Bereits synchronisiert") SyncStateManager.markCompleted("Bereits synchronisiert")
swipeRefreshLayout.isRefreshing = false
return@launch return@launch
} }
// Check if server is reachable // Check if server is reachable
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar") SyncStateManager.markError("Server nicht erreichbar")
swipeRefreshLayout.isRefreshing = false
return@launch return@launch
} }
@@ -288,16 +389,14 @@ class MainActivity : AppCompatActivity() {
val result = syncService.syncNotes() val result = syncService.syncNotes()
if (result.isSuccess) { if (result.isSuccess) {
showToast("${result.syncedCount} Notizen synchronisiert") SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes() loadNotes()
} else { } else {
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}") SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Pull-to-Refresh sync failed", e) Logger.e(TAG, "Pull-to-Refresh sync failed", e)
showToast("❌ Fehler: ${e.message}") SyncStateManager.markError(e.message)
} finally {
swipeRefreshLayout.isRefreshing = false
} }
} }
} }
@@ -320,36 +419,20 @@ class MainActivity : AppCompatActivity() {
): Boolean = false ): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition val position = viewHolder.bindingAdapterPosition
val note = adapter.currentList[position] val swipedNote = adapter.currentList[position]
val notesCopy = adapter.currentList.toMutableList()
// Track pending deletion to prevent flicker // Store original list BEFORE removing note
pendingDeletions.add(note.id) val originalList = adapter.currentList.toList()
// Remove from list immediately for visual feedback // Remove from list for visual feedback (NOT from storage yet!)
notesCopy.removeAt(position) val listWithoutNote = originalList.toMutableList().apply {
adapter.submitList(notesCopy) removeAt(position)
// Show Snackbar with UNDO
Snackbar.make(
recyclerViewNotes,
"Notiz gelöscht",
Snackbar.LENGTH_LONG
).setAction("RÜCKGÄNGIG") {
// UNDO: Remove from pending deletions and restore
pendingDeletions.remove(note.id)
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)
pendingDeletions.remove(note.id)
loadNotes()
} }
} adapter.submitList(listWithoutNote)
}).show()
// Show dialog with ability to restore
showServerDeletionDialog(swipedNote, originalList)
} }
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
@@ -361,10 +444,163 @@ class MainActivity : AppCompatActivity() {
itemTouchHelper.attachToRecyclerView(recyclerViewNotes) itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
} }
private fun setupFab() { private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
fabAddNote.setOnClickListener { val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
openNoteEditor(null)
if (alwaysDeleteFromServer) {
// Auto-delete from server without asking
deleteNoteLocally(note, deleteFromServer = true)
return
} }
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle("Notiz löschen")
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?")
.setView(dialogView)
.setNeutralButton("Abbrechen") { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList)
}
.setOnCancelListener {
// User pressed back - also restore
adapter.submitList(originalList)
}
.setPositiveButton("Nur lokal") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
}
// NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false)
}
.setNegativeButton("Vom Server löschen") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
}
deleteNoteLocally(note, deleteFromServer = true)
}
.setCancelable(true)
.show()
}
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Delete from storage
storage.deleteNote(note.id)
// Reload to reflect changes
loadNotes()
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
"\"${note.title}\" wird lokal und vom Server gelöscht"
} else {
"\"${note.title}\" lokal gelöscht (Server bleibt)"
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction("RÜCKGÄNGIG") {
// UNDO: Restore note
storage.saveNote(note)
pendingDeletions.remove(note.id)
loadNotes()
}
.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO
pendingDeletions.remove(note.id)
// Delete from server if requested
if (deleteFromServer) {
lifecycleScope.launch {
try {
val webdavService = WebDavSyncService(this@MainActivity)
val success = webdavService.deleteNoteFromServer(note.id)
if (success) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Vom Server gelöscht",
Toast.LENGTH_SHORT
).show()
}
} else {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Löschung fehlgeschlagen",
Toast.LENGTH_LONG
).show()
}
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Fehler: ${e.message}",
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}
}).show()
}
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
private fun setupFab() {
fabAddNote.setOnClickListener { view ->
showNoteTypePopup(view)
}
}
/**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/
private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try {
val fields = popupMenu.javaClass.declaredFields
for (field in fields) {
if ("mPopup" == field.name) {
field.isAccessible = true
val menuPopupHelper = field.get(popupMenu)
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
setForceIcons.invoke(menuPopupHelper, true)
break
}
}
} catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
}
popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false
}
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
true
}
popupMenu.show()
} }
private fun loadNotes() { private fun loadNotes() {
@@ -405,6 +641,11 @@ class MainActivity : AppCompatActivity() {
} }
private fun triggerManualSync() { private fun triggerManualSync() {
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("manual")) {
return
}
lifecycleScope.launch { lifecycleScope.launch {
try { try {
// Create sync service // Create sync service
@@ -413,12 +654,10 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping") Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
showToast("Bereits synchronisiert") SyncStateManager.markCompleted("Bereits synchronisiert")
return@launch return@launch
} }
showToast("Starte Synchronisation...")
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) { val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable() syncService.isServerReachable()
@@ -426,7 +665,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting") Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
showToast("Server nicht erreichbar") SyncStateManager.markError("Server nicht erreichbar")
return@launch return@launch
} }
@@ -437,20 +676,21 @@ class MainActivity : AppCompatActivity() {
// Show result // Show result
if (result.isSuccess) { if (result.isSuccess) {
showToast("Sync erfolgreich: ${result.syncedCount} Notizen") SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes() // Reload notes loadNotes() // Reload notes
} else { } else {
showToast("Sync Fehler: ${result.errorMessage}") SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } catch (e: Exception) {
showToast("Sync Fehler: ${e.message}") SyncStateManager.markError(e.message)
} }
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu) menuInflater.inflate(R.menu.menu_main, menu)
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
return true return true
} }

View File

@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
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.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import dev.dettmer.simplenotes.adapters.ChecklistEditorAdapter
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
/**
* Editor Activity für Notizen und Checklisten
*
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
*/
class NoteEditorActivity : AppCompatActivity() { class NoteEditorActivity : AppCompatActivity() {
// Views
private lateinit var toolbar: MaterialToolbar
private lateinit var tilTitle: TextInputLayout
private lateinit var editTextTitle: TextInputEditText private lateinit var editTextTitle: TextInputEditText
private lateinit var tilContent: TextInputLayout
private lateinit var editTextContent: TextInputEditText private lateinit var editTextContent: TextInputEditText
private lateinit var checklistContainer: LinearLayout
private lateinit var rvChecklistItems: RecyclerView
private lateinit var btnAddItem: MaterialButton
private lateinit var storage: NotesStorage private lateinit var storage: NotesStorage
// State
private var existingNote: Note? = null private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT
private val checklistItems = mutableListOf<ChecklistItem>()
private var checklistAdapter: ChecklistEditorAdapter? = null
private var itemTouchHelper: ItemTouchHelper? = null
companion object { companion object {
private const val TAG = "NoteEditorActivity"
const val EXTRA_NOTE_ID = "extra_note_id" const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type"
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -36,39 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
storage = NotesStorage(this) storage = NotesStorage(this)
// Setup toolbar findViews()
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar) setupToolbar()
setSupportActionBar(toolbar) loadNoteOrDetermineType()
supportActionBar?.apply { setupUIForNoteType()
setDisplayHomeAsUpEnabled(true)
// 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
} }
// Find views private fun findViews() {
toolbar = findViewById(R.id.toolbar)
tilTitle = findViewById(R.id.tilTitle)
editTextTitle = findViewById(R.id.editTextTitle) editTextTitle = findViewById(R.id.editTextTitle)
tilContent = findViewById(R.id.tilContent)
editTextContent = findViewById(R.id.editTextContent) editTextContent = findViewById(R.id.editTextContent)
checklistContainer = findViewById(R.id.checklistContainer)
rvChecklistItems = findViewById(R.id.rvChecklistItems)
btnAddItem = findViewById(R.id.btnAddItem)
}
// Load existing note if editing private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
private fun loadNoteOrDetermineType() {
val noteId = intent.getStringExtra(EXTRA_NOTE_ID) val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
if (noteId != null) { if (noteId != null) {
// Existierende Notiz laden
existingNote = storage.loadNote(noteId) existingNote = storage.loadNote(noteId)
existingNote?.let { existingNote?.let { note ->
editTextTitle.setText(it.title) editTextTitle.setText(note.title)
editTextContent.setText(it.content) currentNoteType = note.noteType
supportActionBar?.title = "Notiz bearbeiten"
when (note.noteType) {
NoteType.TEXT -> {
editTextContent.setText(note.content)
supportActionBar?.title = getString(R.string.edit_note)
}
NoteType.CHECKLIST -> {
note.checklistItems?.let { items ->
checklistItems.clear()
checklistItems.addAll(items.sortedBy { it.order })
}
supportActionBar?.title = getString(R.string.edit_checklist)
}
}
} }
} else { } else {
supportActionBar?.title = "Neue Notiz" // Neue Notiz - Typ aus Intent
val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
currentNoteType = try {
NoteType.valueOf(typeString)
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
when (currentNoteType) {
NoteType.TEXT -> {
supportActionBar?.title = getString(R.string.new_note)
}
NoteType.CHECKLIST -> {
supportActionBar?.title = getString(R.string.new_checklist)
// Erstes leeres Item hinzufügen
checklistItems.add(ChecklistItem.createEmpty(0))
}
}
}
}
private fun setupUIForNoteType() {
when (currentNoteType) {
NoteType.TEXT -> {
tilContent.visibility = View.VISIBLE
checklistContainer.visibility = View.GONE
}
NoteType.CHECKLIST -> {
tilContent.visibility = View.GONE
checklistContainer.visibility = View.VISIBLE
setupChecklistRecyclerView()
}
}
}
private fun setupChecklistRecyclerView() {
checklistAdapter = ChecklistEditorAdapter(
items = checklistItems,
onItemCheckedChanged = { position, isChecked ->
if (position in checklistItems.indices) {
checklistItems[position].isChecked = isChecked
}
},
onItemTextChanged = { position, newText ->
if (position in checklistItems.indices) {
checklistItems[position] = checklistItems[position].copy(text = newText)
}
},
onItemDeleted = { position ->
deleteChecklistItem(position)
},
onAddNewItem = { position ->
addChecklistItemAt(position)
},
onStartDrag = { viewHolder ->
itemTouchHelper?.startDrag(viewHolder)
}
)
rvChecklistItems.apply {
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
adapter = checklistAdapter
}
// Drag & Drop Setup
val callback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0 // Kein Swipe
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
checklistAdapter?.moveItem(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Nicht verwendet
}
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
}
itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
// Add Item Button
btnAddItem.setOnClickListener {
addChecklistItemAt(checklistItems.size)
}
}
private fun addChecklistItemAt(position: Int) {
val newItem = ChecklistItem.createEmpty(position)
checklistAdapter?.insertItem(position, newItem)
// Zum neuen Item scrollen und fokussieren
rvChecklistItems.scrollToPosition(position)
checklistAdapter?.focusItem(rvChecklistItems, position)
}
private fun deleteChecklistItem(position: Int) {
checklistAdapter?.removeItem(position)
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
if (checklistItems.isEmpty()) {
addChecklistItemAt(0)
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_editor, menu) menuInflater.inflate(R.menu.menu_editor, menu)
// Delete nur für existierende Notizen
// Show delete only for existing notes
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
return true return true
} }
@@ -92,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
private fun saveNote() { private fun saveNote() {
val title = editTextTitle.text?.toString()?.trim() ?: "" val title = editTextTitle.text?.toString()?.trim() ?: ""
when (currentNoteType) {
NoteType.TEXT -> {
val content = editTextContent.text?.toString()?.trim() ?: "" val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) { if (title.isEmpty() && content.isEmpty()) {
showToast("Notiz ist leer") showToast(getString(R.string.note_is_empty))
return return
} }
val note = if (existingNote != null) { val note = if (existingNote != null) {
// Update existing note
existingNote!!.copy( existingNote!!.copy(
title = title, title = title,
content = content, content = content,
noteType = NoteType.TEXT,
checklistItems = null,
updatedAt = System.currentTimeMillis(), updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING syncStatus = SyncStatus.PENDING
) )
} else { } else {
// Create new note
Note( Note(
title = title, title = title,
content = content, content = content,
noteType = NoteType.TEXT,
checklistItems = null,
deviceId = DeviceIdGenerator.getDeviceId(this), deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY syncStatus = SyncStatus.LOCAL_ONLY
) )
} }
storage.saveNote(note) storage.saveNote(note)
showToast("Notiz gespeichert") }
NoteType.CHECKLIST -> {
// Leere Items filtern
val validItems = checklistItems.filter { it.text.isNotBlank() }
if (title.isEmpty() && validItems.isEmpty()) {
showToast(getString(R.string.note_is_empty))
return
}
// Order neu setzen
val orderedItems = validItems.mapIndexed { index, item ->
item.copy(order = index)
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = "", // Leer für Checklisten
noteType = NoteType.CHECKLIST,
checklistItems = orderedItems,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = "",
noteType = NoteType.CHECKLIST,
checklistItems = orderedItems,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
}
showToast(getString(R.string.note_saved))
finish() finish()
} }
private fun confirmDelete() { private fun confirmDelete() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("Notiz löschen?") .setTitle(getString(R.string.delete_note_title))
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.") .setMessage(getString(R.string.delete_note_message))
.setPositiveButton("Löschen") { _, _ -> .setPositiveButton(getString(R.string.delete)) { _, _ ->
deleteNote() deleteNote()
} }
.setNegativeButton("Abbrechen", null) .setNegativeButton(getString(R.string.cancel), null)
.show() .show()
} }
private fun deleteNote() { private fun deleteNote() {
existingNote?.let { existingNote?.let {
storage.deleteNote(it.id) storage.deleteNote(it.id)
showToast("Notiz gelöscht") showToast(getString(R.string.note_deleted))
finish() finish()
} }
} }

View File

@@ -9,6 +9,7 @@ import android.os.PowerManager
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.RadioButton import android.widget.RadioButton
@@ -24,8 +25,6 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.color.DynamicColors 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.dettmer.simplenotes.backup.BackupManager import dev.dettmer.simplenotes.backup.BackupManager
@@ -33,11 +32,11 @@ import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.utils.UrlValidator import dev.dettmer.simplenotes.utils.UrlValidator
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -50,6 +49,7 @@ class SettingsActivity : AppCompatActivity() {
private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync" 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 GITHUB_PROFILE_URL = "https://github.com/inventory69"
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE" private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
private const val CONNECTION_TIMEOUT_MS = 3000
} }
private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout
@@ -57,14 +57,15 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var editTextUsername: EditText private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText private lateinit var editTextPassword: EditText
private lateinit var switchAutoSync: SwitchCompat private lateinit var switchAutoSync: SwitchCompat
private lateinit var switchMarkdownExport: SwitchCompat private lateinit var switchMarkdownAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button private lateinit var buttonSyncNow: Button
private lateinit var buttonCreateBackup: Button private lateinit var buttonCreateBackup: Button
private lateinit var buttonRestoreFromFile: Button private lateinit var buttonRestoreFromFile: Button
private lateinit var buttonRestoreFromServer: Button private lateinit var buttonRestoreFromServer: Button
private lateinit var buttonImportMarkdown: Button private lateinit var buttonManualMarkdownSync: Button
private lateinit var textViewServerStatus: TextView private lateinit var textViewServerStatus: TextView
private lateinit var textViewManualSyncInfo: TextView
// Protocol Selection UI // Protocol Selection UI
private lateinit var protocolRadioGroup: RadioGroup private lateinit var protocolRadioGroup: RadioGroup
@@ -81,6 +82,11 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var cardDeveloperProfile: MaterialCardView private lateinit var cardDeveloperProfile: MaterialCardView
private lateinit var cardLicense: MaterialCardView private lateinit var cardLicense: MaterialCardView
// Debug Section UI
private lateinit var switchFileLogging: com.google.android.material.materialswitch.MaterialSwitch
private lateinit var buttonExportLogs: Button
private lateinit var buttonClearLogs: Button
// Backup Manager // Backup Manager
private val backupManager by lazy { BackupManager(this) } private val backupManager by lazy { BackupManager(this) }
@@ -122,6 +128,7 @@ class SettingsActivity : AppCompatActivity() {
setupListeners() setupListeners()
setupSyncIntervalPicker() setupSyncIntervalPicker()
setupAboutSection() setupAboutSection()
setupDebugSection()
} }
private fun findViews() { private fun findViews() {
@@ -130,14 +137,15 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername = findViewById(R.id.editTextUsername) editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword) editTextPassword = findViewById(R.id.editTextPassword)
switchAutoSync = findViewById(R.id.switchAutoSync) switchAutoSync = findViewById(R.id.switchAutoSync)
switchMarkdownExport = findViewById(R.id.switchMarkdownExport) switchMarkdownAutoSync = findViewById(R.id.switchMarkdownAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection) buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow) buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonCreateBackup = findViewById(R.id.buttonCreateBackup) buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile) buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer) buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown) buttonManualMarkdownSync = findViewById(R.id.buttonManualMarkdownSync)
textViewServerStatus = findViewById(R.id.textViewServerStatus) textViewServerStatus = findViewById(R.id.textViewServerStatus)
textViewManualSyncInfo = findViewById(R.id.textViewManualSyncInfo)
// Protocol Selection UI // Protocol Selection UI
protocolRadioGroup = findViewById(R.id.protocolRadioGroup) protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
@@ -153,6 +161,11 @@ class SettingsActivity : AppCompatActivity() {
cardGitHubRepo = findViewById(R.id.cardGitHubRepo) cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile) cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
cardLicense = findViewById(R.id.cardLicense) cardLicense = findViewById(R.id.cardLicense)
// Debug Section UI
switchFileLogging = findViewById(R.id.switchFileLogging)
buttonExportLogs = findViewById(R.id.buttonExportLogs)
buttonClearLogs = findViewById(R.id.buttonClearLogs)
} }
private fun loadSettings() { private fun loadSettings() {
@@ -180,7 +193,14 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, "")) editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, "")) editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first)
// Load Markdown Auto-Sync (backward compatible)
val markdownExport = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
val markdownAutoImport = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
val markdownAutoSync = markdownExport && markdownAutoImport
switchMarkdownAutoSync.isChecked = markdownAutoSync
updateMarkdownButtonVisibility()
// Update hint text based on selected protocol // Update hint text based on selected protocol
updateProtocolHint() updateProtocolHint()
@@ -269,17 +289,16 @@ class SettingsActivity : AppCompatActivity() {
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null) showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
} }
buttonImportMarkdown.setOnClickListener { buttonManualMarkdownSync.setOnClickListener {
saveSettings() performManualMarkdownSync()
importMarkdownChanges()
} }
switchAutoSync.setOnCheckedChangeListener { _, isChecked -> switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked) onAutoSyncToggled(isChecked)
} }
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked -> switchMarkdownAutoSync.setOnCheckedChangeListener { _, isChecked ->
onMarkdownExportToggled(isChecked) onMarkdownAutoSyncToggled(isChecked)
} }
// Clear error when user starts typing again // Clear error when user starts typing again
@@ -304,7 +323,10 @@ class SettingsActivity : AppCompatActivity() {
*/ */
private fun setupSyncIntervalPicker() { private fun setupSyncIntervalPicker() {
// Load current interval from preferences // Load current interval from preferences
val currentInterval = prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES) val currentInterval = prefs.getLong(
Constants.PREF_SYNC_INTERVAL_MINUTES,
Constants.DEFAULT_SYNC_INTERVAL_MINUTES
)
// Set checked radio button based on current interval // Set checked radio button based on current interval
val checkedId = when (currentInterval) { val checkedId = when (currentInterval) {
@@ -349,13 +371,12 @@ class SettingsActivity : AppCompatActivity() {
* Setup about section with version info and clickable cards * Setup about section with version info and clickable cards
*/ */
private fun setupAboutSection() { private fun setupAboutSection() {
// Display app version with build date // Display app version
try { try {
val versionName = BuildConfig.VERSION_NAME val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE val versionCode = BuildConfig.VERSION_CODE
val buildDate = BuildConfig.BUILD_DATE
textViewAppVersion.text = "Version $versionName ($versionCode)\nErstellt am: $buildDate" textViewAppVersion.text = "Version $versionName ($versionCode)"
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e) Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = "Version nicht verfügbar" textViewAppVersion.text = "Version nicht verfügbar"
@@ -377,6 +398,109 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
/**
* Setup Debug section with file logging toggle and export functionality
*/
private fun setupDebugSection() {
// Load current file logging state
val fileLoggingEnabled = prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
switchFileLogging.isChecked = fileLoggingEnabled
// Update Logger state
Logger.setFileLoggingEnabled(fileLoggingEnabled)
// Toggle file logging
switchFileLogging.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, isChecked).apply()
Logger.setFileLoggingEnabled(isChecked)
if (isChecked) {
showToast("📝 Datei-Logging aktiviert")
Logger.i(TAG, "File logging enabled by user")
} else {
showToast("📝 Datei-Logging deaktiviert")
}
}
// Export logs button
buttonExportLogs.setOnClickListener {
exportAndShareLogs()
}
// Clear logs button
buttonClearLogs.setOnClickListener {
showClearLogsConfirmation()
}
}
/**
* Export logs and share via system share sheet
*/
private fun exportAndShareLogs() {
lifecycleScope.launch {
try {
val logFile = Logger.getLogFile(this@SettingsActivity)
if (logFile == null || !logFile.exists() || logFile.length() == 0L) {
showToast("📭 Keine Logs vorhanden")
return@launch
}
// Create share intent using FileProvider
val logUri = FileProvider.getUriForFile(
this@SettingsActivity,
"${BuildConfig.APPLICATION_ID}.fileprovider",
logFile
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, "SimpleNotes Sync Logs")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "Logs teilen via..."))
Logger.i(TAG, "Logs exported and shared")
} catch (e: Exception) {
Logger.e(TAG, "Failed to export logs", e)
showToast("❌ Fehler beim Exportieren: ${e.message}")
}
}
}
/**
* Show confirmation dialog before clearing logs
*/
private fun showClearLogsConfirmation() {
AlertDialog.Builder(this)
.setTitle("Logs löschen?")
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
.setPositiveButton("Löschen") { _, _ ->
clearLogs()
}
.setNegativeButton("Abbrechen", null)
.show()
}
/**
* Clear all log files
*/
private fun clearLogs() {
try {
val cleared = Logger.clearLogFile(this)
if (cleared) {
showToast("🗑️ Logs gelöscht")
} else {
showToast("📭 Keine Logs zum Löschen")
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to clear logs", e)
showToast("❌ Fehler beim Löschen: ${e.message}")
}
}
/** /**
* Opens URL in browser * Opens URL in browser
*/ */
@@ -458,6 +582,14 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun syncNow() { private fun syncNow() {
// 🔄 v1.3.1: Check if sync already running (Button wird deaktiviert)
if (!SyncStateManager.tryStartSync("settings")) {
return
}
// Disable button during sync
buttonSyncNow.isEnabled = false
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val syncService = WebDavSyncService(this@SettingsActivity) val syncService = WebDavSyncService(this@SettingsActivity)
@@ -465,14 +597,16 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
showToast("✅ Bereits synchronisiert") showToast("✅ Bereits synchronisiert")
SyncStateManager.markCompleted()
return@launch return@launch
} }
showToast("Synchronisiere...") showToast("🔄 Synchronisiere...")
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar") showToast("⚠️ Server nicht erreichbar")
SyncStateManager.markError("Server nicht erreichbar")
checkServerStatus() // Server-Status aktualisieren checkServerStatus() // Server-Status aktualisieren
return@launch return@launch
} }
@@ -481,18 +615,24 @@ class SettingsActivity : AppCompatActivity() {
if (result.isSuccess) { if (result.isSuccess) {
if (result.hasConflicts) { if (result.hasConflicts) {
showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!") showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
} else { } else {
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert") showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
} }
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
checkServerStatus() // ✅ Server-Status nach Sync aktualisieren checkServerStatus() // ✅ Server-Status nach Sync aktualisieren
} else { } else {
showToast("Sync fehlgeschlagen: ${result.errorMessage}") showToast("Sync fehlgeschlagen: ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
checkServerStatus() // ✅ Auch bei Fehler aktualisieren checkServerStatus() // ✅ Auch bei Fehler aktualisieren
} }
} catch (e: Exception) { } catch (e: Exception) {
showToast("Fehler: ${e.message}") showToast("Fehler: ${e.message}")
SyncStateManager.markError(e.message)
checkServerStatus() // ✅ Auch bei Exception aktualisieren checkServerStatus() // ✅ Auch bei Exception aktualisieren
} finally {
// Re-enable button
buttonSyncNow.isEnabled = true
} }
} }
} }
@@ -514,8 +654,8 @@ class SettingsActivity : AppCompatActivity() {
try { try {
val url = URL(serverUrl) val url = URL(serverUrl)
val connection = url.openConnection() as HttpURLConnection val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 3000 connection.connectTimeout = CONNECTION_TIMEOUT_MS
connection.readTimeout = 3000 connection.readTimeout = CONNECTION_TIMEOUT_MS
val code = connection.responseCode val code = connection.responseCode
connection.disconnect() connection.disconnect()
code in 200..299 || code == 401 // 401 = Server da, Auth fehlt code in 200..299 || code == 401 // 401 = Server da, Auth fehlt
@@ -548,7 +688,7 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
private fun onMarkdownExportToggled(enabled: Boolean) { private fun onMarkdownAutoSyncToggled(enabled: Boolean) {
if (enabled) { if (enabled) {
// Initial-Export wenn Feature aktiviert wird // Initial-Export wenn Feature aktiviert wird
lifecycleScope.launch { lifecycleScope.launch {
@@ -559,7 +699,7 @@ class SettingsActivity : AppCompatActivity() {
if (currentNoteCount > 0) { if (currentNoteCount > 0) {
// Zeige Progress-Dialog // Zeige Progress-Dialog
val progressDialog = ProgressDialog(this@SettingsActivity).apply { val progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown-Export") setTitle("Markdown Auto-Sync")
setMessage("Exportiere Notizen nach Markdown...") setMessage("Exportiere Notizen nach Markdown...")
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL) setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
max = currentNoteCount max = currentNoteCount
@@ -577,7 +717,7 @@ class SettingsActivity : AppCompatActivity() {
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
progressDialog.dismiss() progressDialog.dismiss()
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren") showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
switchMarkdownExport.isChecked = false switchMarkdownAutoSync.isChecked = false
return@launch return@launch
} }
@@ -597,8 +737,13 @@ class SettingsActivity : AppCompatActivity() {
progressDialog.dismiss() progressDialog.dismiss()
// Speichere Einstellung // Speichere beide Einstellungen
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply() prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
// Erfolgs-Nachricht // Erfolgs-Nachricht
showToast("$exportedCount Notizen nach Markdown exportiert") showToast("$exportedCount Notizen nach Markdown exportiert")
@@ -608,76 +753,38 @@ class SettingsActivity : AppCompatActivity() {
showToast("❌ Export fehlgeschlagen: ${e.message}") showToast("❌ Export fehlgeschlagen: ${e.message}")
// Deaktiviere Toggle bei Fehler // Deaktiviere Toggle bei Fehler
switchMarkdownExport.isChecked = false switchMarkdownAutoSync.isChecked = false
return@launch return@launch
} }
} else { } else {
// Keine Notizen vorhanden - speichere Einstellung direkt // Keine Notizen vorhanden - speichere Einstellungen direkt
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply() prefs.edit()
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert") .putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
} .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
} catch (e: Exception) { updateMarkdownButtonVisibility()
Logger.e(TAG, "Error toggling markdown export: ${e.message}") showToast(
showToast("Fehler: ${e.message}") "Markdown Auto-Sync aktiviert - " +
switchMarkdownExport.isChecked = false "Notizen werden als .md-Dateien exportiert und importiert"
}
}
} else {
// Deaktivieren - nur Setting speichern
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
}
}
private fun importMarkdownChanges() {
// Prüfen ob Server konfiguriert ist
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
showToast("Bitte zuerst WebDAV-Server konfigurieren")
return
}
// Import-Dialog mit Warnung
AlertDialog.Builder(this)
.setTitle("Markdown-Import")
.setMessage(
"Importiert Änderungen aus .md-Dateien vom Server.\n\n" +
"⚠️ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" +
"Fortfahren?"
) )
.setPositiveButton("Importieren") { _, _ ->
performMarkdownImport(serverUrl, username, password)
}
.setNegativeButton("Abbrechen", null)
.show()
} }
private fun performMarkdownImport(serverUrl: String, username: String, password: String) {
showToast("Importiere Markdown-Dateien...")
lifecycleScope.launch(Dispatchers.IO) {
try {
val syncService = WebDavSyncService(this@SettingsActivity)
val importCount = syncService.syncMarkdownFiles(serverUrl, username, password)
withContext(Dispatchers.Main) {
if (importCount > 0) {
showToast("$importCount Notizen aus Markdown importiert")
// Benachrichtige MainActivity zum Neuladen
sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED"))
} else {
showToast("Keine Markdown-Änderungen gefunden")
}
}
} catch (e: Exception) { } catch (e: Exception) {
withContext(Dispatchers.Main) { Logger.e(TAG, "Error toggling markdown auto-sync: ${e.message}")
showToast("Import-Fehler: ${e.message}") showToast("Fehler: ${e.message}")
switchMarkdownAutoSync.isChecked = false
} }
} }
} else {
// Deaktivieren - Settings speichern
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
showToast("Markdown Auto-Sync deaktiviert - nur JSON-Sync aktiv")
} }
} }
@@ -714,11 +821,13 @@ class SettingsActivity : AppCompatActivity() {
intent.data = Uri.parse("package:$packageName") intent.data = Uri.parse("package:$packageName")
startActivity(intent) startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
// Fallback: Open general battery settings // Fallback: Open general battery settings
try { try {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(intent) startActivity(intent)
} catch (e2: Exception) { } catch (e2: Exception) {
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
showToast("Bitte Akku-Optimierung manuell deaktivieren") showToast("Bitte Akku-Optimierung manuell deaktivieren")
} }
} }
@@ -737,49 +846,6 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
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 { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
@@ -842,7 +908,6 @@ class SettingsActivity : AppCompatActivity() {
} }
// Custom View mit Radio Buttons // Custom View mit Radio Buttons
val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null)
val radioGroup = android.widget.RadioGroup(this).apply { val radioGroup = android.widget.RadioGroup(this).apply {
orientation = android.widget.RadioGroup.VERTICAL orientation = android.widget.RadioGroup.VERTICAL
setPadding(50, 20, 50, 20) setPadding(50, 20, 50, 20)
@@ -851,20 +916,20 @@ class SettingsActivity : AppCompatActivity() {
// Radio Buttons erstellen // Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply { val radioMerge = android.widget.RadioButton(this).apply {
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten" text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
id = 0 id = android.view.View.generateViewId()
isChecked = true isChecked = true
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
val radioReplace = android.widget.RadioButton(this).apply { val radioReplace = android.widget.RadioButton(this).apply {
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren" text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
id = 1 id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
val radioOverwrite = android.widget.RadioButton(this).apply { val radioOverwrite = android.widget.RadioButton(this).apply {
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten" text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
id = 2 id = android.view.View.generateViewId()
setPadding(10, 10, 10, 10) setPadding(10, 10, 10, 10)
} }
@@ -903,8 +968,8 @@ class SettingsActivity : AppCompatActivity() {
.setView(mainLayout) .setView(mainLayout)
.setPositiveButton("Wiederherstellen") { _, _ -> .setPositiveButton("Wiederherstellen") { _, _ ->
val selectedMode = when (radioGroup.checkedRadioButtonId) { val selectedMode = when (radioGroup.checkedRadioButtonId) {
1 -> RestoreMode.REPLACE radioReplace.id -> RestoreMode.REPLACE
2 -> RestoreMode.OVERWRITE_DUPLICATES radioOverwrite.id -> RestoreMode.OVERWRITE_DUPLICATES
else -> RestoreMode.MERGE else -> RestoreMode.MERGE
} }
@@ -935,12 +1000,12 @@ class SettingsActivity : AppCompatActivity() {
progressDialog.dismiss() progressDialog.dismiss()
if (result.success) { if (result.success) {
val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen" val message = result.message ?: "Wiederhergestellt: ${result.importedNotes} Notizen"
showToast("$message") showToast("$message")
// Refresh MainActivity's note list // Refresh MainActivity's note list
setResult(RESULT_OK) setResult(RESULT_OK)
broadcastNotesChanged() broadcastNotesChanged(result.importedNotes)
} else { } else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler") showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
} }
@@ -953,12 +1018,7 @@ class SettingsActivity : AppCompatActivity() {
} }
/** /**
* Führt Restore vom Server durch (Task #1.2.0-05b) * Server-Restore mit Restore-Modi (v1.3.0)
* Nutzt neues universelles Dialog-System mit Restore-Modi
*
* HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion
* unterstützt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet.
* TODO: WebDavSyncService.restoreFromServer() erweitern für v1.2.1+
*/ */
private fun performRestoreFromServer(mode: RestoreMode) { private fun performRestoreFromServer(mode: RestoreMode) {
lifecycleScope.launch { lifecycleScope.launch {
@@ -970,7 +1030,6 @@ class SettingsActivity : AppCompatActivity() {
try { try {
Logger.d(TAG, "📥 Restoring from server (mode: $mode)") Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
Logger.w(TAG, "⚠️ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)")
// Auto-Backup erstellen (Sicherheitsnetz) // Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = backupManager.createAutoBackup() val autoBackupUri = backupManager.createAutoBackup()
@@ -981,8 +1040,7 @@ class SettingsActivity : AppCompatActivity() {
// Server-Restore durchführen // Server-Restore durchführen
val webdavService = WebDavSyncService(this@SettingsActivity) val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
// Nutzt alte Funktion (immer REPLACE) webdavService.restoreFromServer(mode) // ✅ Pass mode parameter
webdavService.restoreFromServer()
} }
progressDialog.dismiss() progressDialog.dismiss()
@@ -990,7 +1048,7 @@ class SettingsActivity : AppCompatActivity() {
if (result.isSuccess) { if (result.isSuccess) {
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen") showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
setResult(RESULT_OK) setResult(RESULT_OK)
broadcastNotesChanged() broadcastNotesChanged(result.restoredCount)
} else { } else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler") showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
} }
@@ -1005,13 +1063,76 @@ class SettingsActivity : AppCompatActivity() {
/** /**
* Sendet Broadcast dass Notizen geändert wurden * Sendet Broadcast dass Notizen geändert wurden
*/ */
private fun broadcastNotesChanged() { private fun broadcastNotesChanged(count: Int = 0) {
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED) val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
intent.putExtra("success", true) intent.putExtra("success", true)
intent.putExtra("syncedCount", 0) intent.putExtra("syncedCount", count)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent) LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
} }
/**
* Updates visibility of manual sync button based on Auto-Sync toggle state
*/
private fun updateMarkdownButtonVisibility() {
val autoSyncEnabled = switchMarkdownAutoSync.isChecked
val visibility = if (autoSyncEnabled) View.GONE else View.VISIBLE
textViewManualSyncInfo.visibility = visibility
buttonManualMarkdownSync.visibility = visibility
}
/**
* Performs manual Markdown sync (Export + Import)
* Called when manual sync button is clicked
*/
private fun performManualMarkdownSync() {
lifecycleScope.launch {
var progressDialog: ProgressDialog? = null
try {
// Validierung
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "")
val username = prefs.getString(Constants.KEY_USERNAME, "")
val password = prefs.getString(Constants.KEY_PASSWORD, "")
if (serverUrl.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) {
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
return@launch
}
// Progress-Dialog
progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown-Sync")
setMessage("Synchronisiere Markdown-Dateien...")
setCancelable(false)
show()
}
// Sync ausführen
val syncService = dev.dettmer.simplenotes.sync.WebDavSyncService(this@SettingsActivity)
val result = syncService.manualMarkdownSync()
progressDialog.dismiss()
// Erfolgs-Nachricht
val message = "✅ Sync abgeschlossen\n" +
"📤 ${result.exportedCount} exportiert\n" +
"📥 ${result.importedCount} importiert"
showToast(message)
Logger.d(
"SettingsActivity",
"Manual markdown sync: exported=${result.exportedCount}, " +
"imported=${result.importedCount}"
)
} catch (e: Exception) {
progressDialog?.dismiss()
showToast("❌ Sync fehlgeschlagen: ${e.message}")
Logger.e("SettingsActivity", "Manual markdown sync failed", e)
}
}
}
/** /**
* Zeigt Error-Dialog an * Zeigt Error-Dialog an
*/ */

View File

@@ -0,0 +1,181 @@
package dev.dettmer.simplenotes.adapters
import android.graphics.Paint
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistItem
/**
* Adapter für die Bearbeitung von Checklist-Items im Editor
*
* v1.4.0: Checklisten-Feature
*/
class ChecklistEditorAdapter(
private val items: MutableList<ChecklistItem>,
private val onItemCheckedChanged: (Int, Boolean) -> Unit,
private val onItemTextChanged: (Int, String) -> Unit,
private val onItemDeleted: (Int) -> Unit,
private val onAddNewItem: (Int) -> Unit,
private val onStartDrag: (RecyclerView.ViewHolder) -> Unit
) : RecyclerView.Adapter<ChecklistEditorAdapter.ViewHolder>() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val dragHandle: ImageView = view.findViewById(R.id.ivDragHandle)
val checkbox: MaterialCheckBox = view.findViewById(R.id.cbItem)
val editText: EditText = view.findViewById(R.id.etItemText)
val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteItem)
private var textWatcher: TextWatcher? = null
@Suppress("NestedBlockDepth", "UNUSED_PARAMETER")
fun bind(item: ChecklistItem, position: Int) {
// Vorherigen TextWatcher entfernen um Loops zu vermeiden
textWatcher?.let { editText.removeTextChangedListener(it) }
// Checkbox
checkbox.isChecked = item.isChecked
checkbox.setOnCheckedChangeListener { _, isChecked ->
onItemCheckedChanged(bindingAdapterPosition, isChecked)
updateStrikethrough(isChecked)
}
// Text
editText.setText(item.text)
updateStrikethrough(item.isChecked)
// TextWatcher für Änderungen
textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onItemTextChanged(pos, s?.toString() ?: "")
}
}
}
editText.addTextChangedListener(textWatcher)
// Enter-Taste = neues Item
editText.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_NEXT ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onAddNewItem(pos + 1)
}
true
} else {
false
}
}
// Delete Button
deleteButton.setOnClickListener {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onItemDeleted(pos)
}
}
// Drag Handle Touch Listener
dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onStartDrag(this)
}
false
}
}
private fun updateStrikethrough(isChecked: Boolean) {
if (isChecked) {
editText.paintFlags = editText.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
editText.alpha = CHECKED_ITEM_ALPHA
} else {
editText.paintFlags = editText.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
editText.alpha = UNCHECKED_ITEM_ALPHA
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_checklist_editor, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position], position)
}
override fun getItemCount(): Int = items.size
/**
* Bewegt ein Item von einer Position zu einer anderen (für Drag & Drop)
*/
fun moveItem(fromPosition: Int, toPosition: Int) {
val item = items.removeAt(fromPosition)
items.add(toPosition, item)
notifyItemMoved(fromPosition, toPosition)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
/**
* Entfernt ein Item an der angegebenen Position
*/
fun removeItem(position: Int) {
if (position in items.indices) {
items.removeAt(position)
notifyItemRemoved(position)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
}
/**
* Fügt ein neues Item an der angegebenen Position ein
*/
fun insertItem(position: Int, item: ChecklistItem) {
items.add(position, item)
notifyItemInserted(position)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
/**
* Fokussiert das EditText des Items an der angegebenen Position
*/
fun focusItem(recyclerView: RecyclerView, position: Int) {
recyclerView.post {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) as? ViewHolder
viewHolder?.editText?.requestFocus()
}
}
companion object {
/** Alpha-Wert für abgehakte Items (durchgestrichen) */
private const val CHECKED_ITEM_ALPHA = 0.6f
/** Alpha-Wert für nicht abgehakte Items */
private const val UNCHECKED_ITEM_ALPHA = 1.0f
}
}

View File

@@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.toReadableTime import dev.dettmer.simplenotes.utils.toReadableTime
import dev.dettmer.simplenotes.utils.truncate import dev.dettmer.simplenotes.utils.truncate
/**
* Adapter für die Notizen-Liste
*
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
*/
class NotesAdapter( class NotesAdapter(
private val onNoteClick: (Note) -> Unit private val onNoteClick: (Note) -> Unit
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) { ) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
@@ -31,16 +37,46 @@ class NotesAdapter(
} }
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon)
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle) private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent) private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview)
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp) private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus) private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
fun bind(note: Note) { fun bind(note: Note) {
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" } // Titel
textViewContent.text = note.content.truncate(100) textViewTitle.text = note.title.ifEmpty {
itemView.context.getString(R.string.untitled)
}
textViewTimestamp.text = note.updatedAt.toReadableTime() textViewTimestamp.text = note.updatedAt.toReadableTime()
// v1.4.0: Typ-spezifische Anzeige
when (note.noteType) {
NoteType.TEXT -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24)
textViewContent.text = note.content.truncate(100)
textViewContent.visibility = View.VISIBLE
textViewChecklistPreview.visibility = View.GONE
}
NoteType.CHECKLIST -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24)
textViewContent.visibility = View.GONE
textViewChecklistPreview.visibility = View.VISIBLE
// Fortschritt berechnen
val items = note.checklistItems ?: emptyList()
val checkedCount = items.count { it.isChecked }
val totalCount = items.size
textViewChecklistPreview.text = if (totalCount > 0) {
itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount)
} else {
itemView.context.getString(R.string.empty_checklist)
}
}
}
// Sync Icon nur zeigen wenn Sync konfiguriert ist // Sync Icon nur zeigen wenn Sync konfiguriert ist
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)

View File

@@ -49,10 +49,10 @@ class BackupManager(private val context: Context) {
Logger.d(TAG, " Found ${allNotes.size} notes to backup") Logger.d(TAG, " Found ${allNotes.size} notes to backup")
val backupData = BackupData( val backupData = BackupData(
backup_version = BACKUP_VERSION, backupVersion = BACKUP_VERSION,
created_at = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
notes_count = allNotes.size, notesCount = allNotes.size,
app_version = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
notes = allNotes notes = allNotes
) )
@@ -65,7 +65,7 @@ class BackupManager(private val context: Context) {
BackupResult( BackupResult(
success = true, success = true,
notes_count = allNotes.size, notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen" message = "Backup erstellt: ${allNotes.size} Notizen"
) )
@@ -99,10 +99,10 @@ class BackupManager(private val context: Context) {
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val backupData = BackupData( val backupData = BackupData(
backup_version = BACKUP_VERSION, backupVersion = BACKUP_VERSION,
created_at = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
notes_count = allNotes.size, notesCount = allNotes.size,
app_version = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
notes = allNotes notes = allNotes
) )
@@ -149,7 +149,7 @@ class BackupManager(private val context: Context) {
} }
val backupData = gson.fromJson(jsonString, BackupData::class.java) val backupData = gson.fromJson(jsonString, BackupData::class.java)
Logger.d(TAG, " Backup valid: ${backupData.notes_count} notes, version ${backupData.backup_version}") Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
// 3. Auto-Backup erstellen (Sicherheitsnetz) // 3. Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = createAutoBackup() val autoBackupUri = createAutoBackup()
@@ -164,7 +164,7 @@ class BackupManager(private val context: Context) {
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes) RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
} }
Logger.d(TAG, "✅ Restore completed: ${result.imported_notes} imported, ${result.skipped_notes} skipped") Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
result result
} catch (e: Exception) { } catch (e: Exception) {
@@ -184,10 +184,11 @@ class BackupManager(private val context: Context) {
val backupData = gson.fromJson(jsonString, BackupData::class.java) val backupData = gson.fromJson(jsonString, BackupData::class.java)
// Version kompatibel? // Version kompatibel?
if (backupData.backup_version > BACKUP_VERSION) { if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult( return ValidationResult(
isValid = false, isValid = false,
errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} benötigt v${BACKUP_VERSION}+)" errorMessage = "Backup-Version nicht unterstützt " +
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
) )
} }
@@ -238,8 +239,8 @@ class BackupManager(private val context: Context) {
return RestoreResult( return RestoreResult(
success = true, success = true,
imported_notes = newNotes.size, importedNotes = newNotes.size,
skipped_notes = skippedNotes, skippedNotes = skippedNotes,
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen" message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
) )
} }
@@ -259,8 +260,8 @@ class BackupManager(private val context: Context) {
return RestoreResult( return RestoreResult(
success = true, success = true,
imported_notes = backupNotes.size, importedNotes = backupNotes.size,
skipped_notes = 0, skippedNotes = 0,
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert" message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
) )
} }
@@ -283,9 +284,9 @@ class BackupManager(private val context: Context) {
return RestoreResult( return RestoreResult(
success = true, success = true,
imported_notes = newNotes.size, importedNotes = newNotes.size,
skipped_notes = 0, skippedNotes = 0,
overwritten_notes = overwrittenNotes.size, overwrittenNotes = overwrittenNotes.size,
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben" message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
) )
} }
@@ -312,12 +313,17 @@ class BackupManager(private val context: Context) {
/** /**
* Backup-Daten Struktur (JSON) * Backup-Daten Struktur (JSON)
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
*/ */
data class BackupData( data class BackupData(
val backup_version: Int, @com.google.gson.annotations.SerializedName("backup_version")
val created_at: Long, val backupVersion: Int,
val notes_count: Int, @com.google.gson.annotations.SerializedName("created_at")
val app_version: String, val createdAt: Long,
@com.google.gson.annotations.SerializedName("notes_count")
val notesCount: Int,
@com.google.gson.annotations.SerializedName("app_version")
val appVersion: String,
val notes: List<Note> val notes: List<Note>
) )
@@ -335,7 +341,7 @@ enum class RestoreMode {
*/ */
data class BackupResult( data class BackupResult(
val success: Boolean, val success: Boolean,
val notes_count: Int = 0, val notesCount: Int = 0,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )
@@ -345,9 +351,9 @@ data class BackupResult(
*/ */
data class RestoreResult( data class RestoreResult(
val success: Boolean, val success: Boolean,
val imported_notes: Int = 0, val importedNotes: Int = 0,
val skipped_notes: Int = 0, val skippedNotes: Int = 0,
val overwritten_notes: Int = 0, val overwrittenNotes: Int = 0,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )

View File

@@ -0,0 +1,34 @@
package dev.dettmer.simplenotes.models
import java.util.UUID
/**
* Repräsentiert ein einzelnes Item in einer Checkliste
*
* v1.4.0: Checklisten-Feature
*
* @property id Eindeutige ID für Sync-Konflikterkennung
* @property text Der Text des Items
* @property isChecked Ob das Item abgehakt ist
* @property order Sortierreihenfolge (0-basiert)
*/
data class ChecklistItem(
val id: String = UUID.randomUUID().toString(),
val text: String = "",
var isChecked: Boolean = false,
var order: Int = 0
) {
companion object {
/**
* Erstellt ein neues leeres ChecklistItem
*/
fun createEmpty(order: Int): ChecklistItem {
return ChecklistItem(
id = UUID.randomUUID().toString(),
text = "",
isChecked = false,
order = order
)
}
}
}

View File

@@ -0,0 +1,81 @@
package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import org.json.JSONArray
import org.json.JSONObject
data class DeletionRecord(
val id: String,
val deletedAt: Long,
val deviceId: String
)
data class DeletionTracker(
val version: Int = 1,
val deletedNotes: MutableList<DeletionRecord> = mutableListOf()
) {
fun addDeletion(noteId: String, deviceId: String) {
if (!deletedNotes.any { it.id == noteId }) {
deletedNotes.add(DeletionRecord(noteId, System.currentTimeMillis(), deviceId))
}
}
fun isDeleted(noteId: String): Boolean {
return deletedNotes.any { it.id == noteId }
}
fun getDeletionTimestamp(noteId: String): Long? {
return deletedNotes.find { it.id == noteId }?.deletedAt
}
fun removeDeletion(noteId: String) {
deletedNotes.removeIf { it.id == noteId }
}
fun toJson(): String {
val jsonObject = JSONObject()
jsonObject.put("version", version)
val notesArray = JSONArray()
for (record in deletedNotes) {
val recordObj = JSONObject()
recordObj.put("id", record.id)
recordObj.put("deletedAt", record.deletedAt)
recordObj.put("deviceId", record.deviceId)
notesArray.put(recordObj)
}
jsonObject.put("deletedNotes", notesArray)
return jsonObject.toString(2) // Pretty print with 2-space indent
}
companion object {
private const val TAG = "DeletionTracker"
fun fromJson(json: String): DeletionTracker? {
return try {
val jsonObject = JSONObject(json)
val version = jsonObject.optInt("version", 1)
val deletedNotes = mutableListOf<DeletionRecord>()
val notesArray = jsonObject.optJSONArray("deletedNotes")
if (notesArray != null) {
for (i in 0 until notesArray.length()) {
val recordObj = notesArray.getJSONObject(i)
val record = DeletionRecord(
id = recordObj.getString("id"),
deletedAt = recordObj.getLong("deletedAt"),
deviceId = recordObj.getString("deviceId")
)
deletedNotes.add(record)
}
}
DeletionTracker(version, deletedNotes)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}")
null
}
}
}
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -13,53 +14,125 @@ data class Note(
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String, val deviceId: String,
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
// v1.4.0: Checklisten-Felder
val noteType: NoteType = NoteType.TEXT,
val checklistItems: List<ChecklistItem>? = null
) { ) {
/**
* Serialisiert Note zu JSON (v1.4.0: Nutzt Gson für komplexe Strukturen)
*/
fun toJson(): String { fun toJson(): String {
return """ val gson = com.google.gson.GsonBuilder()
{ .setPrettyPrinting()
"id": "$id", .create()
"title": "${title.escapeJson()}", return gson.toJson(this)
"content": "${content.escapeJson()}",
"createdAt": $createdAt,
"updatedAt": $updatedAt,
"deviceId": "$deviceId",
"syncStatus": "${syncStatus.name}"
}
""".trimIndent()
} }
/** /**
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08) * Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
* Format kompatibel mit Obsidian, Joplin, Typora * Format kompatibel mit Obsidian, Joplin, Typora
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/ */
fun toMarkdown(): String { fun toMarkdown(): String {
return """ val header = """
--- ---
id: $id id: $id
created: ${formatISO8601(createdAt)} created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)} updated: ${formatISO8601(updatedAt)}
device: $deviceId device: $deviceId
type: ${noteType.name.lowercase()}
--- ---
# $title # $title
$content
""".trimIndent() """.trimIndent()
return when (noteType) {
NoteType.TEXT -> header + content
NoteType.CHECKLIST -> {
val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "[x]" else "[ ]"
"- $checkbox ${item.text}"
} ?: ""
header + checklistMarkdown
}
}
} }
companion object { companion object {
private const val TAG = "Note"
/**
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
*/
fun fromJson(json: String): Note? { fun fromJson(json: String): Note? {
return try { return try {
val gson = com.google.gson.Gson() val gson = com.google.gson.Gson()
gson.fromJson(json, Note::class.java) val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject
// Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT
val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) {
try {
NoteType.valueOf(jsonObject.get("noteType").asString)
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
} else {
NoteType.TEXT
}
// Parsen der Basis-Note
val rawNote = gson.fromJson(json, NoteRaw::class.java)
// Checklist-Items parsen (kann null sein)
val checklistItemsType = object : com.google.gson.reflect.TypeToken<List<ChecklistItem>>() {}.type
val checklistItems = if (jsonObject.has("checklistItems") &&
!jsonObject.get("checklistItems").isJsonNull
) {
gson.fromJson<List<ChecklistItem>>(
jsonObject.get("checklistItems"),
checklistItemsType
)
} else {
null
}
// Note mit korrekten Werten erstellen
Note(
id = rawNote.id,
title = rawNote.title,
content = rawNote.content,
createdAt = rawNote.createdAt,
updatedAt = rawNote.updatedAt,
deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType,
checklistItems = checklistItems
)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
null null
} }
} }
/**
* Hilfsklasse für Gson-Parsing mit nullable Feldern
*/
private data class NoteRaw(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val content: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String = "",
val syncStatus: SyncStatus? = null
)
/** /**
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09) * Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
* *
* @param md Markdown-String mit YAML Frontmatter * @param md Markdown-String mit YAML Frontmatter
* @return Note-Objekt oder null bei Parse-Fehler * @return Note-Objekt oder null bei Parse-Fehler
@@ -87,10 +160,47 @@ $content
.firstOrNull { it.startsWith("# ") } .firstOrNull { it.startsWith("# ") }
?.removePrefix("# ")?.trim() ?: "Untitled" ?.removePrefix("# ")?.trim() ?: "Untitled"
// Extract content (everything after heading) // v1.4.0: Prüfe ob type: checklist im Frontmatter
val content = contentBlock val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
.substringAfter("# $title\n\n", "") val noteType = when (noteTypeStr) {
"checklist" -> NoteType.CHECKLIST
else -> NoteType.TEXT
}
// v1.4.0: Parse Content basierend auf Typ
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
val contentAfterTitle = if (titleLineIndex >= 0) {
// Alles nach der Titel-Zeile, überspringe führende Leerzeilen
contentBlock.lines()
.drop(titleLineIndex + 1)
.dropWhile { it.isBlank() }
.joinToString("\n")
.trim() .trim()
} else {
// Fallback: Gesamter Content (kein Titel gefunden)
contentBlock.trim()
}
val content: String
val checklistItems: List<ChecklistItem>?
if (noteType == NoteType.CHECKLIST) {
// Parse Checklist Items
val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE)
checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult ->
ChecklistItem(
id = UUID.randomUUID().toString(),
text = matchResult.groupValues[2].trim(),
isChecked = matchResult.groupValues[1].lowercase() == "x",
order = index
)
}.toList().ifEmpty { null }
content = "" // Checklisten haben keinen "content"
} else {
content = contentAfterTitle
checklistItems = null
}
Note( Note(
id = metadata["id"] ?: UUID.randomUUID().toString(), id = metadata["id"] ?: UUID.randomUUID().toString(),
@@ -99,9 +209,12 @@ $content
createdAt = parseISO8601(metadata["created"] ?: ""), createdAt = parseISO8601(metadata["created"] ?: ""),
updatedAt = parseISO8601(metadata["updated"] ?: ""), updatedAt = parseISO8601(metadata["updated"] ?: ""),
deviceId = metadata["device"] ?: "desktop", deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
noteType = noteType,
checklistItems = checklistItems
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
null null
} }
} }
@@ -126,6 +239,7 @@ $content
sdf.timeZone = TimeZone.getTimeZone("UTC") sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(dateString)?.time ?: System.currentTimeMillis() sdf.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
System.currentTimeMillis() // Fallback System.currentTimeMillis() // Fallback
} }
} }

View File

@@ -0,0 +1,11 @@
package dev.dettmer.simplenotes.models
/**
* Definiert die verschiedenen Notiz-Typen
*
* v1.4.0: Checklisten-Feature
*/
enum class NoteType {
TEXT, // Normale Text-Notiz (Standard)
CHECKLIST // Checkliste mit abhakbaren Items
}

View File

@@ -1,11 +1,18 @@
package dev.dettmer.simplenotes.storage package dev.dettmer.simplenotes.storage
import android.content.Context import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import java.io.File import java.io.File
class NotesStorage(private val context: Context) { class NotesStorage(private val context: Context) {
companion object {
private const val TAG = "NotesStorage"
}
private val notesDir: File = File(context.filesDir, "notes").apply { private val notesDir: File = File(context.filesDir, "notes").apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
@@ -34,19 +41,89 @@ class NotesStorage(private val context: Context) {
fun deleteNote(id: String): Boolean { fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json") val file = File(notesDir, "$id.json")
return file.delete() val deleted = file.delete()
if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId)
}
return deleted
} }
fun deleteAllNotes(): Boolean { fun deleteAllNotes(): Boolean {
return try { return try {
notesDir.listFiles() val notes = loadAllNotes()
?.filter { it.extension == "json" } val deviceId = DeviceIdGenerator.getDeviceId(context)
?.forEach { it.delete() }
for (note in notes) {
deleteNote(note.id) // Uses trackDeletion() automatically
}
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true true
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to delete all notes", e)
false false
} }
} }
// === Deletion Tracking ===
private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json")
}
fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile()
if (!file.exists()) {
return DeletionTracker()
}
return try {
val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker()
} catch (e: Exception) {
Logger.e(TAG, "Failed to load deletion tracker", e)
DeletionTracker()
}
}
fun saveDeletionTracker(tracker: DeletionTracker) {
try {
val file = getDeletionTrackerFile()
file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
}
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e)
}
}
fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion: $noteId")
}
fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker()
return tracker.isDeleted(noteId)
}
fun clearDeletionTracker() {
saveDeletionTracker(DeletionTracker())
Logger.d(TAG, "🗑️ Deletion tracker cleared")
}
fun getNotesDir(): File = notesDir fun getNotesDir(): File = notesDir
} }

View File

@@ -227,7 +227,11 @@ class NetworkMonitor(private val context: Context) {
if (isWifi) { if (isWifi) {
lastConnectedNetworkId = activeNetwork.toString() lastConnectedNetworkId = activeNetwork.toString()
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId") Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change") Logger.d(
TAG,
" 📡 WiFi already connected at startup - " +
"onAvailable() will only trigger on network change"
)
} else { } else {
lastConnectedNetworkId = null lastConnectedNetworkId = null
Logger.d(TAG, " ⚠️ Not on WiFi at startup") Logger.d(TAG, " ⚠️ Not on WiFi at startup")
@@ -268,7 +272,7 @@ class NetworkMonitor(private val context: Context) {
connectivityManager.unregisterNetworkCallback(networkCallback) connectivityManager.unregisterNetworkCallback(networkCallback)
Logger.d(TAG, "✅ WiFi monitoring stopped") Logger.d(TAG, "✅ WiFi monitoring stopped")
} catch (e: Exception) { } catch (e: Exception) {
// Already unregistered Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}")
} }
} }
} }

View File

@@ -0,0 +1,129 @@
package dev.dettmer.simplenotes.sync
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import dev.dettmer.simplenotes.utils.Logger
/**
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
*
* Verhindert doppelte Syncs und informiert die UI über den aktuellen Status.
* Thread-safe Singleton mit LiveData für UI-Reaktivität.
*/
object SyncStateManager {
private const val TAG = "SyncStateManager"
/**
* Mögliche Sync-Zustände
*/
enum class SyncState {
IDLE, // Kein Sync aktiv
SYNCING, // Sync läuft gerade
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
ERROR // Sync fehlgeschlagen (kurz anzeigen)
}
/**
* Detaillierte Sync-Informationen für UI
*/
data class SyncStatus(
val state: SyncState = SyncState.IDLE,
val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
val timestamp: Long = System.currentTimeMillis()
)
// Private mutable LiveData
private val _syncStatus = MutableLiveData(SyncStatus())
// Public immutable LiveData für Observer
val syncStatus: LiveData<SyncStatus> = _syncStatus
// Lock für Thread-Sicherheit
private val lock = Any()
/**
* Prüft ob gerade ein Sync läuft
*/
val isSyncing: Boolean
get() = _syncStatus.value?.state == SyncState.SYNCING
/**
* Versucht einen Sync zu starten.
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
*/
fun tryStartSync(source: String): Boolean {
synchronized(lock) {
if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
return false
}
Logger.d(TAG, "🔄 Starting sync from: $source")
_syncStatus.postValue(
SyncStatus(
state = SyncState.SYNCING,
message = "Synchronisiere...",
source = source
)
)
return true
}
}
/**
* Markiert Sync als erfolgreich abgeschlossen
*/
fun markCompleted(message: String? = null) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource")
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
)
)
}
}
/**
* Markiert Sync als fehlgeschlagen
*/
fun markError(errorMessage: String?) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
_syncStatus.postValue(
SyncStatus(
state = SyncState.ERROR,
message = errorMessage,
source = currentSource
)
)
}
}
/**
* Setzt Status zurück auf IDLE
*/
fun reset() {
synchronized(lock) {
_syncStatus.postValue(SyncStatus())
}
}
/**
* Aktualisiert die Nachricht während des Syncs (z.B. Progress)
*/
fun updateMessage(message: String) {
synchronized(lock) {
val current = _syncStatus.value ?: return
if (current.state == SyncState.SYNCING) {
_syncStatus.postValue(current.copy(message = message))
}
}
}
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
@@ -22,6 +23,21 @@ class SyncWorker(
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED" const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
} }
/**
* Prüft ob die App im Vordergrund ist.
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
*/
private fun isAppInForeground(): Boolean {
val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = applicationContext.packageName
return appProcesses.any { process ->
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
process.processName == packageName
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
@@ -117,7 +133,11 @@ class SyncWorker(
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Processing result") Logger.d(TAG, "📍 Step 4: Processing result")
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}") Logger.d(
TAG,
"📦 Sync result: success=${result.isSuccess}, " +
"count=${result.syncedCount}, error=${result.errorMessage}"
)
} }
if (result.isSuccess) { if (result.isSuccess) {
@@ -127,7 +147,12 @@ class SyncWorker(
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes") Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde // Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
if (result.syncedCount > 0) { if (result.syncedCount > 0) {
val appInForeground = isAppInForeground()
if (appInForeground) {
Logger.d(TAG, " App in foreground - skipping notification (UI shows changes)")
} else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, " Showing success notification...") Logger.d(TAG, " Showing success notification...")
} }
@@ -135,6 +160,7 @@ class SyncWorker(
applicationContext, applicationContext,
result.syncedCount result.syncedCount
) )
}
} else { } else {
Logger.d(TAG, " No changes to sync - no notification") Logger.d(TAG, " No changes to sync - no notification")
} }

View File

@@ -5,12 +5,17 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* WiFi-Sync BroadcastReceiver
*
* Triggert Sync wenn WiFi verbunden wird (jedes WiFi, keine SSID-Prüfung mehr)
* Die eigentliche Server-Erreichbarkeitsprüfung erfolgt im SyncWorker.
*/
class WifiSyncReceiver : BroadcastReceiver() { class WifiSyncReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@@ -22,33 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
return return
} }
// Check if connected to home WiFi // Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
if (isConnectedToHomeWifi(context)) { if (isConnectedToWifi(context)) {
scheduleSyncWork(context) scheduleSyncWork(context)
} }
} }
private fun isConnectedToHomeWifi(context: Context): Boolean { /**
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) * Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false * Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
*/
private fun isConnectedToWifi(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
return false
}
// Get current SSID
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
as WifiManager
val wifiInfo = wifiManager.connectionInfo
val currentSSID = wifiInfo.ssid.replace("\"", "")
return currentSSID == homeSSID
} }
private fun scheduleSyncWork(context: Context) { private fun scheduleSyncWork(context: Context) {

View File

@@ -6,7 +6,6 @@ object Constants {
const val KEY_SERVER_URL = "server_url" const val KEY_SERVER_URL = "server_url"
const val KEY_USERNAME = "username" const val KEY_USERNAME = "username"
const val KEY_PASSWORD = "password" const val KEY_PASSWORD = "password"
const val KEY_HOME_SSID = "home_ssid"
const val KEY_AUTO_SYNC = "auto_sync_enabled" const val KEY_AUTO_SYNC = "auto_sync_enabled"
const val KEY_LAST_SYNC = "last_sync_timestamp" const val KEY_LAST_SYNC = "last_sync_timestamp"
@@ -23,6 +22,13 @@ object Constants {
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled" const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled" const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
// 🔥 v1.3.0: Performance & Multi-Device Sync
const val KEY_ALWAYS_CHECK_SERVER = "always_check_server"
const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server"
// 🔥 v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
// WorkManager // WorkManager
const val SYNC_WORK_TAG = "notes_sync" const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L const val SYNC_DELAY_SECONDS = 5L

View File

@@ -7,6 +7,9 @@ import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private const val DAYS_THRESHOLD = 7L
private const val TRUNCATE_SUFFIX_LENGTH = 3
// Toast Extensions // Toast Extensions
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) { fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show() Toast.makeText(this, message, duration).show()
@@ -27,7 +30,7 @@ fun Long.toReadableTime(): String {
val hours = TimeUnit.MILLISECONDS.toHours(diff) val hours = TimeUnit.MILLISECONDS.toHours(diff)
"Vor $hours Std" "Vor $hours Std"
} }
diff < TimeUnit.DAYS.toMillis(7) -> { diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
val days = TimeUnit.MILLISECONDS.toDays(diff) val days = TimeUnit.MILLISECONDS.toDays(diff)
"Vor $days Tagen" "Vor $days Tagen"
} }
@@ -41,7 +44,7 @@ fun Long.toReadableTime(): String {
// Truncate long strings // Truncate long strings
fun String.truncate(maxLength: Int): String { fun String.truncate(maxLength: Int): String {
return if (length > maxLength) { return if (length > maxLength) {
substring(0, maxLength - 3) + "..." substring(0, maxLength - TRUNCATE_SUFFIX_LENGTH) + "..."
} else { } else {
this this
} }

View File

@@ -5,7 +5,6 @@ import android.util.Log
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import java.io.File import java.io.File
import java.io.FileWriter import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -15,10 +14,34 @@ import java.util.*
*/ */
object Logger { object Logger {
private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge
private var fileLoggingEnabled = false private var fileLoggingEnabled = false
private var logFile: File? = null private var logFile: File? = null
private var appContext: Context? = null
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
private val maxLogEntries = 500 // Nur letzte 500 Einträge
/**
* Setzt den File-Logging Status (für UI Toggle)
*/
fun setFileLoggingEnabled(enabled: Boolean) {
fileLoggingEnabled = enabled
if (!enabled) {
logFile = null
}
}
/**
* Gibt zurück, ob File-Logging aktiviert ist
*/
fun isFileLoggingEnabled(): Boolean = fileLoggingEnabled
/**
* Initialisiert den Logger mit App-Context
*/
fun init(context: Context) {
appContext = context.applicationContext
}
/** /**
* Aktiviert File-Logging für Debugging * Aktiviert File-Logging für Debugging
@@ -50,11 +73,47 @@ object Logger {
*/ */
fun getLogFile(): File? = logFile fun getLogFile(): File? = logFile
/**
* Gibt Log-Datei mit Context zurück (für SettingsActivity)
*/
fun getLogFile(context: Context): File? {
if (logFile == null && fileLoggingEnabled) {
logFile = File(context.filesDir, "simplenotes_debug.log")
}
return logFile
}
/**
* Löscht die Log-Datei
*/
fun clearLogFile(context: Context): Boolean {
return try {
val file = File(context.filesDir, "simplenotes_debug.log")
if (file.exists()) {
file.delete()
logFile = null
true
} else {
false
}
} catch (e: Exception) {
Log.e("Logger", "Failed to clear log file", e)
false
}
}
/** /**
* Schreibt Log-Eintrag in Datei * Schreibt Log-Eintrag in Datei
*/ */
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) { private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
if (!fileLoggingEnabled || logFile == null) return if (!fileLoggingEnabled) return
// Lazy-init logFile mit appContext
if (logFile == null && appContext != null) {
logFile = File(appContext!!.filesDir, "simplenotes_debug.log")
}
if (logFile == null) return
try { try {
val timestamp = dateFormat.format(Date()) val timestamp = dateFormat.format(Date())
@@ -80,13 +139,13 @@ object Logger {
} }
/** /**
* Begrenzt Log-Datei auf maxLogEntries * Begrenzt Log-Datei auf MAX_LOG_ENTRIES
*/ */
private fun trimLogFile() { private fun trimLogFile() {
try { try {
val lines = logFile?.readLines() ?: return val lines = logFile?.readLines() ?: return
if (lines.size > maxLogEntries) { if (lines.size > MAX_LOG_ENTRIES) {
val trimmed = lines.takeLast(maxLogEntries) val trimmed = lines.takeLast(MAX_LOG_ENTRIES)
logFile?.writeText(trimmed.joinToString("\n") + "\n") logFile?.writeText(trimmed.joinToString("\n") + "\n")
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -20,6 +20,7 @@ object NotificationHelper {
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status" private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val SYNC_NOTIFICATION_ID = 2 private const val SYNC_NOTIFICATION_ID = 2
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
/** /**
* Erstellt Notification Channel (Android 8.0+) * Erstellt Notification Channel (Android 8.0+)
@@ -286,7 +287,7 @@ object NotificationHelper {
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper()).postDelayed({
manager.cancel(SYNC_NOTIFICATION_ID) manager.cancel(SYNC_NOTIFICATION_ID)
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout") Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
}, 30_000) }, AUTO_CANCEL_TIMEOUT_MS)
} }
/** /**

View File

@@ -0,0 +1,21 @@
package dev.dettmer.simplenotes.utils
/**
* Exception für Sync-spezifische Fehler
*
* Verwendet anstelle von generischen Exceptions für bessere
* Fehlerbehandlung und klarere Fehlermeldungen.
*/
class SyncException(
message: String,
cause: Throwable? = null
) : Exception(message, cause)
/**
* Exception für Validierungsfehler
*
* Verwendet für ungültige Eingaben oder Konfigurationsfehler.
*/
class ValidationException(
message: String
) : IllegalArgumentException(message)

View File

@@ -8,6 +8,16 @@ import java.net.URL
*/ */
object UrlValidator { object UrlValidator {
// RFC 1918 Private IP Ranges
private const val PRIVATE_CLASS_A_FIRST_OCTET = 10
private const val PRIVATE_CLASS_B_FIRST_OCTET = 172
private const val PRIVATE_CLASS_B_SECOND_OCTET_MIN = 16
private const val PRIVATE_CLASS_B_SECOND_OCTET_MAX = 31
private const val PRIVATE_CLASS_C_FIRST_OCTET = 192
private const val PRIVATE_CLASS_C_SECOND_OCTET = 168
private const val LOCALHOST_FIRST_OCTET = 127
private const val OCTET_MAX_VALUE = 255
/** /**
* Prüft ob eine URL eine lokale/private Adresse ist * Prüft ob eine URL eine lokale/private Adresse ist
* Erlaubt: * Erlaubt:
@@ -17,6 +27,7 @@ object UrlValidator {
* - 127.x.x.x (Localhost) * - 127.x.x.x (Localhost)
* - .local domains (mDNS/Bonjour) * - .local domains (mDNS/Bonjour)
*/ */
@Suppress("ReturnCount") // Early returns for validation checks are clearer
fun isLocalUrl(url: String): Boolean { fun isLocalUrl(url: String): Boolean {
return try { return try {
val parsedUrl = URL(url) val parsedUrl = URL(url)
@@ -40,25 +51,29 @@ object UrlValidator {
val octets = match.groupValues.drop(1).map { it.toInt() } val octets = match.groupValues.drop(1).map { it.toInt() }
// Validate octets are in range 0-255 // Validate octets are in range 0-255
if (octets.any { it > 255 }) { if (octets.any { it > OCTET_MAX_VALUE }) {
return false return false
} }
val (o1, o2, o3, o4) = octets // Extract octets individually (destructuring with 4 elements triggers detekt warning)
val o1 = octets[0]
val o2 = octets[1]
// Check RFC 1918 private IP ranges // Check RFC 1918 private IP ranges
return when { return when {
// 10.0.0.0/8 (10.0.0.0 - 10.255.255.255) // 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
o1 == 10 -> true o1 == PRIVATE_CLASS_A_FIRST_OCTET -> true
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255) // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
o1 == 172 && o2 in 16..31 -> true o1 == PRIVATE_CLASS_B_FIRST_OCTET &&
o2 in PRIVATE_CLASS_B_SECOND_OCTET_MIN..PRIVATE_CLASS_B_SECOND_OCTET_MAX -> true
// 192.168.0.0/16 (192.168.0.0 - 192.168.255.255) // 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
o1 == 192 && o2 == 168 -> true o1 == PRIVATE_CLASS_C_FIRST_OCTET &&
o2 == PRIVATE_CLASS_C_SECOND_OCTET -> true
// 127.0.0.0/8 (Localhost) // 127.0.0.0/8 (Localhost)
o1 == 127 -> true o1 == LOCALHOST_FIRST_OCTET -> true
else -> false else -> false
} }
@@ -67,7 +82,7 @@ object UrlValidator {
// Not a recognized local address // Not a recognized local address
false false
} catch (e: Exception) { } catch (e: Exception) {
// Invalid URL format Logger.w("UrlValidator", "Failed to parse URL: ${e.message}")
false false
} }
} }

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4v2z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z"/>
</vector>

View File

@@ -2,6 +2,7 @@
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
@@ -18,8 +19,9 @@
app:title="@string/edit_note" app:title="@string/edit_note"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
<!-- Material 3 Outlined TextInputLayout with 16dp corners --> <!-- Title Input (für beide Typen) -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:layout_margin="16dp"
@@ -44,8 +46,9 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<!-- Material 3 Outlined TextInputLayout for Content --> <!-- Content Input (nur für TEXT sichtbar) -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilContent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
@@ -74,4 +77,39 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<!-- v1.4.0: Checklist Container (nur für CHECKLIST sichtbar) -->
<LinearLayout
android:id="@+id/checklistContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<!-- Checklist Items RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvChecklistItems"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginHorizontal="8dp"
android:clipToPadding="false"
android:paddingBottom="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_checklist_editor" />
<!-- Add Item Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddItem"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:text="@string/add_item"
app:icon="@android:drawable/ic_input_add" />
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -22,6 +22,39 @@
app:title="@string/app_name" app:title="@string/app_name"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
<!-- 🔄 v1.3.1: Sync Status Banner -->
<LinearLayout
android:id="@+id/syncStatusBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"
android:background="?attr/colorPrimaryContainer"
android:visibility="gone">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/syncProgressIndicator"
android:layout_width="24dp"
android:layout_height="24dp"
android:indeterminate="true"
app:indicatorSize="24dp"
app:trackThickness="3dp"
app:indicatorColor="?attr/colorOnPrimaryContainer" />
<TextView
android:id="@+id/syncStatusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:text="@string/sync_status_syncing"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnPrimaryContainer" />
</LinearLayout>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) --> <!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->

View File

@@ -429,11 +429,11 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Markdown Export Toggle --> <!-- Markdown Auto-Sync Toggle (fusioniert Export + Auto-Import) -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp" android:layout_marginBottom="8dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
@@ -441,34 +441,47 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1" android:layout_weight="1"
android:text="📝 Markdown Export (Desktop-Zugriff)" android:text="🔄 Markdown Auto-Sync"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" /> android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat <androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchMarkdownExport" android:id="@+id/switchMarkdownAutoSync"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:checked="true" /> android:checked="false" />
</LinearLayout> </LinearLayout>
<!-- Import Markdown Button --> <!-- Auto-Sync Info Text -->
<Button
android:id="@+id/buttonImportMarkdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📥 Markdown-Änderungen importieren"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Import Info Text -->
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginBottom="16dp"
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)" android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall" android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" /> android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Manual Sync Info (nur sichtbar wenn Auto-Sync OFF) -->
<TextView
android:id="@+id/textViewManualSyncInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text="Oder synchronisiere Markdown-Dateien manuell:"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurface"
android:visibility="gone" />
<!-- Manual Sync Button (nur sichtbar wenn Auto-Sync OFF) -->
<Button
android:id="@+id/buttonManualMarkdownSync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Markdown synchronisieren"
android:visibility="gone"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
@@ -736,6 +749,107 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Debug Section -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Debug &amp; Diagnose"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" />
<!-- File Logging Toggle -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📝 Datei-Logging"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync-Logs in Datei speichern"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchFileLogging"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- F-Droid Privacy Notice -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/file_logging_privacy_notice"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<!-- Export Logs Button -->
<Button
android:id="@+id/buttonExportLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📤 Logs exportieren &amp; teilen"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_marginBottom="8dp" />
<!-- Clear Logs Button -->
<Button
android:id="@+id/buttonClearLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🗑️ Logs löschen"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@@ -15,7 +15,7 @@
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:src="@android:drawable/ic_menu_delete" android:src="@android:drawable/ic_menu_delete"
android:tint="?attr/colorError" app:tint="?attr/colorError"
android:contentDescription="@string/delete" /> android:contentDescription="@string/delete" />
<!-- Title --> <!-- Title -->

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<CheckBox
android:id="@+id/checkboxAlwaysDeleteFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Diese Entscheidung merken"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- v1.4.0: Checklist Item Layout für Editor -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="4dp"
android:paddingHorizontal="8dp"
android:minHeight="48dp">
<!-- Drag Handle -->
<ImageView
android:id="@+id/ivDragHandle"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_drag_handle_24"
android:contentDescription="@string/reorder_item"
android:importantForAccessibility="yes" />
<!-- Checkbox -->
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/cbItem"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp" />
<!-- Text Input (ohne Box, nur transparent) -->
<EditText
android:id="@+id/etItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@null"
android:hint="@string/item_placeholder"
android:inputType="text"
android:imeOptions="actionNext"
android:maxLines="3"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
tools:text="Milch kaufen" />
<!-- Delete Button -->
<ImageButton
android:id="@+id/btnDeleteItem"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_delete_24"
android:contentDescription="@string/delete_item"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Material 3: Filled Card Style (Flat, No Shadow) --> <!-- Material 3: Filled Card Style (Flat, No Shadow) -->
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp" android:layout_marginHorizontal="8dp"
@@ -17,17 +19,37 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:padding="20dp">
<!-- v1.4.0: Header Row mit Icon und Titel -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- v1.4.0: Note Type Icon -->
<ImageView
android:id="@+id/ivNoteTypeIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_note_24"
app:tint="?attr/colorPrimary"
android:contentDescription="@null" />
<!-- Material 3 Typography: TitleMedium --> <!-- Material 3 Typography: TitleMedium -->
<TextView <TextView
android:id="@+id/textViewTitle" android:id="@+id/textViewTitle"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/note_title_placeholder" android:text="@string/note_title_placeholder"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:maxLines="2" android:maxLines="2"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- Material 3 Typography: BodyMedium --> </LinearLayout>
<!-- Content Preview (für TEXT Notizen) -->
<TextView <TextView
android:id="@+id/textViewContent" android:id="@+id/textViewContent"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -39,6 +61,18 @@
android:maxLines="3" android:maxLines="3"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- v1.4.0: Checklist Preview (für CHECKLIST Notizen) -->
<TextView
android:id="@+id/textViewChecklistPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:visibility="gone"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
tools:visibility="visible"
tools:text="2/5 erledigt" />
<!-- Metadata Row mit Timestamp und Sync-Status --> <!-- Metadata Row mit Timestamp und Sync-Status -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -64,7 +98,7 @@
android:layout_height="18dp" android:layout_height="18dp"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:src="@android:drawable/ic_popup_sync" android:src="@android:drawable/ic_popup_sync"
android:tint="?attr/colorPrimary" app:tint="?attr/colorPrimary"
android:contentDescription="@string/sync_status" /> android:contentDescription="@string/sync_status" />
</LinearLayout> </LinearLayout>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_create_text_note"
android:icon="@drawable/ic_note_24"
android:title="@string/create_text_note" />
<item
android:id="@+id/action_create_checklist"
android:icon="@drawable/ic_checklist_24"
android:title="@string/create_checklist" />
</menu>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -4,6 +4,9 @@
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Adaptive Icon Background -->
<color name="ic_launcher_background">#f9e9c8</color>
<!-- Material 3 Light Theme Colors --> <!-- Material 3 Light Theme Colors -->
<color name="md_theme_light_primary">#6750A4</color> <color name="md_theme_light_primary">#6750A4</color>
<color name="md_theme_light_onPrimary">#FFFFFF</color> <color name="md_theme_light_onPrimary">#FFFFFF</color>

View File

@@ -24,6 +24,7 @@
<string name="note_title_placeholder">Note Title</string> <string name="note_title_placeholder">Note Title</string>
<string name="note_content_placeholder">Note content preview…</string> <string name="note_content_placeholder">Note content preview…</string>
<string name="note_timestamp_placeholder">Vor 2 Std</string> <string name="note_timestamp_placeholder">Vor 2 Std</string>
<string name="untitled">Ohne Titel</string>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<string name="delete_note_title">Notiz löschen?</string> <string name="delete_note_title">Notiz löschen?</string>
@@ -42,10 +43,9 @@
<!-- Auto-Sync Settings --> <!-- Auto-Sync Settings -->
<string name="sync_settings">Sync-Einstellungen</string> <string name="sync_settings">Sync-Einstellungen</string>
<string name="home_ssid">Heim-WLAN SSID</string>
<string name="auto_sync">Auto-Sync aktiviert</string> <string name="auto_sync">Auto-Sync aktiviert</string>
<string name="sync_status">Sync-Status</string> <string name="sync_status">Sync-Status</string>
<string name="auto_sync_info"> Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string> <string name="auto_sync_info"> Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string> <string name="backup_restore_title">Backup &amp; Wiederherstellung</string>
@@ -57,4 +57,36 @@
<string name="restore_progress">Stelle Notizen wieder her…</string> <string name="restore_progress">Stelle Notizen wieder her…</string>
<string name="restore_success">✓ %d Notizen wiederhergestellt</string> <string name="restore_success">✓ %d Notizen wiederhergestellt</string>
<string name="restore_error">Fehler: %s</string> <string name="restore_error">Fehler: %s</string>
<!-- Sync Status Banner (v1.3.1) -->
<string name="sync_status_syncing">Synchronisiere…</string>
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
<string name="sync_already_running">Synchronisierung läuft bereits</string>
<!-- Debug/Logging Section (v1.3.2) -->
<string name="file_logging_privacy_notice"> Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
<!-- ========================== -->
<!-- CHECKLIST FEATURE (v1.4.0) -->
<!-- ========================== -->
<!-- FAB Menu -->
<string name="create_text_note">Notiz</string>
<string name="create_checklist">Liste</string>
<!-- Editor -->
<string name="new_checklist">Neue Liste</string>
<string name="edit_checklist">Liste bearbeiten</string>
<string name="add_item">Element hinzufügen</string>
<string name="item_placeholder">Neues Element…</string>
<string name="reorder_item">Element verschieben</string>
<string name="delete_item">Element löschen</string>
<string name="note_is_empty">Notiz ist leer</string>
<string name="note_saved">Notiz gespeichert</string>
<string name="note_deleted">Notiz gelöscht</string>
<!-- List Preview -->
<string name="checklist_progress">%1$d/%2$d erledigt</string>
<string name="empty_checklist">Keine Einträge</string>
</resources> </resources>

View File

@@ -2,4 +2,6 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.detekt) apply false
} }

View File

@@ -0,0 +1,136 @@
# ⚡ v1.3.1: detekt Configuration
# Pragmatic rules for simple-notes-sync
build:
maxIssues: 100 # Allow existing issues for v1.3.1 release, fix in v1.4.0
excludeCorrectable: false
config:
validation: true
warningsAsErrors: false
comments:
CommentOverPrivateProperty:
active: false
UndocumentedPublicClass:
active: false
UndocumentedPublicFunction:
active: false
complexity:
ComplexCondition:
active: true
threshold: 5
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: true
LargeClass:
active: true
threshold: 600 # Increased for WebDavSyncService
LongMethod:
active: true
threshold: 80 # Increased for sync methods
LongParameterList:
active: true
functionThreshold: 6
constructorThreshold: 7
NestedBlockDepth:
active: true
threshold: 5
TooManyFunctions:
active: true
thresholdInFiles: 25
thresholdInClasses: 25
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 10
empty-blocks:
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: "_|(ignore|expected).*"
EmptyFunctionBlock:
active: true
ignoreOverridden: true
exceptions:
SwallowedException:
active: true
ignoredExceptionTypes:
- "InterruptedException"
- "MalformedURLException"
- "NumberFormatException"
- "ParseException"
TooGenericExceptionCaught:
active: true
exceptionNames:
- "Error"
- "Throwable"
allowedExceptionNameRegex: "_|(ignore|expected).*"
naming:
FunctionNaming:
active: true
functionPattern: "[a-zA-Z][a-zA-Z0-9]*"
VariableNaming:
active: true
variablePattern: "[a-z][A-Za-z0-9]*"
PackageNaming:
active: true
packagePattern: "[a-z]+(\\.[a-z][A-Za-z0-9]*)*"
performance:
SpreadOperator:
active: false # Spread operator is fine in most cases
potential-bugs:
CastToNullableType:
active: true
EqualsWithHashCodeExist:
active: true
UnconditionalJumpStatementInLoop:
active: true
style:
ForbiddenComment:
active: true
comments:
- "FIXME:"
- "STOPSHIP:"
allowedPatterns: ""
MagicNumber:
active: true
ignoreNumbers:
- "-1"
- "0"
- "1"
- "2"
- "100"
- "1000"
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreLocalVariableDeclaration: true
ignoreAnnotation: true
ignoreEnums: true
ignoreRanges: true
ignoreExtensionFunctions: true
MaxLineLength:
active: true
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
ReturnCount:
active: true
max: 4
excludedFunctions: []
excludeLabeled: true
excludeReturnFromLambda: true
excludeGuardClauses: true
UnusedImports:
active: true
UnusedPrivateMember:
active: true
allowedNames: "_.*"
WildcardImport:
active: false # Allow wildcard imports

View File

@@ -9,6 +9,8 @@ appcompat = "1.6.1"
material = "1.10.0" material = "1.10.0"
activity = "1.8.0" activity = "1.8.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
ktlint = "12.1.0"
detekt = "1.23.4"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -23,4 +25,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }

View File

@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
### Network Detection ### Network Detection
Instead of SSID-based detection (Android 13+ privacy issues), we use **Gateway IP Comparison**: We use **Gateway IP Comparison** to check if the server is reachable:
```kotlin ```kotlin
fun isInHomeNetwork(): Boolean { fun isInHomeNetwork(): Boolean {
@@ -127,7 +127,7 @@ The app uses **4 different sync triggers** with different use cases:
| **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes | | **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes | | **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes |
| **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes | | **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi enabled/SSID changed | ✅ Yes | | **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi connected | ✅ Yes |
### Server Reachability Check (Pre-Check) ### Server Reachability Check (Pre-Check)
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
| Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None | | Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None |
| Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min | | Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min |
| Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min | | Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min |
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | SSID-based | | WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | WiFi-based |
--- ---
@@ -349,7 +349,7 @@ The app requires **minimal permissions**:
``` ```
**No Location Permissions!** **No Location Permissions!**
Earlier versions required `ACCESS_FINE_LOCATION` for SSID detection. Now we use Gateway IP Comparison. We use Gateway IP Comparison instead of SSID detection. No location permission required.
--- ---

View File

@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
### Network Detection ### Network Detection
Statt SSID-basierter Erkennung (Android 13+ Privacy-Probleme) verwenden wir **Gateway IP Comparison**: Wir verwenden **Gateway IP Comparison** um zu prüfen, ob der Server erreichbar ist:
```kotlin ```kotlin
fun isInHomeNetwork(): Boolean { fun isInHomeNetwork(): Boolean {
@@ -127,7 +127,7 @@ Die App verwendet **4 verschiedene Sync-Trigger** mit unterschiedlichen Anwendun
| **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja | | **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja | | **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja |
| **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja | | **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi an/SSID-Wechsel | ✅ Ja | | **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi verbunden | ✅ Ja |
### Server-Erreichbarkeits-Check (Pre-Check) ### Server-Erreichbarkeits-Check (Pre-Check)
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
| Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins | | Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins |
| Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min | | Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min |
| Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min | | Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min |
| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | SSID-basiert | | WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | WiFi-basiert |
--- ---
@@ -349,7 +349,7 @@ Die App benötigt **minimale Permissions**:
``` ```
**Keine Location Permissions!** **Keine Location Permissions!**
Frühere Versionen benötigten `ACCESS_FINE_LOCATION` für SSID-Erkennung. Jetzt verwenden wir Gateway IP Comparison. Wir verwenden Gateway IP Comparison statt SSID-Erkennung. Keine Standortberechtigung nötig.
--- ---

View File

@@ -8,8 +8,16 @@
## 📝 Note Management ## 📝 Note Management
### Note Types
-**Text notes** - Classic free-form notes
-**Checklists** _(NEW in v1.4.0)_ - Task lists with tap-to-check
- Add items via input field
- ☑️ Tap to check/uncheck
- 📌 Long-press for drag & drop sorting
- 🗑️ Swipe-to-delete individual items
- ~~Strikethrough~~ for completed entries
### Basic Features ### Basic Features
-**Simple text notes** - Focus on content, no distractions
-**Auto-save** - No manual saving needed -**Auto-save** - No manual saving needed
-**Title + content** - Clear structure for each note -**Title + content** - Clear structure for each note
-**Timestamps** - Creation and modification date automatically -**Timestamps** - Creation and modification date automatically
@@ -52,9 +60,11 @@
### Markdown Export ### Markdown Export
-**Automatic export** - Each note → `.md` file -**Automatic export** - Each note → `.md` file
-**Checklists as task lists** _(NEW)_ - `- [ ]` / `- [x]` format (GitHub-compatible)
-**Dual-format** - JSON (master) + Markdown (mirror) -**Dual-format** - JSON (master) + Markdown (mirror)
-**Filename sanitization** - Safe filenames from titles -**Filename sanitization** - Safe filenames from titles
-**Frontmatter metadata** - YAML with ID, timestamps, tags -**Duplicate handling** _(NEW)_ - ID suffix for same titles
-**Frontmatter metadata** - YAML with ID, timestamps, type
-**WebDAV sync** - Parallel to JSON sync -**WebDAV sync** - Parallel to JSON sync
-**Optional** - Toggle in settings -**Optional** - Toggle in settings
-**Initial export** - All existing notes when activated -**Initial export** - All existing notes when activated
@@ -81,16 +91,16 @@
### Auto-Sync ### Auto-Sync
-**Interval selection** - 15, 30 or 60 minutes -**Interval selection** - 15, 30 or 60 minutes
-**WiFi binding** - Only in configured home WiFi -**WiFi trigger** - Sync on WiFi connection _(no SSID restriction)_
-**Battery-friendly** - ~0.2-0.8% per day -**Battery-friendly** - ~0.2-0.8% per day
-**Smart server check** - No errors on foreign networks -**Smart server check** - Sync only when server is reachable
-**WorkManager** - Reliable background execution -**WorkManager** - Reliable background execution
-**Battery optimization compatible** - Works even with Doze mode -**Battery optimization compatible** - Works even with Doze mode
### Sync Triggers (6 total) ### Sync Triggers (6 total)
1.**Periodic sync** - Automatically after interval 1.**Periodic sync** - Automatically after interval
2.**App-start sync** - When opening the app 2.**App-start sync** - When opening the app
3.**WiFi-connect sync** - When home WiFi connects 3.**WiFi-connect sync** - On any WiFi connection
4.**Manual sync** - Button in settings 4.**Manual sync** - Button in settings
5.**Pull-to-refresh** - Swipe gesture in notes list 5.**Pull-to-refresh** - Swipe gesture in notes list
6.**Settings-save sync** - After server configuration 6.**Settings-save sync** - After server configuration
@@ -109,7 +119,6 @@
-**HTTP/HTTPS** - HTTP only local, HTTPS for external -**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication -**Username/password** - Basic authentication
-**Connection test** - Test in settings -**Connection test** - Test in settings
-**Gateway SSID** - WiFi name for auto-sync
-**Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_ -**Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_
-**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/` -**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
@@ -130,6 +139,11 @@
-**Password storage** - Android SharedPreferences (encrypted) -**Password storage** - Android SharedPreferences (encrypted)
-**No third-party libs** - Only Android SDK + Sardine (WebDAV) -**No third-party libs** - Only Android SDK + Sardine (WebDAV)
### Developer Features
-**File logging** - Optional, only when enabled _(NEW in v1.3.2)_
-**Privacy notice** - Explicit warning on activation
-**Local logs** - Logs stay on device
--- ---
## 🔋 Performance & Optimization ## 🔋 Performance & Optimization
@@ -137,7 +151,7 @@
### Battery Efficiency ### Battery Efficiency
-**Optimized sync intervals** - 15/30/60 min -**Optimized sync intervals** - 15/30/60 min
-**WiFi-only** - No mobile data sync -**WiFi-only** - No mobile data sync
-**Smart server check** - Only in home WiFi -**Smart server check** - Sync only when server is reachable
-**WorkManager** - System-optimized execution -**WorkManager** - System-optimized execution
-**Doze mode compatible** - Sync runs even in standby -**Doze mode compatible** - Sync runs even in standby
-**Measured consumption:** -**Measured consumption:**
@@ -171,6 +185,7 @@
- **Dispatchers.IO** - Background operations - **Dispatchers.IO** - Background operations
- **SharedPreferences** - Settings storage - **SharedPreferences** - Settings storage
- **File-based storage** - JSON files locally - **File-based storage** - JSON files locally
- **Custom exceptions** - Dedicated SyncException for better error handling _(NEW in v1.3.2)_
### Dependencies ### Dependencies
- **AndroidX** - Jetpack libraries - **AndroidX** - Jetpack libraries
@@ -208,27 +223,22 @@
## 🔮 Future Features ## 🔮 Future Features
Planned for upcoming versions (see [TODO.md](project-docs/simple-notes-sync/planning/TODO.md)): Planned for upcoming versions:
### v1.3.0 - Web Editor & Organization ### v1.4.0 - Checklists
- **Browser-based editor** - Edit notes in web browser - **Checklist notes** - New note type with checkboxes
- **WebDAV access via browser** - No mount needed - **Completed items** - Strike-through/check off
- **Mobile-optimized** - Responsive design - **Drag & drop** - Reorder items
- **Offline-capable** - Progressive Web App (PWA)
- **Tags/labels** - Categorize notes
- **Search** - Full-text search in all notes
- **Sorting** - By date, title, tags
- **Filter** - Filter by tags
### v1.4.0 - Sharing & Export ### v1.5.0 - Internationalization
- **Share note** - Via share intent - **Multi-language** - German + English UI
- **Export single note** - As .txt or .md - **Language selection** - Selectable in settings
- **Import from text** - Via share intent - **Full translation** - All strings in both languages
### v1.5.0 - Advanced Editor Features ### v1.6.0 - Modern APIs
- **Markdown preview** - In-app rendering - **Replace LocalBroadcastManager** - Use SharedFlow instead
- **Checklists** - TODO lists in notes - **PackageInfo Flags** - Use PackageInfoFlags.of()
- **Syntax highlighting** - For code snippets - **Complexity refactoring** - Split long functions
--- ---
@@ -271,4 +281,4 @@ A: Yes! Download the APK directly from GitHub or use F-Droid.
--- ---
**Last update:** v1.2.1 (2026-01-05) **Last update:** v1.3.2 (2026-01-10)

View File

@@ -8,8 +8,16 @@
## 📝 Notiz-Verwaltung ## 📝 Notiz-Verwaltung
### Notiz-Typen
-**Textnotizen** - Klassische Freitext-Notizen
-**Checklisten** _(NEU in v1.4.0)_ - Aufgabenlisten mit Tap-to-Check
- Items hinzufügen über Eingabefeld
- ☑️ Tap zum Abhaken/Wieder-Öffnen
- 📌 Long-Press für Drag & Drop Sortierung
- 🗑️ Swipe-to-Delete für einzelne Items
- ~~Durchstreichen~~ bei erledigten Einträgen
### Basis-Funktionen ### Basis-Funktionen
-**Einfache Textnotizen** - Fokus auf Inhalt, keine Ablenkung
-**Automatisches Speichern** - Kein manuelles Speichern nötig -**Automatisches Speichern** - Kein manuelles Speichern nötig
-**Titel + Inhalt** - Klare Struktur für jede Notiz -**Titel + Inhalt** - Klare Struktur für jede Notiz
-**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch -**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
@@ -52,9 +60,11 @@
### Markdown-Export ### Markdown-Export
-**Automatischer Export** - Jede Notiz → `.md` Datei -**Automatischer Export** - Jede Notiz → `.md` Datei
-**Checklisten als Task-Listen** _(NEU)_ - `- [ ]` / `- [x]` Format (GitHub-kompatibel)
-**Dual-Format** - JSON (Master) + Markdown (Mirror) -**Dual-Format** - JSON (Master) + Markdown (Mirror)
-**Dateinamen-Sanitization** - Sichere Dateinamen aus Titeln -**Dateinamen-Sanitization** - Sichere Dateinamen aus Titeln
-**Frontmatter-Metadata** - YAML mit ID, Timestamps, Tags -**Duplikat-Handling** _(NEU)_ - ID-Suffix bei gleichen Titeln
-**Frontmatter-Metadata** - YAML mit ID, Timestamps, Type
-**WebDAV-Sync** - Parallel zum JSON-Sync -**WebDAV-Sync** - Parallel zum JSON-Sync
-**Optional** - In Einstellungen ein/ausschaltbar -**Optional** - In Einstellungen ein/ausschaltbar
-**Initial Export** - Alle bestehenden Notizen beim Aktivieren -**Initial Export** - Alle bestehenden Notizen beim Aktivieren
@@ -81,16 +91,16 @@
### Auto-Sync ### Auto-Sync
-**Intervall-Auswahl** - 15, 30 oder 60 Minuten -**Intervall-Auswahl** - 15, 30 oder 60 Minuten
-**WLAN-Bindung** - Nur im konfigurierten Heim-WLAN -**WiFi-Trigger** - Sync bei WiFi-Verbindung _(keine SSID-Einschränkung)_
-**Akkuschonend** - ~0.2-0.8% pro Tag -**Akkuschonend** - ~0.2-0.8% pro Tag
-**Smart Server-Check** - Keine Fehler in fremden Netzwerken -**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - Zuverlässige Background-Ausführung -**WorkManager** - Zuverlässige Background-Ausführung
-**Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode -**Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode
### Sync-Trigger (6 Stück) ### Sync-Trigger (6 Stück)
1.**Periodic Sync** - Automatisch nach Intervall 1.**Periodic Sync** - Automatisch nach Intervall
2.**App-Start Sync** - Beim Öffnen der App 2.**App-Start Sync** - Beim Öffnen der App
3.**WiFi-Connect Sync** - Wenn Heim-WLAN verbindet 3.**WiFi-Connect Sync** - Bei jeder WiFi-Verbindung
4.**Manual Sync** - Button in Einstellungen 4.**Manual Sync** - Button in Einstellungen
5.**Pull-to-Refresh** - Wisch-Geste in Notizliste 5.**Pull-to-Refresh** - Wisch-Geste in Notizliste
6.**Settings-Save Sync** - Nach Server-Konfiguration 6.**Settings-Save Sync** - Nach Server-Konfiguration
@@ -109,7 +119,6 @@
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern -**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication -**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen -**Connection Test** - In Einstellungen testen
-**Gateway SSID** - WLAN-Name für Auto-Sync
-**Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_ -**Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_
-**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/` -**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
@@ -130,14 +139,19 @@
-**Passwort-Speicherung** - Android SharedPreferences (verschlüsselt) -**Passwort-Speicherung** - Android SharedPreferences (verschlüsselt)
-**Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV) -**Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV)
### Entwickler-Features
-**Datei-Logging** - Optional, nur bei Aktivierung _(NEU in v1.3.2)_
-**Datenschutz-Hinweis** - Explizite Warnung bei Aktivierung
-**Lokale Logs** - Logs bleiben auf dem Gerät
--- ---
## 🔋 Performance & Optimierung ## 🔋 Performance & Optimierung
### Akku-Effizienz ### Akku-Effizienz
-**Optimierte Sync-Intervalle** - 15/30/60 Min -**Optimierte Sync-Intervalle** - 15/30/60 Min
-**WLAN-Only** - Kein Mobile Data Sync -**WiFi-Only** - Kein Mobile Data Sync
-**Smart Server-Check** - Nur im Heim-WLAN -**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - System-optimierte Ausführung -**WorkManager** - System-optimierte Ausführung
-**Doze Mode kompatibel** - Sync läuft auch im Standby -**Doze Mode kompatibel** - Sync läuft auch im Standby
-**Gemessener Verbrauch:** -**Gemessener Verbrauch:**
@@ -171,6 +185,7 @@
- **Dispatchers.IO** - Background-Operationen - **Dispatchers.IO** - Background-Operationen
- **SharedPreferences** - Settings-Speicherung - **SharedPreferences** - Settings-Speicherung
- **File-Based Storage** - JSON-Dateien lokal - **File-Based Storage** - JSON-Dateien lokal
- **Custom Exceptions** - Dedizierte SyncException für bessere Fehlerbehandlung _(NEU in v1.3.2)_
### Abhängigkeiten ### Abhängigkeiten
- **AndroidX** - Jetpack Libraries - **AndroidX** - Jetpack Libraries
@@ -208,27 +223,22 @@
## 🔮 Zukünftige Features ## 🔮 Zukünftige Features
Geplant für kommende Versionen (siehe [TODO.md](project-docs/simple-notes-sync/planning/TODO.md)): Geplant für kommende Versionen:
### v1.3.0 - Web Editor & Organisation ### v1.4.0 - Checklisten
- **Browser-basierter Editor** - Notizen im Webbrowser bearbeiten - **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
- **WebDAV-Zugriff via Browser** - Kein Mount nötig - **Erledigte Items** - Durchstreichen/Abhaken
- **Mobile-optimiert** - Responsive Design - **Drag & Drop** - Items neu anordnen
- **Offline-fähig** - Progressive Web App (PWA)
- **Tags/Labels** - Kategorisierung von Notizen
- **Suche** - Volltextsuche in allen Notizen
- **Sortierung** - Nach Datum, Titel, Tags
- **Filter** - Nach Tags filtern
### v1.4.0 - Sharing & Export ### v1.5.0 - Internationalisierung
- **Notiz teilen** - Via Share-Intent - **Mehrsprachigkeit** - Deutsch + Englisch UI
- **Einzelne Notiz exportieren** - Als .txt oder .md - **Sprachauswahl** - In Einstellungen wählbar
- **Import von Text** - Via Share-Intent - **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
### v1.5.0 - Erweiterte Editor-Features ### v1.6.0 - Modern APIs
- **Markdown-Vorschau** - In-App Rendering - **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
- **Checklisten** - TODO-Listen in Notizen - **PackageInfo Flags** - PackageInfoFlags.of() verwenden
- **Syntax-Highlighting** - Für Code-Snippets - **Komplexitäts-Refactoring** - Lange Funktionen aufteilen
--- ---
@@ -271,4 +281,4 @@ A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
--- ---
**Letzte Aktualisierung:** v1.2.1 (2026-01-05) **Letzte Aktualisierung:** v1.3.2 (2026-01-10)

View File

@@ -0,0 +1,5 @@
Unter der Haube haben wir ordentlich aufgeraumt:
- Verbesserte Sync-Performance durch optimierten Code
- Stabilere Fehlerbehandlung bei Verbindungsproblemen
- Speichereffizientere Datenverarbeitung
- Datenschutz-Hinweis fur Datei-Logging hinzugefugt

View File

@@ -0,0 +1,12 @@
NEU: Checklisten!
- Erstelle Checklisten-Notizen mit Tap-to-Check
- Markdown-Export als GitHub-Style Aufgabenlisten
Fixes:
- Robusteres Markdown-Parsing
- Doppelte Dateinamen bekommen ID-Suffix
- Keine Benachrichtigungen wenn App offen ist
Privacy:
- 2 WiFi-Permissions entfernt (ACCESS/CHANGE_WIFI_STATE)
- WiFi-Binding funktioniert bereits ohne SSID-Zugriff!

View File

@@ -0,0 +1,12 @@
v1.3.0 - Multi-Device Sync
NEUE FEATURES:
• Multi-Device Sync mit Deletion Tracking (keine Zombie-Notizen)
• Wisch-Geste zum Server-Löschen (verhindert Duplikate auf anderen Geräten)
• E-Tag Performance-Optimierung (~150ms statt 3s)
• Markdown Auto-Sync Toggle (Export + Import vereint)
• Manueller Markdown-Sync Button
• Server-Wiederherstellung Modi (Merge/Replace/Overwrite)
Dank an Thomas aus Bielefeld!
Kompatibel: v1.2.0-v1.3.0

View File

@@ -0,0 +1,13 @@
v1.3.1 - Multi-Device Sync Fix + Performance
Multi-Device JSON Sync (Danke Thomas!):
- JSON-Dateien syncen jetzt zwischen Geräten
- Funktioniert auch ohne Markdown aktiviert
- Keine doppelten Downloads mehr
Performance-Verbesserungen:
- Sync beschleunigt: 12-14s -> 2-3s
- Erster Sync nach MD-Export jetzt schnell
- JSON erreicht Markdown-Geschwindigkeit
+ Sync-Status-UI, Content MD-Import, Debug-Tools

View File

@@ -2,14 +2,15 @@ Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisatio
HAUPTFUNKTIONEN: HAUPTFUNKTIONEN:
Einfache Notizen erstellen und bearbeiten Text-Notizen und Checklisten erstellen
• Checklisten mit Tap-to-Check, Drag & Drop, Swipe-to-Delete
• WebDAV-Synchronisation mit eigenem Server • WebDAV-Synchronisation mit eigenem Server
• Multi-Device Sync (Handy, Tablet, Desktop)
• Markdown-Export für Obsidian/Desktop-Editoren
• Checklisten als GitHub-Style Task-Listen exportieren
• Automatische Synchronisation im Heim-WLAN • Automatische Synchronisation im Heim-WLAN
• Konfigurierbares Sync-Interval (15/30/60 Minuten) • Konfigurierbares Sync-Interval (15/30/60 Minuten)
• Transparente Batterie-Verbrauchsanzeige
• Material Design 3 mit Dynamic Colors (Android 12+) • Material Design 3 mit Dynamic Colors (Android 12+)
• Swipe-to-Delete mit Bestätigungsdialog
• Server-Backup & Wiederherstellung
• Komplett offline nutzbar • Komplett offline nutzbar
• Keine Werbung, keine Tracker • Keine Werbung, keine Tracker
@@ -17,14 +18,23 @@ DATENSCHUTZ:
Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools. Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools.
MULTI-DEVICE SYNC:
• Notizen synchronisieren automatisch zwischen allen Geräten
• Lösch-Tracking verhindert "Zombie-Notizen"
• Intelligente Konfliktlösung durch Timestamps
• Markdown-Dateien für Desktop-Bearbeitung (Obsidian, VS Code, etc.)
• Änderungen von Desktop-Editoren werden automatisch importiert
SYNCHRONISATION: SYNCHRONISATION:
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.) • Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
• Konfigurierbares Interval: 15, 30 oder 60 Minuten • Konfigurierbares Interval: 15, 30 oder 60 Minuten
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
• Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min) • Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
• Doze Mode optimiert für zuverlässige Background-Syncs • Doze Mode optimiert für zuverlässige Background-Syncs
• Manuelle Synchronisation jederzeit möglich • Manuelle Synchronisation jederzeit möglich
• Konfliktfreie Zusammenführung durch Timestamps
MATERIAL DESIGN 3: MATERIAL DESIGN 3:
@@ -32,6 +42,7 @@ MATERIAL DESIGN 3:
• Dynamic Colors (Material You) auf Android 12+ • Dynamic Colors (Material You) auf Android 12+
• Dark Mode Support • Dark Mode Support
• Intuitive Gesten (Swipe-to-Delete) • Intuitive Gesten (Swipe-to-Delete)
• Live Sync-Status Anzeige
Open Source unter MIT-Lizenz Open Source unter MIT-Lizenz
Quellcode: https://github.com/inventory69/simple-notes-sync Quellcode: https://github.com/inventory69/simple-notes-sync

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1 +1 @@
Einfache Notizen-App mit WebDAV-Synchronisation Notizen & Checklisten mit WebDAV-Sync zu deinem eigenen Server

View File

@@ -0,0 +1,5 @@
Under the hood improvements:
- Improved sync performance through optimized code
- More stable error handling for connection issues
- More memory-efficient data processing
- Added privacy notice for file logging

View File

@@ -0,0 +1,12 @@
NEW: Checklists!
- Create checklist notes with tap-to-check items
- Markdown export as GitHub-style task lists
Fixes:
- More robust Markdown parsing
- Duplicate filenames get ID suffix
- No notifications when app is open
Privacy:
- Removed 2 WiFi permissions (ACCESS/CHANGE_WIFI_STATE)
- WiFi binding already works without SSID access!

View File

@@ -0,0 +1,12 @@
v1.3.0 - Multi-Device Sync
NEW FEATURES:
• Multi-Device Sync with deletion tracking (no zombie notes)
• Swipe gesture for server deletion (prevents duplicates on other devices)
• E-Tag performance optimization (~150ms vs 3s)
• Markdown Auto-Sync toggle (unified Export + Import)
• Manual Markdown sync button
• Server restore modes (Merge/Replace/Overwrite)
Thanks to Thomas from Bielefeld!
Compatible: v1.2.0-v1.3.0

View File

@@ -0,0 +1,13 @@
v1.3.1 - Multi-Device Sync Fix + Performance
Multi-Device JSON Sync (Thanks Thomas!):
- JSON files now sync between devices
- Works without Markdown enabled
- No duplicate downloads anymore
Performance Improvements:
- Sync speed: 12-14s -> 2-3s
- First sync after MD export now fast
- JSON matches Markdown speed
+ Sync status UI, content MD import, debug tools

Some files were not shown because too many files have changed in this diff Show More