Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e4b87853 | ||
|
|
45f528ea0e | ||
|
|
cb1bc46405 | ||
|
|
0b143e5f0d | ||
|
|
cf9695844c | ||
|
|
24ea7ec59a | ||
|
|
df4ee4bed0 | ||
|
|
68e8490db8 | ||
|
|
614650e37d | ||
|
|
785a6c011a |
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -9,3 +9,6 @@ contact_links:
|
|||||||
- name: "🐛 Troubleshooting"
|
- name: "🐛 Troubleshooting"
|
||||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
|
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
|
||||||
about: Häufige Probleme und Lösungen / Common issues and solutions
|
about: Häufige Probleme und Lösungen / Common issues and solutions
|
||||||
|
- name: "✨ Feature Requests & Ideas"
|
||||||
|
url: https://github.com/inventory69/simple-notes-sync/discussions/categories/ideas
|
||||||
|
about: Diskutiere neue Features in Discussions / Discuss new features in Discussions
|
||||||
|
|||||||
84
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
84
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,84 +0,0 @@
|
|||||||
name: "💡 Feature Request / Feature-Wunsch"
|
|
||||||
description: Schlage eine neue Funktion vor / Suggest a new feature
|
|
||||||
title: "[FEATURE] "
|
|
||||||
labels: ["enhancement"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
Danke für deinen Feature-Vorschlag! / Thanks for your feature suggestion!
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: feature-description
|
|
||||||
attributes:
|
|
||||||
label: "💡 Feature-Beschreibung / Feature Description"
|
|
||||||
description: Was möchtest du hinzugefügt haben? / What would you like to be added?
|
|
||||||
placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: problem
|
|
||||||
attributes:
|
|
||||||
label: "🎯 Problem / Motivation"
|
|
||||||
description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve?
|
|
||||||
placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: solution
|
|
||||||
attributes:
|
|
||||||
label: "📝 Vorgeschlagene Lösung / Proposed Solution"
|
|
||||||
description: Wie könnte das Feature funktionieren? / How could the feature work?
|
|
||||||
placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview"
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: alternatives
|
|
||||||
attributes:
|
|
||||||
label: "🔄 Alternativen / Alternatives"
|
|
||||||
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: platform
|
|
||||||
attributes:
|
|
||||||
label: "📱 Plattform / Platform"
|
|
||||||
description: Für welche Komponente ist das Feature? / For which component is the feature?
|
|
||||||
options:
|
|
||||||
- Android App
|
|
||||||
- WebDAV Server
|
|
||||||
- Dokumentation / Documentation
|
|
||||||
- Andere / Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: priority
|
|
||||||
attributes:
|
|
||||||
label: "⭐ Priorität (aus deiner Sicht) / Priority (from your perspective)"
|
|
||||||
options:
|
|
||||||
- Nice to have
|
|
||||||
- Wichtig / Important
|
|
||||||
- Sehr wichtig / Very important
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: willing-to-contribute
|
|
||||||
attributes:
|
|
||||||
label: "🤝 Beitragen / Contribute"
|
|
||||||
options:
|
|
||||||
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
|
|
||||||
required: false
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: additional
|
|
||||||
attributes:
|
|
||||||
label: "🔧 Zusätzliche Informationen / Additional Context"
|
|
||||||
description: Screenshots, Mockups, Links, ähnliche Apps, etc.
|
|
||||||
validations:
|
|
||||||
required: false
|
|
||||||
115
CHANGELOG.de.md
115
CHANGELOG.de.md
@@ -8,6 +8,117 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.7.2] - 2026-02-04
|
||||||
|
|
||||||
|
### 🐛 Kritische Fehlerbehebungen
|
||||||
|
|
||||||
|
#### JSON/Markdown Timestamp-Synchronisation
|
||||||
|
|
||||||
|
**Problem:** Externe Editoren (Obsidian, Typora, VS Code, eigene Editoren) aktualisieren Markdown-Inhalt, aber nicht den YAML `updated:` Timestamp, wodurch die Android-App Änderungen überspringt.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Server-Datei Änderungszeit (`mtime`) wird jetzt als Source of Truth statt YAML-Timestamp verwendet
|
||||||
|
- Inhaltsänderungen werden via Hash-Vergleich erkannt
|
||||||
|
- Notizen nach Markdown-Import als `PENDING` markiert → JSON automatisch beim nächsten Sync hochgeladen
|
||||||
|
- Behebt Sortierungsprobleme nach externen Bearbeitungen
|
||||||
|
|
||||||
|
#### SyncStatus auf Server immer PENDING
|
||||||
|
|
||||||
|
**Problem:** Alle JSON-Dateien auf dem Server enthielten `"syncStatus": "PENDING"` auch nach erfolgreichem Sync, was externe Clients verwirrte.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Status wird jetzt auf `SYNCED` gesetzt **vor** JSON-Serialisierung
|
||||||
|
- Server- und lokale Kopien sind jetzt konsistent
|
||||||
|
- Externe Web/Tauri-Editoren können Sync-Status korrekt interpretieren
|
||||||
|
|
||||||
|
#### Deletion Tracker Race Condition
|
||||||
|
|
||||||
|
**Problem:** Batch-Löschungen konnten Lösch-Einträge verlieren durch konkurrierenden Dateizugriff.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Mutex-basierte Synchronisation für Deletion Tracking
|
||||||
|
- Neue `trackDeletionSafe()` Funktion verhindert Race Conditions
|
||||||
|
- Garantiert Zombie-Note-Prevention auch bei schnellen Mehrfach-Löschungen
|
||||||
|
|
||||||
|
#### ISO8601 Timezone-Parsing
|
||||||
|
|
||||||
|
**Problem:** Markdown-Importe schlugen fehl mit Timezone-Offsets wie `+01:00` oder `-05:00`.
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
- Multi-Format ISO8601 Parser mit Fallback-Kette
|
||||||
|
- Unterstützt UTC (Z), Timezone-Offsets (+01:00, +0100) und Millisekunden
|
||||||
|
- Kompatibel mit Obsidian, Typora, VS Code Timestamps
|
||||||
|
|
||||||
|
### ⚡ Performance-Verbesserungen
|
||||||
|
|
||||||
|
#### E-Tag Batch Caching
|
||||||
|
|
||||||
|
- E-Tags werden jetzt in einer einzigen Batch-Operation geschrieben statt N einzelner Schreibvorgänge
|
||||||
|
- Performance-Gewinn: ~50-100ms pro Sync mit mehreren Notizen
|
||||||
|
- Reduzierte Disk-I/O-Operationen
|
||||||
|
|
||||||
|
#### Memory Leak Prevention
|
||||||
|
|
||||||
|
- `SafeSardineWrapper` implementiert jetzt `Closeable` für explizites Resource-Cleanup
|
||||||
|
- HTTP Connection Pool wird nach Sync korrekt aufgeräumt
|
||||||
|
- Verhindert Socket-Exhaustion bei häufigen Syncs
|
||||||
|
|
||||||
|
### 🔧 Technische Details
|
||||||
|
|
||||||
|
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` für thread-sicheres Deletion Tracking
|
||||||
|
- **IMPL_002:** Pattern-basierter ISO8601 Parser mit 8 Format-Varianten
|
||||||
|
- **IMPL_003:** Connection Pool Eviction + Dispatcher Shutdown in `close()`
|
||||||
|
- **IMPL_004:** Batch `SharedPreferences.Editor` Updates
|
||||||
|
- **IMPL_014:** Server `mtime` Parameter in `Note.fromMarkdown()`
|
||||||
|
- **IMPL_015:** `syncStatus` vor `toJson()` Aufruf gesetzt
|
||||||
|
|
||||||
|
### 📚 Dokumentation
|
||||||
|
|
||||||
|
- External Editor Specification für Web/Tauri-Editor-Entwickler
|
||||||
|
- Detaillierte Implementierungs-Dokumentation für alle Bugfixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.1] - 2026-02-02
|
||||||
|
|
||||||
|
### 🐛 Kritische Fehlerbehebungen
|
||||||
|
|
||||||
|
#### Android 9 App-Absturz Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
|
||||||
|
|
||||||
|
**Problem:** App stürzte auf Android 9 (API 28) ab wenn WorkManager Expedited Work für Hintergrund-Sync verwendet wurde.
|
||||||
|
|
||||||
|
**Root Cause:** Wenn `setExpedited()` in WorkManager verwendet wird, muss die `CoroutineWorker` die Methode `getForegroundInfo()` implementieren um eine Foreground Service Notification zurückzugeben. Auf Android 9-11 ruft WorkManager diese Methode auf, aber die Standard-Implementierung wirft `IllegalStateException: Not implemented`.
|
||||||
|
|
||||||
|
**Lösung:** `getForegroundInfo()` in `SyncWorker` implementiert um eine korrekte `ForegroundInfo` mit Sync-Progress-Notification zurückzugeben.
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
- `ForegroundInfo` mit Sync-Progress-Notification für Android 9-11 hinzugefügt
|
||||||
|
- Android 10+: Setzt `FOREGROUND_SERVICE_TYPE_DATA_SYNC` für korrekte Service-Typisierung
|
||||||
|
- Foreground Service Permissions in AndroidManifest.xml hinzugefügt
|
||||||
|
- Notification zeigt Sync-Progress mit indeterminiertem Progress Bar
|
||||||
|
- Danke an [@roughnecks](https://github.com/roughnecks) für das detaillierte Debugging!
|
||||||
|
|
||||||
|
#### VPN-Kompatibilitäts-Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
|
||||||
|
|
||||||
|
- WiFi Socket-Binding erkennt jetzt korrekt Wireguard VPN-Interfaces (tun*, wg*, *-wg-*)
|
||||||
|
- Traffic wird korrekt durch VPN-Tunnel geleitet statt direkt über WiFi
|
||||||
|
- Behebt "Verbindungs-Timeout" beim Sync zu externen Servern über VPN
|
||||||
|
|
||||||
|
### 🔧 Technische Änderungen
|
||||||
|
|
||||||
|
- Neue `SafeSardineWrapper` Klasse stellt korrektes HTTP-Connection-Cleanup sicher
|
||||||
|
- Weniger unnötige 401-Authentifizierungs-Challenges durch preemptive Auth-Header
|
||||||
|
- ProGuard-Regel hinzugefügt um harmlose TextInclusionStrategy-Warnungen zu unterdrücken
|
||||||
|
- VPN-Interface-Erkennung via `NetworkInterface.getNetworkInterfaces()` Pattern-Matching
|
||||||
|
- Foreground Service Erkennung und Notification-System für Hintergrund-Sync-Tasks
|
||||||
|
|
||||||
|
### 🌍 Lokalisierung
|
||||||
|
|
||||||
|
- Hardcodierte deutsche Fehlermeldungen behoben - jetzt String-Resources für korrekte Lokalisierung
|
||||||
|
- Deutsche und englische Strings für Sync-Progress-Notifications hinzugefügt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.0] - 2026-01-26
|
## [1.7.0] - 2026-01-26
|
||||||
|
|
||||||
### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung
|
### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung
|
||||||
@@ -529,8 +640,8 @@ Das komplette UI wurde von XML-Views auf Jetpack Compose migriert. Die App ist j
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||||
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
|
- Complete sync architecture documentation
|
||||||
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
|
- Desktop integration analysis
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
115
CHANGELOG.md
115
CHANGELOG.md
@@ -8,6 +8,117 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.7.2] - 2026-02-04
|
||||||
|
|
||||||
|
### 🐛 Critical Bug Fixes
|
||||||
|
|
||||||
|
#### JSON/Markdown Timestamp Sync
|
||||||
|
|
||||||
|
**Problem:** External editors (Obsidian, Typora, VS Code, custom editors) update Markdown content but don't update YAML `updated:` timestamp, causing the Android app to skip changes.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Server file modification time (`mtime`) is now used as source of truth instead of YAML timestamp
|
||||||
|
- Content changes detected via hash comparison
|
||||||
|
- Notes marked as `PENDING` after Markdown import → JSON automatically re-uploaded on next sync
|
||||||
|
- Fixes sorting issues after external edits
|
||||||
|
|
||||||
|
#### SyncStatus on Server Always PENDING
|
||||||
|
|
||||||
|
**Problem:** All JSON files on server contained `"syncStatus": "PENDING"` even after successful sync, confusing external clients.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Status is now set to `SYNCED` **before** JSON serialization
|
||||||
|
- Server and local copies are now consistent
|
||||||
|
- External web/Tauri editors can correctly interpret sync state
|
||||||
|
|
||||||
|
#### Deletion Tracker Race Condition
|
||||||
|
|
||||||
|
**Problem:** Batch deletes could lose deletion records due to concurrent file access.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Mutex-based synchronization for deletion tracking
|
||||||
|
- New `trackDeletionSafe()` function prevents race conditions
|
||||||
|
- Guarantees zombie note prevention even with rapid deletes
|
||||||
|
|
||||||
|
#### ISO8601 Timezone Parsing
|
||||||
|
|
||||||
|
**Problem:** Markdown imports failed with timezone offsets like `+01:00` or `-05:00`.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Multi-format ISO8601 parser with fallback chain
|
||||||
|
- Supports UTC (Z), timezone offsets (+01:00, +0100), and milliseconds
|
||||||
|
- Compatible with Obsidian, Typora, VS Code timestamps
|
||||||
|
|
||||||
|
### ⚡ Performance Improvements
|
||||||
|
|
||||||
|
#### E-Tag Batch Caching
|
||||||
|
|
||||||
|
- E-Tags are now written in single batch operation instead of N individual writes
|
||||||
|
- Performance gain: ~50-100ms per sync with multiple notes
|
||||||
|
- Reduced disk I/O operations
|
||||||
|
|
||||||
|
#### Memory Leak Prevention
|
||||||
|
|
||||||
|
- `SafeSardineWrapper` now implements `Closeable` for explicit resource cleanup
|
||||||
|
- HTTP connection pool is properly evicted after sync
|
||||||
|
- Prevents socket exhaustion during frequent syncs
|
||||||
|
|
||||||
|
### 🔧 Technical Details
|
||||||
|
|
||||||
|
- **IMPL_001:** `kotlinx.coroutines.sync.Mutex` for thread-safe deletion tracking
|
||||||
|
- **IMPL_002:** Pattern-based ISO8601 parser with 8 format variants
|
||||||
|
- **IMPL_003:** Connection pool eviction + dispatcher shutdown in `close()`
|
||||||
|
- **IMPL_004:** Batch `SharedPreferences.Editor` updates
|
||||||
|
- **IMPL_014:** Server `mtime` parameter in `Note.fromMarkdown()`
|
||||||
|
- **IMPL_015:** `syncStatus` set before `toJson()` call
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- External Editor Specification for web/Tauri editor developers
|
||||||
|
- Detailed implementation documentation for all bugfixes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.7.1] - 2026-02-02
|
||||||
|
|
||||||
|
### 🐛 Critical Bug Fixes
|
||||||
|
|
||||||
|
#### Android 9 App Crash Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
|
||||||
|
|
||||||
|
**Problem:** App crashed on Android 9 (API 28) when using WorkManager Expedited Work for background sync.
|
||||||
|
|
||||||
|
**Root Cause:** When `setExpedited()` is used in WorkManager, the `CoroutineWorker` must implement `getForegroundInfo()` to return a Foreground Service notification. On Android 9-11, WorkManager calls this method, but the default implementation throws `IllegalStateException: Not implemented`.
|
||||||
|
|
||||||
|
**Solution:** Implemented `getForegroundInfo()` in `SyncWorker` to return a proper `ForegroundInfo` with sync progress notification.
|
||||||
|
|
||||||
|
**Details:**
|
||||||
|
- Added `ForegroundInfo` with sync progress notification for Android 9-11
|
||||||
|
- Android 10+: Sets `FOREGROUND_SERVICE_TYPE_DATA_SYNC` for proper service typing
|
||||||
|
- Added Foreground Service permissions to AndroidManifest.xml
|
||||||
|
- Notification shows sync progress with indeterminate progress bar
|
||||||
|
- Thanks to [@roughnecks](https://github.com/roughnecks) for the detailed debugging!
|
||||||
|
|
||||||
|
#### VPN Compatibility Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
|
||||||
|
|
||||||
|
- WiFi socket binding now correctly detects Wireguard VPN interfaces (tun*, wg*, *-wg-*)
|
||||||
|
- Traffic routes through VPN tunnel instead of bypassing it directly to WiFi
|
||||||
|
- Fixes "Connection timeout" when syncing to external servers via VPN
|
||||||
|
|
||||||
|
### 🔧 Technical Changes
|
||||||
|
|
||||||
|
- New `SafeSardineWrapper` class ensures proper HTTP connection cleanup
|
||||||
|
- Reduced unnecessary 401 authentication challenges with preemptive auth headers
|
||||||
|
- Added ProGuard rule to suppress harmless TextInclusionStrategy warnings on older Android versions
|
||||||
|
- VPN interface detection via `NetworkInterface.getNetworkInterfaces()` pattern matching
|
||||||
|
- Foreground Service detection and notification system for background sync tasks
|
||||||
|
|
||||||
|
### 🌍 Localization
|
||||||
|
|
||||||
|
- Fixed hardcoded German error messages - now uses string resources for proper localization
|
||||||
|
- Added German and English strings for sync progress notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.7.0] - 2026-01-26
|
## [1.7.0] - 2026-01-26
|
||||||
|
|
||||||
### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support
|
### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support
|
||||||
@@ -528,8 +639,8 @@ The complete UI has been migrated from XML Views to Jetpack Compose. The app is
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||||
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
|
- Complete sync architecture documentation
|
||||||
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
|
- Desktop integration analysis
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -125,6 +125,18 @@ cd android
|
|||||||
|
|
||||||
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||||
|
|
||||||
|
## 💡 Feature Requests & Ideas
|
||||||
|
|
||||||
|
Have an idea for a new feature or improvement? We'd love to hear it!
|
||||||
|
|
||||||
|
➡️ **How to suggest features:**
|
||||||
|
|
||||||
|
1. Check [existing discussions](https://github.com/inventory69/simple-notes-sync/discussions) to see if someone already suggested it
|
||||||
|
2. If not, start a new discussion in the "Feature Requests / Ideas" category
|
||||||
|
3. Upvote (👍) features you'd like to see
|
||||||
|
|
||||||
|
Features with enough community support will be considered for implementation. Please keep in mind that this app is designed to stay simple and user-friendly.
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 17 // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
versionCode = 19 // 🔧 v1.7.2: Critical Bugfixes (Timestamp Sync, SyncStatus, etc.)
|
||||||
versionName = "1.7.0" // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
versionName = "1.7.2" // 🔧 v1.7.2: Critical Bugfixes
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
6
android/app/proguard-rules.pro
vendored
6
android/app/proguard-rules.pro
vendored
@@ -60,4 +60,8 @@
|
|||||||
-keep class * implements com.google.gson.JsonDeserializer
|
-keep class * implements com.google.gson.JsonDeserializer
|
||||||
|
|
||||||
# Keep your app's data classes
|
# Keep your app's data classes
|
||||||
-keep class dev.dettmer.simplenotes.** { *; }
|
-keep class dev.dettmer.simplenotes.** { *; }
|
||||||
|
|
||||||
|
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
|
||||||
|
# This class only exists on API 35+ but Compose handles the fallback gracefully
|
||||||
|
-dontwarn android.text.Layout$TextInclusionStrategy
|
||||||
|
|||||||
@@ -12,6 +12,11 @@
|
|||||||
<!-- Battery Optimization (for WorkManager background sync) -->
|
<!-- Battery Optimization (for WorkManager background sync) -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<!-- v1.7.1: Foreground Service Type for Android 10+ -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
|
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
@@ -91,6 +96,12 @@
|
|||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<!-- v1.7.1: WorkManager SystemForegroundService for Expedited Work -->
|
||||||
|
<service
|
||||||
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
tools:node="merge" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -133,6 +133,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
||||||
|
logLocaleInfo()
|
||||||
|
|
||||||
findViews()
|
findViews()
|
||||||
setupToolbar()
|
setupToolbar()
|
||||||
setupRecyclerView()
|
setupRecyclerView()
|
||||||
@@ -392,13 +395,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if server is reachable
|
// Check if server is reachable
|
||||||
if (!syncService.isServerReachable()) {
|
if (!syncService.isServerReachable()) {
|
||||||
SyncStateManager.markError("Server nicht erreichbar")
|
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +409,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val result = syncService.syncNotes()
|
val result = syncService.syncNotes()
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||||
loadNotes()
|
loadNotes()
|
||||||
} else {
|
} else {
|
||||||
SyncStateManager.markError(result.errorMessage)
|
SyncStateManager.markError(result.errorMessage)
|
||||||
@@ -672,7 +675,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
val message = getString(R.string.toast_already_synced)
|
||||||
|
SyncStateManager.markCompleted(message)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,7 +687,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
if (!isReachable) {
|
if (!isReachable) {
|
||||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||||
SyncStateManager.markError("Server nicht erreichbar")
|
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,4 +818,39 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
|
||||||
|
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
|
||||||
|
*/
|
||||||
|
private fun logLocaleInfo() {
|
||||||
|
if (!BuildConfig.DEBUG) return
|
||||||
|
|
||||||
|
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
|
||||||
|
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
|
||||||
|
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
|
||||||
|
|
||||||
|
// System Locale
|
||||||
|
val systemLocale = java.util.Locale.getDefault()
|
||||||
|
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
|
||||||
|
|
||||||
|
// Resources Locale
|
||||||
|
val resourcesLocale = resources.configuration.locales[0]
|
||||||
|
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
|
||||||
|
|
||||||
|
// Context Locale (API 24+)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
val contextLocales = resources.configuration.locales
|
||||||
|
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test String Loading
|
||||||
|
val testString = getString(R.string.toast_already_synced)
|
||||||
|
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
|
||||||
|
Logger.d(TAG, "║ Result: '$testString'")
|
||||||
|
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
|
||||||
|
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
|
||||||
|
|
||||||
|
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
showToast("✅ Bereits synchronisiert")
|
showToast(getString(R.string.toast_already_synced))
|
||||||
SyncStateManager.markCompleted()
|
SyncStateManager.markCompleted()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -608,8 +608,8 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
|
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
|
||||||
if (!syncService.isServerReachable()) {
|
if (!syncService.isServerReachable()) {
|
||||||
showToast("⚠️ Server nicht erreichbar")
|
showToast("⚠️ ${getString(R.string.snackbar_server_unreachable)}")
|
||||||
SyncStateManager.markError("Server nicht erreichbar")
|
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||||
checkServerStatus() // Server-Status aktualisieren
|
checkServerStatus() // Server-Status aktualisieren
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ class SimpleNotesApplication : Application() {
|
|||||||
|
|
||||||
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
|
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🌍 v1.7.1: Apply app locale to Application Context
|
||||||
|
*
|
||||||
|
* This ensures ViewModels and other components using Application Context
|
||||||
|
* get the correct locale-specific strings.
|
||||||
|
*/
|
||||||
|
override fun attachBaseContext(base: Context) {
|
||||||
|
// Apply the app locale before calling super
|
||||||
|
// This is handled by AppCompatDelegate which reads from system storage
|
||||||
|
super.attachBaseContext(base)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
|||||||
@@ -210,11 +210,13 @@ type: ${noteType.name.lowercase()}
|
|||||||
/**
|
/**
|
||||||
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||||
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||||
|
* 🔧 v1.7.2 (IMPL_014): Optional serverModifiedTime für korrekte Timestamp-Sync
|
||||||
*
|
*
|
||||||
* @param md Markdown-String mit YAML Frontmatter
|
* @param md Markdown-String mit YAML Frontmatter
|
||||||
|
* @param serverModifiedTime Optionaler Server-Datei mtime (Priorität über YAML timestamp)
|
||||||
* @return Note-Objekt oder null bei Parse-Fehler
|
* @return Note-Objekt oder null bei Parse-Fehler
|
||||||
*/
|
*/
|
||||||
fun fromMarkdown(md: String): Note? {
|
fun fromMarkdown(md: String, serverModifiedTime: Long? = null): Note? {
|
||||||
return try {
|
return try {
|
||||||
// Parse YAML Frontmatter + Markdown Content
|
// Parse YAML Frontmatter + Markdown Content
|
||||||
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||||
@@ -279,12 +281,22 @@ type: ${noteType.name.lowercase()}
|
|||||||
checklistItems = null
|
checklistItems = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_014): Server mtime hat Priorität über YAML timestamp
|
||||||
|
val yamlUpdatedAt = parseISO8601(metadata["updated"] ?: "")
|
||||||
|
val effectiveUpdatedAt = when {
|
||||||
|
serverModifiedTime != null && serverModifiedTime > yamlUpdatedAt -> {
|
||||||
|
Logger.d(TAG, "Using server mtime ($serverModifiedTime) over YAML ($yamlUpdatedAt)")
|
||||||
|
serverModifiedTime
|
||||||
|
}
|
||||||
|
else -> yamlUpdatedAt
|
||||||
|
}
|
||||||
|
|
||||||
Note(
|
Note(
|
||||||
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||||
title = title,
|
title = title,
|
||||||
content = content,
|
content = content,
|
||||||
createdAt = parseISO8601(metadata["created"] ?: ""),
|
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||||
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
updatedAt = effectiveUpdatedAt,
|
||||||
deviceId = metadata["device"] ?: "desktop",
|
deviceId = metadata["device"] ?: "desktop",
|
||||||
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
||||||
noteType = noteType,
|
noteType = noteType,
|
||||||
@@ -307,18 +319,71 @@ type: ${noteType.name.lowercase()}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
|
* 🔧 v1.7.2 (IMPL_002): Robustes ISO8601 Parsing mit Multi-Format Unterstützung
|
||||||
|
*
|
||||||
|
* Unterstützte Formate (in Prioritätsreihenfolge):
|
||||||
|
* 1. 2024-12-21T18:00:00Z (UTC mit Z)
|
||||||
|
* 2. 2024-12-21T18:00:00+01:00 (mit Offset)
|
||||||
|
* 3. 2024-12-21T18:00:00+0100 (Offset ohne Doppelpunkt)
|
||||||
|
* 4. 2024-12-21T18:00:00.123Z (mit Millisekunden)
|
||||||
|
* 5. 2024-12-21T18:00:00.123+01:00 (Millisekunden + Offset)
|
||||||
|
* 6. 2024-12-21 18:00:00 (Leerzeichen statt T)
|
||||||
|
*
|
||||||
* Fallback: Aktueller Timestamp bei Fehler
|
* Fallback: Aktueller Timestamp bei Fehler
|
||||||
|
*
|
||||||
|
* @param dateString ISO8601 Datum-String
|
||||||
|
* @return Unix Timestamp in Millisekunden
|
||||||
*/
|
*/
|
||||||
private fun parseISO8601(dateString: String): Long {
|
private fun parseISO8601(dateString: String): Long {
|
||||||
return try {
|
if (dateString.isBlank()) {
|
||||||
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
return System.currentTimeMillis()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalisiere: Leerzeichen → T
|
||||||
|
val normalized = dateString.trim().replace(' ', 'T')
|
||||||
|
|
||||||
|
// Format-Patterns in Prioritätsreihenfolge
|
||||||
|
val patterns = listOf(
|
||||||
|
// Mit Timezone Z
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss'Z'",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||||
|
|
||||||
|
// Mit Offset XXX (+01:00)
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssXXX",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||||
|
|
||||||
|
// Mit Offset ohne Doppelpunkt (+0100)
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssZ",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSSZ",
|
||||||
|
|
||||||
|
// Ohne Timezone (interpretiere als UTC)
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss",
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSS"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Versuche alle Patterns nacheinander
|
||||||
|
for (pattern in patterns) {
|
||||||
|
@Suppress("SwallowedException") // Intentional: try all patterns before logging
|
||||||
|
try {
|
||||||
|
val sdf = SimpleDateFormat(pattern, Locale.US)
|
||||||
|
// Für Patterns ohne Timezone: UTC annehmen
|
||||||
|
if (!pattern.contains("XXX") && !pattern.contains("Z")) {
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val parsed = sdf.parse(normalized)
|
||||||
|
if (parsed != null) {
|
||||||
|
return parsed.time
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 🔇 Exception intentionally swallowed - try next pattern
|
||||||
|
// Only log if no pattern matches (see fallback below)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback wenn kein Pattern passt
|
||||||
|
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString' with any pattern, using current time")
|
||||||
|
return System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import dev.dettmer.simplenotes.models.DeletionTracker
|
|||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class NotesStorage(private val context: Context) {
|
class NotesStorage(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NotesStorage"
|
private const val TAG = "NotesStorage"
|
||||||
|
// 🔒 v1.7.2 (IMPL_001): Mutex für thread-sichere Deletion Tracker Operationen
|
||||||
|
private val deletionTrackerMutex = Mutex()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||||
@@ -107,6 +111,30 @@ class NotesStorage(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 v1.7.2 (IMPL_001): Thread-sichere Deletion-Tracking mit Mutex
|
||||||
|
*
|
||||||
|
* Verhindert Race Conditions bei Batch-Deletes durch exklusiven Zugriff
|
||||||
|
* auf den Deletion Tracker.
|
||||||
|
*
|
||||||
|
* @param noteId ID der gelöschten Notiz
|
||||||
|
* @param deviceId Geräte-ID für Konflikt-Erkennung
|
||||||
|
*/
|
||||||
|
suspend fun trackDeletionSafe(noteId: String, deviceId: String) {
|
||||||
|
deletionTrackerMutex.withLock {
|
||||||
|
val tracker = loadDeletionTracker()
|
||||||
|
tracker.addDeletion(noteId, deviceId)
|
||||||
|
saveDeletionTracker(tracker)
|
||||||
|
Logger.d(TAG, "📝 Tracked deletion (mutex-protected): $noteId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy-Methode ohne Mutex-Schutz.
|
||||||
|
* Verwendet für synchrone Aufrufe wo Coroutines nicht verfügbar sind.
|
||||||
|
*
|
||||||
|
* @deprecated Verwende trackDeletionSafe() für Thread-Safety wo möglich
|
||||||
|
*/
|
||||||
fun trackDeletion(noteId: String, deviceId: String) {
|
fun trackDeletion(noteId: String, deviceId: String) {
|
||||||
val tracker = loadDeletionTracker()
|
val tracker = loadDeletionTracker()
|
||||||
tracker.addDeletion(noteId, deviceId)
|
tracker.addDeletion(noteId, deviceId)
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package dev.dettmer.simplenotes.sync
|
||||||
|
|
||||||
|
import com.thegrizzlylabs.sardineandroid.DavResource
|
||||||
|
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||||
|
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert
|
||||||
|
* 🔧 v1.7.2 (IMPL_003): Implementiert Closeable für explizites Resource-Management
|
||||||
|
*
|
||||||
|
* Hintergrund:
|
||||||
|
* - OkHttpSardine.exists() schließt den Response-Body nicht
|
||||||
|
* - Dies führt zu "connection leaked" Warnungen im Log
|
||||||
|
* - Kann bei vielen Requests zu Socket-Exhaustion führen
|
||||||
|
* - Session-Cache hält Referenzen ohne explizites Cleanup
|
||||||
|
*
|
||||||
|
* Lösung:
|
||||||
|
* - Eigene exists()-Implementation mit korrektem Response-Cleanup
|
||||||
|
* - Preemptive Authentication um 401-Round-Trips zu vermeiden
|
||||||
|
* - Closeable Pattern für explizite Resource-Freigabe
|
||||||
|
*
|
||||||
|
* @see <a href="https://square.github.io/okhttp/4.x/okhttp/okhttp3/-response-body/">OkHttp Response Body Docs</a>
|
||||||
|
*/
|
||||||
|
class SafeSardineWrapper private constructor(
|
||||||
|
private val delegate: OkHttpSardine,
|
||||||
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val authHeader: String
|
||||||
|
) : Sardine by delegate, Closeable {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SafeSardine"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory-Methode für SafeSardineWrapper
|
||||||
|
*/
|
||||||
|
fun create(
|
||||||
|
okHttpClient: OkHttpClient,
|
||||||
|
username: String,
|
||||||
|
password: String
|
||||||
|
): SafeSardineWrapper {
|
||||||
|
val delegate = OkHttpSardine(okHttpClient).apply {
|
||||||
|
setCredentials(username, password)
|
||||||
|
}
|
||||||
|
val authHeader = Credentials.basic(username, password)
|
||||||
|
return SafeSardineWrapper(delegate, okHttpClient, authHeader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.7.2 (IMPL_003): Track ob bereits geschlossen
|
||||||
|
@Volatile
|
||||||
|
private var isClosed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Sichere exists()-Implementation mit Response Cleanup
|
||||||
|
*
|
||||||
|
* Im Gegensatz zu OkHttpSardine.exists() wird hier:
|
||||||
|
* 1. Preemptive Auth-Header gesendet (kein 401 Round-Trip)
|
||||||
|
* 2. Response.use{} für garantiertes Cleanup verwendet
|
||||||
|
*/
|
||||||
|
override fun exists(url: String): Boolean {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.head()
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
okHttpClient.newCall(request).execute().use { response ->
|
||||||
|
val isSuccess = response.isSuccessful
|
||||||
|
Logger.d(TAG, "exists($url) → $isSuccess (${response.code})")
|
||||||
|
isSuccess
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.d(TAG, "exists($url) failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Wrapper um get() mit Logging
|
||||||
|
*
|
||||||
|
* WICHTIG: Der zurückgegebene InputStream MUSS vom Caller geschlossen werden!
|
||||||
|
* Empfohlen: inputStream.bufferedReader().use { it.readText() }
|
||||||
|
*/
|
||||||
|
override fun get(url: String): InputStream {
|
||||||
|
Logger.d(TAG, "get($url)")
|
||||||
|
return delegate.get(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Wrapper um list() mit Logging
|
||||||
|
*/
|
||||||
|
override fun list(url: String): List<DavResource> {
|
||||||
|
Logger.d(TAG, "list($url)")
|
||||||
|
return delegate.list(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ✅ Wrapper um list(url, depth) mit Logging
|
||||||
|
*/
|
||||||
|
override fun list(url: String, depth: Int): List<DavResource> {
|
||||||
|
Logger.d(TAG, "list($url, depth=$depth)")
|
||||||
|
return delegate.list(url, depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v1.7.2 (IMPL_003): Schließt alle offenen Verbindungen
|
||||||
|
*
|
||||||
|
* Wichtig: Nach close() darf der Client nicht mehr verwendet werden!
|
||||||
|
* Eviction von Connection Pool Einträgen und Cleanup von internen Ressourcen.
|
||||||
|
*/
|
||||||
|
override fun close() {
|
||||||
|
if (isClosed) {
|
||||||
|
Logger.d(TAG, "Already closed, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// OkHttpClient Connection Pool räumen
|
||||||
|
okHttpClient.connectionPool.evictAll()
|
||||||
|
|
||||||
|
// Dispatcher shutdown (beendet laufende Calls)
|
||||||
|
okHttpClient.dispatcher.cancelAll()
|
||||||
|
|
||||||
|
isClosed = true
|
||||||
|
Logger.d(TAG, "✅ Closed successfully (connections evicted)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to close", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet
|
||||||
|
}
|
||||||
@@ -5,8 +5,11 @@ package dev.dettmer.simplenotes.sync
|
|||||||
import android.app.ActivityManager
|
import android.app.ActivityManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ForegroundInfo
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
@@ -26,6 +29,35 @@ class SyncWorker(
|
|||||||
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
|
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔧 v1.7.2: Required for expedited work on Android 9-11
|
||||||
|
*
|
||||||
|
* WorkManager ruft diese Methode auf um die Foreground-Notification zu erstellen
|
||||||
|
* wenn der Worker als Expedited Work gestartet wird.
|
||||||
|
*
|
||||||
|
* Ab Android 12+ wird diese Methode NICHT aufgerufen (neue Expedited API).
|
||||||
|
* Auf Android 9-11 MUSS diese Methode implementiert sein!
|
||||||
|
*
|
||||||
|
* @see https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#foregroundinfo
|
||||||
|
*/
|
||||||
|
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||||
|
val notification = NotificationHelper.createSyncProgressNotification(applicationContext)
|
||||||
|
|
||||||
|
// Android 10+ benötigt foregroundServiceType
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
ForegroundInfo(
|
||||||
|
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ForegroundInfo(
|
||||||
|
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
|
||||||
|
notification
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft ob die App im Vordergrund ist.
|
* Prüft ob die App im Vordergrund ist.
|
||||||
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
|
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import com.thegrizzlylabs.sardineandroid.Sardine
|
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
|
||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||||
@@ -18,14 +17,11 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.net.Inet4Address
|
|
||||||
import java.net.InetAddress
|
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.NetworkInterface
|
import java.net.NetworkInterface
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import javax.net.SocketFactory
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of manual Markdown sync operation
|
* Result of manual Markdown sync operation
|
||||||
@@ -41,7 +37,7 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WebDavSyncService"
|
private const val TAG = "WebDavSyncService"
|
||||||
private const val SOCKET_TIMEOUT_MS = 1000 // 🆕 v1.7.0: Reduziert von 2s auf 1s
|
private const val SOCKET_TIMEOUT_MS = 10000 // 🔧 v1.7.2: 10s für stabile Verbindungen (1s war zu kurz)
|
||||||
private const val MAX_FILENAME_LENGTH = 200
|
private const val MAX_FILENAME_LENGTH = 200
|
||||||
private const val ETAG_PREVIEW_LENGTH = 8
|
private const val ETAG_PREVIEW_LENGTH = 8
|
||||||
private const val CONTENT_PREVIEW_LENGTH = 50
|
private const val CONTENT_PREVIEW_LENGTH = 50
|
||||||
@@ -56,9 +52,7 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz
|
private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz
|
||||||
|
|
||||||
// ⚡ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert)
|
// ⚡ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert)
|
||||||
private var sessionSardine: Sardine? = null
|
private var sessionSardine: SafeSardineWrapper? = null
|
||||||
private var sessionWifiAddress: InetAddress? = null
|
|
||||||
private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgeführt
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
@@ -91,129 +85,38 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚡ v1.3.1: Gecachte WiFi-Adresse zurückgeben oder berechnen
|
* 🔒 v1.7.1: Checks if any VPN/Wireguard interface is active.
|
||||||
|
*
|
||||||
|
* Wireguard VPNs run as separate network interfaces (tun*, wg*, *-wg-*),
|
||||||
|
* and are NOT detected via NetworkCapabilities.TRANSPORT_VPN!
|
||||||
|
*
|
||||||
|
* @return true if VPN interface is detected
|
||||||
*/
|
*/
|
||||||
private fun getOrCacheWiFiAddress(): InetAddress? {
|
@Suppress("unused") // Reserved for future VPN detection feature
|
||||||
// Return cached if already checked this session
|
private fun isVpnInterfaceActive(): Boolean {
|
||||||
if (sessionWifiAddressChecked) {
|
|
||||||
return sessionWifiAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate and cache
|
|
||||||
sessionWifiAddress = getWiFiInetAddressInternal()
|
|
||||||
sessionWifiAddressChecked = true
|
|
||||||
return sessionWifiAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
|
|
||||||
*/
|
|
||||||
@Suppress("ReturnCount") // Early returns for network validation checks
|
|
||||||
private fun getWiFiInetAddressInternal(): InetAddress? {
|
|
||||||
try {
|
try {
|
||||||
Logger.d(TAG, "🔍 getWiFiInetAddress() called")
|
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
|
||||||
|
|
||||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
val network = connectivityManager.activeNetwork
|
|
||||||
Logger.d(TAG, " Active network: $network")
|
|
||||||
|
|
||||||
if (network == null) {
|
|
||||||
Logger.d(TAG, "❌ No active network")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(network)
|
|
||||||
Logger.d(TAG, " Network capabilities: $capabilities")
|
|
||||||
|
|
||||||
if (capabilities == null) {
|
|
||||||
Logger.d(TAG, "❌ No network capabilities")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active
|
|
||||||
// When VPN is active, traffic should route through VPN, not directly via WiFi
|
|
||||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
|
||||||
Logger.d(TAG, "🔒 VPN detected - using default routing (traffic will go through VPN)")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nur wenn WiFi aktiv (und kein VPN)
|
|
||||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
|
||||||
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.d(TAG, "✅ Network is WiFi, searching for interface...")
|
|
||||||
|
|
||||||
@Suppress("LoopWithTooManyJumpStatements") // Network interface filtering requires multiple conditions
|
|
||||||
// Finde WiFi Interface
|
|
||||||
val interfaces = NetworkInterface.getNetworkInterfaces()
|
|
||||||
while (interfaces.hasMoreElements()) {
|
while (interfaces.hasMoreElements()) {
|
||||||
val iface = interfaces.nextElement()
|
val iface = interfaces.nextElement()
|
||||||
|
|
||||||
Logger.d(TAG, " Checking interface: ${iface.name}, isUp=${iface.isUp}")
|
|
||||||
|
|
||||||
// WiFi Interfaces: wlan0, wlan1, etc.
|
|
||||||
if (!iface.name.startsWith("wlan")) continue
|
|
||||||
if (!iface.isUp) continue
|
if (!iface.isUp) continue
|
||||||
|
|
||||||
val addresses = iface.inetAddresses
|
val name = iface.name.lowercase()
|
||||||
while (addresses.hasMoreElements()) {
|
// Check for VPN/Wireguard interface patterns:
|
||||||
val addr = addresses.nextElement()
|
// - tun0, tun1, etc. (OpenVPN, generic VPN)
|
||||||
|
// - wg0, wg1, etc. (Wireguard)
|
||||||
Logger.d(
|
// - *-wg-* (Mullvad, ProtonVPN style: se-sto-wg-202)
|
||||||
TAG,
|
if (name.startsWith("tun") ||
|
||||||
" Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, " +
|
name.startsWith("wg") ||
|
||||||
"loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}"
|
name.contains("-wg-") ||
|
||||||
)
|
name.startsWith("ppp")) {
|
||||||
|
Logger.d(TAG, "🔒 VPN interface detected: ${iface.name}")
|
||||||
// Nur IPv4, nicht loopback, nicht link-local
|
return true
|
||||||
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
|
|
||||||
Logger.d(TAG, "✅ Found WiFi IP: ${addr.hostAddress} on ${iface.name}")
|
|
||||||
return addr
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.w(TAG, "⚠️ No WiFi interface found, using default routing")
|
|
||||||
return null
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "❌ Failed to get WiFi interface", e)
|
Logger.w(TAG, "⚠️ Failed to check VPN interfaces: ${e.message}")
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom SocketFactory die an WiFi-IP bindet (VPN Fix)
|
|
||||||
*/
|
|
||||||
private inner class WiFiSocketFactory(private val wifiAddress: InetAddress) : SocketFactory() {
|
|
||||||
override fun createSocket(): Socket {
|
|
||||||
val socket = Socket()
|
|
||||||
socket.bind(InetSocketAddress(wifiAddress, 0))
|
|
||||||
Logger.d(TAG, "🔌 Socket bound to WiFi IP: ${wifiAddress.hostAddress}")
|
|
||||||
return socket
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createSocket(host: String, port: Int): Socket {
|
|
||||||
val socket = createSocket()
|
|
||||||
socket.connect(InetSocketAddress(host, port))
|
|
||||||
return socket
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
|
|
||||||
return createSocket(host, port)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createSocket(host: InetAddress, port: Int): Socket {
|
|
||||||
val socket = createSocket()
|
|
||||||
socket.connect(InetSocketAddress(host, port))
|
|
||||||
return socket
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
|
|
||||||
return createSocket(address, port)
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -235,39 +138,44 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt einen neuen Sardine-Client (intern)
|
* Erstellt einen neuen Sardine-Client (intern)
|
||||||
|
*
|
||||||
|
* 🆕 v1.7.2: Intelligentes Routing basierend auf Ziel-Adresse
|
||||||
|
* - Lokale Server: WiFi-Binding (bypass VPN)
|
||||||
|
* - Externe Server: Default-Routing (nutzt VPN wenn aktiv)
|
||||||
|
*
|
||||||
|
* 🔧 v1.7.1: Verwendet SafeSardineWrapper statt OkHttpSardine
|
||||||
|
* - Verhindert Connection Leaks durch proper Response-Cleanup
|
||||||
|
* - Preemptive Authentication für weniger 401-Round-Trips
|
||||||
*/
|
*/
|
||||||
private fun createSardineClient(): Sardine? {
|
private fun createSardineClient(): SafeSardineWrapper? {
|
||||||
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
|
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
|
||||||
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
|
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
|
||||||
|
|
||||||
Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding")
|
Logger.d(TAG, "🔧 Creating SafeSardineWrapper")
|
||||||
Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
|
|
||||||
|
|
||||||
// ⚡ v1.3.1: Verwende gecachte WiFi-Adresse
|
val okHttpClient = OkHttpClient.Builder()
|
||||||
val wifiAddress = getOrCacheWiFiAddress()
|
.connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
val okHttpClient = if (wifiAddress != null) {
|
return SafeSardineWrapper.create(okHttpClient, username, password)
|
||||||
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.socketFactory(WiFiSocketFactory(wifiAddress))
|
|
||||||
.build()
|
|
||||||
} else {
|
|
||||||
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
|
|
||||||
OkHttpClient.Builder().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
return OkHttpSardine(okHttpClient).apply {
|
|
||||||
setCredentials(username, password)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚡ v1.3.1: Session-Caches leeren (am Ende von syncNotes)
|
* ⚡ v1.3.1: Session-Caches leeren (am Ende von syncNotes)
|
||||||
|
* 🔧 v1.7.2 (IMPL_003): Schließt Sardine-Client explizit für Resource-Cleanup
|
||||||
*/
|
*/
|
||||||
private fun clearSessionCache() {
|
private fun clearSessionCache() {
|
||||||
|
// 🆕 v1.7.2: Explizites Schließen des Sardine-Clients
|
||||||
|
sessionSardine?.let { sardine ->
|
||||||
|
try {
|
||||||
|
sardine.close()
|
||||||
|
Logger.d(TAG, "🧹 Sardine client closed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to close Sardine client: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sessionSardine = null
|
sessionSardine = null
|
||||||
sessionWifiAddress = null
|
|
||||||
sessionWifiAddressChecked = false
|
|
||||||
notesDirEnsured = false
|
notesDirEnsured = false
|
||||||
markdownDirEnsured = false
|
markdownDirEnsured = false
|
||||||
Logger.d(TAG, "🧹 Session caches cleared")
|
Logger.d(TAG, "🧹 Session caches cleared")
|
||||||
@@ -394,8 +302,10 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val notesUrl = getNotesUrl(serverUrl)
|
val notesUrl = getNotesUrl(serverUrl)
|
||||||
|
// 🔧 v1.7.2: Exception wird NICHT gefangen - muss nach oben propagieren!
|
||||||
|
// Wenn sardine.exists() timeout hat, soll hasUnsyncedChanges() das behandeln
|
||||||
if (!sardine.exists(notesUrl)) {
|
if (!sardine.exists(notesUrl)) {
|
||||||
Logger.d(TAG, "📁 /notes/ doesn't exist - no server changes")
|
Logger.d(TAG, "📁 /notes/ doesn't exist - assuming no server changes")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,8 +434,11 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
hasServerChanges
|
hasServerChanges
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "Failed to check for unsynced changes", e)
|
// 🔧 v1.7.2 KRITISCH: Bei Server-Fehler (Timeout, etc.) return TRUE!
|
||||||
true // Safe default
|
// Grund: Besser fälschlich synchen als "Already synced" zeigen obwohl Server nicht erreichbar
|
||||||
|
Logger.e(TAG, "❌ Failed to check server for changes: ${e.message}")
|
||||||
|
Logger.d(TAG, "⚠️ Returning TRUE (will attempt sync) - server check failed")
|
||||||
|
true // Sicherheitshalber TRUE → Sync wird versucht und gibt dann echte Fehlermeldung
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,19 +561,19 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
SyncResult(
|
SyncResult(
|
||||||
isSuccess = false,
|
isSuccess = false,
|
||||||
errorMessage = when (e) {
|
errorMessage = when (e) {
|
||||||
is java.net.UnknownHostException -> "Server nicht erreichbar"
|
is java.net.UnknownHostException -> context.getString(R.string.snackbar_server_unreachable)
|
||||||
is java.net.SocketTimeoutException -> "Verbindungs-Timeout"
|
is java.net.SocketTimeoutException -> context.getString(R.string.snackbar_connection_timeout)
|
||||||
is javax.net.ssl.SSLException -> "SSL-Fehler"
|
is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl)
|
||||||
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
|
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
|
||||||
when (e.statusCode) {
|
when (e.statusCode) {
|
||||||
401 -> "Authentifizierung fehlgeschlagen"
|
401 -> context.getString(R.string.sync_error_auth_failed)
|
||||||
403 -> "Zugriff verweigert"
|
403 -> context.getString(R.string.sync_error_access_denied)
|
||||||
404 -> "Server-Pfad nicht gefunden"
|
404 -> context.getString(R.string.sync_error_path_not_found)
|
||||||
500 -> "Server-Fehler"
|
500 -> context.getString(R.string.sync_error_server)
|
||||||
else -> "HTTP-Fehler: ${e.statusCode}"
|
else -> context.getString(R.string.sync_error_http, e.statusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> e.message ?: "Unbekannter Fehler"
|
else -> e.message ?: context.getString(R.string.sync_error_unknown)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -771,6 +684,14 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
Logger.d(TAG, "📥 Auto-importing Markdown files...")
|
Logger.d(TAG, "📥 Auto-importing Markdown files...")
|
||||||
markdownImportedCount = importMarkdownFiles(sardine, serverUrl)
|
markdownImportedCount = importMarkdownFiles(sardine, serverUrl)
|
||||||
Logger.d(TAG, "✅ Auto-imported: $markdownImportedCount Markdown files")
|
Logger.d(TAG, "✅ Auto-imported: $markdownImportedCount Markdown files")
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_014): Re-upload notes that were updated from Markdown
|
||||||
|
if (markdownImportedCount > 0) {
|
||||||
|
Logger.d(TAG, "📤 Re-uploading notes updated from Markdown (JSON sync)...")
|
||||||
|
val reUploadedCount = uploadLocalNotes(sardine, serverUrl)
|
||||||
|
Logger.d(TAG, "✅ Re-uploaded: $reUploadedCount notes (JSON updated on server)")
|
||||||
|
syncedCount += reUploadedCount
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, "⏭️ Markdown auto-import disabled")
|
Logger.d(TAG, "⏭️ Markdown auto-import disabled")
|
||||||
}
|
}
|
||||||
@@ -823,19 +744,19 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
SyncResult(
|
SyncResult(
|
||||||
isSuccess = false,
|
isSuccess = false,
|
||||||
errorMessage = when (e) {
|
errorMessage = when (e) {
|
||||||
is java.net.UnknownHostException -> "Server nicht erreichbar: ${e.message}"
|
is java.net.UnknownHostException -> "${context.getString(R.string.snackbar_server_unreachable)}: ${e.message}"
|
||||||
is java.net.SocketTimeoutException -> "Verbindungs-Timeout: ${e.message}"
|
is java.net.SocketTimeoutException -> "${context.getString(R.string.snackbar_connection_timeout)}: ${e.message}"
|
||||||
is javax.net.ssl.SSLException -> "SSL-Fehler"
|
is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl)
|
||||||
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
|
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
|
||||||
when (e.statusCode) {
|
when (e.statusCode) {
|
||||||
401 -> "Authentifizierung fehlgeschlagen"
|
401 -> context.getString(R.string.sync_error_auth_failed)
|
||||||
403 -> "Zugriff verweigert"
|
403 -> context.getString(R.string.sync_error_access_denied)
|
||||||
404 -> "Server-Pfad nicht gefunden"
|
404 -> context.getString(R.string.sync_error_path_not_found)
|
||||||
500 -> "Server-Fehler"
|
500 -> context.getString(R.string.sync_error_server)
|
||||||
else -> "HTTP-Fehler: ${e.statusCode}"
|
else -> context.getString(R.string.sync_error_http, e.statusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> e.message ?: "Unbekannter Fehler"
|
else -> e.message ?: context.getString(R.string.sync_error_unknown)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -854,49 +775,53 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val localNotes = storage.loadAllNotes()
|
val localNotes = storage.loadAllNotes()
|
||||||
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_004): Batch E-Tag Updates für Performance
|
||||||
|
val etagUpdates = mutableMapOf<String, String?>()
|
||||||
|
|
||||||
for (note in localNotes) {
|
for (note in localNotes) {
|
||||||
try {
|
try {
|
||||||
// 1. JSON-Upload (Task #1.2.1-13: nutzt getNotesUrl())
|
// 1. JSON-Upload (Task #1.2.1-13: nutzt getNotesUrl())
|
||||||
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
||||||
val notesUrl = getNotesUrl(serverUrl)
|
val notesUrl = getNotesUrl(serverUrl)
|
||||||
val noteUrl = "$notesUrl${note.id}.json"
|
val noteUrl = "$notesUrl${note.id}.json"
|
||||||
val jsonBytes = note.toJson().toByteArray()
|
|
||||||
|
// 🔧 v1.7.2 FIX (IMPL_015): Status VOR Serialisierung auf SYNCED setzen
|
||||||
|
// Verhindert dass Server-JSON "syncStatus": "PENDING" enthält
|
||||||
|
val noteToUpload = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||||
|
val jsonBytes = noteToUpload.toJson().toByteArray()
|
||||||
|
|
||||||
Logger.d(TAG, " 📤 Uploading: ${note.id}.json (${note.title})")
|
Logger.d(TAG, " 📤 Uploading: ${note.id}.json (${note.title})")
|
||||||
sardine.put(noteUrl, jsonBytes, "application/json")
|
sardine.put(noteUrl, jsonBytes, "application/json")
|
||||||
Logger.d(TAG, " ✅ Upload successful")
|
Logger.d(TAG, " ✅ Upload successful")
|
||||||
|
|
||||||
// Update sync status
|
// Lokale Kopie auch mit SYNCED speichern
|
||||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
storage.saveNote(noteToUpload)
|
||||||
storage.saveNote(updatedNote)
|
|
||||||
uploadedCount++
|
uploadedCount++
|
||||||
|
|
||||||
// ⚡ v1.3.1: Refresh E-Tag after upload to prevent re-download
|
// ⚡ v1.3.1: Refresh E-Tag after upload to prevent re-download
|
||||||
// Get new E-Tag from server via PROPFIND
|
// 🔧 v1.7.2 (IMPL_004): Sammle E-Tags für Batch-Update
|
||||||
try {
|
try {
|
||||||
val uploadedResource = sardine.list(noteUrl, 0).firstOrNull()
|
val uploadedResource = sardine.list(noteUrl, 0).firstOrNull()
|
||||||
val newETag = uploadedResource?.etag
|
val newETag = uploadedResource?.etag
|
||||||
|
etagUpdates["etag_json_${note.id}"] = newETag
|
||||||
if (newETag != null) {
|
if (newETag != null) {
|
||||||
prefs.edit().putString("etag_json_${note.id}", newETag).apply()
|
Logger.d(TAG, " ⚡ Queued E-Tag: ${newETag.take(ETAG_PREVIEW_LENGTH)}")
|
||||||
Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(ETAG_PREVIEW_LENGTH)}")
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: invalidate if server doesn't provide E-Tag
|
Logger.d(TAG, " ⚠️ No E-Tag from server, will invalidate")
|
||||||
prefs.edit().remove("etag_json_${note.id}").apply()
|
|
||||||
Logger.d(TAG, " ⚠️ No E-Tag from server, invalidated cache")
|
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.w(TAG, " ⚠️ Failed to refresh E-Tag: ${e.message}")
|
Logger.w(TAG, " ⚠️ Failed to get E-Tag: ${e.message}")
|
||||||
prefs.edit().remove("etag_json_${note.id}").apply()
|
etagUpdates["etag_json_${note.id}"] = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Markdown-Export (NEU in v1.2.0)
|
// 2. Markdown-Export (NEU in v1.2.0)
|
||||||
// Läuft NACH erfolgreichem JSON-Upload
|
// Läuft NACH erfolgreichem JSON-Upload
|
||||||
if (markdownExportEnabled) {
|
if (markdownExportEnabled) {
|
||||||
try {
|
try {
|
||||||
exportToMarkdown(sardine, serverUrl, note)
|
exportToMarkdown(sardine, serverUrl, noteToUpload)
|
||||||
Logger.d(TAG, " 📝 MD exported: ${note.title}")
|
Logger.d(TAG, " 📝 MD exported: ${noteToUpload.title}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "MD-Export failed for ${note.id}: ${e.message}")
|
Logger.e(TAG, "MD-Export failed for ${noteToUpload.id}: ${e.message}")
|
||||||
// Kein throw! JSON-Sync darf nicht blockiert werden
|
// Kein throw! JSON-Sync darf nicht blockiert werden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -909,9 +834,45 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 v1.7.2 (IMPL_004): Batch-Update aller E-Tags in einer Operation
|
||||||
|
if (etagUpdates.isNotEmpty()) {
|
||||||
|
batchUpdateETags(etagUpdates)
|
||||||
|
}
|
||||||
|
|
||||||
return uploadedCount
|
return uploadedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔧 v1.7.2 (IMPL_004): Batch-Update von E-Tags
|
||||||
|
*
|
||||||
|
* Schreibt alle E-Tags in einer einzelnen I/O-Operation statt einzeln.
|
||||||
|
* Performance-Gewinn: ~50-100ms pro Batch (statt N × apply())
|
||||||
|
*
|
||||||
|
* @param updates Map von E-Tag Keys zu Values (null = remove)
|
||||||
|
*/
|
||||||
|
private fun batchUpdateETags(updates: Map<String, String?>) {
|
||||||
|
try {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
var putCount = 0
|
||||||
|
var removeCount = 0
|
||||||
|
|
||||||
|
updates.forEach { (key, value) ->
|
||||||
|
if (value != null) {
|
||||||
|
editor.putString(key, value)
|
||||||
|
putCount++
|
||||||
|
} else {
|
||||||
|
editor.remove(key)
|
||||||
|
removeCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.apply()
|
||||||
|
Logger.d(TAG, "⚡ Batch-updated E-Tags: $putCount saved, $removeCount removed")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to batch-update E-Tags", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
|
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
|
||||||
*
|
*
|
||||||
@@ -1017,22 +978,11 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
): Int = withContext(Dispatchers.IO) {
|
): Int = withContext(Dispatchers.IO) {
|
||||||
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
|
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
|
||||||
|
|
||||||
// ⚡ v1.3.1: Use cached WiFi address
|
val okHttpClient = OkHttpClient.Builder()
|
||||||
val wifiAddress = getOrCacheWiFiAddress()
|
.connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
val okHttpClient = if (wifiAddress != null) {
|
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||||
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
|
|
||||||
OkHttpClient.Builder()
|
|
||||||
.socketFactory(WiFiSocketFactory(wifiAddress))
|
|
||||||
.build()
|
|
||||||
} else {
|
|
||||||
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
|
|
||||||
OkHttpClient.Builder().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
val sardine = OkHttpSardine(okHttpClient).apply {
|
|
||||||
setCredentials(username, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
val mdUrl = getMarkdownUrl(serverUrl)
|
val mdUrl = getMarkdownUrl(serverUrl)
|
||||||
|
|
||||||
@@ -1146,9 +1096,32 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
"modified=$serverModified lastSync=$lastSyncTime"
|
"modified=$serverModified lastSync=$lastSyncTime"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FIRST: Check deletion tracker - if locally deleted, skip unless re-created on server
|
||||||
|
if (deletionTracker.isDeleted(noteId)) {
|
||||||
|
val deletedAt = deletionTracker.getDeletionTimestamp(noteId)
|
||||||
|
|
||||||
|
// Smart check: Was note re-created on server after deletion?
|
||||||
|
if (deletedAt != null && serverModified > deletedAt) {
|
||||||
|
Logger.d(TAG, " 📝 Note re-created on server after deletion: $noteId")
|
||||||
|
deletionTracker.removeDeletion(noteId)
|
||||||
|
trackerModified = true
|
||||||
|
// Continue with download below
|
||||||
|
} else {
|
||||||
|
Logger.d(TAG, " ⏭️ Skipping deleted note: $noteId")
|
||||||
|
skippedDeleted++
|
||||||
|
processedIds.add(noteId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if file exists locally
|
||||||
|
val localNote = storage.loadNote(noteId)
|
||||||
|
val fileExistsLocally = localNote != null
|
||||||
|
|
||||||
// PRIMARY: Timestamp check (works on first sync!)
|
// PRIMARY: Timestamp check (works on first sync!)
|
||||||
// Same logic as Markdown sync - skip if not modified since last sync
|
// Same logic as Markdown sync - skip if not modified since last sync
|
||||||
if (!forceOverwrite && lastSyncTime > 0 && serverModified <= lastSyncTime) {
|
// BUT: Always download if file doesn't exist locally!
|
||||||
|
if (!forceOverwrite && fileExistsLocally && lastSyncTime > 0 && serverModified <= lastSyncTime) {
|
||||||
skippedUnchanged++
|
skippedUnchanged++
|
||||||
Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)")
|
Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)")
|
||||||
processedIds.add(noteId)
|
processedIds.add(noteId)
|
||||||
@@ -1157,13 +1130,19 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
// SECONDARY: E-Tag check (for performance after first sync)
|
// SECONDARY: E-Tag check (for performance after first sync)
|
||||||
// Catches cases where file was re-uploaded with same content
|
// Catches cases where file was re-uploaded with same content
|
||||||
if (!forceOverwrite && serverETag != null && serverETag == cachedETag) {
|
// BUT: Always download if file doesn't exist locally!
|
||||||
|
if (!forceOverwrite && fileExistsLocally && serverETag != null && serverETag == cachedETag) {
|
||||||
skippedUnchanged++
|
skippedUnchanged++
|
||||||
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)")
|
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)")
|
||||||
processedIds.add(noteId)
|
processedIds.add(noteId)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If file doesn't exist locally, always download
|
||||||
|
if (!fileExistsLocally) {
|
||||||
|
Logger.d(TAG, " 📥 File missing locally - forcing download")
|
||||||
|
}
|
||||||
|
|
||||||
// 🐛 DEBUG: Log download reason
|
// 🐛 DEBUG: Log download reason
|
||||||
val downloadReason = when {
|
val downloadReason = when {
|
||||||
lastSyncTime == 0L -> "First sync ever"
|
lastSyncTime == 0L -> "First sync ever"
|
||||||
@@ -1180,28 +1159,9 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
||||||
val remoteNote = Note.fromJson(jsonContent) ?: continue
|
val remoteNote = Note.fromJson(jsonContent) ?: continue
|
||||||
|
|
||||||
// NEW: Check if note was deleted locally
|
|
||||||
if (deletionTracker.isDeleted(remoteNote.id)) {
|
|
||||||
val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id)
|
|
||||||
|
|
||||||
// Smart check: Was note re-created on server after deletion?
|
|
||||||
if (deletedAt != null && remoteNote.updatedAt > deletedAt) {
|
|
||||||
Logger.d(TAG, " 📝 Note re-created on server after deletion: ${remoteNote.id}")
|
|
||||||
deletionTracker.removeDeletion(remoteNote.id)
|
|
||||||
trackerModified = true
|
|
||||||
// Continue with download below
|
|
||||||
} else {
|
|
||||||
Logger.d(TAG, " ⏭️ Skipping deleted note: ${remoteNote.id}")
|
|
||||||
skippedDeleted++
|
|
||||||
processedIds.add(remoteNote.id)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
processedIds.add(remoteNote.id) // 🆕 Mark as processed
|
processedIds.add(remoteNote.id) // 🆕 Mark as processed
|
||||||
|
|
||||||
val localNote = storage.loadNote(remoteNote.id)
|
// Note: localNote was already loaded above for existence check
|
||||||
|
|
||||||
when {
|
when {
|
||||||
localNote == null -> {
|
localNote == null -> {
|
||||||
// New note from server
|
// New note from server
|
||||||
@@ -1544,8 +1504,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
return@withContext try {
|
return@withContext try {
|
||||||
Logger.d(TAG, "📝 Starting Markdown sync...")
|
Logger.d(TAG, "📝 Starting Markdown sync...")
|
||||||
|
|
||||||
val sardine = OkHttpSardine()
|
val okHttpClient = OkHttpClient.Builder().build()
|
||||||
sardine.setCredentials(username, password)
|
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||||
|
|
||||||
val mdUrl = getMarkdownUrl(serverUrl)
|
val mdUrl = getMarkdownUrl(serverUrl)
|
||||||
|
|
||||||
@@ -1657,8 +1617,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
|
val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
|
||||||
Logger.d(TAG, " Downloaded ${mdContent.length} chars")
|
Logger.d(TAG, " Downloaded ${mdContent.length} chars")
|
||||||
|
|
||||||
// Parse to Note
|
// 🔧 v1.7.2 (IMPL_014): Server mtime übergeben für korrekte Timestamp-Sync
|
||||||
val mdNote = Note.fromMarkdown(mdContent)
|
val mdNote = Note.fromMarkdown(mdContent, serverModifiedTime)
|
||||||
if (mdNote == null) {
|
if (mdNote == null) {
|
||||||
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
|
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
|
||||||
continue
|
continue
|
||||||
@@ -1706,7 +1666,8 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
// Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich?
|
// Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich?
|
||||||
val contentChanged = localNote != null && (
|
val contentChanged = localNote != null && (
|
||||||
mdNote.content != localNote.content ||
|
mdNote.content != localNote.content ||
|
||||||
mdNote.title != localNote.title
|
mdNote.title != localNote.title ||
|
||||||
|
mdNote.checklistItems != localNote.checklistItems
|
||||||
)
|
)
|
||||||
|
|
||||||
if (contentChanged) {
|
if (contentChanged) {
|
||||||
@@ -1733,16 +1694,15 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
|
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// ⚡ v1.3.1 FIX: Content geändert aber YAML-Timestamp nicht aktualisiert → Importieren!
|
// 🔧 v1.7.2 (IMPL_014): Content geändert → Importieren UND als PENDING markieren!
|
||||||
|
// PENDING triggert JSON-Upload beim nächsten Sync-Zyklus
|
||||||
contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> {
|
contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> {
|
||||||
// Inhalt wurde extern geändert ohne YAML-Update → mit aktuellem Timestamp importieren
|
|
||||||
val newTimestamp = System.currentTimeMillis()
|
|
||||||
storage.saveNote(mdNote.copy(
|
storage.saveNote(mdNote.copy(
|
||||||
updatedAt = newTimestamp,
|
updatedAt = serverModifiedTime, // Server mtime verwenden
|
||||||
syncStatus = SyncStatus.SYNCED
|
syncStatus = SyncStatus.PENDING // ⬅️ KRITISCH: Triggert JSON-Upload
|
||||||
))
|
))
|
||||||
importedCount++
|
importedCount++
|
||||||
Logger.d(TAG, " ✅ Imported changed content (YAML timestamp outdated): ${mdNote.title}")
|
Logger.d(TAG, " ✅ Imported changed content (marked PENDING for JSON sync): ${mdNote.title}")
|
||||||
}
|
}
|
||||||
mdNote.updatedAt > localNote.updatedAt -> {
|
mdNote.updatedAt > localNote.updatedAt -> {
|
||||||
// Markdown has newer YAML timestamp
|
// Markdown has newer YAML timestamp
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
|||||||
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val TIMESTAMP_UPDATE_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main screen displaying the notes list
|
* Main screen displaying the notes list
|
||||||
* v1.5.0: Jetpack Compose MainActivity Redesign
|
* v1.5.0: Jetpack Compose MainActivity Redesign
|
||||||
@@ -96,6 +98,15 @@ fun MainScreen(
|
|||||||
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||||
val gridState = rememberLazyStaggeredGridState()
|
val gridState = rememberLazyStaggeredGridState()
|
||||||
|
|
||||||
|
// ⏱️ Timestamp ticker - increments every 30 seconds to trigger recomposition of relative times
|
||||||
|
var timestampTicker by remember { mutableStateOf(0L) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(TIMESTAMP_UPDATE_INTERVAL_MS)
|
||||||
|
timestampTicker = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute isSyncing once
|
// Compute isSyncing once
|
||||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
||||||
|
|
||||||
@@ -197,6 +208,7 @@ fun MainScreen(
|
|||||||
showSyncStatus = viewModel.isServerConfigured(),
|
showSyncStatus = viewModel.isServerConfigured(),
|
||||||
selectedNoteIds = selectedNotes,
|
selectedNoteIds = selectedNotes,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onNoteClick = { note ->
|
onNoteClick = { note ->
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
@@ -215,6 +227,7 @@ fun MainScreen(
|
|||||||
showSyncStatus = viewModel.isServerConfigured(),
|
showSyncStatus = viewModel.isServerConfigured(),
|
||||||
selectedNotes = selectedNotes,
|
selectedNotes = selectedNotes,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
onNoteClick = { note -> onOpenNote(note.id) },
|
onNoteClick = { note -> onOpenNote(note.id) },
|
||||||
|
|||||||
@@ -536,7 +536,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
// Check for unsynced changes
|
// Check for unsynced changes
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
||||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
val message = getApplication<Application>().getString(R.string.toast_already_synced)
|
||||||
|
SyncStateManager.markCompleted(message)
|
||||||
loadNotes()
|
loadNotes()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,12 +63,17 @@ fun NoteCard(
|
|||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
val ticker = timestampTicker
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -65,11 +66,18 @@ fun NoteCardGrid(
|
|||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit
|
onLongClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val noteSize = note.getSize()
|
|
||||||
|
// 🚀 Performance: Cache noteSize - nur bei note-Änderung neu berechnen
|
||||||
|
val noteSize = remember(note.id, note.content, note.checklistItems) { note.getSize() }
|
||||||
|
|
||||||
|
// ⏱️ Reading timestampTicker triggers recomposition only for visible cards
|
||||||
|
@Suppress("UNUSED_VARIABLE")
|
||||||
|
val ticker = timestampTicker
|
||||||
|
|
||||||
// Dynamische maxLines basierend auf Größe
|
// Dynamische maxLines basierend auf Größe
|
||||||
val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3
|
val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3
|
||||||
|
|||||||
@@ -20,13 +20,16 @@ import dev.dettmer.simplenotes.models.Note
|
|||||||
* - NO caching tricks
|
* - NO caching tricks
|
||||||
* - Selection state passed through as parameters
|
* - Selection state passed through as parameters
|
||||||
* - Tap behavior changes based on selection mode
|
* - Tap behavior changes based on selection mode
|
||||||
|
* - ⏱️ timestampTicker triggers recomposition for relative time updates
|
||||||
*/
|
*/
|
||||||
|
@Suppress("LongParameterList") // Composable with many UI state parameters
|
||||||
@Composable
|
@Composable
|
||||||
fun NotesList(
|
fun NotesList(
|
||||||
notes: List<Note>,
|
notes: List<Note>,
|
||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
selectedNotes: Set<String> = emptySet(),
|
selectedNotes: Set<String> = emptySet(),
|
||||||
isSelectionMode: Boolean = false,
|
isSelectionMode: Boolean = false,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
listState: LazyListState = rememberLazyListState(),
|
listState: LazyListState = rememberLazyListState(),
|
||||||
onNoteClick: (Note) -> Unit,
|
onNoteClick: (Note) -> Unit,
|
||||||
@@ -50,6 +53,7 @@ fun NotesList(
|
|||||||
showSyncStatus = showSyncStatus,
|
showSyncStatus = showSyncStatus,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import dev.dettmer.simplenotes.utils.Constants
|
|||||||
* - Keine Lücken mehr durch FullLine-Items
|
* - Keine Lücken mehr durch FullLine-Items
|
||||||
* - Selection mode support
|
* - Selection mode support
|
||||||
* - Efficient LazyVerticalStaggeredGrid
|
* - Efficient LazyVerticalStaggeredGrid
|
||||||
|
* - ⏱️ timestampTicker triggers recomposition for relative time updates
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun NotesStaggeredGrid(
|
fun NotesStaggeredGrid(
|
||||||
@@ -30,11 +31,11 @@ fun NotesStaggeredGrid(
|
|||||||
showSyncStatus: Boolean,
|
showSyncStatus: Boolean,
|
||||||
selectedNoteIds: Set<String>,
|
selectedNoteIds: Set<String>,
|
||||||
isSelectionMode: Boolean,
|
isSelectionMode: Boolean,
|
||||||
|
timestampTicker: Long = 0L,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onNoteClick: (Note) -> Unit,
|
onNoteClick: (Note) -> Unit,
|
||||||
onNoteLongClick: (Note) -> Unit
|
onNoteLongClick: (Note) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
||||||
LazyVerticalStaggeredGrid(
|
LazyVerticalStaggeredGrid(
|
||||||
columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS),
|
columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS),
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
@@ -51,7 +52,8 @@ fun NotesStaggeredGrid(
|
|||||||
) {
|
) {
|
||||||
items(
|
items(
|
||||||
items = notes,
|
items = notes,
|
||||||
key = { it.id }
|
key = { it.id },
|
||||||
|
contentType = { "NoteCardGrid" }
|
||||||
// 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite)
|
// 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite)
|
||||||
) { note ->
|
) { note ->
|
||||||
val isSelected = selectedNoteIds.contains(note.id)
|
val isSelected = selectedNoteIds.contains(note.id)
|
||||||
@@ -62,6 +64,7 @@ fun NotesStaggeredGrid(
|
|||||||
showSyncStatus = showSyncStatus,
|
showSyncStatus = showSyncStatus,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
timestampTicker = timestampTicker,
|
||||||
onClick = { onNoteClick(note) },
|
onClick = { onNoteClick(note) },
|
||||||
onLongClick = { onNoteLongClick(note) }
|
onLongClick = { onNoteLongClick(note) }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -780,10 +780,42 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
serverUrl != "https://"
|
serverUrl != "https://"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
/**
|
||||||
|
* 🌍 v1.7.1: Get string resources with correct app locale
|
||||||
|
*
|
||||||
|
* AndroidViewModel uses Application context which may not have the correct locale
|
||||||
|
* applied when using per-app language settings. We need to get a Context that
|
||||||
|
* respects AppCompatDelegate.getApplicationLocales().
|
||||||
|
*/
|
||||||
|
private fun getString(resId: Int): String {
|
||||||
|
// Get context with correct locale configuration from AppCompatDelegate
|
||||||
|
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
|
||||||
|
val context = if (!appLocales.isEmpty) {
|
||||||
|
// Create configuration with app locale
|
||||||
|
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
|
||||||
|
config.setLocale(appLocales.get(0))
|
||||||
|
getApplication<Application>().createConfigurationContext(config)
|
||||||
|
} else {
|
||||||
|
// Use system locale (default)
|
||||||
|
getApplication<Application>()
|
||||||
|
}
|
||||||
|
return context.getString(resId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
private fun getString(resId: Int, vararg formatArgs: Any): String {
|
||||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
// Get context with correct locale configuration from AppCompatDelegate
|
||||||
|
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
|
||||||
|
val context = if (!appLocales.isEmpty) {
|
||||||
|
// Create configuration with app locale
|
||||||
|
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
|
||||||
|
config.setLocale(appLocales.get(0))
|
||||||
|
getApplication<Application>().createConfigurationContext(config)
|
||||||
|
} else {
|
||||||
|
// Use system locale (default)
|
||||||
|
getApplication<Application>()
|
||||||
|
}
|
||||||
|
return context.getString(resId, *formatArgs)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun emitToast(message: String) {
|
private suspend fun emitToast(message: String) {
|
||||||
_showToast.emit(message)
|
_showToast.emit(message)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ object NotificationHelper {
|
|||||||
private const val CHANNEL_ID = "notes_sync_channel"
|
private const val CHANNEL_ID = "notes_sync_channel"
|
||||||
private const val NOTIFICATION_ID = 1001
|
private const val NOTIFICATION_ID = 1001
|
||||||
private const val SYNC_NOTIFICATION_ID = 2
|
private const val SYNC_NOTIFICATION_ID = 2
|
||||||
|
const val SYNC_PROGRESS_NOTIFICATION_ID = 1003 // v1.7.2: For expedited work foreground notification
|
||||||
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +55,26 @@ object NotificationHelper {
|
|||||||
Logger.d(TAG, "🗑️ Cleared old sync notifications")
|
Logger.d(TAG, "🗑️ Cleared old sync notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔧 v1.7.2: Erstellt Notification für Sync-Progress (Expedited Work)
|
||||||
|
*
|
||||||
|
* Wird von SyncWorker.getForegroundInfo() aufgerufen auf Android 9-11.
|
||||||
|
* Muss eine gültige, sichtbare Notification zurückgeben.
|
||||||
|
*
|
||||||
|
* @return Notification (nicht anzeigen, nur erstellen)
|
||||||
|
*/
|
||||||
|
fun createSyncProgressNotification(context: Context): android.app.Notification {
|
||||||
|
return NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||||
|
.setContentTitle(context.getString(R.string.sync_in_progress))
|
||||||
|
.setContentText(context.getString(R.string.sync_in_progress_text))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(0, 0, true) // Indeterminate progress
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zeigt Erfolgs-Notification nach Sync
|
* Zeigt Erfolgs-Notification nach Sync
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -93,9 +93,21 @@
|
|||||||
<string name="snackbar_server_error">Server-Fehler: %s</string>
|
<string name="snackbar_server_error">Server-Fehler: %s</string>
|
||||||
<string name="snackbar_already_synced">Bereits synchronisiert</string>
|
<string name="snackbar_already_synced">Bereits synchronisiert</string>
|
||||||
<string name="snackbar_server_unreachable">Server nicht erreichbar</string>
|
<string name="snackbar_server_unreachable">Server nicht erreichbar</string>
|
||||||
|
<string name="snackbar_connection_timeout">Verbindungs-Timeout</string>
|
||||||
<string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string>
|
<string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string>
|
||||||
<string name="snackbar_nothing_to_sync">ℹ️ Nichts zu syncen</string>
|
<string name="snackbar_nothing_to_sync">ℹ️ Nichts zu syncen</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SYNC ERROR MESSAGES -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="sync_error_ssl">SSL-Fehler</string>
|
||||||
|
<string name="sync_error_auth_failed">Authentifizierung fehlgeschlagen</string>
|
||||||
|
<string name="sync_error_access_denied">Zugriff verweigert</string>
|
||||||
|
<string name="sync_error_path_not_found">Server-Pfad nicht gefunden</string>
|
||||||
|
<string name="sync_error_server">Server-Fehler</string>
|
||||||
|
<string name="sync_error_http">HTTP-Fehler: %d</string>
|
||||||
|
<string name="sync_error_unknown">Unbekannter Fehler</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- URL VALIDATION ERRORS -->
|
<!-- URL VALIDATION ERRORS -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -426,6 +438,8 @@
|
|||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<string name="notification_channel_name">Notizen Synchronisierung</string>
|
<string name="notification_channel_name">Notizen Synchronisierung</string>
|
||||||
<string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string>
|
<string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string>
|
||||||
|
<string name="sync_in_progress">Synchronisierung läuft</string>
|
||||||
|
<string name="sync_in_progress_text">Notizen werden synchronisiert…</string>
|
||||||
<string name="notification_sync_success_title">Sync erfolgreich</string>
|
<string name="notification_sync_success_title">Sync erfolgreich</string>
|
||||||
<string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string>
|
<string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string>
|
||||||
<string name="notification_sync_failed_title">Sync fehlgeschlagen</string>
|
<string name="notification_sync_failed_title">Sync fehlgeschlagen</string>
|
||||||
|
|||||||
@@ -93,9 +93,21 @@
|
|||||||
<string name="snackbar_server_error">Server error: %s</string>
|
<string name="snackbar_server_error">Server error: %s</string>
|
||||||
<string name="snackbar_already_synced">Already synced</string>
|
<string name="snackbar_already_synced">Already synced</string>
|
||||||
<string name="snackbar_server_unreachable">Server not reachable</string>
|
<string name="snackbar_server_unreachable">Server not reachable</string>
|
||||||
|
<string name="snackbar_connection_timeout">Connection timeout</string>
|
||||||
<string name="snackbar_synced_count">✅ Synced: %d notes</string>
|
<string name="snackbar_synced_count">✅ Synced: %d notes</string>
|
||||||
<string name="snackbar_nothing_to_sync">ℹ️ Nothing to sync</string>
|
<string name="snackbar_nothing_to_sync">ℹ️ Nothing to sync</string>
|
||||||
|
|
||||||
|
<!-- ============================= -->
|
||||||
|
<!-- SYNC ERROR MESSAGES -->
|
||||||
|
<!-- ============================= -->
|
||||||
|
<string name="sync_error_ssl">SSL error</string>
|
||||||
|
<string name="sync_error_auth_failed">Authentication failed</string>
|
||||||
|
<string name="sync_error_access_denied">Access denied</string>
|
||||||
|
<string name="sync_error_path_not_found">Server path not found</string>
|
||||||
|
<string name="sync_error_server">Server error</string>
|
||||||
|
<string name="sync_error_http">HTTP error: %d</string>
|
||||||
|
<string name="sync_error_unknown">Unknown error</string>
|
||||||
|
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- URL VALIDATION ERRORS -->
|
<!-- URL VALIDATION ERRORS -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -426,6 +438,8 @@
|
|||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<string name="notification_channel_name">Notes Synchronization</string>
|
<string name="notification_channel_name">Notes Synchronization</string>
|
||||||
<string name="notification_channel_desc">Notifications about sync status</string>
|
<string name="notification_channel_desc">Notifications about sync status</string>
|
||||||
|
<string name="sync_in_progress">Syncing</string>
|
||||||
|
<string name="sync_in_progress_text">Syncing notes…</string>
|
||||||
<string name="notification_sync_success_title">Sync successful</string>
|
<string name="notification_sync_success_title">Sync successful</string>
|
||||||
<string name="notification_sync_success_message">%d note(s) synchronized</string>
|
<string name="notification_sync_success_message">%d note(s) synchronized</string>
|
||||||
<string name="notification_sync_failed_title">Sync failed</string>
|
<string name="notification_sync_failed_title">Sync failed</string>
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🐛 v1.7.2: Basic validation tests for v1.7.2 bugfixes
|
||||||
|
*
|
||||||
|
* This test file validates that the critical bugfixes are working:
|
||||||
|
* - IMPL_001: Deletion Tracker Race Condition
|
||||||
|
* - IMPL_002: ISO8601 Timezone Parsing
|
||||||
|
* - IMPL_003: SafeSardine Memory Leak (Closeable)
|
||||||
|
* - IMPL_004: E-Tag Batch Caching
|
||||||
|
* - IMPL_014: JSON/Markdown Timestamp Sync
|
||||||
|
* - IMPL_015: SyncStatus PENDING Fix
|
||||||
|
*/
|
||||||
|
class BugfixValidationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_015 - Note toJson contains all fields`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test-123",
|
||||||
|
title = "Test Note",
|
||||||
|
content = "Content",
|
||||||
|
deviceId = "device-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = note.toJson()
|
||||||
|
|
||||||
|
// Verify JSON contains critical fields
|
||||||
|
assertTrue("JSON should contain id", json.contains("\"id\""))
|
||||||
|
assertTrue("JSON should contain title", json.contains("\"title\""))
|
||||||
|
assertTrue("JSON should contain deviceId", json.contains("\"deviceId\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_015 - Note copy preserves all fields`() {
|
||||||
|
val original = Note(
|
||||||
|
id = "original-123",
|
||||||
|
title = "Original",
|
||||||
|
content = "Content",
|
||||||
|
deviceId = "device-1",
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
val copied = original.copy(syncStatus = SyncStatus.SYNCED)
|
||||||
|
|
||||||
|
// Verify copy worked correctly
|
||||||
|
assertEquals("original-123", copied.id)
|
||||||
|
assertEquals("Original", copied.title)
|
||||||
|
assertEquals(SyncStatus.SYNCED, copied.syncStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_014 - fromMarkdown accepts serverModifiedTime parameter`() {
|
||||||
|
val markdown = """
|
||||||
|
---
|
||||||
|
id: test-456
|
||||||
|
title: Test
|
||||||
|
created: 2026-01-01T10:00:00Z
|
||||||
|
updated: 2026-01-01T11:00:00Z
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# Test
|
||||||
|
|
||||||
|
Content
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val serverMtime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// This should not crash - parameter is optional
|
||||||
|
val note1 = Note.fromMarkdown(markdown)
|
||||||
|
assertNotNull(note1)
|
||||||
|
|
||||||
|
val note2 = Note.fromMarkdown(markdown, serverModifiedTime = serverMtime)
|
||||||
|
assertNotNull(note2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMPL_002 - fromMarkdown handles various timestamp formats`() {
|
||||||
|
// UTC format with Z
|
||||||
|
val markdown1 = """
|
||||||
|
---
|
||||||
|
id: test-utc
|
||||||
|
title: UTC Test
|
||||||
|
created: 2026-02-04T12:30:45Z
|
||||||
|
updated: 2026-02-04T12:30:45Z
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# UTC Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val note1 = Note.fromMarkdown(markdown1)
|
||||||
|
assertNotNull("Should parse UTC format", note1)
|
||||||
|
|
||||||
|
// Format with timezone offset
|
||||||
|
val markdown2 = """
|
||||||
|
---
|
||||||
|
id: test-tz
|
||||||
|
title: Timezone Test
|
||||||
|
created: 2026-02-04T13:30:45+01:00
|
||||||
|
updated: 2026-02-04T13:30:45+01:00
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# Timezone Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val note2 = Note.fromMarkdown(markdown2)
|
||||||
|
assertNotNull("Should parse timezone offset format", note2)
|
||||||
|
|
||||||
|
// Format without timezone (should be treated as UTC)
|
||||||
|
val markdown3 = """
|
||||||
|
---
|
||||||
|
id: test-no-tz
|
||||||
|
title: No TZ Test
|
||||||
|
created: 2026-02-04T12:30:45
|
||||||
|
updated: 2026-02-04T12:30:45
|
||||||
|
device: device-1
|
||||||
|
type: text
|
||||||
|
---
|
||||||
|
# No TZ Test
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val note3 = Note.fromMarkdown(markdown3)
|
||||||
|
assertNotNull("Should parse format without timezone", note3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Note data class has all required fields`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "field-test",
|
||||||
|
title = "Field Test",
|
||||||
|
content = "Content",
|
||||||
|
deviceId = "device-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verify all critical fields exist
|
||||||
|
assertNotNull(note.id)
|
||||||
|
assertNotNull(note.title)
|
||||||
|
assertNotNull(note.content)
|
||||||
|
assertNotNull(note.deviceId)
|
||||||
|
assertNotNull(note.noteType)
|
||||||
|
assertNotNull(note.syncStatus)
|
||||||
|
assertNotNull(note.createdAt)
|
||||||
|
assertNotNull(note.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SyncStatus enum has all required values`() {
|
||||||
|
// Verify all sync states exist
|
||||||
|
assertNotNull(SyncStatus.PENDING)
|
||||||
|
assertNotNull(SyncStatus.SYNCED)
|
||||||
|
assertNotNull(SyncStatus.LOCAL_ONLY)
|
||||||
|
assertNotNull(SyncStatus.CONFLICT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Note toJson and fromJson roundtrip works`() {
|
||||||
|
val original = Note(
|
||||||
|
id = "roundtrip-123",
|
||||||
|
title = "Roundtrip Test",
|
||||||
|
content = "Test Content",
|
||||||
|
deviceId = "device-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
val json = original.toJson()
|
||||||
|
val restored = Note.fromJson(json)
|
||||||
|
|
||||||
|
assertNotNull(restored)
|
||||||
|
assertEquals(original.id, restored!!.id)
|
||||||
|
assertEquals(original.title, restored.title)
|
||||||
|
assertEquals(original.content, restored.content)
|
||||||
|
assertEquals(original.deviceId, restored.deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
5
fastlane/metadata/android/de-DE/changelogs/18.txt
Normal file
5
fastlane/metadata/android/de-DE/changelogs/18.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
• Behoben: App-Absturz auf Android 9 - Thanks to @roughnecks
|
||||||
|
• Behoben: Deutsche Texte trotz englischer App-Sprache
|
||||||
|
• Verbessert: Sync-Verbindungsstabilität
|
||||||
|
• Verbessert: Code-Qualität und Zuverlässigkeit
|
||||||
|
|
||||||
8
fastlane/metadata/android/de-DE/changelogs/19.txt
Normal file
8
fastlane/metadata/android/de-DE/changelogs/19.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
📝 KRITISCHE BUG FIXES & EDITOR-VORBEREITUNG
|
||||||
|
|
||||||
|
• Verbessert: Auto-Aktualisierung der Zeitstempel in UI (alle 30s)
|
||||||
|
• Behoben: Änderungen von externen Editoren nicht synchronisiert
|
||||||
|
• Behoben: Server-JSON zeigt immer "PENDING" Status
|
||||||
|
• Behoben: Deletion Tracker Race Condition bei Batch-Löschungen
|
||||||
|
• Behoben: ISO8601 Timezone Parsing (+01:00, -05:00)
|
||||||
|
• Verbessert: E-Tag Batch Caching Performance (~50-100ms schneller)
|
||||||
4
fastlane/metadata/android/en-US/changelogs/18.txt
Normal file
4
fastlane/metadata/android/en-US/changelogs/18.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
• Fixed: App crash on Android 9 - Thanks to @roughnecks
|
||||||
|
• Fixed: German text appearing despite English language setting
|
||||||
|
• Improved: Sync connection stability (longer timeout)
|
||||||
|
• Improved: Code quality and reliability
|
||||||
8
fastlane/metadata/android/en-US/changelogs/19.txt
Normal file
8
fastlane/metadata/android/en-US/changelogs/19.txt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
📝 CRITICAL BUG FIXES & EDITOR PREPARATION
|
||||||
|
|
||||||
|
• Improved: Auto-updating timestamps in UI (every 30s)
|
||||||
|
• Fixed: External editor changes not synced
|
||||||
|
• Fixed: Server JSON always showing "PENDING" status
|
||||||
|
• Fixed: Deletion tracker race condition in batch deletes
|
||||||
|
• Fixed: ISO8601 timezone parsing (+01:00, -05:00)
|
||||||
|
• Improved: E-Tag batch caching performance (~50-100ms faster)
|
||||||
Reference in New Issue
Block a user