15 Commits

Author SHA1 Message Date
inventory69
7128c25bd5 Merge feature/v1.4.1-bugfixes: Bugfixes + Checklist improvements 2026-01-11 21:59:24 +01:00
inventory69
356ccde627 feat(v1.4.1): Bugfixes + Checklist auto line wrap
Fixed:
- Delete notes from older app versions (v1.2.0 compatibility)
- Checklist sync backwards compatibility (v1.3.x)
  - Fallback content in GitHub task list format
  - Recovery mode for lost checklistItems

Improved:
- Checklist auto line wrap (no maxLines limit)
- Enter key creates new item (via TextWatcher)

Metadata:
- Changelogs for versionCode 12
- IzzyOnDroid metadata updated
2026-01-11 21:59:09 +01:00
inventory69
9b37078cce Change repository URL in CONTRIBUTING.md
Updated repository URL in Quick Start section.
2026-01-11 16:23:51 +01:00
inventory69
dee85233b6 fix: Remove dynamic build date for Reproducible Builds
Fixes #7 - Thanks @IzzySoft for reporting and investigating!

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

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

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

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

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

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

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

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

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

🔧 Technical:
- Clear lastSyncTimestamp on restore
- Clear E-Tag caches on restore
- E-Tag refresh after upload
- Fixed timestamp update after MD export
2026-01-08 23:09:59 +01:00
105 changed files with 3050 additions and 580 deletions

View File

@@ -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
View File

@@ -42,3 +42,4 @@ Thumbs.db
*.tmp
*.swp
*~
test-apks/

View File

@@ -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

View File

@@ -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
```

View File

@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
| **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"

View File

@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
| **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"

View File

@@ -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

View File

@@ -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
View File

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

View File

@@ -1,6 +1,9 @@
plugins {
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
}

View File

@@ -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" />

View File

@@ -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>,
@@ -597,4 +800,4 @@ class MainActivity : AppCompatActivity() {
}
}
}
}
}

View File

@@ -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"
}
// Find views
findViews()
setupToolbar()
loadNoteOrDetermineType()
setupUIForNoteType()
}
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)
// Load existing note if editing
checklistContainer = findViewById(R.id.checklistContainer)
rvChecklistItems = findViewById(R.id.rvChecklistItems)
btnAddItem = findViewById(R.id.btnAddItem)
}
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() ?: ""
val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) {
showToast("Notiz ist leer")
return
when (currentNoteType) {
NoteType.TEXT -> {
val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) {
showToast(getString(R.string.note_is_empty))
return
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
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)
}
}
val note = if (existingNote != null) {
// Update existing note
existingNote!!.copy(
title = title,
content = content,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
// Create new note
Note(
title = title,
content = content,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
showToast("Notiz gespeichert")
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()
}
}

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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
)

View File

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

View File

@@ -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
}
}

View File

@@ -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()
""".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", "")
.trim()
// 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
}
}

View File

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

View File

@@ -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}")
}
}
}

View File

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

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.sync
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,14 +147,20 @@ 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) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Showing success notification...")
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...")
}
NotificationHelper.showSyncSuccess(
applicationContext,
result.syncedCount
)
}
NotificationHelper.showSyncSuccess(
applicationContext,
result.syncedCount
)
} else {
Logger.d(TAG, " No changes to sync - no notification")
}

View File

@@ -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,18 +599,29 @@ class WebDavSyncService(private val context: Context) {
}
suspend fun syncNotes(): SyncResult = withContext(Dispatchers.IO) {
Logger.d(TAG, "═══════════════════════════════════════")
Logger.d(TAG, "🔄 syncNotes() ENTRY")
Logger.d(TAG, "Context: ${context.javaClass.simpleName}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
// 🔒 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}")
Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
return@withContext try {
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()
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
// 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) // 🔥 v1.1.2: Track successful sync
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now)
.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")

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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)
}
/**

View File

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

View File

@@ -8,6 +8,16 @@ import java.net.URL
*/
object UrlValidator {
// 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
<LinearLayout
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>

View File

@@ -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) -->

View File

@@ -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 &amp; Diagnose"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" />
<!-- File Logging Toggle -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📝 Datei-Logging"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync-Logs in Datei speichern"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchFileLogging"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- F-Droid Privacy Notice -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/file_logging_privacy_notice"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<!-- Export Logs Button -->
<Button
android:id="@+id/buttonExportLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📤 Logs exportieren &amp; teilen"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_marginBottom="8dp" />
<!-- Clear Logs Button -->
<Button
android:id="@+id/buttonClearLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🗑️ Logs löschen"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -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 -->

View 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>

View File

@@ -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">
<!-- Material 3 Typography: TitleMedium -->
<TextView
android:id="@+id/textViewTitle"
<!-- v1.4.0: Header Row mit Icon und Titel -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/note_title_placeholder"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:maxLines="2"
android:ellipsize="end" />
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- Material 3 Typography: BodyMedium -->
<!-- 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="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" />
</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>

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -4,6 +4,9 @@
<color name="black">#FF000000</color>
<color name="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>

View File

@@ -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 &amp; 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>

View File

@@ -14,4 +14,4 @@ class ExampleUnitTest {
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
}

View File

@@ -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
}

View File

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

View File

@@ -9,6 +9,8 @@ appcompat = "1.6.1"
material = "1.10.0"
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" }

View File

@@ -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.
---

View File

@@ -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.
---

View File

@@ -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)

View File

@@ -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)

View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -2,14 +2,15 @@ Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisatio
HAUPTFUNKTIONEN:
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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