Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e96757fab | ||
|
|
547c0a1011 | ||
|
|
b79c0d25e6 | ||
|
|
cf1142afa2 | ||
|
|
359325bf64 | ||
|
|
c7d0f899e7 | ||
|
|
5121a7b2b8 | ||
|
|
04664c8920 | ||
|
|
2a56dd8128 | ||
|
|
63af7d30dc | ||
|
|
62423f5a5b | ||
|
|
9eabc9a5f0 |
42
.github/workflows/build-production-apk.yml
vendored
@@ -61,33 +61,15 @@ jobs:
|
||||
run: |
|
||||
mkdir -p apk-output
|
||||
|
||||
# === Standard Flavor (mit Google Services) ===
|
||||
# Universal APK (funktioniert auf allen Geraeten)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-universal-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk
|
||||
# Standard Flavor - Universal APK
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
|
||||
|
||||
# ARM64 APK (moderne Geräte 2018+)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk
|
||||
# F-Droid Flavor - Universal APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||
|
||||
# ARMv7 APK (ältere Geräte)
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-armeabi-v7a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk
|
||||
|
||||
# === F-Droid Flavor (ohne Google Services) ===
|
||||
# Universal APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-universal-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk
|
||||
|
||||
# ARM64 APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk
|
||||
|
||||
# ARMv7 APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk
|
||||
|
||||
echo "✅ APK-Dateien vorbereitet (Standard + F-Droid):"
|
||||
echo "✅ APK-Dateien vorbereitet:"
|
||||
ls -lh apk-output/
|
||||
|
||||
- name: APK-Artefakte hochladen
|
||||
@@ -138,14 +120,8 @@ jobs:
|
||||
|
||||
| Variante | Datei | Info |
|
||||
|----------|-------|------|
|
||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk` | Funktioniert auf allen Android-Geraeten |
|
||||
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk` | Kleinere Dateigröße fuer 64-bit Geräte |
|
||||
| Aelter (<2018) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk` | Fuer 32-bit ARM Geräte |
|
||||
| F-Droid Universal | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk` | Fuer F-Droid Store |
|
||||
| F-Droid ARM64 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk` | F-Droid 64-bit |
|
||||
| F-Droid ARMv7 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk` | F-Droid 32-bit |
|
||||
|
||||
💡 **Nicht sicher?** → Nimm die **Universal** APK!
|
||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
|
||||
|
||||
---
|
||||
|
||||
|
||||
194
CHANGELOG.md
@@ -6,6 +6,200 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
19
README.en.md
@@ -6,7 +6,9 @@
|
||||
[](https://m3.material.io/)
|
||||
[](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**
|
||||
|
||||
@@ -32,7 +34,7 @@
|
||||
- 🔋 **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 +73,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 +85,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 +101,4 @@ MIT License - see [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3
|
||||
**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3
|
||||
|
||||
18
README.md
@@ -6,7 +6,9 @@
|
||||
[](https://m3.material.io/)
|
||||
[](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)
|
||||
|
||||
@@ -32,7 +34,7 @@
|
||||
- 🔋 **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 +73,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 +88,7 @@ cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Build-Anleitung:** [DOCS.md](DOCS.md)
|
||||
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||
|
||||
---
|
||||
|
||||
@@ -102,4 +104,4 @@ MIT License - siehe [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3
|
||||
**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3
|
||||
|
||||
61
android/.editorconfig
Normal file
@@ -0,0 +1,61 @@
|
||||
# ⚡ v1.3.1: EditorConfig for ktlint
|
||||
# https://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
max_line_length = 120
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{kt,kts}]
|
||||
# ktlint rules
|
||||
ktlint_code_style = android_studio
|
||||
ktlint_standard_no-wildcard-imports = disabled
|
||||
ktlint_standard_package-name = enabled
|
||||
ktlint_standard_filename = enabled
|
||||
ktlint_standard_class-naming = enabled
|
||||
ktlint_standard_function-naming = enabled
|
||||
ktlint_standard_property-naming = enabled
|
||||
ktlint_standard_backing-property-naming = enabled
|
||||
ktlint_standard_enum-entry-name-case = enabled
|
||||
ktlint_standard_multiline-if-else = enabled
|
||||
ktlint_standard_no-empty-class-body = enabled
|
||||
ktlint_standard_no-empty-first-line-in-class-body = enabled
|
||||
ktlint_standard_blank-line-before-declaration = enabled
|
||||
ktlint_standard_context-receiver-wrapping = enabled
|
||||
ktlint_standard_function-expression-body = disabled
|
||||
ktlint_standard_function-literal = enabled
|
||||
ktlint_standard_function-type-modifier-spacing = enabled
|
||||
ktlint_standard_kdoc-wrapping = enabled
|
||||
ktlint_standard_modifier-list-spacing = enabled
|
||||
ktlint_standard_no-blank-line-in-list = enabled
|
||||
ktlint_standard_no-consecutive-comments = disabled
|
||||
ktlint_standard_no-single-line-block-comment = enabled
|
||||
ktlint_standard_parameter-list-spacing = enabled
|
||||
ktlint_standard_parameter-list-wrapping = enabled
|
||||
ktlint_standard_property-wrapping = enabled
|
||||
ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled
|
||||
ktlint_standard_statement-wrapping = enabled
|
||||
ktlint_standard_string-template-indent = disabled
|
||||
ktlint_standard_try-catch-finally-spacing = enabled
|
||||
ktlint_standard_type-argument-list-spacing = enabled
|
||||
ktlint_standard_type-parameter-list-spacing = enabled
|
||||
ktlint_standard_value-argument-comment = enabled
|
||||
ktlint_standard_value-parameter-comment = enabled
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{xml,json}]
|
||||
indent_size = 2
|
||||
|
||||
[*.yml]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -1,6 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
|
||||
// alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.detekt)
|
||||
}
|
||||
|
||||
import java.util.Properties
|
||||
@@ -17,8 +20,8 @@ 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 = 10 // 🚀 v1.3.2: Lint-Cleanup "Clean Slate"
|
||||
versionName = "1.3.2" // 🚀 v1.3.2: Code-Qualität-Release (alle einfachen Lint-Issues behoben)
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -32,23 +35,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 +70,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
|
||||
@@ -143,4 +148,28 @@ dependencies {
|
||||
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
|
||||
}
|
||||
@@ -21,20 +21,29 @@ 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
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@@ -44,9 +53,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 +76,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 +109,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 +129,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 +223,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 +241,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 +252,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -178,6 +264,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 +272,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 +319,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 +350,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 +363,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (serverUrl.isNullOrEmpty()) {
|
||||
showToast("⚠️ Server noch nicht konfiguriert")
|
||||
swipeRefreshLayout.isRefreshing = false
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -272,15 +372,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 +386,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 +416,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,6 +441,116 @@ class MainActivity : AppCompatActivity() {
|
||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private fun setupFab() {
|
||||
fabAddNote.setOnClickListener {
|
||||
openNoteEditor(null)
|
||||
@@ -405,6 +595,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 +608,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 +619,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 +630,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 +703,4 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -377,6 +399,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 +583,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 +598,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 +616,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 +655,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 +689,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 +700,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 +718,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 +738,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 +754,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 +822,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 +847,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 +909,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 +917,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 +969,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 +1001,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 +1019,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 +1031,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 +1041,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 +1049,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 +1064,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
|
||||
*/
|
||||
|
||||
@@ -49,10 +49,10 @@ class BackupManager(private val context: Context) {
|
||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
backupVersion = BACKUP_VERSION,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
notesCount = allNotes.size,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
@@ -65,7 +65,7 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
BackupResult(
|
||||
success = true,
|
||||
notes_count = allNotes.size,
|
||||
notesCount = allNotes.size,
|
||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
||||
)
|
||||
|
||||
@@ -99,10 +99,10 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
val backupData = BackupData(
|
||||
backup_version = BACKUP_VERSION,
|
||||
created_at = System.currentTimeMillis(),
|
||||
notes_count = allNotes.size,
|
||||
app_version = BuildConfig.VERSION_NAME,
|
||||
backupVersion = BACKUP_VERSION,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
notesCount = allNotes.size,
|
||||
appVersion = BuildConfig.VERSION_NAME,
|
||||
notes = allNotes
|
||||
)
|
||||
|
||||
@@ -149,7 +149,7 @@ class BackupManager(private val context: Context) {
|
||||
}
|
||||
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
Logger.d(TAG, " Backup valid: ${backupData.notes_count} notes, version ${backupData.backup_version}")
|
||||
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
|
||||
|
||||
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
||||
val autoBackupUri = createAutoBackup()
|
||||
@@ -164,7 +164,7 @@ class BackupManager(private val context: Context) {
|
||||
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
||||
}
|
||||
|
||||
Logger.d(TAG, "✅ Restore completed: ${result.imported_notes} imported, ${result.skipped_notes} skipped")
|
||||
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
|
||||
result
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -184,10 +184,11 @@ class BackupManager(private val context: Context) {
|
||||
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||
|
||||
// Version kompatibel?
|
||||
if (backupData.backup_version > BACKUP_VERSION) {
|
||||
if (backupData.backupVersion > BACKUP_VERSION) {
|
||||
return ValidationResult(
|
||||
isValid = false,
|
||||
errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} benötigt v${BACKUP_VERSION}+)"
|
||||
errorMessage = "Backup-Version nicht unterstützt " +
|
||||
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -238,8 +239,8 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = skippedNotes,
|
||||
importedNotes = newNotes.size,
|
||||
skippedNotes = skippedNotes,
|
||||
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
|
||||
)
|
||||
}
|
||||
@@ -259,8 +260,8 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = backupNotes.size,
|
||||
skipped_notes = 0,
|
||||
importedNotes = backupNotes.size,
|
||||
skippedNotes = 0,
|
||||
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
|
||||
)
|
||||
}
|
||||
@@ -283,9 +284,9 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
return RestoreResult(
|
||||
success = true,
|
||||
imported_notes = newNotes.size,
|
||||
skipped_notes = 0,
|
||||
overwritten_notes = overwrittenNotes.size,
|
||||
importedNotes = newNotes.size,
|
||||
skippedNotes = 0,
|
||||
overwrittenNotes = overwrittenNotes.size,
|
||||
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
|
||||
)
|
||||
}
|
||||
@@ -312,12 +313,17 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Backup-Daten Struktur (JSON)
|
||||
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
|
||||
*/
|
||||
data class BackupData(
|
||||
val backup_version: Int,
|
||||
val created_at: Long,
|
||||
val notes_count: Int,
|
||||
val app_version: String,
|
||||
@com.google.gson.annotations.SerializedName("backup_version")
|
||||
val backupVersion: Int,
|
||||
@com.google.gson.annotations.SerializedName("created_at")
|
||||
val createdAt: Long,
|
||||
@com.google.gson.annotations.SerializedName("notes_count")
|
||||
val notesCount: Int,
|
||||
@com.google.gson.annotations.SerializedName("app_version")
|
||||
val appVersion: String,
|
||||
val notes: List<Note>
|
||||
)
|
||||
|
||||
@@ -335,7 +341,7 @@ enum class RestoreMode {
|
||||
*/
|
||||
data class BackupResult(
|
||||
val success: Boolean,
|
||||
val notes_count: Int = 0,
|
||||
val notesCount: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
@@ -345,9 +351,9 @@ data class BackupResult(
|
||||
*/
|
||||
data class RestoreResult(
|
||||
val success: Boolean,
|
||||
val imported_notes: Int = 0,
|
||||
val skipped_notes: Int = 0,
|
||||
val overwritten_notes: Int = 0,
|
||||
val importedNotes: Int = 0,
|
||||
val skippedNotes: Int = 0,
|
||||
val overwrittenNotes: Int = 0,
|
||||
val message: String? = null,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -49,11 +50,14 @@ $content
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "Note"
|
||||
|
||||
fun fromJson(json: String): Note? {
|
||||
return try {
|
||||
val gson = com.google.gson.Gson()
|
||||
gson.fromJson(json, Note::class.java)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -102,6 +106,7 @@ $content
|
||||
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -126,6 +131,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -227,7 +227,11 @@ class NetworkMonitor(private val context: Context) {
|
||||
if (isWifi) {
|
||||
lastConnectedNetworkId = activeNetwork.toString()
|
||||
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
|
||||
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change")
|
||||
Logger.d(
|
||||
TAG,
|
||||
" 📡 WiFi already connected at startup - " +
|
||||
"onAvailable() will only trigger on network change"
|
||||
)
|
||||
} else {
|
||||
lastConnectedNetworkId = null
|
||||
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
|
||||
@@ -268,7 +272,7 @@ class NetworkMonitor(private val context: Context) {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Logger.d(TAG, "✅ WiFi monitoring stopped")
|
||||
} catch (e: Exception) {
|
||||
// Already unregistered
|
||||
Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package dev.dettmer.simplenotes.sync
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
|
||||
/**
|
||||
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
|
||||
*
|
||||
* Verhindert doppelte Syncs und informiert die UI über den aktuellen Status.
|
||||
* Thread-safe Singleton mit LiveData für UI-Reaktivität.
|
||||
*/
|
||||
object SyncStateManager {
|
||||
|
||||
private const val TAG = "SyncStateManager"
|
||||
|
||||
/**
|
||||
* Mögliche Sync-Zustände
|
||||
*/
|
||||
enum class SyncState {
|
||||
IDLE, // Kein Sync aktiv
|
||||
SYNCING, // Sync läuft gerade
|
||||
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
|
||||
ERROR // Sync fehlgeschlagen (kurz anzeigen)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaillierte Sync-Informationen für UI
|
||||
*/
|
||||
data class SyncStatus(
|
||||
val state: SyncState = SyncState.IDLE,
|
||||
val message: String? = null,
|
||||
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
|
||||
val timestamp: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
// Private mutable LiveData
|
||||
private val _syncStatus = MutableLiveData(SyncStatus())
|
||||
|
||||
// Public immutable LiveData für Observer
|
||||
val syncStatus: LiveData<SyncStatus> = _syncStatus
|
||||
|
||||
// Lock für Thread-Sicherheit
|
||||
private val lock = Any()
|
||||
|
||||
/**
|
||||
* Prüft ob gerade ein Sync läuft
|
||||
*/
|
||||
val isSyncing: Boolean
|
||||
get() = _syncStatus.value?.state == SyncState.SYNCING
|
||||
|
||||
/**
|
||||
* Versucht einen Sync zu starten.
|
||||
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
|
||||
*/
|
||||
fun tryStartSync(source: String): Boolean {
|
||||
synchronized(lock) {
|
||||
if (isSyncing) {
|
||||
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
|
||||
return false
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Starting sync from: $source")
|
||||
_syncStatus.postValue(
|
||||
SyncStatus(
|
||||
state = SyncState.SYNCING,
|
||||
message = "Synchronisiere...",
|
||||
source = source
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert Sync als erfolgreich abgeschlossen
|
||||
*/
|
||||
fun markCompleted(message: String? = null) {
|
||||
synchronized(lock) {
|
||||
val currentSource = _syncStatus.value?.source
|
||||
Logger.d(TAG, "✅ Sync completed from: $currentSource")
|
||||
_syncStatus.postValue(
|
||||
SyncStatus(
|
||||
state = SyncState.COMPLETED,
|
||||
message = message,
|
||||
source = currentSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Markiert Sync als fehlgeschlagen
|
||||
*/
|
||||
fun markError(errorMessage: String?) {
|
||||
synchronized(lock) {
|
||||
val currentSource = _syncStatus.value?.source
|
||||
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
|
||||
_syncStatus.postValue(
|
||||
SyncStatus(
|
||||
state = SyncState.ERROR,
|
||||
message = errorMessage,
|
||||
source = currentSource
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt Status zurück auf IDLE
|
||||
*/
|
||||
fun reset() {
|
||||
synchronized(lock) {
|
||||
_syncStatus.postValue(SyncStatus())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Nachricht während des Syncs (z.B. Progress)
|
||||
*/
|
||||
fun updateMessage(message: String) {
|
||||
synchronized(lock) {
|
||||
val current = _syncStatus.value ?: return
|
||||
if (current.state == SyncState.SYNCING) {
|
||||
_syncStatus.postValue(current.copy(message = message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,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) {
|
||||
|
||||
@@ -28,6 +28,7 @@ class WifiSyncReceiver : BroadcastReceiver() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount") // Early returns for WiFi validation checks
|
||||
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
|
||||
|
||||
@@ -23,6 +23,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
|
||||
|
||||
@@ -7,6 +7,9 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val DAYS_THRESHOLD = 7L
|
||||
private const val TRUNCATE_SUFFIX_LENGTH = 3
|
||||
|
||||
// Toast Extensions
|
||||
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
|
||||
Toast.makeText(this, message, duration).show()
|
||||
@@ -27,7 +30,7 @@ fun Long.toReadableTime(): String {
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(diff)
|
||||
"Vor $hours Std"
|
||||
}
|
||||
diff < TimeUnit.DAYS.toMillis(7) -> {
|
||||
diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
|
||||
val days = TimeUnit.MILLISECONDS.toDays(diff)
|
||||
"Vor $days Tagen"
|
||||
}
|
||||
@@ -41,7 +44,7 @@ fun Long.toReadableTime(): String {
|
||||
// Truncate long strings
|
||||
fun String.truncate(maxLength: Int): String {
|
||||
return if (length > maxLength) {
|
||||
substring(0, maxLength - 3) + "..."
|
||||
substring(0, maxLength - TRUNCATE_SUFFIX_LENGTH) + "..."
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.util.Log
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -15,10 +14,34 @@ import java.util.*
|
||||
*/
|
||||
object Logger {
|
||||
|
||||
private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge
|
||||
|
||||
private var fileLoggingEnabled = false
|
||||
private var logFile: File? = null
|
||||
private var appContext: Context? = null
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||
private val maxLogEntries = 500 // Nur letzte 500 Einträge
|
||||
|
||||
/**
|
||||
* Setzt den File-Logging Status (für UI Toggle)
|
||||
*/
|
||||
fun setFileLoggingEnabled(enabled: Boolean) {
|
||||
fileLoggingEnabled = enabled
|
||||
if (!enabled) {
|
||||
logFile = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt zurück, ob File-Logging aktiviert ist
|
||||
*/
|
||||
fun isFileLoggingEnabled(): Boolean = fileLoggingEnabled
|
||||
|
||||
/**
|
||||
* Initialisiert den Logger mit App-Context
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiviert File-Logging für Debugging
|
||||
@@ -50,11 +73,47 @@ object Logger {
|
||||
*/
|
||||
fun getLogFile(): File? = logFile
|
||||
|
||||
/**
|
||||
* Gibt Log-Datei mit Context zurück (für SettingsActivity)
|
||||
*/
|
||||
fun getLogFile(context: Context): File? {
|
||||
if (logFile == null && fileLoggingEnabled) {
|
||||
logFile = File(context.filesDir, "simplenotes_debug.log")
|
||||
}
|
||||
return logFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht die Log-Datei
|
||||
*/
|
||||
fun clearLogFile(context: Context): Boolean {
|
||||
return try {
|
||||
val file = File(context.filesDir, "simplenotes_debug.log")
|
||||
if (file.exists()) {
|
||||
file.delete()
|
||||
logFile = null
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Logger", "Failed to clear log file", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt Log-Eintrag in Datei
|
||||
*/
|
||||
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
|
||||
if (!fileLoggingEnabled || logFile == null) return
|
||||
if (!fileLoggingEnabled) return
|
||||
|
||||
// Lazy-init logFile mit appContext
|
||||
if (logFile == null && appContext != null) {
|
||||
logFile = File(appContext!!.filesDir, "simplenotes_debug.log")
|
||||
}
|
||||
|
||||
if (logFile == null) return
|
||||
|
||||
try {
|
||||
val timestamp = dateFormat.format(Date())
|
||||
@@ -80,13 +139,13 @@ object Logger {
|
||||
}
|
||||
|
||||
/**
|
||||
* Begrenzt Log-Datei auf maxLogEntries
|
||||
* Begrenzt Log-Datei auf MAX_LOG_ENTRIES
|
||||
*/
|
||||
private fun trimLogFile() {
|
||||
try {
|
||||
val lines = logFile?.readLines() ?: return
|
||||
if (lines.size > maxLogEntries) {
|
||||
val trimmed = lines.takeLast(maxLogEntries)
|
||||
if (lines.size > MAX_LOG_ENTRIES) {
|
||||
val trimmed = lines.takeLast(MAX_LOG_ENTRIES)
|
||||
logFile?.writeText(trimmed.joinToString("\n") + "\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -20,6 +20,7 @@ object NotificationHelper {
|
||||
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val SYNC_NOTIFICATION_ID = 2
|
||||
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* Erstellt Notification Channel (Android 8.0+)
|
||||
@@ -286,7 +287,7 @@ object NotificationHelper {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
manager.cancel(SYNC_NOTIFICATION_ID)
|
||||
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
|
||||
}, 30_000)
|
||||
}, AUTO_CANCEL_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.dettmer.simplenotes.utils
|
||||
|
||||
/**
|
||||
* Exception für Sync-spezifische Fehler
|
||||
*
|
||||
* Verwendet anstelle von generischen Exceptions für bessere
|
||||
* Fehlerbehandlung und klarere Fehlermeldungen.
|
||||
*/
|
||||
class SyncException(
|
||||
message: String,
|
||||
cause: Throwable? = null
|
||||
) : Exception(message, cause)
|
||||
|
||||
/**
|
||||
* Exception für Validierungsfehler
|
||||
*
|
||||
* Verwendet für ungültige Eingaben oder Konfigurationsfehler.
|
||||
*/
|
||||
class ValidationException(
|
||||
message: String
|
||||
) : IllegalArgumentException(message)
|
||||
@@ -8,6 +8,16 @@ import java.net.URL
|
||||
*/
|
||||
object UrlValidator {
|
||||
|
||||
// RFC 1918 Private IP Ranges
|
||||
private const val PRIVATE_CLASS_A_FIRST_OCTET = 10
|
||||
private const val PRIVATE_CLASS_B_FIRST_OCTET = 172
|
||||
private const val PRIVATE_CLASS_B_SECOND_OCTET_MIN = 16
|
||||
private const val PRIVATE_CLASS_B_SECOND_OCTET_MAX = 31
|
||||
private const val PRIVATE_CLASS_C_FIRST_OCTET = 192
|
||||
private const val PRIVATE_CLASS_C_SECOND_OCTET = 168
|
||||
private const val LOCALHOST_FIRST_OCTET = 127
|
||||
private const val OCTET_MAX_VALUE = 255
|
||||
|
||||
/**
|
||||
* Prüft ob eine URL eine lokale/private Adresse ist
|
||||
* Erlaubt:
|
||||
@@ -17,6 +27,7 @@ object UrlValidator {
|
||||
* - 127.x.x.x (Localhost)
|
||||
* - .local domains (mDNS/Bonjour)
|
||||
*/
|
||||
@Suppress("ReturnCount") // Early returns for validation checks are clearer
|
||||
fun isLocalUrl(url: String): Boolean {
|
||||
return try {
|
||||
val parsedUrl = URL(url)
|
||||
@@ -40,25 +51,29 @@ object UrlValidator {
|
||||
val octets = match.groupValues.drop(1).map { it.toInt() }
|
||||
|
||||
// Validate octets are in range 0-255
|
||||
if (octets.any { it > 255 }) {
|
||||
if (octets.any { it > OCTET_MAX_VALUE }) {
|
||||
return false
|
||||
}
|
||||
|
||||
val (o1, o2, o3, o4) = octets
|
||||
// Extract octets individually (destructuring with 4 elements triggers detekt warning)
|
||||
val o1 = octets[0]
|
||||
val o2 = octets[1]
|
||||
|
||||
// Check RFC 1918 private IP ranges
|
||||
return when {
|
||||
// 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
|
||||
o1 == 10 -> true
|
||||
o1 == PRIVATE_CLASS_A_FIRST_OCTET -> true
|
||||
|
||||
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
||||
o1 == 172 && o2 in 16..31 -> true
|
||||
o1 == PRIVATE_CLASS_B_FIRST_OCTET &&
|
||||
o2 in PRIVATE_CLASS_B_SECOND_OCTET_MIN..PRIVATE_CLASS_B_SECOND_OCTET_MAX -> true
|
||||
|
||||
// 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
|
||||
o1 == 192 && o2 == 168 -> true
|
||||
o1 == PRIVATE_CLASS_C_FIRST_OCTET &&
|
||||
o2 == PRIVATE_CLASS_C_SECOND_OCTET -> true
|
||||
|
||||
// 127.0.0.0/8 (Localhost)
|
||||
o1 == 127 -> true
|
||||
o1 == LOCALHOST_FIRST_OCTET -> true
|
||||
|
||||
else -> false
|
||||
}
|
||||
@@ -67,7 +82,7 @@ object UrlValidator {
|
||||
// Not a recognized local address
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
// Invalid URL format
|
||||
Logger.w("UrlValidator", "Failed to parse URL: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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 & Diagnose"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- File Logging Toggle -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceVariant"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📝 Datei-Logging"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync-Logs in Datei speichern"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchFileLogging"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- F-Droid Privacy Notice -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/file_logging_privacy_notice"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp" />
|
||||
|
||||
<!-- Export Logs Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonExportLogs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="📤 Logs exportieren & teilen"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<!-- Clear Logs Button -->
|
||||
<Button
|
||||
android:id="@+id/buttonClearLogs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="🗑️ Logs löschen"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:src="@android:drawable/ic_menu_delete"
|
||||
android:tint="?attr/colorError"
|
||||
app:tint="?attr/colorError"
|
||||
android:contentDescription="@string/delete" />
|
||||
|
||||
<!-- Title -->
|
||||
|
||||
15
android/app/src/main/res/layout/dialog_server_deletion.xml
Normal 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>
|
||||
@@ -64,7 +64,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 316 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 774 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 319 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 327 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
@@ -4,6 +4,9 @@
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
|
||||
<!-- Adaptive Icon Background -->
|
||||
<color name="ic_launcher_background">#f9e9c8</color>
|
||||
|
||||
<!-- Material 3 Light Theme Colors -->
|
||||
<color name="md_theme_light_primary">#6750A4</color>
|
||||
<color name="md_theme_light_onPrimary">#FFFFFF</color>
|
||||
|
||||
@@ -57,4 +57,13 @@
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@@ -14,4 +14,4 @@ class ExampleUnitTest {
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,6 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.ktlint) apply false
|
||||
alias(libs.plugins.detekt) apply false
|
||||
}
|
||||
136
android/config/detekt/detekt.yml
Normal file
@@ -0,0 +1,136 @@
|
||||
# ⚡ v1.3.1: detekt Configuration
|
||||
# Pragmatic rules for simple-notes-sync
|
||||
|
||||
build:
|
||||
maxIssues: 100 # Allow existing issues for v1.3.1 release, fix in v1.4.0
|
||||
excludeCorrectable: false
|
||||
|
||||
config:
|
||||
validation: true
|
||||
warningsAsErrors: false
|
||||
|
||||
comments:
|
||||
CommentOverPrivateProperty:
|
||||
active: false
|
||||
UndocumentedPublicClass:
|
||||
active: false
|
||||
UndocumentedPublicFunction:
|
||||
active: false
|
||||
|
||||
complexity:
|
||||
ComplexCondition:
|
||||
active: true
|
||||
threshold: 5
|
||||
CyclomaticComplexMethod:
|
||||
active: true
|
||||
threshold: 15
|
||||
ignoreSingleWhenExpression: true
|
||||
LargeClass:
|
||||
active: true
|
||||
threshold: 600 # Increased for WebDavSyncService
|
||||
LongMethod:
|
||||
active: true
|
||||
threshold: 80 # Increased for sync methods
|
||||
LongParameterList:
|
||||
active: true
|
||||
functionThreshold: 6
|
||||
constructorThreshold: 7
|
||||
NestedBlockDepth:
|
||||
active: true
|
||||
threshold: 5
|
||||
TooManyFunctions:
|
||||
active: true
|
||||
thresholdInFiles: 25
|
||||
thresholdInClasses: 25
|
||||
thresholdInInterfaces: 20
|
||||
thresholdInObjects: 20
|
||||
thresholdInEnums: 10
|
||||
|
||||
empty-blocks:
|
||||
EmptyCatchBlock:
|
||||
active: true
|
||||
allowedExceptionNameRegex: "_|(ignore|expected).*"
|
||||
EmptyFunctionBlock:
|
||||
active: true
|
||||
ignoreOverridden: true
|
||||
|
||||
exceptions:
|
||||
SwallowedException:
|
||||
active: true
|
||||
ignoredExceptionTypes:
|
||||
- "InterruptedException"
|
||||
- "MalformedURLException"
|
||||
- "NumberFormatException"
|
||||
- "ParseException"
|
||||
TooGenericExceptionCaught:
|
||||
active: true
|
||||
exceptionNames:
|
||||
- "Error"
|
||||
- "Throwable"
|
||||
allowedExceptionNameRegex: "_|(ignore|expected).*"
|
||||
|
||||
naming:
|
||||
FunctionNaming:
|
||||
active: true
|
||||
functionPattern: "[a-zA-Z][a-zA-Z0-9]*"
|
||||
VariableNaming:
|
||||
active: true
|
||||
variablePattern: "[a-z][A-Za-z0-9]*"
|
||||
PackageNaming:
|
||||
active: true
|
||||
packagePattern: "[a-z]+(\\.[a-z][A-Za-z0-9]*)*"
|
||||
|
||||
performance:
|
||||
SpreadOperator:
|
||||
active: false # Spread operator is fine in most cases
|
||||
|
||||
potential-bugs:
|
||||
CastToNullableType:
|
||||
active: true
|
||||
EqualsWithHashCodeExist:
|
||||
active: true
|
||||
UnconditionalJumpStatementInLoop:
|
||||
active: true
|
||||
|
||||
style:
|
||||
ForbiddenComment:
|
||||
active: true
|
||||
comments:
|
||||
- "FIXME:"
|
||||
- "STOPSHIP:"
|
||||
allowedPatterns: ""
|
||||
MagicNumber:
|
||||
active: true
|
||||
ignoreNumbers:
|
||||
- "-1"
|
||||
- "0"
|
||||
- "1"
|
||||
- "2"
|
||||
- "100"
|
||||
- "1000"
|
||||
ignoreHashCodeFunction: true
|
||||
ignorePropertyDeclaration: true
|
||||
ignoreLocalVariableDeclaration: true
|
||||
ignoreAnnotation: true
|
||||
ignoreEnums: true
|
||||
ignoreRanges: true
|
||||
ignoreExtensionFunctions: true
|
||||
MaxLineLength:
|
||||
active: true
|
||||
maxLineLength: 120
|
||||
excludePackageStatements: true
|
||||
excludeImportStatements: true
|
||||
ReturnCount:
|
||||
active: true
|
||||
max: 4
|
||||
excludedFunctions: []
|
||||
excludeLabeled: true
|
||||
excludeReturnFromLambda: true
|
||||
excludeGuardClauses: true
|
||||
UnusedImports:
|
||||
active: true
|
||||
UnusedPrivateMember:
|
||||
active: true
|
||||
allowedNames: "_.*"
|
||||
WildcardImport:
|
||||
active: false # Allow wildcard imports
|
||||
@@ -9,6 +9,8 @@ appcompat = "1.6.1"
|
||||
material = "1.10.0"
|
||||
activity = "1.8.0"
|
||||
constraintlayout = "2.1.4"
|
||||
ktlint = "12.1.0"
|
||||
detekt = "1.23.4"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -23,4 +25,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
||||
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -130,6 +130,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
|
||||
@@ -171,6 +176,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 +187,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 +214,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 +272,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)
|
||||
@@ -130,6 +130,11 @@
|
||||
- ✅ **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
|
||||
@@ -171,6 +176,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 +187,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 +214,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 +272,4 @@ A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)
|
||||
**Letzte Aktualisierung:** v1.3.2 (2026-01-10)
|
||||
5
fastlane/metadata/android/de-DE/changelogs/10.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Unter der Haube haben wir ordentlich aufgeraumt:
|
||||
- Verbesserte Sync-Performance durch optimierten Code
|
||||
- Stabilere Fehlerbehandlung bei Verbindungsproblemen
|
||||
- Speichereffizientere Datenverarbeitung
|
||||
- Datenschutz-Hinweis fur Datei-Logging hinzugefugt
|
||||
12
fastlane/metadata/android/de-DE/changelogs/7.txt
Normal 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
|
||||
12
fastlane/metadata/android/de-DE/changelogs/8.txt
Normal 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
|
||||
13
fastlane/metadata/android/de-DE/changelogs/9.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
v1.3.1 - Multi-Device Sync Fix + Performance
|
||||
|
||||
Multi-Device JSON Sync (Danke Thomas!):
|
||||
- JSON-Dateien syncen jetzt zwischen Geräten
|
||||
- Funktioniert auch ohne Markdown aktiviert
|
||||
- Keine doppelten Downloads mehr
|
||||
|
||||
Performance-Verbesserungen:
|
||||
- Sync beschleunigt: 12-14s -> 2-3s
|
||||
- Erster Sync nach MD-Export jetzt schnell
|
||||
- JSON erreicht Markdown-Geschwindigkeit
|
||||
|
||||
+ Sync-Status-UI, Content MD-Import, Debug-Tools
|
||||
@@ -4,12 +4,14 @@ HAUPTFUNKTIONEN:
|
||||
|
||||
• Einfache Notizen erstellen und bearbeiten
|
||||
• WebDAV-Synchronisation mit eigenem Server
|
||||
• Multi-Device Sync (Handy, Tablet, Desktop)
|
||||
• Markdown-Export für Obsidian/Desktop-Editoren
|
||||
• 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
|
||||
• Swipe-to-Delete mit Server-Sync
|
||||
• Server-Backup & Wiederherstellung (Merge/Replace/Overwrite)
|
||||
• Komplett offline nutzbar
|
||||
• Keine Werbung, keine Tracker
|
||||
|
||||
@@ -17,14 +19,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 +43,7 @@ MATERIAL DESIGN 3:
|
||||
• Dynamic Colors (Material You) auf Android 12+
|
||||
• Dark Mode Support
|
||||
• Intuitive Gesten (Swipe-to-Delete)
|
||||
• Live Sync-Status Anzeige
|
||||
|
||||
Open Source unter MIT-Lizenz
|
||||
Quellcode: https://github.com/inventory69/simple-notes-sync
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 93 KiB |
5
fastlane/metadata/android/en-US/changelogs/10.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Under the hood improvements:
|
||||
- Improved sync performance through optimized code
|
||||
- More stable error handling for connection issues
|
||||
- More memory-efficient data processing
|
||||
- Added privacy notice for file logging
|
||||
12
fastlane/metadata/android/en-US/changelogs/7.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
v1.2.2 - Backward Compatibility for v1.2.0 Users
|
||||
|
||||
Critical Bugfix
|
||||
• Server restore now finds ALL notes (Root + /notes/)
|
||||
• Users upgrading from v1.2.0 no longer lose data
|
||||
• Old notes from Root folder are found during restore
|
||||
|
||||
Technical Details
|
||||
• Dual-mode download only active for server restore
|
||||
• Normal syncs remain fast (scan only /notes/)
|
||||
• Automatic deduplication prevents duplicates
|
||||
• Smooth migration: New uploads go to /notes/, old ones remain readable
|
||||
12
fastlane/metadata/android/en-US/changelogs/8.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
v1.3.0 - Multi-Device Sync
|
||||
|
||||
NEW FEATURES:
|
||||
• Multi-Device Sync with deletion tracking (no zombie notes)
|
||||
• Swipe gesture for server deletion (prevents duplicates on other devices)
|
||||
• E-Tag performance optimization (~150ms vs 3s)
|
||||
• Markdown Auto-Sync toggle (unified Export + Import)
|
||||
• Manual Markdown sync button
|
||||
• Server restore modes (Merge/Replace/Overwrite)
|
||||
|
||||
Thanks to Thomas from Bielefeld!
|
||||
Compatible: v1.2.0-v1.3.0
|
||||
13
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
v1.3.1 - Multi-Device Sync Fix + Performance
|
||||
|
||||
Multi-Device JSON Sync (Thanks Thomas!):
|
||||
- JSON files now sync between devices
|
||||
- Works without Markdown enabled
|
||||
- No duplicate downloads anymore
|
||||
|
||||
Performance Improvements:
|
||||
- Sync speed: 12-14s -> 2-3s
|
||||
- First sync after MD export now fast
|
||||
- JSON matches Markdown speed
|
||||
|
||||
+ Sync status UI, content MD import, debug tools
|
||||
@@ -4,12 +4,14 @@ KEY FEATURES:
|
||||
|
||||
• Create and edit simple notes
|
||||
• WebDAV synchronization with your own server
|
||||
• Multi-device sync (phone, tablet, desktop)
|
||||
• Markdown export for Obsidian/desktop editors
|
||||
• Automatic synchronization on home WiFi
|
||||
• Configurable sync interval (15/30/60 minutes)
|
||||
• Transparent battery usage display
|
||||
• Material Design 3 with Dynamic Colors (Android 12+)
|
||||
• Swipe-to-delete with confirmation dialog
|
||||
• Server backup & restore
|
||||
• Swipe-to-delete with server sync
|
||||
• Server backup & restore (Merge/Replace/Overwrite)
|
||||
• Fully usable offline
|
||||
• No ads, no trackers
|
||||
|
||||
@@ -17,14 +19,23 @@ PRIVACY:
|
||||
|
||||
Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools.
|
||||
|
||||
MULTI-DEVICE SYNC:
|
||||
|
||||
• Notes sync automatically between all your devices
|
||||
• Deletion tracking prevents "zombie notes"
|
||||
• Smart conflict resolution through timestamps
|
||||
• Markdown files for desktop editing (Obsidian, VS Code, etc.)
|
||||
• Changes from desktop editors are auto-imported
|
||||
|
||||
SYNCHRONIZATION:
|
||||
|
||||
• Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
|
||||
• Configurable interval: 15, 30, or 60 minutes
|
||||
• Optimized performance: skips unchanged files (~2-3s sync time)
|
||||
• E-Tag caching for 20x faster "no changes" checks
|
||||
• Measured battery consumption: only ~0.4% per day (at 30min)
|
||||
• Doze Mode optimized for reliable background syncs
|
||||
• Manual synchronization available anytime
|
||||
• Conflict-free merging through timestamps
|
||||
|
||||
MATERIAL DESIGN 3:
|
||||
|
||||
@@ -32,6 +43,7 @@ MATERIAL DESIGN 3:
|
||||
• Dynamic Colors (Material You) on Android 12+
|
||||
• Dark Mode support
|
||||
• Intuitive gestures (Swipe-to-delete)
|
||||
• Live sync status indicator
|
||||
|
||||
Open Source under MIT License
|
||||
Source code: https://github.com/inventory69/simple-notes-sync
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,8 +1,8 @@
|
||||
Categories:
|
||||
- Writing
|
||||
License: MIT
|
||||
AuthorName: Liq Dettmer
|
||||
AuthorEmail: liq@dettmer.dev
|
||||
AuthorName: inventory69
|
||||
AuthorEmail: admin@dettmer.dev
|
||||
AuthorWebSite: https://dettmer.dev
|
||||
SourceCode: https://github.com/inventory69/simple-notes-sync
|
||||
IssueTracker: https://github.com/inventory69/simple-notes-sync/issues
|
||||
@@ -63,7 +63,63 @@ Builds:
|
||||
scandelete:
|
||||
- android/gradle/wrapper
|
||||
|
||||
- versionName: 1.2.1
|
||||
versionCode: 6
|
||||
commit: v1.2.1
|
||||
subdir: android/app
|
||||
sudo:
|
||||
- apt-get update
|
||||
- apt-get install -y openjdk-17-jdk-headless
|
||||
- update-java-alternatives -a
|
||||
gradle:
|
||||
- fdroid
|
||||
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
|
||||
scandelete:
|
||||
- android/gradle/wrapper
|
||||
|
||||
- versionName: 1.2.2
|
||||
versionCode: 7
|
||||
commit: v1.2.2
|
||||
subdir: android/app
|
||||
sudo:
|
||||
- apt-get update
|
||||
- apt-get install -y openjdk-17-jdk-headless
|
||||
- update-java-alternatives -a
|
||||
gradle:
|
||||
- fdroid
|
||||
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
|
||||
scandelete:
|
||||
- android/gradle/wrapper
|
||||
|
||||
- versionName: 1.3.0
|
||||
versionCode: 8
|
||||
commit: v1.3.0
|
||||
subdir: android/app
|
||||
sudo:
|
||||
- apt-get update
|
||||
- apt-get install -y openjdk-17-jdk-headless
|
||||
- update-java-alternatives -a
|
||||
gradle:
|
||||
- fdroid
|
||||
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
|
||||
scandelete:
|
||||
- android/gradle/wrapper
|
||||
|
||||
- versionName: 1.3.1
|
||||
versionCode: 9
|
||||
commit: v1.3.1
|
||||
subdir: android/app
|
||||
sudo:
|
||||
- apt-get update
|
||||
- apt-get install -y openjdk-17-jdk-headless
|
||||
- update-java-alternatives -a
|
||||
gradle:
|
||||
- fdroid
|
||||
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
|
||||
scandelete:
|
||||
- android/gradle/wrapper
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 1.2.0
|
||||
CurrentVersionCode: 5
|
||||
CurrentVersion: 1.3.1
|
||||
CurrentVersionCode: 9
|
||||
|
||||