16 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Compatible with: v1.2.0-v1.3.0
2026-01-07 12:27:27 +01:00
inventory69
62423f5a5b Release v1.2.2: Backward compatibility for v1.2.0 users
- Added dual-mode download for server restore
- Scans both /notes/ (new) and Root (old v1.2.0) folders
- Normal sync only uses /notes/ for performance
- Fixed URL construction bugs
- Updated F-Droid changelogs
2026-01-05 16:46:07 +01:00
inventory69
9eabc9a5f0 [skip ci] 📚 Docs: Reorganize + Web Editor to v1.3.0
## 📁 Reorganization
- Moved all docs to docs/ folder (FEATURES, BACKUP, DESKTOP, DOCS)
- Updated all cross-references in README.md/en
- Fixed internal links in docs

## �� Corrections
- FEATURES.md: Fixed build variants - both are 100% FOSS (no Google Services)
- Clarified: App is completely FOSS with no proprietary libraries

##  Changes
- Web Editor moved from v1.6.0 to v1.3.0 (earlier implementation)
- Combined with organization features (tags, search, sorting)
2026-01-05 12:43:01 +01:00
111 changed files with 4086 additions and 674 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 |
---

View File

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

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

@@ -6,7 +6,9 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
**🌍 Languages:** [Deutsch](README.md) · **English**
@@ -24,15 +26,16 @@
## ✨ 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
➡️ **Complete feature list:** [FEATURES.en.md](FEATURES.en.md)
➡️ **Complete feature list:** [FEATURES.en.md](docs/FEATURES.en.md)
---
@@ -71,12 +74,9 @@ docker compose up -d
| Document | Content |
|----------|---------|
| **[QUICKSTART.en.md](QUICKSTART.en.md)** | Step-by-step installation |
| **[FEATURES.en.md](FEATURES.en.md)** | Complete feature list |
| **[BACKUP.en.md](BACKUP.en.md)** | Backup & restore guide |
| **[DESKTOP.en.md](DESKTOP.en.md)** | Desktop integration (Markdown) |
| **[DOCS.en.md](DOCS.en.md)** | Technical details & troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
| **[FEATURES.en.md](docs/FEATURES.en.md)** | Complete feature list |
| **[BACKUP.en.md](docs/BACKUP.en.md)** | Backup & restore guide |
| **[DESKTOP.en.md](docs/DESKTOP.en.md)** | Desktop integration (Markdown) |
---
## 🛠️ Development
@@ -86,7 +86,7 @@ cd android
./gradlew assembleStandardRelease
```
➡️ **Build guide:** [DOCS.en.md](DOCS.en.md)
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
---
@@ -102,4 +102,4 @@ MIT License - see [LICENSE](LICENSE)
---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -6,7 +6,9 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
@@ -24,15 +26,16 @@
## ✨ 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
➡️ **Vollständige Feature-Liste:** [FEATURES.md](FEATURES.md)
➡️ **Vollständige Feature-Liste:** [FEATURES.md](docs/FEATURES.md)
---
@@ -71,10 +74,10 @@ docker compose up -d
| Dokument | Inhalt |
|----------|--------|
| **[QUICKSTART.md](QUICKSTART.md)** | Schritt-für-Schritt Installation |
| **[FEATURES.md](FEATURES.md)** | Vollständige Feature-Liste |
| **[BACKUP.md](BACKUP.md)** | Backup & Wiederherstellung |
| **[DESKTOP.md](DESKTOP.md)** | Desktop-Integration (Markdown) |
| **[DOCS.md](DOCS.md)** | Technische Details & Troubleshooting |
| **[FEATURES.md](docs/FEATURES.md)** | Vollständige Feature-Liste |
| **[BACKUP.md](docs/BACKUP.md)** | Backup & Wiederherstellung |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop-Integration (Markdown) |
| **[DOCS.md](docs/DOCS.md)** | Technische Details & Troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Versionshistorie |
---
@@ -86,7 +89,7 @@ cd android
./gradlew assembleStandardRelease
```
➡️ **Build-Anleitung:** [DOCS.md](DOCS.md)
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
---
@@ -102,4 +105,4 @@ MIT License - siehe [LICENSE](LICENSE)
---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3

61
android/.editorconfig Normal file
View File

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

View File

@@ -1,6 +1,9 @@
plugins {
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 = 6 // 🐛 v1.2.1: Markdown Initial Export Bugfix
versionName = "1.2.1" // 🐛 v1.2.1: Markdown Initial Export Bugfix
versionCode = 11 // 🚀 v1.4.0: Checklists Feature
versionName = "1.4.0" // 🚀 v1.4.0: Checklists, Multi-Device Sync Fixes, UX Improvements
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

@@ -21,20 +21,32 @@ import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.card.MaterialCardView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast
import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView
import android.widget.CheckBox
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
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() {
@@ -44,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>()
@@ -60,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
}
/**
@@ -91,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
@@ -110,6 +132,65 @@ class MainActivity : AppCompatActivity() {
setupFab()
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() {
@@ -145,6 +226,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
@@ -157,6 +244,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
}
@@ -167,6 +255,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch
}
@@ -178,6 +267,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")
@@ -185,14 +275,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
}
}
@@ -229,6 +322,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() {
@@ -256,6 +353,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)
@@ -263,7 +366,7 @@ class MainActivity : AppCompatActivity() {
if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert")
swipeRefreshLayout.isRefreshing = false
SyncStateManager.reset()
return@launch
}
@@ -272,15 +375,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
}
@@ -288,16 +389,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)
}
}
}
@@ -320,36 +419,20 @@ class MainActivity : AppCompatActivity() {
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition
val note = adapter.currentList[position]
val notesCopy = adapter.currentList.toMutableList()
val position = viewHolder.bindingAdapterPosition
val swipedNote = adapter.currentList[position]
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Store original list BEFORE removing note
val originalList = adapter.currentList.toList()
// Remove from list immediately for visual feedback
notesCopy.removeAt(position)
adapter.submitList(notesCopy)
// Remove from list for visual feedback (NOT from storage yet!)
val listWithoutNote = originalList.toMutableList().apply {
removeAt(position)
}
adapter.submitList(listWithoutNote)
// Show Snackbar with UNDO
Snackbar.make(
recyclerViewNotes,
"Notiz gelöscht",
Snackbar.LENGTH_LONG
).setAction("RÜCKGÄNGIG") {
// UNDO: Remove from pending deletions and restore
pendingDeletions.remove(note.id)
loadNotes()
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO → Actually delete the note
storage.deleteNote(note.id)
pendingDeletions.remove(note.id)
loadNotes()
}
}
}).show()
// Show dialog with ability to restore
showServerDeletionDialog(swipedNote, originalList)
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
@@ -361,10 +444,163 @@ class MainActivity : AppCompatActivity() {
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
}
private fun setupFab() {
fabAddNote.setOnClickListener {
openNoteEditor(null)
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
if (alwaysDeleteFromServer) {
// Auto-delete from server without asking
deleteNoteLocally(note, deleteFromServer = true)
return
}
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle("Notiz löschen")
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?")
.setView(dialogView)
.setNeutralButton("Abbrechen") { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList)
}
.setOnCancelListener {
// User pressed back - also restore
adapter.submitList(originalList)
}
.setPositiveButton("Nur lokal") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
}
// NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false)
}
.setNegativeButton("Vom Server löschen") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
}
deleteNoteLocally(note, deleteFromServer = true)
}
.setCancelable(true)
.show()
}
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Delete from storage
storage.deleteNote(note.id)
// Reload to reflect changes
loadNotes()
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
"\"${note.title}\" wird lokal und vom Server gelöscht"
} else {
"\"${note.title}\" lokal gelöscht (Server bleibt)"
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction("RÜCKGÄNGIG") {
// UNDO: Restore note
storage.saveNote(note)
pendingDeletions.remove(note.id)
loadNotes()
}
.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO
pendingDeletions.remove(note.id)
// Delete from server if requested
if (deleteFromServer) {
lifecycleScope.launch {
try {
val webdavService = WebDavSyncService(this@MainActivity)
val success = webdavService.deleteNoteFromServer(note.id)
if (success) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Vom Server gelöscht",
Toast.LENGTH_SHORT
).show()
}
} else {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Löschung fehlgeschlagen",
Toast.LENGTH_LONG
).show()
}
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Fehler: ${e.message}",
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}
}).show()
}
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
private fun setupFab() {
fabAddNote.setOnClickListener { view ->
showNoteTypePopup(view)
}
}
/**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/
private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try {
val fields = popupMenu.javaClass.declaredFields
for (field in fields) {
if ("mPopup" == field.name) {
field.isAccessible = true
val menuPopupHelper = field.get(popupMenu)
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
setForceIcons.invoke(menuPopupHelper, true)
break
}
}
} catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
}
popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false
}
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
true
}
popupMenu.show()
}
private fun loadNotes() {
@@ -405,6 +641,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
@@ -413,12 +654,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()
@@ -426,7 +665,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
}
@@ -437,20 +676,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
}
@@ -509,4 +749,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

@@ -9,6 +9,7 @@ import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.MenuItem
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.RadioButton
@@ -24,8 +25,6 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.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
@@ -33,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
@@ -50,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
@@ -57,14 +57,15 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText
private lateinit var switchAutoSync: SwitchCompat
private lateinit var switchMarkdownExport: SwitchCompat
private lateinit var switchMarkdownAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button
private lateinit var buttonCreateBackup: Button
private lateinit var buttonRestoreFromFile: Button
private lateinit var buttonRestoreFromServer: Button
private lateinit var buttonImportMarkdown: Button
private lateinit var buttonManualMarkdownSync: Button
private lateinit var textViewServerStatus: TextView
private lateinit var textViewManualSyncInfo: TextView
// Protocol Selection UI
private lateinit var protocolRadioGroup: RadioGroup
@@ -81,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) }
@@ -122,6 +128,7 @@ class SettingsActivity : AppCompatActivity() {
setupListeners()
setupSyncIntervalPicker()
setupAboutSection()
setupDebugSection()
}
private fun findViews() {
@@ -130,14 +137,15 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword)
switchAutoSync = findViewById(R.id.switchAutoSync)
switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
switchMarkdownAutoSync = findViewById(R.id.switchMarkdownAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
buttonManualMarkdownSync = findViewById(R.id.buttonManualMarkdownSync)
textViewServerStatus = findViewById(R.id.textViewServerStatus)
textViewManualSyncInfo = findViewById(R.id.textViewManualSyncInfo)
// Protocol Selection UI
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
@@ -153,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() {
@@ -180,7 +193,14 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first)
// Load Markdown Auto-Sync (backward compatible)
val markdownExport = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
val markdownAutoImport = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
val markdownAutoSync = markdownExport && markdownAutoImport
switchMarkdownAutoSync.isChecked = markdownAutoSync
updateMarkdownButtonVisibility()
// Update hint text based on selected protocol
updateProtocolHint()
@@ -269,17 +289,16 @@ class SettingsActivity : AppCompatActivity() {
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
}
buttonImportMarkdown.setOnClickListener {
saveSettings()
importMarkdownChanges()
buttonManualMarkdownSync.setOnClickListener {
performManualMarkdownSync()
}
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked)
}
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
onMarkdownExportToggled(isChecked)
switchMarkdownAutoSync.setOnCheckedChangeListener { _, isChecked ->
onMarkdownAutoSyncToggled(isChecked)
}
// Clear error when user starts typing again
@@ -304,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) {
@@ -349,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"
@@ -377,6 +398,109 @@ class SettingsActivity : AppCompatActivity() {
}
}
/**
* Setup Debug section with file logging toggle and export functionality
*/
private fun setupDebugSection() {
// Load current file logging state
val fileLoggingEnabled = prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)
switchFileLogging.isChecked = fileLoggingEnabled
// Update Logger state
Logger.setFileLoggingEnabled(fileLoggingEnabled)
// Toggle file logging
switchFileLogging.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean(Constants.KEY_FILE_LOGGING_ENABLED, isChecked).apply()
Logger.setFileLoggingEnabled(isChecked)
if (isChecked) {
showToast("📝 Datei-Logging aktiviert")
Logger.i(TAG, "File logging enabled by user")
} else {
showToast("📝 Datei-Logging deaktiviert")
}
}
// Export logs button
buttonExportLogs.setOnClickListener {
exportAndShareLogs()
}
// Clear logs button
buttonClearLogs.setOnClickListener {
showClearLogsConfirmation()
}
}
/**
* Export logs and share via system share sheet
*/
private fun exportAndShareLogs() {
lifecycleScope.launch {
try {
val logFile = Logger.getLogFile(this@SettingsActivity)
if (logFile == null || !logFile.exists() || logFile.length() == 0L) {
showToast("📭 Keine Logs vorhanden")
return@launch
}
// Create share intent using FileProvider
val logUri = FileProvider.getUriForFile(
this@SettingsActivity,
"${BuildConfig.APPLICATION_ID}.fileprovider",
logFile
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, logUri)
putExtra(Intent.EXTRA_SUBJECT, "SimpleNotes Sync Logs")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, "Logs teilen via..."))
Logger.i(TAG, "Logs exported and shared")
} catch (e: Exception) {
Logger.e(TAG, "Failed to export logs", e)
showToast("❌ Fehler beim Exportieren: ${e.message}")
}
}
}
/**
* Show confirmation dialog before clearing logs
*/
private fun showClearLogsConfirmation() {
AlertDialog.Builder(this)
.setTitle("Logs löschen?")
.setMessage("Alle gespeicherten Sync-Logs werden unwiderruflich gelöscht.")
.setPositiveButton("Löschen") { _, _ ->
clearLogs()
}
.setNegativeButton("Abbrechen", null)
.show()
}
/**
* Clear all log files
*/
private fun clearLogs() {
try {
val cleared = Logger.clearLogFile(this)
if (cleared) {
showToast("🗑️ Logs gelöscht")
} else {
showToast("📭 Keine Logs zum Löschen")
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to clear logs", e)
showToast("❌ Fehler beim Löschen: ${e.message}")
}
}
/**
* Opens URL in browser
*/
@@ -458,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)
@@ -465,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
}
@@ -481,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
}
}
}
@@ -514,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
@@ -548,7 +688,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
private fun onMarkdownExportToggled(enabled: Boolean) {
private fun onMarkdownAutoSyncToggled(enabled: Boolean) {
if (enabled) {
// Initial-Export wenn Feature aktiviert wird
lifecycleScope.launch {
@@ -559,7 +699,7 @@ class SettingsActivity : AppCompatActivity() {
if (currentNoteCount > 0) {
// Zeige Progress-Dialog
val progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown-Export")
setTitle("Markdown Auto-Sync")
setMessage("Exportiere Notizen nach Markdown...")
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
max = currentNoteCount
@@ -577,7 +717,7 @@ class SettingsActivity : AppCompatActivity() {
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
progressDialog.dismiss()
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
switchMarkdownExport.isChecked = false
switchMarkdownAutoSync.isChecked = false
return@launch
}
@@ -597,8 +737,13 @@ class SettingsActivity : AppCompatActivity() {
progressDialog.dismiss()
// Speichere Einstellung
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
// Speichere beide Einstellungen
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
// Erfolgs-Nachricht
showToast("$exportedCount Notizen nach Markdown exportiert")
@@ -608,76 +753,38 @@ class SettingsActivity : AppCompatActivity() {
showToast("❌ Export fehlgeschlagen: ${e.message}")
// Deaktiviere Toggle bei Fehler
switchMarkdownExport.isChecked = false
switchMarkdownAutoSync.isChecked = false
return@launch
}
} else {
// Keine Notizen vorhanden - speichere Einstellung direkt
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
// Keine Notizen vorhanden - speichere Einstellungen direkt
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
showToast(
"Markdown Auto-Sync aktiviert - " +
"Notizen werden als .md-Dateien exportiert und importiert"
)
}
} catch (e: Exception) {
Logger.e(TAG, "Error toggling markdown export: ${e.message}")
Logger.e(TAG, "Error toggling markdown auto-sync: ${e.message}")
showToast("Fehler: ${e.message}")
switchMarkdownExport.isChecked = false
switchMarkdownAutoSync.isChecked = false
}
}
} else {
// Deaktivieren - nur Setting speichern
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
}
}
private fun importMarkdownChanges() {
// Prüfen ob Server konfiguriert ist
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
showToast("Bitte zuerst WebDAV-Server konfigurieren")
return
}
// Import-Dialog mit Warnung
AlertDialog.Builder(this)
.setTitle("Markdown-Import")
.setMessage(
"Importiert Änderungen aus .md-Dateien vom Server.\n\n" +
"⚠️ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" +
"Fortfahren?"
)
.setPositiveButton("Importieren") { _, _ ->
performMarkdownImport(serverUrl, username, password)
}
.setNegativeButton("Abbrechen", null)
.show()
}
private fun performMarkdownImport(serverUrl: String, username: String, password: String) {
showToast("Importiere Markdown-Dateien...")
lifecycleScope.launch(Dispatchers.IO) {
try {
val syncService = WebDavSyncService(this@SettingsActivity)
val importCount = syncService.syncMarkdownFiles(serverUrl, username, password)
withContext(Dispatchers.Main) {
if (importCount > 0) {
showToast("$importCount Notizen aus Markdown importiert")
// Benachrichtige MainActivity zum Neuladen
sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED"))
} else {
showToast("Keine Markdown-Änderungen gefunden")
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showToast("Import-Fehler: ${e.message}")
}
}
// Deaktivieren - Settings speichern
prefs.edit()
.putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
.putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
.apply()
updateMarkdownButtonVisibility()
showToast("Markdown Auto-Sync deaktiviert - nur JSON-Sync aktiv")
}
}
@@ -714,11 +821,13 @@ class SettingsActivity : AppCompatActivity() {
intent.data = Uri.parse("package:$packageName")
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")
}
}
@@ -737,49 +846,6 @@ class SettingsActivity : AppCompatActivity() {
}
}
private fun showRestoreConfirmation() {
android.app.AlertDialog.Builder(this)
.setTitle(R.string.restore_confirmation_title)
.setMessage(R.string.restore_confirmation_message)
.setPositiveButton(R.string.restore_button) { _, _ ->
performRestore()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun performRestore() {
val progressDialog = android.app.ProgressDialog(this).apply {
setMessage(getString(R.string.restore_progress))
setCancelable(false)
show()
}
CoroutineScope(Dispatchers.Main).launch {
try {
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
webdavService.restoreFromServer()
}
progressDialog.dismiss()
if (result.isSuccess) {
showToast(getString(R.string.restore_success, result.restoredCount))
// Refresh MainActivity's note list
setResult(RESULT_OK)
} else {
showToast(getString(R.string.restore_error, result.errorMessage))
}
checkServerStatus()
} catch (e: Exception) {
progressDialog.dismiss()
showToast(getString(R.string.restore_error, e.message))
checkServerStatus()
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
android.R.id.home -> {
@@ -842,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)
@@ -851,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)
}
@@ -903,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
}
@@ -935,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()
broadcastNotesChanged(result.importedNotes)
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
}
@@ -953,12 +1018,7 @@ class SettingsActivity : AppCompatActivity() {
}
/**
* Führt Restore vom Server durch (Task #1.2.0-05b)
* Nutzt neues universelles Dialog-System mit Restore-Modi
*
* HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion
* unterstützt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet.
* TODO: WebDavSyncService.restoreFromServer() erweitern für v1.2.1+
* Server-Restore mit Restore-Modi (v1.3.0)
*/
private fun performRestoreFromServer(mode: RestoreMode) {
lifecycleScope.launch {
@@ -970,7 +1030,6 @@ class SettingsActivity : AppCompatActivity() {
try {
Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
Logger.w(TAG, "⚠️ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)")
// Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = backupManager.createAutoBackup()
@@ -981,8 +1040,7 @@ class SettingsActivity : AppCompatActivity() {
// Server-Restore durchführen
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
// Nutzt alte Funktion (immer REPLACE)
webdavService.restoreFromServer()
webdavService.restoreFromServer(mode) // ✅ Pass mode parameter
}
progressDialog.dismiss()
@@ -990,7 +1048,7 @@ class SettingsActivity : AppCompatActivity() {
if (result.isSuccess) {
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
setResult(RESULT_OK)
broadcastNotesChanged()
broadcastNotesChanged(result.restoredCount)
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
}
@@ -1005,13 +1063,76 @@ class SettingsActivity : AppCompatActivity() {
/**
* Sendet Broadcast dass Notizen geändert wurden
*/
private fun broadcastNotesChanged() {
private fun broadcastNotesChanged(count: Int = 0) {
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
intent.putExtra("success", true)
intent.putExtra("syncedCount", 0)
intent.putExtra("syncedCount", count)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
/**
* Updates visibility of manual sync button based on Auto-Sync toggle state
*/
private fun updateMarkdownButtonVisibility() {
val autoSyncEnabled = switchMarkdownAutoSync.isChecked
val visibility = if (autoSyncEnabled) View.GONE else View.VISIBLE
textViewManualSyncInfo.visibility = visibility
buttonManualMarkdownSync.visibility = visibility
}
/**
* Performs manual Markdown sync (Export + Import)
* Called when manual sync button is clicked
*/
private fun performManualMarkdownSync() {
lifecycleScope.launch {
var progressDialog: ProgressDialog? = null
try {
// Validierung
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "")
val username = prefs.getString(Constants.KEY_USERNAME, "")
val password = prefs.getString(Constants.KEY_PASSWORD, "")
if (serverUrl.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) {
showToast("⚠️ Bitte zuerst WebDAV-Server konfigurieren")
return@launch
}
// Progress-Dialog
progressDialog = ProgressDialog(this@SettingsActivity).apply {
setTitle("Markdown-Sync")
setMessage("Synchronisiere Markdown-Dateien...")
setCancelable(false)
show()
}
// Sync ausführen
val syncService = dev.dettmer.simplenotes.sync.WebDavSyncService(this@SettingsActivity)
val result = syncService.manualMarkdownSync()
progressDialog.dismiss()
// Erfolgs-Nachricht
val message = "✅ Sync abgeschlossen\n" +
"📤 ${result.exportedCount} exportiert\n" +
"📥 ${result.importedCount} importiert"
showToast(message)
Logger.d(
"SettingsActivity",
"Manual markdown sync: exported=${result.exportedCount}, " +
"imported=${result.importedCount}"
)
} catch (e: Exception) {
progressDialog?.dismiss()
showToast("❌ Sync fehlgeschlagen: ${e.message}")
Logger.e("SettingsActivity", "Manual markdown sync failed", e)
}
}
}
/**
* Zeigt Error-Dialog an
*/

View File

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

View File

@@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import 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

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

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -13,53 +14,125 @@ 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)
*/
fun toJson(): String {
return """
{
"id": "$id",
"title": "${title.escapeJson()}",
"content": "${content.escapeJson()}",
"createdAt": $createdAt,
"updatedAt": $updatedAt,
"deviceId": "$deviceId",
"syncStatus": "${syncStatus.name}"
}
""".trimIndent()
val gson = com.google.gson.GsonBuilder()
.setPrettyPrinting()
.create()
return gson.toJson(this)
}
/**
* 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
val checklistItems = if (jsonObject.has("checklistItems") &&
!jsonObject.get("checklistItems").isJsonNull
) {
gson.fromJson<List<ChecklistItem>>(
jsonObject.get("checklistItems"),
checklistItemsType
)
} else {
null
}
// Note mit korrekten Werten erstellen
Note(
id = rawNote.id,
title = rawNote.title,
content = rawNote.content,
createdAt = rawNote.createdAt,
updatedAt = rawNote.updatedAt,
deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType,
checklistItems = checklistItems
)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
null
}
}
/**
* Hilfsklasse für Gson-Parsing mit nullable Feldern
*/
private data class NoteRaw(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val content: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String = "",
val syncStatus: SyncStatus? = null
)
/**
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
* 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 +160,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 +209,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 +239,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

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

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

@@ -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"
@@ -23,6 +22,13 @@ object Constants {
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
// 🔥 v1.3.0: Performance & Multi-Device Sync
const val KEY_ALWAYS_CHECK_SERVER = "always_check_server"
const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server"
// 🔥 v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
// WorkManager
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

@@ -429,11 +429,11 @@
</com.google.android.material.card.MaterialCardView>
<!-- Markdown Export Toggle -->
<!-- Markdown Auto-Sync Toggle (fusioniert Export + Auto-Import) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginBottom="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
@@ -441,34 +441,47 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="📝 Markdown Export (Desktop-Zugriff)"
android:text="🔄 Markdown Auto-Sync"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchMarkdownExport"
android:id="@+id/switchMarkdownAutoSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
android:checked="false" />
</LinearLayout>
<!-- Import Markdown Button -->
<Button
android:id="@+id/buttonImportMarkdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📥 Markdown-Änderungen importieren"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Import Info Text -->
<!-- Auto-Sync Info Text -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
android:layout_marginBottom="16dp"
android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Manual Sync Info (nur sichtbar wenn Auto-Sync OFF) -->
<TextView
android:id="@+id/textViewManualSyncInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text="Oder synchronisiere Markdown-Dateien manuell:"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurface"
android:visibility="gone" />
<!-- Manual Sync Button (nur sichtbar wenn Auto-Sync OFF) -->
<Button
android:id="@+id/buttonManualMarkdownSync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Markdown synchronisieren"
android:visibility="gone"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -736,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,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<CheckBox
android:id="@+id/checkboxAlwaysDeleteFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Diese Entscheidung merken"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>

View File

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

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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

@@ -317,7 +317,7 @@ Step-by-step:
---
**📚 See also:**
- [QUICKSTART.en.md](QUICKSTART.en.md) - App installation and setup
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App installation and setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [DESKTOP.en.md](DESKTOP.en.md) - Desktop integration with Markdown

View File

@@ -317,7 +317,7 @@ Schritt-für-Schritt:
---
**📚 Siehe auch:**
- [QUICKSTART.md](QUICKSTART.md) - App-Installation und Einrichtung
- [QUICKSTART.md](../QUICKSTART.md) - App-Installation und Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown

View File

@@ -498,7 +498,7 @@ Planned for v1.3.0+:
---
**📚 See also:**
- [QUICKSTART.en.md](QUICKSTART.en.md) - App setup
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [BACKUP.en.md](BACKUP.en.md) - Backup & restore

View File

@@ -498,7 +498,7 @@ Geplant für v1.3.0+:
---
**📚 Siehe auch:**
- [QUICKSTART.md](QUICKSTART.md) - App-Einrichtung
- [QUICKSTART.md](../QUICKSTART.md) - App-Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [BACKUP.md](BACKUP.md) - Backup & Wiederherstellung

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
@@ -181,9 +196,10 @@
- ✅ **OkHttp** - HTTP client (via Sardine)
### Build Variants
- ✅ **Standard** - Google Play version (with Google services prepared)
- ✅ **F-Droid** - FOSS version (no Google dependencies)
- ✅ **Standard** - Universal APK (100% FOSS, no Google dependencies)
- ✅ **F-Droid** - Identical to Standard (100% FOSS)
- ✅ **Debug/Release** - Development and production
- ✅ **No Google Services** - Completely FOSS, no proprietary libraries
---
@@ -207,23 +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 - Advanced Organization
- ⏳ **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
---
@@ -266,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
@@ -181,9 +196,10 @@
- ✅ **OkHttp** - HTTP Client (via Sardine)
### Build-Varianten
- ✅ **Standard** - Google Play Version (mit Google-Services vorbereitet)
- ✅ **F-Droid** - FOSS Version (keine Google-Dependencies)
- ✅ **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
- ✅ **F-Droid** - Identisch mit Standard (100% FOSS)
- ✅ **Debug/Release** - Entwicklung und Production
- ✅ **Keine Google Services** - Komplett FOSS, keine proprietären Bibliotheken
---
@@ -207,23 +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 - Erweiterte Organisation
- ⏳ **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
---
@@ -266,4 +281,4 @@ A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
---
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)
**Letzte Aktualisierung:** v1.3.2 (2026-01-10)

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
v1.2.2 - Rückwärtskompatibilität für v1.2.0 User
Kritische Fehlerbehebung
• Server-Wiederherstellung findet jetzt ALLE Notizen (Root + /notes/)
• User die von v1.2.0 upgraden verlieren keine Daten mehr
• Alte Notizen aus Root-Ordner werden beim Restore gefunden
Technische Details
• Dual-Mode Download nur bei Server-Restore aktiv
• Normale Syncs bleiben schnell (scannen nur /notes/)
• Automatische Deduplication verhindert Duplikate
• Sanfte Migration: Neue Uploads gehen in /notes/, alte bleiben lesbar

View File

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

View File

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

View File

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

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