Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7128c25bd5 | ||
|
|
356ccde627 | ||
|
|
9b37078cce | ||
|
|
dee85233b6 | ||
|
|
fbcca3807d | ||
|
|
e3e64b83e2 | ||
|
|
2324743f43 | ||
|
|
0e96757fab | ||
|
|
547c0a1011 | ||
|
|
b79c0d25e6 | ||
|
|
cf1142afa2 | ||
|
|
359325bf64 | ||
|
|
c7d0f899e7 | ||
|
|
5121a7b2b8 | ||
|
|
04664c8920 |
42
.github/workflows/build-production-apk.yml
vendored
@@ -61,33 +61,15 @@ jobs:
|
||||
run: |
|
||||
mkdir -p apk-output
|
||||
|
||||
# === Standard Flavor (mit Google Services) ===
|
||||
# Universal APK (funktioniert auf allen Geraeten)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-universal-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk
|
||||
# Standard Flavor - Universal APK
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
|
||||
|
||||
# ARM64 APK (moderne Geräte 2018+)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk
|
||||
# F-Droid Flavor - Universal APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||
|
||||
# ARMv7 APK (ältere Geräte)
|
||||
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):"
|
||||
echo "✅ APK-Dateien vorbereitet:"
|
||||
ls -lh apk-output/
|
||||
|
||||
- name: APK-Artefakte hochladen
|
||||
@@ -138,14 +120,8 @@ jobs:
|
||||
|
||||
| Variante | Datei | Info |
|
||||
|----------|-------|------|
|
||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk` | Funktioniert auf allen Android-Geraeten |
|
||||
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk` | Kleinere Dateigröße fuer 64-bit Geräte |
|
||||
| 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!
|
||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
|
||||
|
||||
---
|
||||
|
||||
|
||||
1
.gitignore
vendored
@@ -42,3 +42,4 @@ Thumbs.db
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
test-apks/
|
||||
|
||||
209
CHANGELOG.md
@@ -6,6 +6,215 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [1.4.1] - 2026-01-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **🗑️ Löschen älterer Notizen (v1.2.0 Kompatibilität)**
|
||||
- Notizen aus App-Version v1.2.0 oder früher werden jetzt korrekt vom Server gelöscht
|
||||
- Behebt Problem bei Multi-Device-Nutzung mit älteren Notizen
|
||||
|
||||
- **🔄 Checklisten-Sync Abwärtskompatibilität**
|
||||
- Checklisten werden jetzt auch als Text-Fallback im `content`-Feld gespeichert
|
||||
- Ältere App-Versionen (v1.3.x) zeigen Checklisten als lesbaren Text
|
||||
- Format: GitHub-Style Task-Listen (`[ ] Item` / `[x] Item`)
|
||||
- Recovery-Mode: Falls Checklisten-Items verloren gehen, werden sie aus dem Content wiederhergestellt
|
||||
|
||||
### Improved
|
||||
|
||||
- **📝 Checklisten Auto-Zeilenumbruch**
|
||||
- Lange Checklisten-Texte werden jetzt automatisch umgebrochen
|
||||
- Keine Begrenzung auf 3 Zeilen mehr
|
||||
- Enter-Taste erstellt weiterhin ein neues Item
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.5.0** wird das nächste größere Release. Wir sammeln Ideen und Feedback!
|
||||
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
@@ -14,7 +14,7 @@ Danke, dass du zu Simple Notes Sync beitragen möchtest!
|
||||
|
||||
1. **Fork & Clone**
|
||||
```bash
|
||||
git clone https://github.com/DEIN-USERNAME/simple-notes-sync.git
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync
|
||||
```
|
||||
|
||||
@@ -139,7 +139,7 @@ Thanks for wanting to contribute to Simple Notes Sync!
|
||||
|
||||
1. **Fork & Clone**
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/simple-notes-sync.git
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync
|
||||
```
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
||||
| **Username** | `noteuser` |
|
||||
| **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.
|
||||
|
||||
@@ -158,9 +157,8 @@ For reliable auto-sync:
|
||||
# Should show "Up"
|
||||
```
|
||||
|
||||
2. **Same WiFi?**
|
||||
2. **Same network?**
|
||||
- Smartphone and server must be on same network
|
||||
- Check SSID in app settings
|
||||
|
||||
3. **IP address correct?**
|
||||
```bash
|
||||
@@ -193,9 +191,9 @@ For reliable auto-sync:
|
||||
2. **Battery optimization disabled?**
|
||||
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
||||
|
||||
3. **On correct WiFi?**
|
||||
- Sync only works when SSID = Gateway SSID
|
||||
- Check current SSID in Android settings
|
||||
3. **Connected to WiFi?**
|
||||
- Auto-sync triggers on any WiFi connection
|
||||
- Check if you're connected to a WiFi network
|
||||
|
||||
4. **Test manually:**
|
||||
- ⚙️ Settings → "Sync now"
|
||||
|
||||
@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
|
||||
| **Benutzername** | `noteuser` |
|
||||
| **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.
|
||||
|
||||
@@ -158,9 +157,8 @@ Für zuverlässigen Auto-Sync:
|
||||
# Sollte "Up" zeigen
|
||||
```
|
||||
|
||||
2. **Gleiche WLAN?**
|
||||
2. **Gleiches Netzwerk?**
|
||||
- Smartphone und Server müssen im selben Netzwerk sein
|
||||
- Prüfe SSID in App-Einstellungen
|
||||
|
||||
3. **IP-Adresse korrekt?**
|
||||
```bash
|
||||
@@ -193,9 +191,9 @@ Für zuverlässigen Auto-Sync:
|
||||
2. **Akku-Optimierung deaktiviert?**
|
||||
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
||||
|
||||
3. **Im richtigen WLAN?**
|
||||
- Sync funktioniert nur wenn SSID = Gateway SSID
|
||||
- Prüfe aktuelle SSID in Android-Einstellungen
|
||||
3. **Mit WiFi verbunden?**
|
||||
- Auto-Sync triggert bei jeder WiFi-Verbindung
|
||||
- Prüfe, ob du mit einem WLAN verbunden bist
|
||||
|
||||
4. **Manuell testen:**
|
||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||
|
||||
@@ -26,11 +26,12 @@
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- ✅ **NEW: Checklists** - Tap-to-check, drag & drop, swipe-to-delete
|
||||
- 📝 **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)
|
||||
- 💾 **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
|
||||
- 🎨 **Material Design 3** - Dark mode & dynamic colors
|
||||
|
||||
@@ -85,7 +86,7 @@ cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md)
|
||||
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
|
||||
|
||||
---
|
||||
|
||||
@@ -101,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
|
||||
|
||||
@@ -26,11 +26,12 @@
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- ✅ **NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete
|
||||
- 📝 **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)
|
||||
- 💾 **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
|
||||
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
|
||||
|
||||
@@ -88,7 +89,7 @@ cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md)
|
||||
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||
|
||||
---
|
||||
|
||||
@@ -104,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
@@ -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
|
||||
@@ -1,6 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
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
|
||||
@@ -17,13 +20,10 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 8 // 🚀 v1.3.0: Multi-Device Sync with deletion tracking
|
||||
versionName = "1.3.0" // 🚀 v1.3.0: Multi-Device Sync, E-Tag caching, Markdown auto-import
|
||||
versionCode = 12 // 🔧 v1.4.1: Bugfixes (Root-Delete, Checklist Compat)
|
||||
versionName = "1.4.1" // 🔧 v1.4.1: Root-Folder Delete Fix, Checklisten-Sync Abwärtskompatibilität
|
||||
|
||||
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
|
||||
@@ -32,23 +32,15 @@ android {
|
||||
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
|
||||
// Note: APK splits are disabled to ensure single APK output
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
create("fdroid") {
|
||||
dimension = "distribution"
|
||||
// F-Droid builds have no proprietary dependencies
|
||||
// All dependencies in this project are already FOSS-compatible
|
||||
// No APK splits - F-Droid expects single universal APK
|
||||
}
|
||||
|
||||
create("standard") {
|
||||
@@ -75,6 +67,16 @@ android {
|
||||
}
|
||||
|
||||
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 {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -139,8 +141,26 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
|
||||
// 🔥 NEU: Helper function für Build Date
|
||||
fun getBuildDate(): String {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
return dateFormat.format(Date())
|
||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
|
||||
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
|
||||
// ktlint {
|
||||
// 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
|
||||
}
|
||||
@@ -5,8 +5,6 @@
|
||||
<!-- Network & Sync Permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
@@ -39,8 +39,14 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
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() {
|
||||
|
||||
@@ -50,9 +56,16 @@ class MainActivity : AppCompatActivity() {
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
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 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
|
||||
private val pendingDeletions = mutableSetOf<String>()
|
||||
|
||||
@@ -66,6 +79,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||
private const val SYNC_COMPLETED_DELAY_MS = 1500L
|
||||
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,9 +112,10 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// File Logging aktivieren wenn eingestellt
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
// Alte Sync-Notifications beim App-Start löschen
|
||||
@@ -115,7 +131,69 @@ class MainActivity : AppCompatActivity() {
|
||||
setupRecyclerView()
|
||||
setupFab()
|
||||
|
||||
// v1.4.1: Migrate checklists for backwards compatibility
|
||||
migrateChecklistsForBackwardsCompat()
|
||||
|
||||
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() {
|
||||
@@ -151,6 +229,12 @@ class MainActivity : AppCompatActivity() {
|
||||
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)")
|
||||
|
||||
// Update last sync timestamp
|
||||
@@ -163,6 +247,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -173,6 +258,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -184,6 +270,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
@@ -191,14 +278,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
}
|
||||
@@ -235,6 +325,10 @@ class MainActivity : AppCompatActivity() {
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
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() {
|
||||
@@ -262,6 +356,12 @@ class MainActivity : AppCompatActivity() {
|
||||
swipeRefreshLayout.setOnRefreshListener {
|
||||
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 {
|
||||
try {
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
@@ -269,7 +369,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (serverUrl.isNullOrEmpty()) {
|
||||
showToast("⚠️ Server noch nicht konfiguriert")
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -278,15 +378,13 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||
showToast("✅ Bereits synchronisiert")
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if server is reachable
|
||||
if (!syncService.isServerReachable()) {
|
||||
showToast("⚠️ Server nicht erreichbar")
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -294,16 +392,14 @@ class MainActivity : AppCompatActivity() {
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
if (result.isSuccess) {
|
||||
showToast("✅ ${result.syncedCount} Notizen synchronisiert")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
loadNotes()
|
||||
} else {
|
||||
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
|
||||
showToast("❌ Fehler: ${e.message}")
|
||||
} finally {
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -430,16 +526,28 @@ class MainActivity : AppCompatActivity() {
|
||||
val success = webdavService.deleteNoteFromServer(note.id)
|
||||
if (success) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(this@MainActivity, "Vom Server gelöscht", Toast.LENGTH_SHORT).show()
|
||||
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()
|
||||
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()
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"Server-Fehler: ${e.message}",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -449,12 +557,55 @@ class MainActivity : AppCompatActivity() {
|
||||
}).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
||||
*/
|
||||
private fun setupFab() {
|
||||
fabAddNote.setOnClickListener {
|
||||
openNoteEditor(null)
|
||||
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() {
|
||||
val notes = storage.loadAllNotes()
|
||||
|
||||
@@ -493,6 +644,11 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun triggerManualSync() {
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("manual")) {
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
// Create sync service
|
||||
@@ -501,12 +657,10 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||
showToast("✅ Bereits synchronisiert")
|
||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||
return@launch
|
||||
}
|
||||
|
||||
showToast("Starte Synchronisation...")
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
@@ -514,7 +668,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
showToast("Server nicht erreichbar")
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -525,20 +679,21 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
// Show result
|
||||
if (result.isSuccess) {
|
||||
showToast("Sync erfolgreich: ${result.syncedCount} Notizen")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
loadNotes() // Reload notes
|
||||
} else {
|
||||
showToast("Sync Fehler: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
showToast("Sync Fehler: ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -578,6 +733,54 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
|
||||
*
|
||||
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
|
||||
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
|
||||
*
|
||||
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
|
||||
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
|
||||
*
|
||||
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
|
||||
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
|
||||
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
|
||||
*/
|
||||
private fun migrateChecklistsForBackwardsCompat() {
|
||||
val migrationKey = "v1.4.1_checklist_migration_done"
|
||||
|
||||
// Nur einmal ausführen
|
||||
if (prefs.getBoolean(migrationKey, false)) {
|
||||
return
|
||||
}
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val checklistsToMigrate = allNotes.filter { note ->
|
||||
note.noteType == NoteType.CHECKLIST &&
|
||||
note.content.isBlank() &&
|
||||
note.checklistItems?.isNotEmpty() == true
|
||||
}
|
||||
|
||||
if (checklistsToMigrate.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
|
||||
|
||||
for (note in checklistsToMigrate) {
|
||||
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
|
||||
// generiert und hochgeladen wird
|
||||
val updatedNote = note.copy(
|
||||
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
|
||||
)
|
||||
storage.saveNote(updatedNote)
|
||||
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
|
||||
}
|
||||
|
||||
// Migration als erledigt markieren
|
||||
prefs.edit().putBoolean(migrationKey, true).apply()
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
|
||||
@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
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.button.MaterialButton
|
||||
import com.google.android.material.color.DynamicColors
|
||||
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.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
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() {
|
||||
|
||||
// Views
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var tilTitle: TextInputLayout
|
||||
private lateinit var editTextTitle: TextInputEditText
|
||||
private lateinit var tilContent: TextInputLayout
|
||||
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
|
||||
|
||||
// State
|
||||
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 {
|
||||
private const val TAG = "NoteEditorActivity"
|
||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -36,39 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
storage = NotesStorage(this)
|
||||
|
||||
// Setup toolbar
|
||||
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
// 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
|
||||
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
|
||||
findViews()
|
||||
setupToolbar()
|
||||
loadNoteOrDetermineType()
|
||||
setupUIForNoteType()
|
||||
}
|
||||
|
||||
// Find views
|
||||
private fun findViews() {
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
tilTitle = findViewById(R.id.tilTitle)
|
||||
editTextTitle = findViewById(R.id.editTextTitle)
|
||||
tilContent = findViewById(R.id.tilContent)
|
||||
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)
|
||||
|
||||
if (noteId != null) {
|
||||
// Existierende Notiz laden
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let {
|
||||
editTextTitle.setText(it.title)
|
||||
editTextContent.setText(it.content)
|
||||
supportActionBar?.title = "Notiz bearbeiten"
|
||||
existingNote?.let { note ->
|
||||
editTextTitle.setText(note.title)
|
||||
currentNoteType = note.noteType
|
||||
|
||||
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 {
|
||||
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 {
|
||||
menuInflater.inflate(R.menu.menu_editor, menu)
|
||||
|
||||
// Show delete only for existing notes
|
||||
// Delete nur für existierende Notizen
|
||||
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -92,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
private fun saveNote() {
|
||||
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
||||
|
||||
when (currentNoteType) {
|
||||
NoteType.TEXT -> {
|
||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (title.isEmpty() && content.isEmpty()) {
|
||||
showToast("Notiz ist leer")
|
||||
showToast(getString(R.string.note_is_empty))
|
||||
return
|
||||
}
|
||||
|
||||
val note = if (existingNote != null) {
|
||||
// Update existing note
|
||||
existingNote!!.copy(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
} else {
|
||||
// Create new note
|
||||
Note(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private fun confirmDelete() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Notiz löschen?")
|
||||
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.")
|
||||
.setPositiveButton("Löschen") { _, _ ->
|
||||
.setTitle(getString(R.string.delete_note_title))
|
||||
.setMessage(getString(R.string.delete_note_message))
|
||||
.setPositiveButton(getString(R.string.delete)) { _, _ ->
|
||||
deleteNote()
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.setNegativeButton(getString(R.string.cancel), null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteNote() {
|
||||
existingNote?.let {
|
||||
storage.deleteNote(it.id)
|
||||
showToast("Notiz gelöscht")
|
||||
showToast(getString(R.string.note_deleted))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,6 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
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.launch
|
||||
import dev.dettmer.simplenotes.backup.BackupManager
|
||||
@@ -34,11 +32,11 @@ import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.utils.UrlValidator
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
@@ -51,6 +49,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync"
|
||||
private const val GITHUB_PROFILE_URL = "https://github.com/inventory69"
|
||||
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
||||
private const val CONNECTION_TIMEOUT_MS = 3000
|
||||
}
|
||||
|
||||
private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout
|
||||
@@ -83,6 +82,11 @@ class SettingsActivity : AppCompatActivity() {
|
||||
private lateinit var cardDeveloperProfile: 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
|
||||
private val backupManager by lazy { BackupManager(this) }
|
||||
|
||||
@@ -124,6 +128,7 @@ class SettingsActivity : AppCompatActivity() {
|
||||
setupListeners()
|
||||
setupSyncIntervalPicker()
|
||||
setupAboutSection()
|
||||
setupDebugSection()
|
||||
}
|
||||
|
||||
private fun findViews() {
|
||||
@@ -156,6 +161,11 @@ class SettingsActivity : AppCompatActivity() {
|
||||
cardGitHubRepo = findViewById(R.id.cardGitHubRepo)
|
||||
cardDeveloperProfile = findViewById(R.id.cardDeveloperProfile)
|
||||
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() {
|
||||
@@ -313,7 +323,10 @@ class SettingsActivity : AppCompatActivity() {
|
||||
*/
|
||||
private fun setupSyncIntervalPicker() {
|
||||
// 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
|
||||
val checkedId = when (currentInterval) {
|
||||
@@ -358,13 +371,12 @@ class SettingsActivity : AppCompatActivity() {
|
||||
* Setup about section with version info and clickable cards
|
||||
*/
|
||||
private fun setupAboutSection() {
|
||||
// Display app version with build date
|
||||
// Display app version
|
||||
try {
|
||||
val versionName = BuildConfig.VERSION_NAME
|
||||
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) {
|
||||
Logger.e(TAG, "Failed to load version info", e)
|
||||
textViewAppVersion.text = "Version nicht verfügbar"
|
||||
@@ -386,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
|
||||
*/
|
||||
@@ -467,6 +582,14 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||
@@ -474,14 +597,16 @@ class SettingsActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
showToast("✅ Bereits synchronisiert")
|
||||
SyncStateManager.markCompleted()
|
||||
return@launch
|
||||
}
|
||||
|
||||
showToast("Synchronisiere...")
|
||||
showToast("🔄 Synchronisiere...")
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
|
||||
if (!syncService.isServerReachable()) {
|
||||
showToast("⚠️ Server nicht erreichbar")
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
checkServerStatus() // Server-Status aktualisieren
|
||||
return@launch
|
||||
}
|
||||
@@ -490,18 +615,24 @@ class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (result.hasConflicts) {
|
||||
showToast("Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
|
||||
showToast("✅ Sync abgeschlossen. ${result.conflictCount} Konflikte erkannt!")
|
||||
} else {
|
||||
showToast("Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
|
||||
showToast("✅ Erfolgreich! ${result.syncedCount} Notizen synchronisiert")
|
||||
}
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
checkServerStatus() // ✅ Server-Status nach Sync aktualisieren
|
||||
} else {
|
||||
showToast("Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
checkServerStatus() // ✅ Auch bei Fehler aktualisieren
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
showToast("Fehler: ${e.message}")
|
||||
showToast("❌ Fehler: ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
checkServerStatus() // ✅ Auch bei Exception aktualisieren
|
||||
} finally {
|
||||
// Re-enable button
|
||||
buttonSyncNow.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -523,8 +654,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||
try {
|
||||
val url = URL(serverUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 3000
|
||||
connection.readTimeout = 3000
|
||||
connection.connectTimeout = CONNECTION_TIMEOUT_MS
|
||||
connection.readTimeout = CONNECTION_TIMEOUT_MS
|
||||
val code = connection.responseCode
|
||||
connection.disconnect()
|
||||
code in 200..299 || code == 401 // 401 = Server da, Auth fehlt
|
||||
@@ -633,7 +764,10 @@ class SettingsActivity : AppCompatActivity() {
|
||||
.apply()
|
||||
|
||||
updateMarkdownButtonVisibility()
|
||||
showToast("Markdown Auto-Sync aktiviert - Notizen werden als .md-Dateien exportiert und importiert")
|
||||
showToast(
|
||||
"Markdown Auto-Sync aktiviert - " +
|
||||
"Notizen werden als .md-Dateien exportiert und importiert"
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -687,11 +821,13 @@ class SettingsActivity : AppCompatActivity() {
|
||||
intent.data = Uri.parse("package:$packageName")
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
|
||||
// Fallback: Open general battery settings
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
startActivity(intent)
|
||||
} catch (e2: Exception) {
|
||||
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
|
||||
showToast("Bitte Akku-Optimierung manuell deaktivieren")
|
||||
}
|
||||
}
|
||||
@@ -710,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 {
|
||||
return when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
@@ -815,7 +908,6 @@ class SettingsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// Custom View mit Radio Buttons
|
||||
val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null)
|
||||
val radioGroup = android.widget.RadioGroup(this).apply {
|
||||
orientation = android.widget.RadioGroup.VERTICAL
|
||||
setPadding(50, 20, 50, 20)
|
||||
@@ -824,20 +916,20 @@ class SettingsActivity : AppCompatActivity() {
|
||||
// Radio Buttons erstellen
|
||||
val radioMerge = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
|
||||
id = 0
|
||||
id = android.view.View.generateViewId()
|
||||
isChecked = true
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioReplace = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
|
||||
id = 1
|
||||
id = android.view.View.generateViewId()
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
val radioOverwrite = android.widget.RadioButton(this).apply {
|
||||
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
|
||||
id = 2
|
||||
id = android.view.View.generateViewId()
|
||||
setPadding(10, 10, 10, 10)
|
||||
}
|
||||
|
||||
@@ -876,8 +968,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||
.setView(mainLayout)
|
||||
.setPositiveButton("Wiederherstellen") { _, _ ->
|
||||
val selectedMode = when (radioGroup.checkedRadioButtonId) {
|
||||
1 -> RestoreMode.REPLACE
|
||||
2 -> RestoreMode.OVERWRITE_DUPLICATES
|
||||
radioReplace.id -> RestoreMode.REPLACE
|
||||
radioOverwrite.id -> RestoreMode.OVERWRITE_DUPLICATES
|
||||
else -> RestoreMode.MERGE
|
||||
}
|
||||
|
||||
@@ -908,12 +1000,12 @@ class SettingsActivity : AppCompatActivity() {
|
||||
progressDialog.dismiss()
|
||||
|
||||
if (result.success) {
|
||||
val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen"
|
||||
val message = result.message ?: "Wiederhergestellt: ${result.importedNotes} Notizen"
|
||||
showToast("✅ $message")
|
||||
|
||||
// Refresh MainActivity's note list
|
||||
setResult(RESULT_OK)
|
||||
broadcastNotesChanged(result.imported_notes)
|
||||
broadcastNotesChanged(result.importedNotes)
|
||||
} else {
|
||||
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
||||
}
|
||||
@@ -1022,10 +1114,16 @@ class SettingsActivity : AppCompatActivity() {
|
||||
progressDialog.dismiss()
|
||||
|
||||
// Erfolgs-Nachricht
|
||||
val message = "✅ Sync abgeschlossen\n📤 ${result.exportedCount} exportiert\n📥 ${result.importedCount} importiert"
|
||||
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}")
|
||||
Logger.d(
|
||||
"SettingsActivity",
|
||||
"Manual markdown sync: exported=${result.exportedCount}, " +
|
||||
"imported=${result.importedCount}"
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
progressDialog?.dismiss()
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package dev.dettmer.simplenotes.adapters
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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)
|
||||
|
||||
// v1.4.1: TextWatcher für Änderungen + Enter-Erkennung für neues Item
|
||||
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) return
|
||||
|
||||
val text = s?.toString() ?: ""
|
||||
|
||||
// Prüfe ob ein Newline eingegeben wurde
|
||||
if (text.contains("\n")) {
|
||||
// Newline entfernen und neues Item erstellen
|
||||
val cleanText = text.replace("\n", "")
|
||||
editText.setText(cleanText)
|
||||
editText.setSelection(cleanText.length)
|
||||
onItemTextChanged(pos, cleanText)
|
||||
onAddNewItem(pos + 1)
|
||||
} else {
|
||||
onItemTextChanged(pos, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
editText.addTextChangedListener(textWatcher)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||
import dev.dettmer.simplenotes.utils.truncate
|
||||
|
||||
/**
|
||||
* Adapter für die Notizen-Liste
|
||||
*
|
||||
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
|
||||
*/
|
||||
class NotesAdapter(
|
||||
private val onNoteClick: (Note) -> Unit
|
||||
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
||||
@@ -31,16 +37,46 @@ class NotesAdapter(
|
||||
}
|
||||
|
||||
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 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 imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
||||
|
||||
fun bind(note: Note) {
|
||||
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
|
||||
textViewContent.text = note.content.truncate(100)
|
||||
// Titel
|
||||
textViewTitle.text = note.title.ifEmpty {
|
||||
itemView.context.getString(R.string.untitled)
|
||||
}
|
||||
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
|
||||
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
|
||||
@@ -49,10 +49,10 @@ class BackupManager(private val context: Context) {
|
||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
backupVersion = BACKUP_VERSION,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
notesCount = allNotes.size,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
BackupResult(
|
||||
success = true,
|
||||
notes_count = allNotes.size,
|
||||
notesCount = allNotes.size,
|
||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
||||
)
|
||||
|
||||
@@ -99,10 +99,10 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
backupVersion = BACKUP_VERSION,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
notesCount = allNotes.size,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
@@ -149,7 +149,7 @@ class BackupManager(private val context: Context) {
|
||||
}
|
||||
|
||||
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)
|
||||
val autoBackupUri = createAutoBackup()
|
||||
@@ -164,7 +164,7 @@ class BackupManager(private val context: Context) {
|
||||
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
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -184,10 +184,11 @@ class BackupManager(private val context: Context) {
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
|
||||
// Version kompatibel?
|
||||
if (backupData.backup_version > BACKUP_VERSION) {
|
||||
if (backupData.backupVersion > BACKUP_VERSION) {
|
||||
return ValidationResult(
|
||||
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(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = skippedNotes,
|
||||
importedNotes = newNotes.size,
|
||||
skippedNotes = skippedNotes,
|
||||
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
|
||||
)
|
||||
}
|
||||
@@ -259,8 +260,8 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = backupNotes.size,
|
||||
skipped_notes = 0,
|
||||
importedNotes = backupNotes.size,
|
||||
skippedNotes = 0,
|
||||
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
|
||||
)
|
||||
}
|
||||
@@ -283,9 +284,9 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = 0,
|
||||
overwritten_notes = overwrittenNotes.size,
|
||||
importedNotes = newNotes.size,
|
||||
skippedNotes = 0,
|
||||
overwrittenNotes = overwrittenNotes.size,
|
||||
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
|
||||
)
|
||||
}
|
||||
@@ -312,12 +313,17 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Backup-Daten Struktur (JSON)
|
||||
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
|
||||
*/
|
||||
data class BackupData(
|
||||
val backup_version: Int,
|
||||
val created_at: Long,
|
||||
val notes_count: Int,
|
||||
val app_version: String,
|
||||
@com.google.gson.annotations.SerializedName("backup_version")
|
||||
val backupVersion: Int,
|
||||
@com.google.gson.annotations.SerializedName("created_at")
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -335,7 +341,7 @@ enum class RestoreMode {
|
||||
*/
|
||||
data class BackupResult(
|
||||
val success: Boolean,
|
||||
val notes_count: Int = 0,
|
||||
val notesCount: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -345,9 +351,9 @@ data class BackupResult(
|
||||
*/
|
||||
data class RestoreResult(
|
||||
val success: Boolean,
|
||||
val imported_notes: Int = 0,
|
||||
val skipped_notes: Int = 0,
|
||||
val overwritten_notes: Int = 0,
|
||||
val importedNotes: Int = 0,
|
||||
val skippedNotes: Int = 0,
|
||||
val overwrittenNotes: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -49,6 +50,8 @@ data class DeletionTracker(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DeletionTracker"
|
||||
|
||||
fun fromJson(json: String): DeletionTracker? {
|
||||
return try {
|
||||
val jsonObject = JSONObject(json)
|
||||
@@ -70,6 +73,7 @@ data class DeletionTracker(
|
||||
|
||||
DeletionTracker(version, deletedNotes)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -13,53 +14,195 @@ data class Note(
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
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
|
||||
* v1.4.1: Für Checklisten wird ein Fallback-Content generiert, damit ältere
|
||||
* App-Versionen (v1.3.x) die Notiz als Text anzeigen können.
|
||||
*/
|
||||
fun toJson(): String {
|
||||
return """
|
||||
{
|
||||
"id": "$id",
|
||||
"title": "${title.escapeJson()}",
|
||||
"content": "${content.escapeJson()}",
|
||||
"createdAt": $createdAt,
|
||||
"updatedAt": $updatedAt,
|
||||
"deviceId": "$deviceId",
|
||||
"syncStatus": "${syncStatus.name}"
|
||||
val gson = com.google.gson.GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.create()
|
||||
|
||||
// v1.4.1: Für Checklisten den Fallback-Content generieren
|
||||
val noteToSerialize = if (noteType == NoteType.CHECKLIST && checklistItems != null) {
|
||||
this.copy(content = generateChecklistFallbackContent())
|
||||
} else {
|
||||
this
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
return gson.toJson(noteToSerialize)
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.1: Generiert einen lesbaren Text-Fallback aus Checklist-Items.
|
||||
* Format: GitHub-Style Task-Listen (kompatibel mit Markdown)
|
||||
*
|
||||
* Beispiel:
|
||||
* [ ] Milch kaufen
|
||||
* [x] Brot gekauft
|
||||
* [ ] Eier
|
||||
*
|
||||
* Wird von älteren App-Versionen (v1.3.x) als normaler Text angezeigt.
|
||||
*/
|
||||
private fun generateChecklistFallbackContent(): String {
|
||||
return checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
|
||||
val checkbox = if (item.isChecked) "[x]" else "[ ]"
|
||||
"$checkbox ${item.text}"
|
||||
} ?: ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
||||
* Format kompatibel mit Obsidian, Joplin, Typora
|
||||
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||
*/
|
||||
fun toMarkdown(): String {
|
||||
return """
|
||||
val header = """
|
||||
---
|
||||
id: $id
|
||||
created: ${formatISO8601(createdAt)}
|
||||
updated: ${formatISO8601(updatedAt)}
|
||||
device: $deviceId
|
||||
type: ${noteType.name.lowercase()}
|
||||
---
|
||||
|
||||
# $title
|
||||
|
||||
$content
|
||||
""".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 {
|
||||
private const val TAG = "Note"
|
||||
|
||||
/**
|
||||
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
|
||||
*/
|
||||
fun fromJson(json: String): Note? {
|
||||
return try {
|
||||
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) {
|
||||
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
|
||||
var checklistItems: List<ChecklistItem>? = if (jsonObject.has("checklistItems") &&
|
||||
!jsonObject.get("checklistItems").isJsonNull
|
||||
) {
|
||||
gson.fromJson<List<ChecklistItem>>(
|
||||
jsonObject.get("checklistItems"),
|
||||
checklistItemsType
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
// v1.4.1: Recovery-Mode - Falls Checkliste aber keine Items,
|
||||
// versuche Content als Fallback zu parsen
|
||||
if (noteType == NoteType.CHECKLIST &&
|
||||
(checklistItems == null || checklistItems.isEmpty()) &&
|
||||
rawNote.content.isNotBlank()) {
|
||||
|
||||
val recoveredItems = parseChecklistFromContent(rawNote.content)
|
||||
if (recoveredItems.isNotEmpty()) {
|
||||
Logger.d(TAG, "🔄 Recovered ${recoveredItems.size} checklist items from content fallback")
|
||||
checklistItems = recoveredItems
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
)
|
||||
|
||||
/**
|
||||
* v1.4.1: Parst GitHub-Style Checklisten aus Text (Recovery-Mode).
|
||||
*
|
||||
* Unterstützte Formate:
|
||||
* - [ ] Unchecked item
|
||||
* - [x] Checked item
|
||||
* - [X] Checked item (case insensitive)
|
||||
*
|
||||
* Wird verwendet, wenn eine v1.4.0 Checkliste von einer älteren
|
||||
* App-Version (v1.3.x) bearbeitet wurde und die checklistItems verloren gingen.
|
||||
*
|
||||
* @param content Der Text-Content der Notiz
|
||||
* @return Liste von ChecklistItems oder leere Liste
|
||||
*/
|
||||
private fun parseChecklistFromContent(content: String): List<ChecklistItem> {
|
||||
val pattern = Regex("""^\s*\[([ xX])\]\s*(.+)$""", RegexOption.MULTILINE)
|
||||
return pattern.findAll(content).mapIndexed { index, match ->
|
||||
val checked = match.groupValues[1].lowercase() == "x"
|
||||
val text = match.groupValues[2].trim()
|
||||
ChecklistItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
text = text,
|
||||
isChecked = checked,
|
||||
order = index
|
||||
)
|
||||
}.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @return Note-Objekt oder null bei Parse-Fehler
|
||||
@@ -87,10 +230,47 @@ $content
|
||||
.firstOrNull { it.startsWith("# ") }
|
||||
?.removePrefix("# ")?.trim() ?: "Untitled"
|
||||
|
||||
// Extract content (everything after heading)
|
||||
val content = contentBlock
|
||||
.substringAfter("# $title\n\n", "")
|
||||
// v1.4.0: Prüfe ob type: checklist im Frontmatter
|
||||
val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
|
||||
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()
|
||||
} 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(
|
||||
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||
@@ -99,9 +279,12 @@ $content
|
||||
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
||||
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) {
|
||||
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -126,6 +309,7 @@ $content
|
||||
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
|
||||
System.currentTimeMillis() // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -227,7 +227,11 @@ class NetworkMonitor(private val context: Context) {
|
||||
if (isWifi) {
|
||||
lastConnectedNetworkId = activeNetwork.toString()
|
||||
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
|
||||
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change")
|
||||
Logger.d(
|
||||
TAG,
|
||||
" 📡 WiFi already connected at startup - " +
|
||||
"onAvailable() will only trigger on network change"
|
||||
)
|
||||
} else {
|
||||
lastConnectedNetworkId = null
|
||||
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
|
||||
@@ -268,7 +272,7 @@ class NetworkMonitor(private val context: Context) {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Logger.d(TAG, "✅ WiFi monitoring stopped")
|
||||
} catch (e: Exception) {
|
||||
// Already unregistered
|
||||
Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
@@ -22,6 +23,21 @@ class SyncWorker(
|
||||
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) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -117,7 +133,11 @@ class SyncWorker(
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
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) {
|
||||
@@ -127,7 +147,12 @@ class SyncWorker(
|
||||
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||
|
||||
// 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) {
|
||||
val appInForeground = isAppInForeground()
|
||||
if (appInForeground) {
|
||||
Logger.d(TAG, "ℹ️ App in foreground - skipping notification (UI shows changes)")
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Showing success notification...")
|
||||
}
|
||||
@@ -135,6 +160,7 @@ class SyncWorker(
|
||||
applicationContext,
|
||||
result.syncedCount
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Logger.d(TAG, "ℹ️ No changes to sync - no notification")
|
||||
}
|
||||
|
||||
@@ -12,14 +12,15 @@ import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.SyncException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import java.net.URL
|
||||
import java.util.Date
|
||||
@@ -37,11 +38,24 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WebDavSyncService"
|
||||
private const val SOCKET_TIMEOUT_MS = 2000
|
||||
private const val MAX_FILENAME_LENGTH = 200
|
||||
private const val ETAG_PREVIEW_LENGTH = 8
|
||||
private const val CONTENT_PREVIEW_LENGTH = 50
|
||||
|
||||
// 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern
|
||||
private val syncMutex = Mutex()
|
||||
}
|
||||
|
||||
private val storage: NotesStorage
|
||||
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private var markdownDirEnsured = false // Cache für Ordner-Existenz
|
||||
private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz
|
||||
|
||||
// ⚡ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert)
|
||||
private var sessionSardine: Sardine? = null
|
||||
private var sessionWifiAddress: InetAddress? = null
|
||||
private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgeführt
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG) {
|
||||
@@ -73,10 +87,26 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚡ v1.3.1: Gecachte WiFi-Adresse zurückgeben oder berechnen
|
||||
*/
|
||||
private fun getOrCacheWiFiAddress(): InetAddress? {
|
||||
// Return cached if already checked this session
|
||||
if (sessionWifiAddressChecked) {
|
||||
return sessionWifiAddress
|
||||
}
|
||||
|
||||
// Calculate and cache
|
||||
sessionWifiAddress = getWiFiInetAddressInternal()
|
||||
sessionWifiAddressChecked = true
|
||||
return sessionWifiAddress
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
|
||||
*/
|
||||
private fun getWiFiInetAddress(): InetAddress? {
|
||||
@Suppress("ReturnCount") // Early returns for network validation checks
|
||||
private fun getWiFiInetAddressInternal(): InetAddress? {
|
||||
try {
|
||||
Logger.d(TAG, "🔍 getWiFiInetAddress() called")
|
||||
|
||||
@@ -120,7 +150,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
while (addresses.hasMoreElements()) {
|
||||
val addr = addresses.nextElement()
|
||||
|
||||
Logger.d(TAG, " Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}")
|
||||
Logger.d(
|
||||
TAG,
|
||||
" Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, " +
|
||||
"loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}"
|
||||
)
|
||||
|
||||
// Nur IPv4, nicht loopback, nicht link-local
|
||||
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
|
||||
@@ -171,15 +205,35 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSardine(): Sardine? {
|
||||
/**
|
||||
* ⚡ v1.3.1: Gecachten Sardine-Client zurückgeben oder erstellen
|
||||
* Spart ~100ms pro Aufruf durch Wiederverwendung
|
||||
*/
|
||||
private fun getOrCreateSardine(): Sardine? {
|
||||
// Return cached if available
|
||||
sessionSardine?.let {
|
||||
Logger.d(TAG, "⚡ Reusing cached Sardine client")
|
||||
return it
|
||||
}
|
||||
|
||||
// Create new client
|
||||
val sardine = createSardineClient()
|
||||
sessionSardine = sardine
|
||||
return sardine
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Sardine-Client (intern)
|
||||
*/
|
||||
private fun createSardineClient(): Sardine? {
|
||||
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
|
||||
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
|
||||
|
||||
Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding")
|
||||
Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
|
||||
|
||||
// Versuche WiFi-IP zu finden
|
||||
val wifiAddress = getWiFiInetAddress()
|
||||
// ⚡ v1.3.1: Verwende gecachte WiFi-Adresse
|
||||
val wifiAddress = getOrCacheWiFiAddress()
|
||||
|
||||
val okHttpClient = if (wifiAddress != null) {
|
||||
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
|
||||
@@ -196,6 +250,18 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚡ v1.3.1: Session-Caches leeren (am Ende von syncNotes)
|
||||
*/
|
||||
private fun clearSessionCache() {
|
||||
sessionSardine = null
|
||||
sessionWifiAddress = null
|
||||
sessionWifiAddressChecked = false
|
||||
notesDirEnsured = false
|
||||
markdownDirEnsured = false
|
||||
Logger.d(TAG, "🧹 Session caches cleared")
|
||||
}
|
||||
|
||||
private fun getServerUrl(): String? {
|
||||
return prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
}
|
||||
@@ -266,6 +332,31 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚡ v1.3.1: Stellt sicher dass notes/ Ordner existiert (mit Cache)
|
||||
*
|
||||
* Spart ~500ms pro Sync durch Caching
|
||||
*/
|
||||
private fun ensureNotesDirectoryExists(sardine: Sardine, notesUrl: String) {
|
||||
if (notesDirEnsured) {
|
||||
Logger.d(TAG, "⚡ notes/ directory already verified (cached)")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.d(TAG, "🔍 Checking if notes/ directory exists...")
|
||||
if (!sardine.exists(notesUrl)) {
|
||||
Logger.d(TAG, "📁 Creating notes/ directory...")
|
||||
sardine.createDirectory(notesUrl)
|
||||
}
|
||||
Logger.d(TAG, " ✅ notes/ directory ready")
|
||||
notesDirEnsured = true
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH checking/creating notes/ directory!", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if server has changes using E-Tag caching
|
||||
*
|
||||
@@ -280,6 +371,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
* 3. If changed → server has updates
|
||||
* 4. If unchanged → skip sync
|
||||
*/
|
||||
@Suppress("ReturnCount") // Early returns for conditional checks
|
||||
private suspend fun checkServerForChanges(sardine: Sardine, serverUrl: String): Boolean {
|
||||
return try {
|
||||
val startTime = System.currentTimeMillis()
|
||||
@@ -298,47 +390,13 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
// ====== JSON FILES CHECK (/notes/) ======
|
||||
|
||||
// Optimierung 1: E-Tag Check (fastest - ~100ms)
|
||||
val cachedETag = prefs.getString("notes_collection_etag", null)
|
||||
var jsonHasChanges = false
|
||||
// ⚡ v1.3.1: File-level E-Tag check in downloadRemoteNotes() is optimal!
|
||||
// Collection E-Tag doesn't work (server-dependent, doesn't track file changes)
|
||||
// → Always proceed to download phase where file-level E-Tags provide fast skips
|
||||
|
||||
if (cachedETag != null) {
|
||||
try {
|
||||
val resources = sardine.list(notesUrl, 0) // Depth 0 = only collection itself
|
||||
val currentETag = resources.firstOrNull()?.contentLength?.toString() ?: ""
|
||||
|
||||
if (currentETag == cachedETag) {
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
Logger.d(TAG, "⚡ E-Tag match - no JSON changes (${elapsed}ms)")
|
||||
// Don't return yet - check Markdown too!
|
||||
} else {
|
||||
Logger.d(TAG, "🔄 E-Tag changed - JSON files have updates")
|
||||
return true // Early return if JSON changed
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "E-Tag check failed: ${e.message}, falling back to timestamp check")
|
||||
jsonHasChanges = true
|
||||
}
|
||||
} else {
|
||||
jsonHasChanges = true
|
||||
}
|
||||
|
||||
// Optimierung 2: Smart Timestamp Check for JSON (medium - ~300ms)
|
||||
if (jsonHasChanges || cachedETag == null) {
|
||||
val resources = sardine.list(notesUrl, 1) // Depth 1 = collection + children
|
||||
|
||||
val jsonHasNewer = resources.any { resource ->
|
||||
!resource.isDirectory &&
|
||||
resource.name.endsWith(".json") &&
|
||||
resource.modified?.time?.let { it > lastSyncTime } ?: false
|
||||
}
|
||||
|
||||
if (jsonHasNewer) {
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
Logger.d(TAG, "🔍 JSON check: hasNewer=true (${resources.size} resources, ${elapsed}ms)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
// For hasUnsyncedChanges(): Conservative approach - assume changes may exist
|
||||
// Actual file-level E-Tag checks in downloadRemoteNotes() will skip unchanged files (0ms each)
|
||||
var hasJsonChanges = true // Assume yes, let file E-Tags optimize
|
||||
|
||||
// ====== MARKDOWN FILES CHECK (/notes-md/) ======
|
||||
// IMPORTANT: E-Tag for collections does NOT work for content changes!
|
||||
@@ -365,7 +423,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
resource.modified?.time?.let {
|
||||
val hasNewer = it > lastSyncTime
|
||||
if (hasNewer) {
|
||||
Logger.d(TAG, " 📄 ${resource.name}: modified=${resource.modified}, lastSync=$lastSyncTime")
|
||||
Logger.d(
|
||||
TAG,
|
||||
" 📄 ${resource.name}: modified=${resource.modified}, " +
|
||||
"lastSync=$lastSyncTime"
|
||||
)
|
||||
}
|
||||
hasNewer
|
||||
} ?: false
|
||||
@@ -382,7 +444,15 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
Logger.d(TAG, "✅ No changes detected (JSON + Markdown checked, ${elapsed}ms)")
|
||||
|
||||
// Return TRUE if JSON or Markdown have potential changes
|
||||
// (File-level E-Tags will do the actual skip optimization during sync)
|
||||
if (hasJsonChanges) {
|
||||
Logger.d(TAG, "✅ JSON may have changes - will check file E-Tags (${elapsed}ms)")
|
||||
return true
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ No changes detected (Markdown checked, ${elapsed}ms)")
|
||||
return false
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -429,7 +499,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Perform intelligent server check
|
||||
val sardine = getSardine()
|
||||
val sardine = getOrCreateSardine()
|
||||
val serverUrl = getServerUrl()
|
||||
|
||||
if (sardine == null || serverUrl == null) {
|
||||
@@ -468,10 +538,10 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
Logger.d(TAG, "🔍 Checking server reachability: $host:$port")
|
||||
|
||||
// Socket-Check mit 2s Timeout
|
||||
// Socket-Check mit Timeout
|
||||
// Gibt dem Netzwerk Zeit für Initialisierung (DHCP, Routing, Gateway)
|
||||
val socket = Socket()
|
||||
socket.connect(InetSocketAddress(host, port), 2000)
|
||||
socket.connect(InetSocketAddress(host, port), SOCKET_TIMEOUT_MS)
|
||||
socket.close()
|
||||
|
||||
Logger.d(TAG, "✅ Server is reachable")
|
||||
@@ -484,7 +554,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext SyncResult(
|
||||
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-Zugangsdaten nicht konfiguriert"
|
||||
)
|
||||
@@ -529,6 +599,17 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
|
||||
// 🔒 v1.3.1: Verhindere parallele Syncs
|
||||
if (!syncMutex.tryLock()) {
|
||||
Logger.d(TAG, "⏭️ Sync already in progress - skipping")
|
||||
return@withContext SyncResult(
|
||||
isSuccess = true,
|
||||
syncedCount = 0,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
Logger.d(TAG, "🔄 syncNotes() ENTRY")
|
||||
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
|
||||
@@ -538,9 +619,9 @@ class WebDavSyncService(private val context: Context) {
|
||||
Logger.d(TAG, "📍 Step 1: Getting Sardine client")
|
||||
|
||||
val sardine = try {
|
||||
getSardine()
|
||||
getOrCreateSardine()
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in getSardine()!", e)
|
||||
Logger.e(TAG, "💥 CRASH in getOrCreateSardine()!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
@@ -571,20 +652,9 @@ class WebDavSyncService(private val context: Context) {
|
||||
var conflictCount = 0
|
||||
|
||||
Logger.d(TAG, "📍 Step 3: Checking server directory")
|
||||
// Ensure notes/ directory exists
|
||||
// ⚡ v1.3.1: Verwende gecachte Directory-Checks
|
||||
val notesUrl = getNotesUrl(serverUrl)
|
||||
try {
|
||||
Logger.d(TAG, "🔍 Checking if notes/ directory exists...")
|
||||
if (!sardine.exists(notesUrl)) {
|
||||
Logger.d(TAG, "📁 Creating notes/ directory...")
|
||||
sardine.createDirectory(notesUrl)
|
||||
}
|
||||
Logger.d(TAG, " ✅ notes/ directory ready")
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH checking/creating notes/ directory!", e)
|
||||
e.printStackTrace()
|
||||
throw e
|
||||
}
|
||||
ensureNotesDirectoryExists(sardine, notesUrl)
|
||||
|
||||
// Ensure notes-md/ directory exists (for Markdown export)
|
||||
ensureMarkdownDirectoryExists(sardine, serverUrl)
|
||||
@@ -613,7 +683,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
)
|
||||
syncedCount += downloadResult.downloadedCount
|
||||
conflictCount += downloadResult.conflictCount
|
||||
Logger.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
|
||||
Logger.d(
|
||||
TAG,
|
||||
"✅ Downloaded: ${downloadResult.downloadedCount} notes, " +
|
||||
"Conflicts: ${downloadResult.conflictCount}"
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e)
|
||||
e.printStackTrace()
|
||||
@@ -697,6 +771,12 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
// ⚡ v1.3.1: Session-Caches leeren
|
||||
clearSessionCache()
|
||||
// 🔒 v1.3.1: Sync-Mutex freigeben
|
||||
syncMutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
||||
@@ -712,13 +792,33 @@ class WebDavSyncService(private val context: Context) {
|
||||
val noteUrl = "$notesUrl${note.id}.json"
|
||||
val jsonBytes = note.toJson().toByteArray()
|
||||
|
||||
Logger.d(TAG, " 📤 Uploading: ${note.id}.json (${note.title})")
|
||||
sardine.put(noteUrl, jsonBytes, "application/json")
|
||||
Logger.d(TAG, " ✅ Upload successful")
|
||||
|
||||
// Update sync status
|
||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||
storage.saveNote(updatedNote)
|
||||
uploadedCount++
|
||||
|
||||
// ⚡ v1.3.1: Refresh E-Tag after upload to prevent re-download
|
||||
// Get new E-Tag from server via PROPFIND
|
||||
try {
|
||||
val uploadedResource = sardine.list(noteUrl, 0).firstOrNull()
|
||||
val newETag = uploadedResource?.etag
|
||||
if (newETag != null) {
|
||||
prefs.edit().putString("etag_json_${note.id}", newETag).apply()
|
||||
Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(ETAG_PREVIEW_LENGTH)}")
|
||||
} else {
|
||||
// Fallback: invalidate if server doesn't provide E-Tag
|
||||
prefs.edit().remove("etag_json_${note.id}").apply()
|
||||
Logger.d(TAG, " ⚠️ No E-Tag from server, invalidated cache")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, " ⚠️ Failed to refresh E-Tag: ${e.message}")
|
||||
prefs.edit().remove("etag_json_${note.id}").apply()
|
||||
}
|
||||
|
||||
// 2. Markdown-Export (NEU in v1.2.0)
|
||||
// Läuft NACH erfolgreichem JSON-Upload
|
||||
if (markdownExportEnabled) {
|
||||
@@ -732,6 +832,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Upload failed for note ${note.id}, marking as pending: ${e.message}")
|
||||
// Mark as pending for retry
|
||||
val updatedNote = note.copy(syncStatus = SyncStatus.PENDING)
|
||||
storage.saveNote(updatedNote)
|
||||
@@ -758,8 +859,31 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
// Sanitize Filename (Task #1.2.0-12)
|
||||
val filename = sanitizeFilename(note.title) + ".md"
|
||||
val noteUrl = "$mdUrl/$filename"
|
||||
val baseFilename = sanitizeFilename(note.title)
|
||||
var filename = "$baseFilename.md"
|
||||
var noteUrl = "$mdUrl/$filename"
|
||||
|
||||
// Prüfe ob Datei bereits existiert und von anderer Note stammt
|
||||
try {
|
||||
if (sardine.exists(noteUrl)) {
|
||||
// Lese existierende Datei und prüfe ID im YAML-Header
|
||||
val existingContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
||||
val existingIdMatch = Regex("^---\\n.*?\\nid:\\s*([a-f0-9-]+)", RegexOption.DOT_MATCHES_ALL)
|
||||
.find(existingContent)
|
||||
val existingId = existingIdMatch?.groupValues?.get(1)
|
||||
|
||||
if (existingId != null && existingId != note.id) {
|
||||
// Andere Note hat gleichen Titel - verwende ID-Suffix
|
||||
val shortId = note.id.take(8)
|
||||
filename = "${baseFilename}_$shortId.md"
|
||||
noteUrl = "$mdUrl/$filename"
|
||||
Logger.d(TAG, "📝 Duplicate title, using: $filename")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "⚠️ Could not check existing file: ${e.message}")
|
||||
// Continue with default filename
|
||||
}
|
||||
|
||||
// Konvertiere zu Markdown
|
||||
val mdContent = note.toMarkdown().toByteArray()
|
||||
@@ -780,10 +904,33 @@ class WebDavSyncService(private val context: Context) {
|
||||
return title
|
||||
.replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen
|
||||
.replace(Regex("\\s+"), " ") // Normalisiere Whitespace
|
||||
.take(200) // Max 200 Zeichen (Reserve für .md)
|
||||
.take(MAX_FILENAME_LENGTH) // Max Zeichen (Reserve für .md)
|
||||
.trim('_', ' ') // Trim Underscores/Spaces
|
||||
}
|
||||
|
||||
/**
|
||||
* Generiert eindeutigen Markdown-Dateinamen für eine Notiz.
|
||||
* Bei Duplikaten wird die Note-ID als Suffix angehängt.
|
||||
*
|
||||
* @param note Die Notiz
|
||||
* @param usedFilenames Set der bereits verwendeten Dateinamen (ohne .md)
|
||||
* @return Eindeutiger Dateiname (ohne .md Extension)
|
||||
*/
|
||||
private fun getUniqueMarkdownFilename(note: Note, usedFilenames: MutableSet<String>): String {
|
||||
val baseFilename = sanitizeFilename(note.title)
|
||||
|
||||
return if (usedFilenames.contains(baseFilename)) {
|
||||
// Duplikat - hänge gekürzte ID an
|
||||
val shortId = note.id.take(8)
|
||||
val uniqueFilename = "${baseFilename}_$shortId"
|
||||
usedFilenames.add(uniqueFilename)
|
||||
uniqueFilename
|
||||
} else {
|
||||
usedFilenames.add(baseFilename)
|
||||
baseFilename
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
|
||||
*
|
||||
@@ -800,8 +947,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
): Int = withContext(Dispatchers.IO) {
|
||||
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
|
||||
|
||||
// Erstelle Sardine-Client mit gegebenen Credentials
|
||||
val wifiAddress = getWiFiInetAddress()
|
||||
// ⚡ v1.3.1: Use cached WiFi address
|
||||
val wifiAddress = getOrCacheWiFiAddress()
|
||||
|
||||
val okHttpClient = if (wifiAddress != null) {
|
||||
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
|
||||
@@ -827,6 +974,9 @@ class WebDavSyncService(private val context: Context) {
|
||||
val totalCount = allNotes.size
|
||||
var exportedCount = 0
|
||||
|
||||
// Track used filenames to handle duplicates
|
||||
val usedFilenames = mutableSetOf<String>()
|
||||
|
||||
Logger.d(TAG, "📝 Found $totalCount notes to export")
|
||||
|
||||
allNotes.forEachIndexed { index, note ->
|
||||
@@ -834,8 +984,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
// Progress-Callback
|
||||
onProgress(index + 1, totalCount)
|
||||
|
||||
// Sanitize Filename
|
||||
val filename = sanitizeFilename(note.title) + ".md"
|
||||
// Eindeutiger Filename (mit Duplikat-Handling)
|
||||
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
|
||||
val noteUrl = "$mdUrl/$filename"
|
||||
|
||||
// Konvertiere zu Markdown
|
||||
@@ -845,7 +995,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
sardine.put(noteUrl, mdContent, "text/markdown")
|
||||
|
||||
exportedCount++
|
||||
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}")
|
||||
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
|
||||
@@ -854,6 +1004,15 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes")
|
||||
|
||||
// ⚡ v1.3.1: Set lastSyncTimestamp to enable timestamp-based skip on next sync
|
||||
// This prevents re-downloading all MD files on the first manual sync after initial export
|
||||
if (exportedCount > 0) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
prefs.edit().putLong("last_sync_timestamp", timestamp).apply()
|
||||
Logger.d(TAG, "💾 Set lastSyncTimestamp after initial export (enables fast next sync)")
|
||||
}
|
||||
|
||||
return@withContext exportedCount
|
||||
}
|
||||
|
||||
@@ -886,17 +1045,66 @@ class WebDavSyncService(private val context: Context) {
|
||||
val notesUrl = getNotesUrl(serverUrl)
|
||||
Logger.d(TAG, "🔍 Phase 1: Checking /notes/ at: $notesUrl")
|
||||
|
||||
// ⚡ v1.3.1: Performance - Get last sync time for skip optimization
|
||||
val lastSyncTime = getLastSyncTimestamp()
|
||||
var skippedUnchanged = 0
|
||||
|
||||
if (sardine.exists(notesUrl)) {
|
||||
Logger.d(TAG, " ✅ /notes/ exists, scanning...")
|
||||
val resources = sardine.list(notesUrl)
|
||||
val jsonFiles = resources.filter { !it.isDirectory && it.name.endsWith(".json") }
|
||||
Logger.d(TAG, " 📊 Found ${jsonFiles.size} JSON files on server")
|
||||
|
||||
for (resource in resources) {
|
||||
if (resource.isDirectory || !resource.name.endsWith(".json")) {
|
||||
for (resource in jsonFiles) {
|
||||
|
||||
val noteId = resource.name.removeSuffix(".json")
|
||||
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
|
||||
|
||||
// ⚡ v1.3.1: HYBRID PERFORMANCE - Timestamp + E-Tag (like Markdown!)
|
||||
val serverETag = resource.etag
|
||||
val cachedETag = prefs.getString("etag_json_$noteId", null)
|
||||
val serverModified = resource.modified?.time ?: 0L
|
||||
|
||||
// 🐛 DEBUG: Log every file check to diagnose performance
|
||||
val serverETagPreview = serverETag?.take(ETAG_PREVIEW_LENGTH) ?: "null"
|
||||
val cachedETagPreview = cachedETag?.take(ETAG_PREVIEW_LENGTH) ?: "null"
|
||||
Logger.d(
|
||||
TAG,
|
||||
" 🔍 [$noteId] etag=$serverETagPreview/$cachedETagPreview " +
|
||||
"modified=$serverModified lastSync=$lastSyncTime"
|
||||
)
|
||||
|
||||
// PRIMARY: Timestamp check (works on first sync!)
|
||||
// Same logic as Markdown sync - skip if not modified since last sync
|
||||
if (!forceOverwrite && lastSyncTime > 0 && serverModified <= lastSyncTime) {
|
||||
skippedUnchanged++
|
||||
Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)")
|
||||
processedIds.add(noteId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 🔧 Fix: Build full URL instead of using href directly
|
||||
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
|
||||
// SECONDARY: E-Tag check (for performance after first sync)
|
||||
// Catches cases where file was re-uploaded with same content
|
||||
if (!forceOverwrite && serverETag != null && serverETag == cachedETag) {
|
||||
skippedUnchanged++
|
||||
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)")
|
||||
processedIds.add(noteId)
|
||||
continue
|
||||
}
|
||||
|
||||
// 🐛 DEBUG: Log download reason
|
||||
val downloadReason = when {
|
||||
lastSyncTime == 0L -> "First sync ever"
|
||||
serverModified > lastSyncTime && serverETag == null -> "Modified + no server E-Tag"
|
||||
serverModified > lastSyncTime && cachedETag == null -> "Modified + no cached E-Tag"
|
||||
serverModified > lastSyncTime -> "Modified + E-Tag changed"
|
||||
serverETag == null -> "No server E-Tag"
|
||||
cachedETag == null -> "No cached E-Tag"
|
||||
else -> "E-Tag changed"
|
||||
}
|
||||
Logger.d(TAG, " 📥 Downloading $noteId: $downloadReason")
|
||||
|
||||
// Download and process
|
||||
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
||||
val remoteNote = Note.fromJson(jsonContent) ?: continue
|
||||
|
||||
@@ -928,12 +1136,22 @@ class WebDavSyncService(private val context: Context) {
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}")
|
||||
|
||||
// ⚡ Cache E-Tag for next sync
|
||||
if (serverETag != null) {
|
||||
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
|
||||
}
|
||||
}
|
||||
forceOverwrite -> {
|
||||
// OVERWRITE mode: Always replace regardless of timestamps
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
Logger.d(TAG, " ♻️ Overwritten from /notes/: ${remoteNote.id}")
|
||||
|
||||
// ⚡ Cache E-Tag for next sync
|
||||
if (serverETag != null) {
|
||||
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
|
||||
}
|
||||
}
|
||||
localNote.updatedAt < remoteNote.updatedAt -> {
|
||||
// Remote is newer
|
||||
@@ -946,11 +1164,20 @@ class WebDavSyncService(private val context: Context) {
|
||||
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
downloadedCount++
|
||||
Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}")
|
||||
|
||||
// ⚡ Cache E-Tag for next sync
|
||||
if (serverETag != null) {
|
||||
prefs.edit().putString("etag_json_$noteId", serverETag).apply()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Logger.d(TAG, " 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted)")
|
||||
}
|
||||
Logger.d(
|
||||
TAG,
|
||||
" 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), " +
|
||||
"$skippedUnchanged skipped (unchanged)"
|
||||
)
|
||||
} else {
|
||||
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
|
||||
}
|
||||
@@ -1065,36 +1292,14 @@ class WebDavSyncService(private val context: Context) {
|
||||
private fun saveLastSyncTimestamp() {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// v1.3.0: Save E-Tag only for JSON (Markdown uses timestamp check)
|
||||
try {
|
||||
val sardine = getSardine()
|
||||
val serverUrl = getServerUrl()
|
||||
|
||||
if (sardine != null && serverUrl != null) {
|
||||
val notesUrl = getNotesUrl(serverUrl)
|
||||
|
||||
// JSON E-Tag only
|
||||
val notesResources = sardine.list(notesUrl, 0)
|
||||
val notesETag = notesResources.firstOrNull()?.contentLength?.toString()
|
||||
|
||||
// ⚡ v1.3.1: Simplified - file-level E-Tags cached individually in downloadRemoteNotes()
|
||||
// No need for collection E-Tag (doesn't work reliably across WebDAV servers)
|
||||
prefs.edit()
|
||||
.putLong(Constants.KEY_LAST_SYNC, now)
|
||||
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now)
|
||||
.putString("notes_collection_etag", notesETag)
|
||||
.apply()
|
||||
|
||||
Logger.d(TAG, "💾 Saved sync timestamp + JSON E-Tag")
|
||||
return
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to save E-Tag: ${e.message}")
|
||||
}
|
||||
|
||||
// Fallback: Save timestamp only
|
||||
prefs.edit()
|
||||
.putLong(Constants.KEY_LAST_SYNC, now)
|
||||
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // 🔥 v1.1.2: Track successful sync
|
||||
.apply()
|
||||
Logger.d(TAG, "💾 Saved sync timestamp (file E-Tags cached individually)")
|
||||
}
|
||||
|
||||
fun getLastSyncTimestamp(): Long {
|
||||
@@ -1114,7 +1319,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
mode: dev.dettmer.simplenotes.backup.RestoreMode = dev.dettmer.simplenotes.backup.RestoreMode.REPLACE
|
||||
): RestoreResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext RestoreResult(
|
||||
val sardine = getOrCreateSardine() ?: return@withContext RestoreResult(
|
||||
isSuccess = false,
|
||||
errorMessage = "Server-Zugangsdaten nicht konfiguriert",
|
||||
restoredCount = 0
|
||||
@@ -1137,6 +1342,20 @@ class WebDavSyncService(private val context: Context) {
|
||||
Logger.d(TAG, "🗑️ Clearing deletion tracker (restore mode)")
|
||||
storage.clearDeletionTracker()
|
||||
|
||||
// ⚡ v1.3.1 FIX: Clear lastSyncTimestamp to force download ALL files
|
||||
// Restore = "Server ist die Quelle" → Ignore lokale Sync-History
|
||||
val previousSyncTime = getLastSyncTimestamp()
|
||||
prefs.edit().putLong("last_sync_timestamp", 0).apply()
|
||||
Logger.d(TAG, "🔄 Cleared lastSyncTimestamp (was: $previousSyncTime) - will download all files")
|
||||
|
||||
// ⚡ v1.3.1 FIX: Clear E-Tag caches to force re-download
|
||||
val editor = prefs.edit()
|
||||
prefs.all.keys.filter { it.startsWith("etag_json_") }.forEach { key ->
|
||||
editor.remove(key)
|
||||
}
|
||||
editor.apply()
|
||||
Logger.d(TAG, "🔄 Cleared E-Tag caches - will re-download all files")
|
||||
|
||||
// Determine forceOverwrite flag
|
||||
val forceOverwrite = (mode == dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES)
|
||||
Logger.d(TAG, "forceOverwrite: $forceOverwrite")
|
||||
@@ -1163,7 +1382,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
// 🆕 v1.2.2: Use downloadRemoteNotes() with Root fallback + forceOverwrite
|
||||
// 🆕 v1.3.0: Pass FRESH empty tracker to avoid loading stale cached data
|
||||
Logger.d(TAG, "📡 Calling downloadRemoteNotes() - includeRootFallback: true, forceOverwrite: $forceOverwrite")
|
||||
Logger.d(
|
||||
TAG,
|
||||
"📡 Calling downloadRemoteNotes() - " +
|
||||
"includeRootFallback: true, forceOverwrite: $forceOverwrite"
|
||||
)
|
||||
val emptyTracker = DeletionTracker() // Fresh empty tracker after clear
|
||||
val result = downloadRemoteNotes(
|
||||
sardine = sardine,
|
||||
@@ -1314,6 +1537,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
/**
|
||||
* Auto-import Markdown files during regular sync (v1.3.0)
|
||||
* Called automatically if KEY_MARKDOWN_AUTO_IMPORT is enabled
|
||||
*
|
||||
* ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien
|
||||
*/
|
||||
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
|
||||
return try {
|
||||
@@ -1329,11 +1554,26 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
val mdResources = sardine.list(mdUrl).filter { !it.isDirectory && it.name.endsWith(".md") }
|
||||
var importedCount = 0
|
||||
var skippedCount = 0 // ⚡ v1.3.1: Zähle übersprungene Dateien
|
||||
|
||||
Logger.d(TAG, " 📂 Found ${mdResources.size} markdown files")
|
||||
|
||||
// ⚡ v1.3.1: Performance-Optimierung - Letzten Sync-Zeitpunkt holen
|
||||
val lastSyncTime = getLastSyncTimestamp()
|
||||
Logger.d(TAG, " 📅 Last sync: ${Date(lastSyncTime)}")
|
||||
|
||||
for (resource in mdResources) {
|
||||
try {
|
||||
val serverModifiedTime = resource.modified?.time ?: 0L
|
||||
|
||||
// ⚡ v1.3.1: PERFORMANCE - Skip wenn Datei seit letztem Sync nicht geändert wurde
|
||||
// Das ist der Haupt-Performance-Fix! Spart ~500ms pro Datei bei Nextcloud.
|
||||
if (lastSyncTime > 0 && serverModifiedTime <= lastSyncTime) {
|
||||
skippedCount++
|
||||
Logger.d(TAG, " ⏭️ Skipping ${resource.name}: not modified since last sync")
|
||||
continue
|
||||
}
|
||||
|
||||
Logger.d(TAG, " 🔍 Processing: ${resource.name}, modified=${resource.modified}")
|
||||
|
||||
// Build full URL
|
||||
@@ -1349,16 +1589,57 @@ class WebDavSyncService(private val context: Context) {
|
||||
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
|
||||
continue
|
||||
}
|
||||
Logger.d(TAG, " Parsed: id=${mdNote.id}, title=${mdNote.title}, updatedAt=${Date(mdNote.updatedAt)}")
|
||||
|
||||
// v1.4.0 FIX: Validierung - leere TEXT-Notizen nicht importieren wenn lokal Content existiert
|
||||
val localNote = storage.loadNote(mdNote.id)
|
||||
Logger.d(TAG, " Local note: ${if (localNote == null) "NOT FOUND" else "exists, updatedAt=${Date(localNote.updatedAt)}, syncStatus=${localNote.syncStatus}"}")
|
||||
if (mdNote.noteType == dev.dettmer.simplenotes.models.NoteType.TEXT &&
|
||||
mdNote.content.isBlank() &&
|
||||
localNote != null && localNote.content.isNotBlank()) {
|
||||
Logger.w(
|
||||
TAG,
|
||||
" ⚠️ Skipping ${resource.name}: " +
|
||||
"MD content empty but local has content - likely parse error!"
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Use server file modification time for reliable change detection
|
||||
val serverModifiedTime = resource.modified?.time ?: 0L
|
||||
Logger.d(TAG, " Comparison: serverModified=$serverModifiedTime, localUpdated=${localNote?.updatedAt ?: 0L}")
|
||||
Logger.d(
|
||||
TAG,
|
||||
" Parsed: id=${mdNote.id}, title=${mdNote.title}, " +
|
||||
"updatedAt=${Date(mdNote.updatedAt)}, " +
|
||||
"content=${mdNote.content.take(CONTENT_PREVIEW_LENGTH)}..."
|
||||
)
|
||||
|
||||
// Conflict resolution: Last-Write-Wins
|
||||
Logger.d(
|
||||
TAG,
|
||||
" Local note: " + if (localNote == null) {
|
||||
"NOT FOUND"
|
||||
} else {
|
||||
"exists, updatedAt=${Date(localNote.updatedAt)}, " +
|
||||
"syncStatus=${localNote.syncStatus}"
|
||||
}
|
||||
)
|
||||
|
||||
// ⚡ v1.3.1: Content-basierte Erkennung
|
||||
// Wichtig: Vergleiche IMMER den Inhalt, wenn die Datei seit letztem Sync geändert wurde!
|
||||
// Der YAML-Timestamp kann veraltet sein (z.B. bei externer Bearbeitung ohne Obsidian)
|
||||
Logger.d(
|
||||
TAG,
|
||||
" Comparison: mdUpdatedAt=${mdNote.updatedAt}, " +
|
||||
"localUpdated=${localNote?.updatedAt ?: 0L}"
|
||||
)
|
||||
|
||||
// Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich?
|
||||
val contentChanged = localNote != null && (
|
||||
mdNote.content != localNote.content ||
|
||||
mdNote.title != localNote.title
|
||||
)
|
||||
|
||||
if (contentChanged) {
|
||||
Logger.d(TAG, " 📝 Content differs from local!")
|
||||
}
|
||||
|
||||
// Conflict resolution: Content-First, dann Timestamp
|
||||
when {
|
||||
localNote == null -> {
|
||||
// New note from desktop
|
||||
@@ -1366,57 +1647,51 @@ class WebDavSyncService(private val context: Context) {
|
||||
importedCount++
|
||||
Logger.d(TAG, " ✅ Imported new from Markdown: ${mdNote.title}")
|
||||
}
|
||||
serverModifiedTime > localNote.updatedAt -> {
|
||||
// Server file is newer (based on modification time)
|
||||
Logger.d(TAG, " Decision: Server is newer!")
|
||||
// ⚡ v1.3.1 FIX: Content-basierter Skip - nur wenn Inhalt UND Timestamp gleich
|
||||
localNote.syncStatus == SyncStatus.SYNCED &&
|
||||
!contentChanged &&
|
||||
localNote.updatedAt >= mdNote.updatedAt -> {
|
||||
// Inhalt identisch UND Timestamps passen → Skip
|
||||
skippedCount++
|
||||
Logger.d(
|
||||
TAG,
|
||||
" ⏭️ Skipped ${mdNote.title}: content identical " +
|
||||
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
|
||||
)
|
||||
}
|
||||
// ⚡ v1.3.1 FIX: Content geändert aber YAML-Timestamp nicht aktualisiert → Importieren!
|
||||
contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> {
|
||||
// Inhalt wurde extern geändert ohne YAML-Update → mit aktuellem Timestamp importieren
|
||||
val newTimestamp = System.currentTimeMillis()
|
||||
storage.saveNote(mdNote.copy(
|
||||
updatedAt = newTimestamp,
|
||||
syncStatus = SyncStatus.SYNCED
|
||||
))
|
||||
importedCount++
|
||||
Logger.d(TAG, " ✅ Imported changed content (YAML timestamp outdated): ${mdNote.title}")
|
||||
}
|
||||
mdNote.updatedAt > localNote.updatedAt -> {
|
||||
// Markdown has newer YAML timestamp
|
||||
Logger.d(TAG, " Decision: Markdown has newer timestamp!")
|
||||
if (localNote.syncStatus == SyncStatus.PENDING) {
|
||||
// Conflict: local has pending changes
|
||||
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
|
||||
Logger.w(TAG, " ⚠️ Conflict: Markdown vs local pending: ${mdNote.id}")
|
||||
} else {
|
||||
// Content comparison to preserve timestamps on export-only updates
|
||||
val contentChanged = mdNote.content != localNote.content ||
|
||||
mdNote.title != localNote.title
|
||||
|
||||
// Detect if YAML timestamp wasn't updated despite content change
|
||||
val yamlInconsistent = contentChanged && mdNote.updatedAt <= localNote.updatedAt
|
||||
|
||||
// Log inconsistencies for debugging
|
||||
if (yamlInconsistent) {
|
||||
Logger.w(TAG, " ⚠️ Inconsistency: ${mdNote.title}")
|
||||
Logger.w(TAG, " Content changed but YAML timestamp not updated")
|
||||
Logger.w(TAG, " YAML: ${mdNote.updatedAt}, Local: ${localNote.updatedAt}")
|
||||
Logger.w(TAG, " Using current time as fallback")
|
||||
}
|
||||
|
||||
// Determine final timestamp with auto-correction
|
||||
val finalUpdatedAt: Long = when {
|
||||
// No content change → preserve local timestamp (export-only)
|
||||
!contentChanged -> localNote.updatedAt
|
||||
|
||||
// Content changed + YAML timestamp properly updated
|
||||
!yamlInconsistent -> mdNote.updatedAt
|
||||
|
||||
// Content changed + YAML timestamp NOT updated → use current time
|
||||
else -> System.currentTimeMillis()
|
||||
}
|
||||
|
||||
storage.saveNote(mdNote.copy(
|
||||
updatedAt = finalUpdatedAt,
|
||||
syncStatus = SyncStatus.SYNCED
|
||||
))
|
||||
// Import with the newer YAML timestamp
|
||||
storage.saveNote(mdNote.copy(syncStatus = SyncStatus.SYNCED))
|
||||
importedCount++
|
||||
|
||||
// Detailed logging
|
||||
when {
|
||||
!contentChanged -> Logger.d(TAG, " ✅ Re-synced (export-only, timestamp preserved): ${mdNote.title}")
|
||||
yamlInconsistent -> Logger.d(TAG, " ✅ Updated (content changed, timestamp corrected): ${mdNote.title}")
|
||||
else -> Logger.d(TAG, " ✅ Updated (content changed, YAML timestamp valid): ${mdNote.title}")
|
||||
}
|
||||
Logger.d(TAG, " ✅ Updated from Markdown (newer timestamp): ${mdNote.title}")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: local is newer (server=$serverModifiedTime, local=${localNote.updatedAt})")
|
||||
// Local has pending changes but MD is older - keep local
|
||||
skippedCount++
|
||||
Logger.d(
|
||||
TAG,
|
||||
" ⏭️ Skipped ${mdNote.title}: local is newer or pending " +
|
||||
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -1425,7 +1700,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d(TAG, " 📊 Markdown import complete: $importedCount notes")
|
||||
// ⚡ v1.3.1: Verbessertes Logging mit Skip-Count
|
||||
Logger.d(TAG, " 📊 Markdown import complete: $importedCount imported, $skippedCount skipped (unchanged)")
|
||||
importedCount
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -1488,23 +1764,35 @@ class WebDavSyncService(private val context: Context) {
|
||||
* Deletes a note from the server (JSON + Markdown)
|
||||
* Does NOT delete from local storage!
|
||||
*
|
||||
* v1.4.1: Now supports v1.2.0 compatibility mode - also checks ROOT folder
|
||||
* for notes that were created before the /notes/ directory structure.
|
||||
*
|
||||
* @param noteId The ID of the note to delete
|
||||
* @return true if at least one file was deleted, false otherwise
|
||||
*/
|
||||
suspend fun deleteNoteFromServer(noteId: String): Boolean = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: return@withContext false
|
||||
val sardine = getOrCreateSardine() ?: return@withContext false
|
||||
val serverUrl = getServerUrl() ?: return@withContext false
|
||||
|
||||
var deletedJson = false
|
||||
var deletedMd = false
|
||||
|
||||
// Delete JSON
|
||||
// v1.4.1: Try to delete JSON from /notes/ first (standard path)
|
||||
val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json"
|
||||
if (sardine.exists(jsonUrl)) {
|
||||
sardine.delete(jsonUrl)
|
||||
deletedJson = true
|
||||
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json")
|
||||
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from /notes/)")
|
||||
} else {
|
||||
// v1.4.1: Fallback - check ROOT folder for v1.2.0 compatibility
|
||||
val rootJsonUrl = serverUrl.trimEnd('/') + "/$noteId.json"
|
||||
Logger.d(TAG, "🔍 JSON not in /notes/, checking ROOT: $rootJsonUrl")
|
||||
if (sardine.exists(rootJsonUrl)) {
|
||||
sardine.delete(rootJsonUrl)
|
||||
deletedJson = true
|
||||
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from ROOT - v1.2.0 compat)")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete Markdown (v1.3.0: YAML-scan based approach)
|
||||
@@ -1563,14 +1851,16 @@ class WebDavSyncService(private val context: Context) {
|
||||
*/
|
||||
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getSardine() ?: throw Exception("Sardine client konnte nicht erstellt werden")
|
||||
val serverUrl = getServerUrl() ?: throw Exception("Server-URL nicht konfiguriert")
|
||||
val sardine = getOrCreateSardine()
|
||||
?: throw SyncException("Sardine client konnte nicht erstellt werden")
|
||||
val serverUrl = getServerUrl()
|
||||
?: throw SyncException("Server-URL nicht konfiguriert")
|
||||
|
||||
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
||||
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
||||
|
||||
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||
throw Exception("WebDAV-Server nicht vollständig konfiguriert")
|
||||
throw SyncException("WebDAV-Server nicht vollständig konfiguriert")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Manual Markdown Sync START")
|
||||
|
||||
@@ -5,12 +5,17 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
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() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
@@ -22,33 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connected to home WiFi
|
||||
if (isConnectedToHomeWifi(context)) {
|
||||
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
|
||||
if (isConnectedToWifi(context)) {
|
||||
scheduleSyncWork(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isConnectedToHomeWifi(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
|
||||
|
||||
/**
|
||||
* Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
|
||||
* Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
|
||||
*/
|
||||
private fun isConnectedToWifi(context: Context): Boolean {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||
as ConnectivityManager
|
||||
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
|
||||
if (!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
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
private fun scheduleSyncWork(context: Context) {
|
||||
|
||||
@@ -6,7 +6,6 @@ object Constants {
|
||||
const val KEY_SERVER_URL = "server_url"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_PASSWORD = "password"
|
||||
const val KEY_HOME_SSID = "home_ssid"
|
||||
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
||||
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
||||
|
||||
@@ -27,6 +26,9 @@ object Constants {
|
||||
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
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
|
||||
@@ -7,6 +7,9 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val DAYS_THRESHOLD = 7L
|
||||
private const val TRUNCATE_SUFFIX_LENGTH = 3
|
||||
|
||||
// Toast Extensions
|
||||
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
|
||||
Toast.makeText(this, message, duration).show()
|
||||
@@ -27,7 +30,7 @@ fun Long.toReadableTime(): String {
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(diff)
|
||||
"Vor $hours Std"
|
||||
}
|
||||
diff < TimeUnit.DAYS.toMillis(7) -> {
|
||||
diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
|
||||
val days = TimeUnit.MILLISECONDS.toDays(diff)
|
||||
"Vor $days Tagen"
|
||||
}
|
||||
@@ -41,7 +44,7 @@ fun Long.toReadableTime(): String {
|
||||
// Truncate long strings
|
||||
fun String.truncate(maxLength: Int): String {
|
||||
return if (length > maxLength) {
|
||||
substring(0, maxLength - 3) + "..."
|
||||
substring(0, maxLength - TRUNCATE_SUFFIX_LENGTH) + "..."
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.util.Log
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -15,10 +14,34 @@ import java.util.*
|
||||
*/
|
||||
object Logger {
|
||||
|
||||
private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge
|
||||
|
||||
private var fileLoggingEnabled = false
|
||||
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 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
|
||||
@@ -50,11 +73,47 @@ object Logger {
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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 {
|
||||
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() {
|
||||
try {
|
||||
val lines = logFile?.readLines() ?: return
|
||||
if (lines.size > maxLogEntries) {
|
||||
val trimmed = lines.takeLast(maxLogEntries)
|
||||
if (lines.size > MAX_LOG_ENTRIES) {
|
||||
val trimmed = lines.takeLast(MAX_LOG_ENTRIES)
|
||||
logFile?.writeText(trimmed.joinToString("\n") + "\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -20,6 +20,7 @@ object NotificationHelper {
|
||||
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val SYNC_NOTIFICATION_ID = 2
|
||||
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* Erstellt Notification Channel (Android 8.0+)
|
||||
@@ -286,7 +287,7 @@ object NotificationHelper {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
manager.cancel(SYNC_NOTIFICATION_ID)
|
||||
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
|
||||
}, 30_000)
|
||||
}, AUTO_CANCEL_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
@@ -8,6 +8,16 @@ import java.net.URL
|
||||
*/
|
||||
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
|
||||
* Erlaubt:
|
||||
@@ -17,6 +27,7 @@ object UrlValidator {
|
||||
* - 127.x.x.x (Localhost)
|
||||
* - .local domains (mDNS/Bonjour)
|
||||
*/
|
||||
@Suppress("ReturnCount") // Early returns for validation checks are clearer
|
||||
fun isLocalUrl(url: String): Boolean {
|
||||
return try {
|
||||
val parsedUrl = URL(url)
|
||||
@@ -40,25 +51,29 @@ object UrlValidator {
|
||||
val octets = match.groupValues.drop(1).map { it.toInt() }
|
||||
|
||||
// Validate octets are in range 0-255
|
||||
if (octets.any { it > 255 }) {
|
||||
if (octets.any { it > OCTET_MAX_VALUE }) {
|
||||
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
|
||||
return when {
|
||||
// 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)
|
||||
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)
|
||||
o1 == 192 && o2 == 168 -> true
|
||||
o1 == PRIVATE_CLASS_C_FIRST_OCTET &&
|
||||
o2 == PRIVATE_CLASS_C_SECOND_OCTET -> true
|
||||
|
||||
// 127.0.0.0/8 (Localhost)
|
||||
o1 == 127 -> true
|
||||
o1 == LOCALHOST_FIRST_OCTET -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
@@ -67,7 +82,7 @@ object UrlValidator {
|
||||
// Not a recognized local address
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
// Invalid URL format
|
||||
Logger.w("UrlValidator", "Failed to parse URL: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
10
android/app/src/main/res/drawable/ic_checklist_24.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_delete_24.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_drag_handle_24.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_note_24.xml
Normal 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>
|
||||
@@ -2,6 +2,7 @@
|
||||
<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="match_parent"
|
||||
android:orientation="vertical"
|
||||
@@ -18,8 +19,9 @@
|
||||
app:title="@string/edit_note"
|
||||
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
|
||||
android:id="@+id/tilTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
@@ -44,8 +46,9 @@
|
||||
|
||||
</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
|
||||
android:id="@+id/tilContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
@@ -74,4 +77,39 @@
|
||||
|
||||
</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>
|
||||
|
||||
@@ -22,6 +22,39 @@
|
||||
app:title="@string/app_name"
|
||||
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>
|
||||
|
||||
<!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->
|
||||
|
||||
@@ -749,6 +749,107 @@
|
||||
|
||||
</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 & 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 & 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>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:src="@android:drawable/ic_menu_delete"
|
||||
android:tint="?attr/colorError"
|
||||
app:tint="?attr/colorError"
|
||||
android:contentDescription="@string/delete" />
|
||||
|
||||
<!-- Title -->
|
||||
|
||||
56
android/app/src/main/res/layout/item_checklist_editor.xml
Normal file
@@ -0,0 +1,56 @@
|
||||
<?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) -->
|
||||
<!-- v1.4.1: Auto-Zeilenumbruch für lange Texte -->
|
||||
<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="textMultiLine|textCapSentences"
|
||||
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>
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
|
||||
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
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:layout_marginHorizontal="8dp"
|
||||
@@ -17,17 +19,37 @@
|
||||
android:orientation="vertical"
|
||||
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 -->
|
||||
<TextView
|
||||
android:id="@+id/textViewTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/note_title_placeholder"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Material 3 Typography: BodyMedium -->
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Content Preview (für TEXT Notizen) -->
|
||||
<TextView
|
||||
android:id="@+id/textViewContent"
|
||||
android:layout_width="match_parent"
|
||||
@@ -39,6 +61,18 @@
|
||||
android:maxLines="3"
|
||||
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 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -64,7 +98,7 @@
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:src="@android:drawable/ic_popup_sync"
|
||||
android:tint="?attr/colorPrimary"
|
||||
app:tint="?attr/colorPrimary"
|
||||
android:contentDescription="@string/sync_status" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
11
android/app/src/main/res/menu/menu_fab_note_types.xml
Normal 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>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 316 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 774 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 327 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -4,6 +4,9 @@
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
<!-- Adaptive Icon Background -->
|
||||
<color name="ic_launcher_background">#f9e9c8</color>
|
||||
|
||||
<!-- Material 3 Light Theme Colors -->
|
||||
<color name="md_theme_light_primary">#6750A4</color>
|
||||
<color name="md_theme_light_onPrimary">#FFFFFF</color>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="note_title_placeholder">Note Title</string>
|
||||
<string name="note_content_placeholder">Note content preview…</string>
|
||||
<string name="note_timestamp_placeholder">Vor 2 Std</string>
|
||||
<string name="untitled">Ohne Titel</string>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<string name="delete_note_title">Notiz löschen?</string>
|
||||
@@ -42,10 +43,9 @@
|
||||
|
||||
<!-- Auto-Sync Settings -->
|
||||
<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="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 -->
|
||||
<string name="backup_restore_title">Backup & Wiederherstellung</string>
|
||||
@@ -57,4 +57,36 @@
|
||||
<string name="restore_progress">Stelle Notizen wieder her…</string>
|
||||
<string name="restore_success">✓ %d Notizen wiederhergestellt</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>
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.ktlint) apply false
|
||||
alias(libs.plugins.detekt) apply false
|
||||
}
|
||||
136
android/config/detekt/detekt.yml
Normal 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
|
||||
@@ -9,6 +9,8 @@ appcompat = "1.6.1"
|
||||
material = "1.10.0"
|
||||
activity = "1.8.0"
|
||||
constraintlayout = "2.1.4"
|
||||
ktlint = "12.1.0"
|
||||
detekt = "1.23.4"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -23,4 +25,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
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" }
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||
|
||||
### 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
|
||||
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 |
|
||||
| **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 |
|
||||
| **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)
|
||||
|
||||
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
||||
| 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 |
|
||||
| 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!**
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
|
||||
|
||||
### 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
|
||||
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 |
|
||||
| **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 |
|
||||
| **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)
|
||||
|
||||
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
||||
| 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 |
|
||||
| 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!**
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,8 +8,16 @@
|
||||
|
||||
## 📝 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
|
||||
- ✅ **Simple text notes** - Focus on content, no distractions
|
||||
- ✅ **Auto-save** - No manual saving needed
|
||||
- ✅ **Title + content** - Clear structure for each note
|
||||
- ✅ **Timestamps** - Creation and modification date automatically
|
||||
@@ -52,9 +60,11 @@
|
||||
|
||||
### Markdown Export
|
||||
- ✅ **Automatic export** - Each note → `.md` file
|
||||
- ✅ **Checklists as task lists** _(NEW)_ - `- [ ]` / `- [x]` format (GitHub-compatible)
|
||||
- ✅ **Dual-format** - JSON (master) + Markdown (mirror)
|
||||
- ✅ **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
|
||||
- ✅ **Optional** - Toggle in settings
|
||||
- ✅ **Initial export** - All existing notes when activated
|
||||
@@ -81,16 +91,16 @@
|
||||
|
||||
### Auto-Sync
|
||||
- ✅ **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
|
||||
- ✅ **Smart server check** - No errors on foreign networks
|
||||
- ✅ **Smart server check** - Sync only when server is reachable
|
||||
- ✅ **WorkManager** - Reliable background execution
|
||||
- ✅ **Battery optimization compatible** - Works even with Doze mode
|
||||
|
||||
### Sync Triggers (6 total)
|
||||
1. ✅ **Periodic sync** - Automatically after interval
|
||||
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
|
||||
5. ✅ **Pull-to-refresh** - Swipe gesture in notes list
|
||||
6. ✅ **Settings-save sync** - After server configuration
|
||||
@@ -109,7 +119,6 @@
|
||||
- ✅ **HTTP/HTTPS** - HTTP only local, HTTPS for external
|
||||
- ✅ **Username/password** - Basic authentication
|
||||
- ✅ **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)_
|
||||
- ✅ **Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
|
||||
|
||||
@@ -130,6 +139,11 @@
|
||||
- ✅ **Password storage** - Android SharedPreferences (encrypted)
|
||||
- ✅ **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
|
||||
@@ -137,7 +151,7 @@
|
||||
### Battery Efficiency
|
||||
- ✅ **Optimized sync intervals** - 15/30/60 min
|
||||
- ✅ **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
|
||||
- ✅ **Doze mode compatible** - Sync runs even in standby
|
||||
- ✅ **Measured consumption:**
|
||||
@@ -171,6 +185,7 @@
|
||||
- ✅ **Dispatchers.IO** - Background operations
|
||||
- ✅ **SharedPreferences** - Settings storage
|
||||
- ✅ **File-based storage** - JSON files locally
|
||||
- ✅ **Custom exceptions** - Dedicated SyncException for better error handling _(NEW in v1.3.2)_
|
||||
|
||||
### Dependencies
|
||||
- ✅ **AndroidX** - Jetpack libraries
|
||||
@@ -208,27 +223,22 @@
|
||||
|
||||
## 🔮 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
|
||||
- ⏳ **Browser-based editor** - Edit notes in web browser
|
||||
- ⏳ **WebDAV access via browser** - No mount needed
|
||||
- ⏳ **Mobile-optimized** - Responsive design
|
||||
- ⏳ **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 - Checklists
|
||||
- ⏳ **Checklist notes** - New note type with checkboxes
|
||||
- ⏳ **Completed items** - Strike-through/check off
|
||||
- ⏳ **Drag & drop** - Reorder items
|
||||
|
||||
### v1.4.0 - Sharing & Export
|
||||
- ⏳ **Share note** - Via share intent
|
||||
- ⏳ **Export single note** - As .txt or .md
|
||||
- ⏳ **Import from text** - Via share intent
|
||||
### v1.5.0 - Internationalization
|
||||
- ⏳ **Multi-language** - German + English UI
|
||||
- ⏳ **Language selection** - Selectable in settings
|
||||
- ⏳ **Full translation** - All strings in both languages
|
||||
|
||||
### v1.5.0 - Advanced Editor Features
|
||||
- ⏳ **Markdown preview** - In-app rendering
|
||||
- ⏳ **Checklists** - TODO lists in notes
|
||||
- ⏳ **Syntax highlighting** - For code snippets
|
||||
### v1.6.0 - Modern APIs
|
||||
- ⏳ **Replace LocalBroadcastManager** - Use SharedFlow instead
|
||||
- ⏳ **PackageInfo Flags** - Use PackageInfoFlags.of()
|
||||
- ⏳ **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)
|
||||
|
||||
@@ -8,8 +8,16 @@
|
||||
|
||||
## 📝 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
|
||||
- ✅ **Einfache Textnotizen** - Fokus auf Inhalt, keine Ablenkung
|
||||
- ✅ **Automatisches Speichern** - Kein manuelles Speichern nötig
|
||||
- ✅ **Titel + Inhalt** - Klare Struktur für jede Notiz
|
||||
- ✅ **Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
|
||||
@@ -52,9 +60,11 @@
|
||||
|
||||
### Markdown-Export
|
||||
- ✅ **Automatischer Export** - Jede Notiz → `.md` Datei
|
||||
- ✅ **Checklisten als Task-Listen** _(NEU)_ - `- [ ]` / `- [x]` Format (GitHub-kompatibel)
|
||||
- ✅ **Dual-Format** - JSON (Master) + Markdown (Mirror)
|
||||
- ✅ **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
|
||||
- ✅ **Optional** - In Einstellungen ein/ausschaltbar
|
||||
- ✅ **Initial Export** - Alle bestehenden Notizen beim Aktivieren
|
||||
@@ -81,16 +91,16 @@
|
||||
|
||||
### Auto-Sync
|
||||
- ✅ **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
|
||||
- ✅ **Smart Server-Check** - Keine Fehler in fremden Netzwerken
|
||||
- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar
|
||||
- ✅ **WorkManager** - Zuverlässige Background-Ausführung
|
||||
- ✅ **Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode
|
||||
|
||||
### Sync-Trigger (6 Stück)
|
||||
1. ✅ **Periodic Sync** - Automatisch nach Intervall
|
||||
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
|
||||
5. ✅ **Pull-to-Refresh** - Wisch-Geste in Notizliste
|
||||
6. ✅ **Settings-Save Sync** - Nach Server-Konfiguration
|
||||
@@ -109,7 +119,6 @@
|
||||
- ✅ **HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
|
||||
- ✅ **Username/Password** - Basic Authentication
|
||||
- ✅ **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)_
|
||||
- ✅ **Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
|
||||
|
||||
@@ -130,14 +139,19 @@
|
||||
- ✅ **Passwort-Speicherung** - Android SharedPreferences (verschlüsselt)
|
||||
- ✅ **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
|
||||
|
||||
### Akku-Effizienz
|
||||
- ✅ **Optimierte Sync-Intervalle** - 15/30/60 Min
|
||||
- ✅ **WLAN-Only** - Kein Mobile Data Sync
|
||||
- ✅ **Smart Server-Check** - Nur im Heim-WLAN
|
||||
- ✅ **WiFi-Only** - Kein Mobile Data Sync
|
||||
- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar
|
||||
- ✅ **WorkManager** - System-optimierte Ausführung
|
||||
- ✅ **Doze Mode kompatibel** - Sync läuft auch im Standby
|
||||
- ✅ **Gemessener Verbrauch:**
|
||||
@@ -171,6 +185,7 @@
|
||||
- ✅ **Dispatchers.IO** - Background-Operationen
|
||||
- ✅ **SharedPreferences** - Settings-Speicherung
|
||||
- ✅ **File-Based Storage** - JSON-Dateien lokal
|
||||
- ✅ **Custom Exceptions** - Dedizierte SyncException für bessere Fehlerbehandlung _(NEU in v1.3.2)_
|
||||
|
||||
### Abhängigkeiten
|
||||
- ✅ **AndroidX** - Jetpack Libraries
|
||||
@@ -208,27 +223,22 @@
|
||||
|
||||
## 🔮 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
|
||||
- ⏳ **Browser-basierter Editor** - Notizen im Webbrowser bearbeiten
|
||||
- ⏳ **WebDAV-Zugriff via Browser** - Kein Mount nötig
|
||||
- ⏳ **Mobile-optimiert** - Responsive Design
|
||||
- ⏳ **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 - Checklisten
|
||||
- ⏳ **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
|
||||
- ⏳ **Erledigte Items** - Durchstreichen/Abhaken
|
||||
- ⏳ **Drag & Drop** - Items neu anordnen
|
||||
|
||||
### v1.4.0 - Sharing & Export
|
||||
- ⏳ **Notiz teilen** - Via Share-Intent
|
||||
- ⏳ **Einzelne Notiz exportieren** - Als .txt oder .md
|
||||
- ⏳ **Import von Text** - Via Share-Intent
|
||||
### v1.5.0 - Internationalisierung
|
||||
- ⏳ **Mehrsprachigkeit** - Deutsch + Englisch UI
|
||||
- ⏳ **Sprachauswahl** - In Einstellungen wählbar
|
||||
- ⏳ **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
|
||||
|
||||
### v1.5.0 - Erweiterte Editor-Features
|
||||
- ⏳ **Markdown-Vorschau** - In-App Rendering
|
||||
- ⏳ **Checklisten** - TODO-Listen in Notizen
|
||||
- ⏳ **Syntax-Highlighting** - Für Code-Snippets
|
||||
### v1.6.0 - Modern APIs
|
||||
- ⏳ **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
|
||||
- ⏳ **PackageInfo Flags** - PackageInfoFlags.of() verwenden
|
||||
- ⏳ **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)
|
||||
|
||||
5
fastlane/metadata/android/de-DE/changelogs/10.txt
Normal 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
|
||||
12
fastlane/metadata/android/de-DE/changelogs/11.txt
Normal 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!
|
||||
4
fastlane/metadata/android/de-DE/changelogs/12.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
• Bugfix: Löschen von Notizen aus älteren App-Versionen (v1.2.0)
|
||||
• Bugfix: Checklisten-Sync mit älteren App-Versionen (v1.3.x)
|
||||
• Checklisten werden jetzt auch als Text-Fallback gespeichert
|
||||
• Neu: Checklisten-Texte werden automatisch umgebrochen
|
||||
13
fastlane/metadata/android/de-DE/changelogs/9.txt
Normal 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
|
||||
@@ -2,14 +2,15 @@ Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisatio
|
||||
|
||||
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
|
||||
• 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
|
||||
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
|
||||
• Transparente Batterie-Verbrauchsanzeige
|
||||
• Material Design 3 mit Dynamic Colors (Android 12+)
|
||||
• Swipe-to-Delete mit Bestätigungsdialog
|
||||
• Server-Backup & Wiederherstellung
|
||||
• Komplett offline nutzbar
|
||||
• 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.
|
||||
|
||||
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:
|
||||
|
||||
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
|
||||
• 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)
|
||||
• Doze Mode optimiert für zuverlässige Background-Syncs
|
||||
• Manuelle Synchronisation jederzeit möglich
|
||||
• Konfliktfreie Zusammenführung durch Timestamps
|
||||
|
||||
MATERIAL DESIGN 3:
|
||||
|
||||
@@ -32,6 +42,7 @@ MATERIAL DESIGN 3:
|
||||
• Dynamic Colors (Material You) auf Android 12+
|
||||
• Dark Mode Support
|
||||
• Intuitive Gesten (Swipe-to-Delete)
|
||||
• Live Sync-Status Anzeige
|
||||
|
||||
Open Source unter MIT-Lizenz
|
||||
Quellcode: https://github.com/inventory69/simple-notes-sync
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 93 KiB |
@@ -1 +1 @@
|
||||
Einfache Notizen-App mit WebDAV-Synchronisation
|
||||
Notizen & Checklisten mit WebDAV-Sync zu deinem eigenen Server
|
||||
5
fastlane/metadata/android/en-US/changelogs/10.txt
Normal 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
|
||||
12
fastlane/metadata/android/en-US/changelogs/11.txt
Normal 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!
|
||||
5
fastlane/metadata/android/en-US/changelogs/12.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
• Bugfix: Deleting notes from older app versions (v1.2.0)
|
||||
• Bugfix: Checklist sync with older app versions (v1.3.x)
|
||||
• Checklists are now also saved as text fallback
|
||||
• New: Checklist texts now wrap automatically
|
||||
p automatically
|
||||
13
fastlane/metadata/android/en-US/changelogs/9.txt
Normal 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
|
||||