Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f528ea0e | ||
|
|
cb1bc46405 | ||
|
|
0b143e5f0d | ||
|
|
cf9695844c | ||
|
|
24ea7ec59a | ||
|
|
df4ee4bed0 | ||
|
|
68e8490db8 | ||
|
|
614650e37d | ||
|
|
785a6c011a | ||
|
|
a96d373e78 | ||
|
|
a59e89fe91 | ||
|
|
91beee0f8b | ||
|
|
c536ad3177 | ||
|
|
6dba091c03 | ||
|
|
5135c711a5 | ||
|
|
ebab347d4b | ||
|
|
cb63aa1220 | ||
|
|
0df8282eb4 | ||
|
|
b70bc4d8f6 | ||
|
|
217a174478 | ||
|
|
d58d9036cb | ||
|
|
dfdccfe6c7 |
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -9,3 +9,6 @@ contact_links:
|
||||
- name: "🐛 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
|
||||
- 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
|
||||
87
.github/workflows/build-debug-apk.yml
vendored
Normal file
87
.github/workflows/build-debug-apk.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Build Debug APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'debug/**'
|
||||
- 'fix/**'
|
||||
- 'feature/**'
|
||||
workflow_dispatch: # Manueller Trigger möglich
|
||||
|
||||
jobs:
|
||||
build-debug:
|
||||
name: Build Debug APK
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Extract version info
|
||||
run: |
|
||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
BRANCH_NAME=${GITHUB_REF#refs/heads/}
|
||||
COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||
|
||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
||||
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||
echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV
|
||||
echo "BUILD_TIME=$(date +'%Y-%m-%d_%H-%M-%S')" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Debug APK (Standard + F-Droid)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
||||
|
||||
- name: Prepare Debug APK artifacts
|
||||
run: |
|
||||
mkdir -p debug-apks
|
||||
|
||||
cp android/app/build/outputs/apk/standard/debug/app-standard-debug.apk \
|
||||
debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-standard-debug.apk
|
||||
|
||||
cp android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk \
|
||||
debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-fdroid-debug.apk
|
||||
|
||||
echo "✅ Debug APK Files ready:"
|
||||
ls -lh debug-apks/
|
||||
|
||||
- name: Upload Debug APK Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: simple-notes-sync-debug-v${{ env.VERSION_NAME }}-${{ env.BUILD_TIME }}
|
||||
path: debug-apks/*.apk
|
||||
retention-days: 30 # Debug Builds länger aufbewahren
|
||||
compression-level: 0 # APK ist bereits komprimiert
|
||||
|
||||
- name: Create summary
|
||||
run: |
|
||||
echo "## 🐛 Debug APK Build" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Build Info" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Version:** v${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Branch:** ${{ env.BRANCH_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** ${{ env.COMMIT_SHA }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Built:** ${{ env.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Download" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Debug APK available in the Artifacts section above (expires in 30 days)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Installation" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||
echo "# Enable unknown sources" >> $GITHUB_STEP_SUMMARY
|
||||
echo "adb install simple-notes-sync-*-debug.apk" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### What's included?" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Full Logging enabled" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Not production signed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- May have performance impact" >> $GITHUB_STEP_SUMMARY
|
||||
75
.github/workflows/build-production-apk.yml
vendored
75
.github/workflows/build-production-apk.yml
vendored
@@ -2,11 +2,11 @@ name: Build Android Production APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ] # Nur bei Push/Merge auf main triggern
|
||||
workflow_dispatch: # Ermöglicht manuellen Trigger
|
||||
branches: [ main ] # Only trigger on push/merge to main
|
||||
workflow_dispatch: # Enables manual trigger
|
||||
|
||||
permissions:
|
||||
contents: write # Fuer Release-Erstellung erforderlich
|
||||
contents: write # Required for release creation
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -14,50 +14,50 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Java einrichten
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Semantic Versionsnummer aus build.gradle.kts extrahieren
|
||||
- name: Extract semantic version from build.gradle.kts
|
||||
run: |
|
||||
# Version aus build.gradle.kts fuer F-Droid Kompatibilität
|
||||
# Version from build.gradle.kts for F-Droid compatibility
|
||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
|
||||
# Semantische Versionierung (nicht datums-basiert)
|
||||
# Semantic versioning (not date-based)
|
||||
BUILD_NUMBER="$VERSION_CODE"
|
||||
|
||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
|
||||
echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV
|
||||
|
||||
echo "🚀 Baue Version: $VERSION_NAME (Code: $BUILD_NUMBER)"
|
||||
echo "🚀 Building version: $VERSION_NAME (Code: $BUILD_NUMBER)"
|
||||
|
||||
- name: Version aus build.gradle.kts verifizieren
|
||||
- name: Verify version from build.gradle.kts
|
||||
run: |
|
||||
echo "✅ Verwende Version aus build.gradle.kts:"
|
||||
echo "✅ Using version from build.gradle.kts:"
|
||||
grep -E "versionCode|versionName" android/app/build.gradle.kts
|
||||
|
||||
- name: Android Signing konfigurieren
|
||||
- name: Configure Android signing
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
|
||||
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
||||
echo "storeFile=simple-notes-release.jks" >> android/key.properties
|
||||
echo "✅ Signing-Konfiguration erstellt"
|
||||
echo "✅ Signing configuration created"
|
||||
|
||||
- name: Produktions-APK bauen (Standard + F-Droid Flavors)
|
||||
- name: Build production APK (Standard + F-Droid Flavors)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
|
||||
|
||||
- name: APK-Varianten mit Versionsnamen kopieren
|
||||
- name: Copy APK variants with version names
|
||||
run: |
|
||||
mkdir -p apk-output
|
||||
|
||||
@@ -69,34 +69,34 @@ jobs:
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||
|
||||
echo "✅ APK-Dateien vorbereitet:"
|
||||
echo "✅ APK files prepared:"
|
||||
ls -lh apk-output/
|
||||
|
||||
- name: APK-Artefakte hochladen
|
||||
- name: Upload APK artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
|
||||
path: apk-output/*.apk
|
||||
retention-days: 90 # Produktions-Builds länger aufbewahren
|
||||
retention-days: 90 # Keep production builds longer
|
||||
|
||||
- name: Commit-Informationen auslesen
|
||||
- name: Extract commit information
|
||||
run: |
|
||||
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
|
||||
|
||||
- name: F-Droid Changelogs lesen
|
||||
- name: Read F-Droid changelogs
|
||||
run: |
|
||||
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
|
||||
# Read German changelog (main language) - Use printf to ensure proper formatting
|
||||
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
||||
echo "CHANGELOG_DE=No German release notes available." >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Lese englische Changelog (optional)
|
||||
# Read English changelog (optional)
|
||||
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||
@@ -127,25 +127,30 @@ jobs:
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📦 Downloads
|
||||
|
||||
| Variante | Datei | Info |
|
||||
|----------|-------|------|
|
||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
|
||||
| Variant | File | Info |
|
||||
|---------|------|------|
|
||||
| **🏆 Recommended** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard version (works on all devices) |
|
||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | For F-Droid Store |
|
||||
|
||||
---
|
||||
|
||||
## 📊 Build-Info
|
||||
## 📊 Build Info
|
||||
|
||||
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
||||
- **Datum:** ${{ env.COMMIT_DATE }}
|
||||
- **Date:** ${{ env.COMMIT_DATE }}
|
||||
- **Commit:** ${{ env.SHORT_SHA }}
|
||||
|
||||
---
|
||||
## 🔐 APK Signature Verification
|
||||
|
||||
**[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)**
|
||||
All APKs are signed with the official release certificate.
|
||||
|
||||
**Recommended:** Verify with [AppVerifier](https://github.com/nicholson-lab/AppVerifier) (Android app)
|
||||
|
||||
**Expected SHA-256:**
|
||||
```
|
||||
42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||
```
|
||||
|
||||
**[📖 Documentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Report Bug](https://github.com/inventory69/simple-notes-sync/issues)**
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
test-apks/
|
||||
server-test/
|
||||
|
||||
# F-Droid metadata (managed in fdroiddata repo)
|
||||
# Exclude fastlane metadata (we want to track those screenshots)
|
||||
|
||||
@@ -8,6 +8,82 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung
|
||||
|
||||
Pinterest-Style Grid, Nur-WLAN Sync-Modus und korrekte VPN-Unterstützung!
|
||||
|
||||
### 🎨 Grid-Layout
|
||||
|
||||
- Pinterest-Style Staggered Grid ohne Lücken
|
||||
- Konsistente 12dp Abstände zwischen Cards
|
||||
- Scroll-Position bleibt erhalten nach Einstellungen
|
||||
- Neue einheitliche `NoteCardGrid` mit dynamischen Vorschauzeilen (3 klein, 6 groß)
|
||||
|
||||
### 📡 Sync-Verbesserungen
|
||||
|
||||
- **Nur-WLAN Sync Toggle** - Sync nur wenn WLAN verbunden
|
||||
- **VPN-Unterstützung** - Sync funktioniert korrekt bei aktivem VPN (Traffic über VPN)
|
||||
- **Server-Wechsel Erkennung** - Alle Notizen auf PENDING zurückgesetzt bei Server-URL Änderung
|
||||
- **Schnellere Server-Prüfung** - Socket-Timeout von 2s auf 1s reduziert
|
||||
- **"Sync läuft bereits" Feedback** - Zeigt Snackbar wenn Sync bereits läuft
|
||||
|
||||
### 🔒 Self-Signed SSL Unterstützung
|
||||
|
||||
- **Dokumentation hinzugefügt** - Anleitung für selbst-signierte Zertifikate
|
||||
- Nutzt Android's eingebauten CA Trust Store
|
||||
- Funktioniert mit ownCloud, Nextcloud, Synology, Home-Servern
|
||||
|
||||
### 🔧 Technisch
|
||||
|
||||
- `NoteCardGrid` Komponente mit dynamischen maxLines
|
||||
- FullLine Spans entfernt für lückenloses Layout
|
||||
- `resetAllSyncStatusToPending()` in NotesStorage
|
||||
- VPN-Erkennung in `getOrCacheWiFiAddress()`
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] - 2026-01-20
|
||||
|
||||
### 🧹 Code-Qualität & Build-Verbesserungen
|
||||
|
||||
76
CHANGELOG.md
76
CHANGELOG.md
@@ -8,6 +8,82 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support
|
||||
|
||||
Pinterest-style grid, WiFi-only sync mode, and proper VPN support!
|
||||
|
||||
### 🎨 Grid Layout
|
||||
|
||||
- Pinterest-style staggered grid without gaps
|
||||
- Consistent 12dp spacing between cards
|
||||
- Scroll position preserved when returning from settings
|
||||
- New unified `NoteCardGrid` with dynamic preview lines (3 small, 6 large)
|
||||
|
||||
### 📡 Sync Improvements
|
||||
|
||||
- **WiFi-only sync toggle** - Sync only when connected to WiFi
|
||||
- **VPN support** - Sync works correctly when VPN is active (traffic routes through VPN)
|
||||
- **Server change detection** - All notes reset to PENDING when server URL changes
|
||||
- **Faster server check** - Socket timeout reduced from 2s to 1s
|
||||
- **"Sync already running" feedback** - Shows snackbar when sync is in progress
|
||||
|
||||
### 🔒 Self-Signed SSL Support
|
||||
|
||||
- **Documentation added** - Guide for using self-signed certificates
|
||||
- Uses Android's built-in CA trust store
|
||||
- Works with ownCloud, Nextcloud, Synology, home servers
|
||||
|
||||
### 🔧 Technical
|
||||
|
||||
- `NoteCardGrid` component with dynamic maxLines
|
||||
- Removed FullLine spans for gapless layout
|
||||
- `resetAllSyncStatusToPending()` in NotesStorage
|
||||
- VPN detection in `getOrCacheWiFiAddress()`
|
||||
|
||||
---
|
||||
|
||||
## [1.6.1] - 2026-01-20
|
||||
|
||||
### 🧹 Code Quality & Build Improvements
|
||||
|
||||
118
README.de.md
118
README.de.md
@@ -1,48 +1,80 @@
|
||||
# Simple Notes Sync 📝
|
||||
<div align="center">
|
||||
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
|
||||
</div>
|
||||
|
||||
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
|
||||
<h1 align="center">Simple Notes Sync</h1>
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
<h4 align="center">Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.</h4>
|
||||
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
|
||||
[<img src="https://f-droid.org/badge/get-it-on-de.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
|
||||
<div align="center">
|
||||
|
||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.de.md)** · **🚀 [Quick Start](QUICKSTART.de.md)**
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
**🌍 Sprachen:** **Deutsch** · [English](README.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
<div align="center">
|
||||
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
|
||||
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
|
||||
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
|
||||
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
|
||||
alt="Get it on Obtainium" align="center" height="54" />
|
||||
</a>
|
||||
|
||||
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
|
||||
<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<strong>SHA-256 Hash des Signaturzertifikats:</strong><br /> 42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Schnellstart](QUICKSTART.de.md)<br />
|
||||
**🌍** Deutsch · [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync-Status">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Notiz bearbeiten">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Checkliste bearbeiten">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Einstellungen">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server-Einstellungen">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync-Einstellungen">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/5.png" width="250" alt="Server settings">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||
</p>
|
||||
|
||||
---
|
||||
<div align="center">
|
||||
|
||||
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Akkuschonend
|
||||
|
||||
</div>
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- ✅ **NEU: Checklisten** - Tap-to-Check, Drag & Drop
|
||||
- 🌍 **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl
|
||||
- 📝 **Offline-First** - Funktioniert ohne Internet
|
||||
- 🔄 **Konfigurierbare Sync-Trigger** - onSave, onResume, WiFi-Verbindung, periodisch (15/30/60 Min), Boot
|
||||
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
|
||||
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
|
||||
- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
|
||||
- 🔋 **Akkuschonend** - ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
|
||||
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
|
||||
- 📝 **Offline-first** – Funktioniert ohne Internet
|
||||
- 📊 **Flexible Ansichten** – Listen- und Grid-Layout
|
||||
- ✅ **Checklisten** – Tap-to-Check, Drag & Drop
|
||||
- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl
|
||||
- 🔄 **Konfigurierbare Sync-Trigger** – onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
|
||||
- 🔒 **Self-hosted** – Deine Daten bleiben bei dir (WebDAV)
|
||||
- 💾 **Lokales Backup** – Export/Import als JSON-Datei (optional verschlüsselt)
|
||||
- 🖥️ **Desktop-Integration** – Markdown-Export für Obsidian, VS Code, Typora
|
||||
- 🔋 **Akkuschonend** – ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
|
||||
- 🎨 **Material Design 3** – Dynamischer Dark/Light Mode & Farben
|
||||
|
||||
➡️ **Vollständige Feature-Liste:** [FEATURES.de.md](docs/FEATURES.de.md)
|
||||
|
||||
---
|
||||
➡️ **Vollständige Feature-Liste:** [docs/FEATURES.de.md](docs/FEATURES.de.md)
|
||||
|
||||
## 🚀 Schnellstart
|
||||
|
||||
@@ -63,17 +95,15 @@ docker compose up -d
|
||||
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
2. Installieren & öffnen
|
||||
3. ⚙️ Einstellungen → Server konfigurieren:
|
||||
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
|
||||
- **User:** `noteuser`
|
||||
- **Passwort:** _(aus .env)_
|
||||
- **WLAN:** _(dein Netzwerk-Name)_
|
||||
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
|
||||
- **User:** `noteuser`
|
||||
- **Passwort:** _(aus .env)_
|
||||
- **WLAN:** _(dein Netzwerk-Name)_
|
||||
4. **Verbindung testen** → Auto-Sync aktivieren
|
||||
5. Fertig! 🎉
|
||||
|
||||
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
| Dokument | Inhalt |
|
||||
@@ -82,13 +112,12 @@ docker compose up -d
|
||||
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
|
||||
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
|
||||
| **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) |
|
||||
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL Zertifikat Setup |
|
||||
| **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting |
|
||||
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
|
||||
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
|
||||
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Entwicklung
|
||||
|
||||
```bash
|
||||
@@ -96,20 +125,19 @@ cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||
|
||||
---
|
||||
➡️ **Build-Anleitung:** [docs/DOCS.de.md#-build--deployment](docs/DOCS.de.md#-build--deployment)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
MIT License - siehe [LICENSE](LICENSE)
|
||||
MIT License – siehe [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
<div align="center">
|
||||
<br /><br />
|
||||
|
||||
**v1.6.0** · Built with ❤️ using Kotlin + Material Design 3
|
||||
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
|
||||
90
README.md
90
README.md
@@ -1,19 +1,48 @@
|
||||
# Simple Notes Sync 📝
|
||||
<div align="center">
|
||||
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
|
||||
</div>
|
||||
|
||||
> Minimalist offline notes with auto-sync to your own server
|
||||
<h1 align="center">Simple Notes Sync</h1>
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
<h4 align="center">Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.</h4>
|
||||
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
|
||||
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
|
||||
<div align="center">
|
||||
|
||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
**🌍 Languages:** [Deutsch](README.de.md) · **English**
|
||||
</div>
|
||||
|
||||
---
|
||||
<div align="center">
|
||||
|
||||
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
|
||||
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
|
||||
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
|
||||
|
||||
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
|
||||
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
|
||||
alt="Get it on Obtainium" align="center" height="54" />
|
||||
</a>
|
||||
|
||||
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
|
||||
<img src="https://f-droid.org/badge/get-it-on.png"
|
||||
alt="Get it on F-Droid" align="center" height="80" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<strong>SHA-256 hash of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
|
||||
**🌍** [Deutsch](README.de.md) · **English**
|
||||
|
||||
</div>
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
@@ -26,24 +55,27 @@
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||
</p>
|
||||
|
||||
---
|
||||
<div align="center">
|
||||
|
||||
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Battery-friendly
|
||||
|
||||
</div>
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- ✅ **NEW: Checklists** - Tap-to-check, drag & drop
|
||||
- 🌍 **NEW: Multilingual** - English/German with language selector
|
||||
- 📝 **Offline-first** - Works without internet
|
||||
- 📊 **Flexible views** - Switch between list and grid layout
|
||||
- ✅ **Checklists** - Tap-to-check, drag & drop
|
||||
- 🌍 **Multilingual** - English/German with language selector
|
||||
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
|
||||
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
||||
- 💾 **Local backup** - Export/Import as JSON file
|
||||
- 💾 **Local backup** - Export/Import as JSON file (encryption available)
|
||||
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
||||
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
|
||||
- 🎨 **Material Design 3** - Dark mode & dynamic colors
|
||||
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
|
||||
|
||||
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Server Setup (5 minutes)
|
||||
@@ -72,8 +104,6 @@ docker compose up -d
|
||||
|
||||
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
| Document | Content |
|
||||
@@ -82,6 +112,7 @@ docker compose up -d
|
||||
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
|
||||
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
|
||||
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
|
||||
| **[SELF_SIGNED_SSL.md](docs/SELF_SIGNED_SSL.md)** | Self-signed SSL certificate setup |
|
||||
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
|
||||
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
|
||||
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
|
||||
@@ -94,18 +125,29 @@ cd android
|
||||
|
||||
➡️ **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
|
||||
|
||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
<div align="center">
|
||||
<br /><br />
|
||||
|
||||
**v1.6.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
|
||||
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 15 // 🔧 v1.6.1: Lint-Cleanup detekt and ktlint
|
||||
versionName = "1.6.1" // 🔧 v1.6.1: Lint-Cleanup detekt and ktlint
|
||||
versionCode = 18 // 🔧 v1.7.1: Android 9 getForegroundInfo Fix (Issue #15)
|
||||
versionName = "1.7.1" // 🔧 v1.7.1: Android 9 getForegroundInfo Fix
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -99,6 +99,11 @@ android {
|
||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||
}
|
||||
|
||||
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
|
||||
testOptions {
|
||||
unitTests.isReturnDefaultValues = true
|
||||
}
|
||||
|
||||
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
||||
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
||||
// composeCompiler { }
|
||||
@@ -140,6 +145,9 @@ dependencies {
|
||||
// SwipeRefreshLayout für Pull-to-Refresh
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
// 🔐 v1.7.0: AndroidX Security Crypto für Backup-Verschlüsselung
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// v1.5.0: Jetpack Compose für Settings Redesign
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
4
android/app/proguard-rules.pro
vendored
4
android/app/proguard-rules.pro
vendored
@@ -61,3 +61,7 @@
|
||||
|
||||
# Keep your app's data classes
|
||||
-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) -->
|
||||
<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! -->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
@@ -91,6 +96,12 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- v1.7.1: WorkManager SystemForegroundService for Expedited Work -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -133,6 +133,9 @@ class MainActivity : AppCompatActivity() {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
||||
logLocaleInfo()
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
@@ -392,13 +395,13 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Check if server is reachable
|
||||
if (!syncService.isServerReachable()) {
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -406,7 +409,7 @@ class MainActivity : AppCompatActivity() {
|
||||
val result = syncService.syncNotes()
|
||||
|
||||
if (result.isSuccess) {
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
|
||||
loadNotes()
|
||||
} else {
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
@@ -672,7 +675,8 @@ class MainActivity : AppCompatActivity() {
|
||||
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||
val message = getString(R.string.toast_already_synced)
|
||||
SyncStateManager.markCompleted(message)
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -683,7 +687,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
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)
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
showToast("✅ Bereits synchronisiert")
|
||||
showToast(getString(R.string.toast_already_synced))
|
||||
SyncStateManager.markCompleted()
|
||||
return@launch
|
||||
}
|
||||
@@ -608,8 +608,8 @@ class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
|
||||
if (!syncService.isServerReachable()) {
|
||||
showToast("⚠️ Server nicht erreichbar")
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
showToast("⚠️ ${getString(R.string.snackbar_server_unreachable)}")
|
||||
SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
|
||||
checkServerStatus() // Server-Status aktualisieren
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
@@ -15,11 +16,29 @@ class SimpleNotesApplication : Application() {
|
||||
|
||||
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() {
|
||||
super.onCreate()
|
||||
|
||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
|
||||
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
|
||||
// appear as offline even though they have a configured server
|
||||
migrateOfflineModeSetting(prefs)
|
||||
|
||||
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
Logger.d(TAG, "📝 File logging enabled at Application startup")
|
||||
@@ -50,4 +69,30 @@ class SimpleNotesApplication : Application() {
|
||||
// WorkManager läuft weiter auch nach onTerminate!
|
||||
// Nur bei deaktiviertem Auto-Sync stoppen wir es
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
|
||||
*
|
||||
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
|
||||
* and NoteEditorViewModel use `true` as default, causing existing users
|
||||
* with configured servers to appear in offline mode after update.
|
||||
*
|
||||
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
|
||||
* a server was already configured.
|
||||
*/
|
||||
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
|
||||
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
|
||||
// If server was configured → offlineMode = false (continue syncing)
|
||||
// If no server → offlineMode = true (new users / offline users)
|
||||
val offlineModeValue = !hasServerConfig
|
||||
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
|
||||
|
||||
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,20 +31,24 @@ class BackupManager(private val context: Context) {
|
||||
private const val BACKUP_VERSION = 1
|
||||
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||
private const val MAGIC_BYTES_LENGTH = 4 // v1.7.0: For encryption check
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(context)
|
||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
||||
|
||||
/**
|
||||
* Erstellt Backup aller Notizen
|
||||
*
|
||||
* @param uri Output-URI (via Storage Access Framework)
|
||||
* @param password Optional password for encryption (null = unencrypted)
|
||||
* @return BackupResult mit Erfolg/Fehler Info
|
||||
*/
|
||||
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
|
||||
suspend fun createBackup(uri: Uri, password: String? = null): BackupResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📦 Creating backup to: $uri")
|
||||
val encryptedSuffix = if (password != null) " (encrypted)" else ""
|
||||
Logger.d(TAG, "📦 Creating backup$encryptedSuffix to: $uri")
|
||||
|
||||
val allNotes = storage.loadAllNotes()
|
||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||
@@ -59,15 +63,22 @@ class BackupManager(private val context: Context) {
|
||||
|
||||
val jsonString = gson.toJson(backupData)
|
||||
|
||||
// 🔐 v1.7.0: Encrypt if password is provided
|
||||
val dataToWrite = if (password != null) {
|
||||
encryptionManager.encrypt(jsonString.toByteArray(), password)
|
||||
} else {
|
||||
jsonString.toByteArray()
|
||||
}
|
||||
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(jsonString.toByteArray())
|
||||
Logger.d(TAG, "✅ Backup created successfully")
|
||||
outputStream.write(dataToWrite)
|
||||
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
|
||||
}
|
||||
|
||||
BackupResult(
|
||||
success = true,
|
||||
notesCount = allNotes.size,
|
||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
||||
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
|
||||
)
|
||||
|
||||
} catch (e: Exception) {
|
||||
@@ -126,20 +137,42 @@ class BackupManager(private val context: Context) {
|
||||
*
|
||||
* @param uri Backup-Datei URI
|
||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||
* @param password Optional password if backup is encrypted
|
||||
* @return RestoreResult mit Details
|
||||
*/
|
||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
|
||||
suspend fun restoreBackup(uri: Uri, mode: RestoreMode, password: String? = null): RestoreResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||
|
||||
// 1. Backup-Datei lesen
|
||||
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
inputStream.bufferedReader().use { it.readText() }
|
||||
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
inputStream.readBytes()
|
||||
} ?: return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Datei konnte nicht gelesen werden"
|
||||
)
|
||||
|
||||
// 🔐 v1.7.0: Check if encrypted and decrypt if needed
|
||||
val jsonString = try {
|
||||
if (encryptionManager.isEncrypted(fileData)) {
|
||||
if (password == null) {
|
||||
return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Backup ist verschlüsselt. Bitte Passwort eingeben."
|
||||
)
|
||||
}
|
||||
val decrypted = encryptionManager.decrypt(fileData, password)
|
||||
String(decrypted)
|
||||
} else {
|
||||
String(fileData)
|
||||
}
|
||||
} catch (e: EncryptionException) {
|
||||
return@withContext RestoreResult(
|
||||
success = false,
|
||||
error = "Entschlüsselung fehlgeschlagen: ${e.message}"
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Backup validieren & parsen
|
||||
val validationResult = validateBackup(jsonString)
|
||||
if (!validationResult.isValid) {
|
||||
@@ -177,6 +210,22 @@ class BackupManager(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔐 v1.7.0: Check if backup file is encrypted
|
||||
*/
|
||||
suspend fun isBackupEncrypted(uri: Uri): Boolean = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||
val header = ByteArray(MAGIC_BYTES_LENGTH)
|
||||
val bytesRead = inputStream.read(header)
|
||||
bytesRead == MAGIC_BYTES_LENGTH && encryptionManager.isEncrypted(header)
|
||||
} ?: false
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to check encryption status", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validiert Backup-Datei
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package dev.dettmer.simplenotes.backup
|
||||
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
/**
|
||||
* 🔐 v1.7.0: Encryption Manager for Backup Files
|
||||
*
|
||||
* Provides AES-256-GCM encryption for local backups with:
|
||||
* - Password-based encryption (PBKDF2 key derivation)
|
||||
* - Random salt + IV for each encryption
|
||||
* - GCM authentication tag for integrity
|
||||
* - Simple file format: [MAGIC][VERSION][SALT][IV][ENCRYPTED_DATA]
|
||||
*/
|
||||
class EncryptionManager {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "EncryptionManager"
|
||||
|
||||
// File format constants
|
||||
private const val MAGIC = "SNE1" // Simple Notes Encrypted v1
|
||||
private const val VERSION: Byte = 1
|
||||
private const val MAGIC_BYTES = 4
|
||||
private const val VERSION_BYTES = 1
|
||||
private const val SALT_LENGTH = 32 // 256 bits
|
||||
private const val IV_LENGTH = 12 // 96 bits (recommended for GCM)
|
||||
private const val HEADER_LENGTH = MAGIC_BYTES + VERSION_BYTES + SALT_LENGTH + IV_LENGTH // 49 bytes
|
||||
|
||||
// Encryption constants
|
||||
private const val KEY_LENGTH = 256 // AES-256
|
||||
private const val GCM_TAG_LENGTH = 128 // 128 bits
|
||||
private const val PBKDF2_ITERATIONS = 100_000 // OWASP recommendation
|
||||
|
||||
// Algorithm names
|
||||
private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256"
|
||||
private const val ENCRYPTION_ALGORITHM = "AES/GCM/NoPadding"
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data with password
|
||||
*
|
||||
* @param data Plaintext data to encrypt
|
||||
* @param password User password
|
||||
* @return Encrypted byte array with header [MAGIC][VERSION][SALT][IV][CIPHERTEXT]
|
||||
*/
|
||||
fun encrypt(data: ByteArray, password: String): ByteArray {
|
||||
Logger.d(TAG, "🔐 Encrypting ${data.size} bytes...")
|
||||
|
||||
// Generate random salt and IV
|
||||
val salt = ByteArray(SALT_LENGTH)
|
||||
val iv = ByteArray(IV_LENGTH)
|
||||
SecureRandom().apply {
|
||||
nextBytes(salt)
|
||||
nextBytes(iv)
|
||||
}
|
||||
|
||||
// Derive encryption key from password
|
||||
val key = deriveKey(password, salt)
|
||||
|
||||
// Encrypt data
|
||||
val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM)
|
||||
val secretKey = SecretKeySpec(key, "AES")
|
||||
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec)
|
||||
val ciphertext = cipher.doFinal(data)
|
||||
|
||||
// Build encrypted file: MAGIC + VERSION + SALT + IV + CIPHERTEXT
|
||||
val result = ByteBuffer.allocate(HEADER_LENGTH + ciphertext.size).apply {
|
||||
put(MAGIC.toByteArray(StandardCharsets.US_ASCII))
|
||||
put(VERSION)
|
||||
put(salt)
|
||||
put(iv)
|
||||
put(ciphertext)
|
||||
}.array()
|
||||
|
||||
Logger.d(TAG, "✅ Encryption successful: ${result.size} bytes (header: $HEADER_LENGTH, ciphertext: ${ciphertext.size})")
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data with password
|
||||
*
|
||||
* @param encryptedData Encrypted byte array (with header)
|
||||
* @param password User password
|
||||
* @return Decrypted plaintext
|
||||
* @throws EncryptionException if decryption fails (wrong password, corrupted data, etc.)
|
||||
*/
|
||||
@Suppress("ThrowsCount") // Multiple validation steps require separate throws
|
||||
fun decrypt(encryptedData: ByteArray, password: String): ByteArray {
|
||||
Logger.d(TAG, "🔓 Decrypting ${encryptedData.size} bytes...")
|
||||
|
||||
// Validate minimum size
|
||||
if (encryptedData.size < HEADER_LENGTH) {
|
||||
throw EncryptionException("File too small: ${encryptedData.size} bytes (expected at least $HEADER_LENGTH)")
|
||||
}
|
||||
|
||||
// Parse header
|
||||
val buffer = ByteBuffer.wrap(encryptedData)
|
||||
|
||||
// Verify magic bytes
|
||||
val magic = ByteArray(MAGIC_BYTES)
|
||||
buffer.get(magic)
|
||||
val magicString = String(magic, StandardCharsets.US_ASCII)
|
||||
if (magicString != MAGIC) {
|
||||
throw EncryptionException("Invalid file format: expected '$MAGIC', got '$magicString'")
|
||||
}
|
||||
|
||||
// Check version
|
||||
val version = buffer.get()
|
||||
if (version != VERSION) {
|
||||
throw EncryptionException("Unsupported version: $version (expected $VERSION)")
|
||||
}
|
||||
|
||||
// Extract salt and IV
|
||||
val salt = ByteArray(SALT_LENGTH)
|
||||
val iv = ByteArray(IV_LENGTH)
|
||||
buffer.get(salt)
|
||||
buffer.get(iv)
|
||||
|
||||
// Extract ciphertext
|
||||
val ciphertext = ByteArray(buffer.remaining())
|
||||
buffer.get(ciphertext)
|
||||
|
||||
// Derive key from password
|
||||
val key = deriveKey(password, salt)
|
||||
|
||||
// Decrypt
|
||||
return try {
|
||||
val cipher = Cipher.getInstance(ENCRYPTION_ALGORITHM)
|
||||
val secretKey = SecretKeySpec(key, "AES")
|
||||
val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec)
|
||||
val plaintext = cipher.doFinal(ciphertext)
|
||||
|
||||
Logger.d(TAG, "✅ Decryption successful: ${plaintext.size} bytes")
|
||||
plaintext
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Decryption failed", e)
|
||||
throw EncryptionException("Decryption failed: ${e.message}. Wrong password?", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive 256-bit encryption key from password using PBKDF2
|
||||
*/
|
||||
private fun deriveKey(password: String, salt: ByteArray): ByteArray {
|
||||
val spec = PBEKeySpec(password.toCharArray(), salt, PBKDF2_ITERATIONS, KEY_LENGTH)
|
||||
val factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM)
|
||||
return factory.generateSecret(spec).encoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data is encrypted (starts with magic bytes)
|
||||
*/
|
||||
fun isEncrypted(data: ByteArray): Boolean {
|
||||
if (data.size < MAGIC_BYTES) return false
|
||||
val magic = data.sliceArray(0 until MAGIC_BYTES)
|
||||
return String(magic, StandardCharsets.US_ASCII) == MAGIC
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when encryption/decryption fails
|
||||
*/
|
||||
class EncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
@@ -323,6 +323,34 @@ type: ${noteType.name.lowercase()}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Note size classification for Staggered Grid Layout
|
||||
*/
|
||||
enum class NoteSize {
|
||||
SMALL, // Compact display (< 80 chars or ≤ 4 checklist items)
|
||||
LARGE; // Full-width display
|
||||
|
||||
companion object {
|
||||
const val SMALL_TEXT_THRESHOLD = 80 // Max characters for compact text note
|
||||
const val SMALL_CHECKLIST_THRESHOLD = 4 // Max items for compact checklist
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Determine note size for grid layout optimization
|
||||
*/
|
||||
fun Note.getSize(): NoteSize {
|
||||
return when (noteType) {
|
||||
NoteType.TEXT -> {
|
||||
if (content.length < NoteSize.SMALL_TEXT_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
|
||||
}
|
||||
NoteType.CHECKLIST -> {
|
||||
val itemCount = checklistItems?.size ?: 0
|
||||
if (itemCount <= NoteSize.SMALL_CHECKLIST_THRESHOLD) NoteSize.SMALL else NoteSize.LARGE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extension für JSON-Escaping
|
||||
fun String.escapeJson(): String {
|
||||
return this
|
||||
|
||||
@@ -124,6 +124,26 @@ class NotesStorage(private val context: Context) {
|
||||
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
|
||||
* This ensures notes are uploaded to the new server on next sync
|
||||
*/
|
||||
fun resetAllSyncStatusToPending(): Int {
|
||||
val notes = loadAllNotes()
|
||||
var updatedCount = 0
|
||||
|
||||
notes.forEach { note ->
|
||||
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
|
||||
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
|
||||
saveNote(updatedNote)
|
||||
updatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
|
||||
return updatedCount
|
||||
}
|
||||
|
||||
|
||||
fun getNotesDir(): File = notesDir
|
||||
}
|
||||
|
||||
@@ -68,15 +68,20 @@ class NetworkMonitor(private val context: Context) {
|
||||
|
||||
lastConnectedNetworkId = currentNetworkId
|
||||
|
||||
// Auto-Sync check
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
|
||||
// WiFi-Connect Trigger prüfen - NICHT KEY_AUTO_SYNC!
|
||||
// Der Callback ist registriert WEIL KEY_SYNC_TRIGGER_WIFI_CONNECT = true
|
||||
// Aber defensive Prüfung für den Fall, dass Settings sich geändert haben
|
||||
val wifiConnectEnabled = prefs.getBoolean(
|
||||
Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT,
|
||||
Constants.DEFAULT_TRIGGER_WIFI_CONNECT
|
||||
)
|
||||
Logger.d(TAG, " WiFi-Connect trigger enabled: $wifiConnectEnabled")
|
||||
|
||||
if (autoSyncEnabled) {
|
||||
Logger.d(TAG, " ✅ Triggering WorkManager...")
|
||||
if (wifiConnectEnabled) {
|
||||
Logger.d(TAG, " ✅ Triggering WiFi-Connect sync...")
|
||||
triggerWifiConnectSync()
|
||||
} else {
|
||||
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering")
|
||||
Logger.d(TAG, " ⏭️ WiFi-Connect trigger disabled in settings")
|
||||
}
|
||||
} else {
|
||||
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
|
||||
@@ -140,23 +145,56 @@ class NetworkMonitor(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Startet WorkManager mit Network Constraints + NetworkCallback
|
||||
*
|
||||
* 🆕 v1.7.0: Überarbeitete Logik - WiFi-Connect Trigger funktioniert UNABHÄNGIG von KEY_AUTO_SYNC
|
||||
* - KEY_AUTO_SYNC + KEY_SYNC_TRIGGER_PERIODIC → Periodic Sync
|
||||
* - KEY_SYNC_TRIGGER_WIFI_CONNECT → WiFi-Connect Trigger (unabhängig!)
|
||||
*/
|
||||
fun startMonitoring() {
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called")
|
||||
|
||||
if (!autoSyncEnabled) {
|
||||
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
|
||||
stopMonitoring()
|
||||
return
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
||||
val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
||||
|
||||
Logger.d(TAG, " Settings: autoSync=$autoSyncEnabled, periodic=$periodicEnabled, wifiConnect=$wifiConnectEnabled")
|
||||
|
||||
// 1. Periodic Sync (nur wenn KEY_AUTO_SYNC UND KEY_SYNC_TRIGGER_PERIODIC aktiv)
|
||||
if (autoSyncEnabled && periodicEnabled) {
|
||||
Logger.d(TAG, "📅 Starting periodic sync...")
|
||||
startPeriodicSync()
|
||||
} else {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||
Logger.d(TAG, "⏭️ Periodic sync disabled (autoSync=$autoSyncEnabled, periodic=$periodicEnabled)")
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)")
|
||||
// 2. WiFi-Connect Trigger (🆕 UNABHÄNGIG von KEY_AUTO_SYNC!)
|
||||
if (wifiConnectEnabled) {
|
||||
Logger.d(TAG, "📶 Starting WiFi monitoring...")
|
||||
startWifiMonitoring()
|
||||
} else {
|
||||
stopWifiMonitoring()
|
||||
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
|
||||
}
|
||||
|
||||
// 1. WorkManager für periodic sync
|
||||
startPeriodicSync()
|
||||
// 3. Logging für Debug
|
||||
if (!autoSyncEnabled && !wifiConnectEnabled) {
|
||||
Logger.d(TAG, "🛑 No background triggers active")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. NetworkCallback für WiFi-Connect Detection
|
||||
startWifiMonitoring()
|
||||
/**
|
||||
* 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor
|
||||
*/
|
||||
@Suppress("SwallowedException")
|
||||
private fun stopWifiMonitoring() {
|
||||
try {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered")
|
||||
} catch (e: Exception) {
|
||||
// Already unregistered - das ist OK
|
||||
Logger.d(TAG, " WiFi callback already unregistered")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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.InputStream
|
||||
|
||||
/**
|
||||
* 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* Lösung:
|
||||
* - Eigene exists()-Implementation mit korrektem Response-Cleanup
|
||||
* - Preemptive Authentication um 401-Round-Trips zu vermeiden
|
||||
*
|
||||
* @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 {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ 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)
|
||||
}
|
||||
|
||||
// Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet
|
||||
}
|
||||
@@ -5,10 +5,14 @@ package dev.dettmer.simplenotes.sync
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -25,6 +29,35 @@ class SyncWorker(
|
||||
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.
|
||||
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
|
||||
@@ -88,6 +121,27 @@ class SyncWorker(
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 2.5: Checking sync gate (canSync)")
|
||||
}
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (WiFi-Only, Offline Mode, Server Config)
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ WiFi-only mode enabled, but not on WiFi - skipping sync")
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ Sync blocked by gate: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (gate blocked)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import com.thegrizzlylabs.sardineandroid.Sardine
|
||||
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||
@@ -41,7 +40,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WebDavSyncService"
|
||||
private const val SOCKET_TIMEOUT_MS = 2000
|
||||
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 ETAG_PREVIEW_LENGTH = 8
|
||||
private const val CONTENT_PREVIEW_LENGTH = 50
|
||||
@@ -56,9 +55,7 @@ class WebDavSyncService(private val context: Context) {
|
||||
private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz
|
||||
|
||||
// ⚡ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert)
|
||||
private var sessionSardine: Sardine? = null
|
||||
private var sessionWifiAddress: InetAddress? = null
|
||||
private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgeführt
|
||||
private var sessionSardine: SafeSardineWrapper? = null
|
||||
|
||||
init {
|
||||
if (BuildConfig.DEBUG) {
|
||||
@@ -91,122 +88,37 @@ 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? {
|
||||
// Return cached if already checked this session
|
||||
if (sessionWifiAddressChecked) {
|
||||
return sessionWifiAddress
|
||||
}
|
||||
|
||||
// Calculate and cache
|
||||
sessionWifiAddress = getWiFiInetAddressInternal()
|
||||
sessionWifiAddressChecked = true
|
||||
return sessionWifiAddress
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
|
||||
*/
|
||||
@Suppress("ReturnCount") // Early returns for network validation checks
|
||||
private fun getWiFiInetAddressInternal(): InetAddress? {
|
||||
private fun isVpnInterfaceActive(): Boolean {
|
||||
try {
|
||||
Logger.d(TAG, "🔍 getWiFiInetAddress() called")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Nur wenn WiFi aktiv
|
||||
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()
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
|
||||
while (interfaces.hasMoreElements()) {
|
||||
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
|
||||
|
||||
val addresses = iface.inetAddresses
|
||||
while (addresses.hasMoreElements()) {
|
||||
val addr = addresses.nextElement()
|
||||
|
||||
Logger.d(
|
||||
TAG,
|
||||
" Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, " +
|
||||
"loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}"
|
||||
)
|
||||
|
||||
// Nur IPv4, nicht loopback, nicht link-local
|
||||
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
|
||||
Logger.d(TAG, "✅ Found WiFi IP: ${addr.hostAddress} on ${iface.name}")
|
||||
return addr
|
||||
}
|
||||
val name = iface.name.lowercase()
|
||||
// Check for VPN/Wireguard interface patterns:
|
||||
// - tun0, tun1, etc. (OpenVPN, generic VPN)
|
||||
// - wg0, wg1, etc. (Wireguard)
|
||||
// - *-wg-* (Mullvad, ProtonVPN style: se-sto-wg-202)
|
||||
if (name.startsWith("tun") ||
|
||||
name.startsWith("wg") ||
|
||||
name.contains("-wg-") ||
|
||||
name.startsWith("ppp")) {
|
||||
Logger.d(TAG, "🔒 VPN interface detected: ${iface.name}")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Logger.w(TAG, "⚠️ No WiFi interface found, using default routing")
|
||||
return null
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "❌ Failed to get WiFi interface", e)
|
||||
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)
|
||||
Logger.w(TAG, "⚠️ Failed to check VPN interfaces: ${e.message}")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -228,30 +140,26 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
/**
|
||||
* 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 password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
|
||||
|
||||
Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding")
|
||||
Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
|
||||
Logger.d(TAG, "🔧 Creating SafeSardineWrapper")
|
||||
|
||||
// ⚡ v1.3.1: Verwende gecachte WiFi-Adresse
|
||||
val wifiAddress = getOrCacheWiFiAddress()
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val okHttpClient = if (wifiAddress != null) {
|
||||
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)
|
||||
}
|
||||
return SafeSardineWrapper.create(okHttpClient, username, password)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,8 +167,6 @@ class WebDavSyncService(private val context: Context) {
|
||||
*/
|
||||
private fun clearSessionCache() {
|
||||
sessionSardine = null
|
||||
sessionWifiAddress = null
|
||||
sessionWifiAddressChecked = false
|
||||
notesDirEnsured = false
|
||||
markdownDirEnsured = false
|
||||
Logger.d(TAG, "🧹 Session caches cleared")
|
||||
@@ -387,8 +293,10 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
|
||||
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)) {
|
||||
Logger.d(TAG, "📁 /notes/ doesn't exist - no server changes")
|
||||
Logger.d(TAG, "📁 /notes/ doesn't exist - assuming no server changes")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -517,8 +425,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
hasServerChanges
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to check for unsynced changes", e)
|
||||
true // Safe default
|
||||
// 🔧 v1.7.2 KRITISCH: Bei Server-Fehler (Timeout, etc.) return TRUE!
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -556,6 +467,63 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.7.0: Prüft ob Gerät aktuell im WLAN ist
|
||||
* Für schnellen Pre-Check VOR dem langsamen Socket-Check
|
||||
*
|
||||
* @return true wenn WLAN verbunden, false sonst (mobil oder kein Netzwerk)
|
||||
*/
|
||||
fun isOnWiFi(): Boolean {
|
||||
return try {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||
as? ConnectivityManager ?: return false
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to check WiFi state", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
|
||||
* Prüft ALLE Voraussetzungen bevor ein Sync gestartet wird.
|
||||
* Diese Funktion sollte VOR jedem syncNotes() Aufruf verwendet werden.
|
||||
*
|
||||
* @return SyncGateResult mit canSync flag und optionalem Blockierungsgrund
|
||||
*/
|
||||
fun canSync(): SyncGateResult {
|
||||
// 1. Offline Mode Check
|
||||
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
|
||||
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
|
||||
}
|
||||
|
||||
// 2. Server configured?
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
|
||||
}
|
||||
|
||||
// 3. WiFi-Only Check
|
||||
val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
|
||||
if (wifiOnlySync && !isOnWiFi()) {
|
||||
return SyncGateResult(canSync = false, blockReason = "wifi_only")
|
||||
}
|
||||
|
||||
return SyncGateResult(canSync = true, blockReason = null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v1.7.0: Result-Klasse für canSync()
|
||||
*/
|
||||
data class SyncGateResult(
|
||||
val canSync: Boolean,
|
||||
val blockReason: String? = null
|
||||
) {
|
||||
val isBlockedByWifiOnly: Boolean get() = blockReason == "wifi_only"
|
||||
}
|
||||
|
||||
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
|
||||
return@withContext try {
|
||||
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
|
||||
@@ -584,19 +552,19 @@ class WebDavSyncService(private val context: Context) {
|
||||
SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = when (e) {
|
||||
is java.net.UnknownHostException -> "Server nicht erreichbar"
|
||||
is java.net.SocketTimeoutException -> "Verbindungs-Timeout"
|
||||
is javax.net.ssl.SSLException -> "SSL-Fehler"
|
||||
is java.net.UnknownHostException -> context.getString(R.string.snackbar_server_unreachable)
|
||||
is java.net.SocketTimeoutException -> context.getString(R.string.snackbar_connection_timeout)
|
||||
is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl)
|
||||
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
|
||||
when (e.statusCode) {
|
||||
401 -> "Authentifizierung fehlgeschlagen"
|
||||
403 -> "Zugriff verweigert"
|
||||
404 -> "Server-Pfad nicht gefunden"
|
||||
500 -> "Server-Fehler"
|
||||
else -> "HTTP-Fehler: ${e.statusCode}"
|
||||
401 -> context.getString(R.string.sync_error_auth_failed)
|
||||
403 -> context.getString(R.string.sync_error_access_denied)
|
||||
404 -> context.getString(R.string.sync_error_path_not_found)
|
||||
500 -> context.getString(R.string.sync_error_server)
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -759,19 +727,19 @@ class WebDavSyncService(private val context: Context) {
|
||||
SyncResult(
|
||||
isSuccess = false,
|
||||
errorMessage = when (e) {
|
||||
is java.net.UnknownHostException -> "Server nicht erreichbar: ${e.message}"
|
||||
is java.net.SocketTimeoutException -> "Verbindungs-Timeout: ${e.message}"
|
||||
is javax.net.ssl.SSLException -> "SSL-Fehler"
|
||||
is java.net.UnknownHostException -> "${context.getString(R.string.snackbar_server_unreachable)}: ${e.message}"
|
||||
is java.net.SocketTimeoutException -> "${context.getString(R.string.snackbar_connection_timeout)}: ${e.message}"
|
||||
is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl)
|
||||
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
|
||||
when (e.statusCode) {
|
||||
401 -> "Authentifizierung fehlgeschlagen"
|
||||
403 -> "Zugriff verweigert"
|
||||
404 -> "Server-Pfad nicht gefunden"
|
||||
500 -> "Server-Fehler"
|
||||
else -> "HTTP-Fehler: ${e.statusCode}"
|
||||
401 -> context.getString(R.string.sync_error_auth_failed)
|
||||
403 -> context.getString(R.string.sync_error_access_denied)
|
||||
404 -> context.getString(R.string.sync_error_path_not_found)
|
||||
500 -> context.getString(R.string.sync_error_server)
|
||||
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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -953,22 +921,11 @@ class WebDavSyncService(private val context: Context) {
|
||||
): Int = withContext(Dispatchers.IO) {
|
||||
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
|
||||
|
||||
// ⚡ v1.3.1: Use cached WiFi address
|
||||
val wifiAddress = getOrCacheWiFiAddress()
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
|
||||
val okHttpClient = if (wifiAddress != null) {
|
||||
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 sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||
|
||||
val mdUrl = getMarkdownUrl(serverUrl)
|
||||
|
||||
@@ -1082,9 +1039,32 @@ class WebDavSyncService(private val context: Context) {
|
||||
"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!)
|
||||
// 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++
|
||||
Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)")
|
||||
processedIds.add(noteId)
|
||||
@@ -1093,13 +1073,19 @@ class WebDavSyncService(private val context: Context) {
|
||||
|
||||
// SECONDARY: E-Tag check (for performance after first sync)
|
||||
// Catches cases where file was re-uploaded with same content
|
||||
if (!forceOverwrite && serverETag != null && serverETag == cachedETag) {
|
||||
// BUT: Always download if file doesn't exist locally!
|
||||
if (!forceOverwrite && fileExistsLocally && serverETag != null && serverETag == cachedETag) {
|
||||
skippedUnchanged++
|
||||
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)")
|
||||
processedIds.add(noteId)
|
||||
continue
|
||||
}
|
||||
|
||||
// If file doesn't exist locally, always download
|
||||
if (!fileExistsLocally) {
|
||||
Logger.d(TAG, " 📥 File missing locally - forcing download")
|
||||
}
|
||||
|
||||
// 🐛 DEBUG: Log download reason
|
||||
val downloadReason = when {
|
||||
lastSyncTime == 0L -> "First sync ever"
|
||||
@@ -1116,28 +1102,9 @@ class WebDavSyncService(private val context: Context) {
|
||||
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
|
||||
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
|
||||
|
||||
val localNote = storage.loadNote(remoteNote.id)
|
||||
|
||||
// Note: localNote was already loaded above for existence check
|
||||
when {
|
||||
localNote == null -> {
|
||||
// New note from server
|
||||
@@ -1480,8 +1447,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
return@withContext try {
|
||||
Logger.d(TAG, "📝 Starting Markdown sync...")
|
||||
|
||||
val sardine = OkHttpSardine()
|
||||
sardine.setCredentials(username, password)
|
||||
val okHttpClient = OkHttpClient.Builder().build()
|
||||
val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
|
||||
|
||||
val mdUrl = getMarkdownUrl(serverUrl)
|
||||
|
||||
|
||||
@@ -83,6 +83,11 @@ fun NoteEditorScreen(
|
||||
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Strings for toast messages (avoid LocalContextGetResourceValueCall lint)
|
||||
val msgNoteIsEmpty = stringResource(R.string.note_is_empty)
|
||||
val msgNoteSaved = stringResource(R.string.note_saved)
|
||||
val msgNoteDeleted = stringResource(R.string.note_deleted)
|
||||
|
||||
// v1.5.0: Auto-keyboard support
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
@@ -111,9 +116,9 @@ fun NoteEditorScreen(
|
||||
when (event) {
|
||||
is NoteEditorEvent.ShowToast -> {
|
||||
val message = when (event.message) {
|
||||
ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty)
|
||||
ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved)
|
||||
ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted)
|
||||
ToastMessage.NOTE_IS_EMPTY -> msgNoteIsEmpty
|
||||
ToastMessage.NOTE_SAVED -> msgNoteSaved
|
||||
ToastMessage.NOTE_DELETED -> msgNoteDeleted
|
||||
}
|
||||
context.showToast(message)
|
||||
}
|
||||
|
||||
@@ -355,6 +355,7 @@ class NoteEditorViewModel(
|
||||
/**
|
||||
* Triggers sync after saving a note (if enabled and server configured)
|
||||
* v1.6.0: New configurable sync trigger
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
*
|
||||
* Separate throttling (5 seconds) to prevent spam when saving multiple times
|
||||
*/
|
||||
@@ -365,14 +366,19 @@ class NoteEditorViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
// Check 2: Server configured?
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
Logger.d(TAG, "⏭️ Offline mode - skipping onSave sync")
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ onSave sync blocked: WiFi-only mode, not on WiFi")
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ onSave sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check 3: Throttling (5 seconds) to prevent spam
|
||||
// Check 2: Throttling (5 seconds) to prevent spam
|
||||
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
|
||||
val now = System.currentTimeMillis()
|
||||
val timeSinceLastSync = now - lastOnSaveSyncTime
|
||||
|
||||
@@ -183,6 +183,9 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
// This ensures UI reflects current offline mode when returning from Settings
|
||||
viewModel.refreshOfflineModeState()
|
||||
|
||||
// 🎨 v1.7.0: Refresh display mode when returning from Settings
|
||||
viewModel.refreshDisplayMode()
|
||||
|
||||
// Register BroadcastReceiver for Background-Sync
|
||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
@@ -50,6 +51,7 @@ import dev.dettmer.simplenotes.ui.main.components.DeleteConfirmationDialog
|
||||
import dev.dettmer.simplenotes.ui.main.components.EmptyState
|
||||
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
||||
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
||||
import dev.dettmer.simplenotes.ui.main.components.NotesStaggeredGrid
|
||||
import dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -82,12 +84,17 @@ fun MainScreen(
|
||||
// 🌟 v1.6.0: Reactive offline mode state
|
||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||
|
||||
// 🎨 v1.7.0: Display mode (list or grid)
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
|
||||
// Delete confirmation dialog state
|
||||
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||
val gridState = rememberLazyStaggeredGridState()
|
||||
|
||||
// Compute isSyncing once
|
||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
||||
@@ -116,9 +123,14 @@ fun MainScreen(
|
||||
}
|
||||
|
||||
// Phase 3: Scroll to top when new note created
|
||||
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
|
||||
LaunchedEffect(scrollToTop) {
|
||||
if (scrollToTop) {
|
||||
listState.animateScrollToItem(0)
|
||||
if (displayMode == "grid") {
|
||||
gridState.animateScrollToItem(0)
|
||||
} else {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
viewModel.resetScrollToTop()
|
||||
}
|
||||
}
|
||||
@@ -177,22 +189,44 @@ fun MainScreen(
|
||||
if (notes.isEmpty()) {
|
||||
EmptyState(modifier = Modifier.weight(1f))
|
||||
} else {
|
||||
NotesList(
|
||||
notes = notes,
|
||||
showSyncStatus = viewModel.isServerConfigured(),
|
||||
selectedNotes = selectedNotes,
|
||||
isSelectionMode = isSelectionMode,
|
||||
listState = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
onNoteClick = { note -> onOpenNote(note.id) },
|
||||
onNoteLongPress = { note ->
|
||||
// Long-press starts selection mode
|
||||
viewModel.startSelectionMode(note.id)
|
||||
},
|
||||
onNoteSelectionToggle = { note ->
|
||||
viewModel.toggleNoteSelection(note.id)
|
||||
}
|
||||
)
|
||||
// 🎨 v1.7.0: Switch between List and Grid based on display mode
|
||||
if (displayMode == "grid") {
|
||||
NotesStaggeredGrid(
|
||||
notes = notes,
|
||||
gridState = gridState,
|
||||
showSyncStatus = viewModel.isServerConfigured(),
|
||||
selectedNoteIds = selectedNotes,
|
||||
isSelectionMode = isSelectionMode,
|
||||
modifier = Modifier.weight(1f),
|
||||
onNoteClick = { note ->
|
||||
if (isSelectionMode) {
|
||||
viewModel.toggleNoteSelection(note.id)
|
||||
} else {
|
||||
onOpenNote(note.id)
|
||||
}
|
||||
},
|
||||
onNoteLongClick = { note ->
|
||||
viewModel.startSelectionMode(note.id)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
NotesList(
|
||||
notes = notes,
|
||||
showSyncStatus = viewModel.isServerConfigured(),
|
||||
selectedNotes = selectedNotes,
|
||||
isSelectionMode = isSelectionMode,
|
||||
listState = listState,
|
||||
modifier = Modifier.weight(1f),
|
||||
onNoteClick = { note -> onOpenNote(note.id) },
|
||||
onNoteLongPress = { note ->
|
||||
// Long-press starts selection mode
|
||||
viewModel.startSelectionMode(note.id)
|
||||
},
|
||||
onNoteSelectionToggle = { note ->
|
||||
viewModel.toggleNoteSelection(note.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.SyncConstants
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -83,6 +82,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $newValue")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Mode State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _displayMode = MutableStateFlow(
|
||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
)
|
||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||
|
||||
/**
|
||||
* Refresh display mode from SharedPreferences
|
||||
* Called when returning from Settings screen
|
||||
*/
|
||||
fun refreshDisplayMode() {
|
||||
val newValue = prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
_displayMode.value = newValue
|
||||
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value} → $newValue")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Sync State (derived from SyncStateManager)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -482,26 +500,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
/**
|
||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
*/
|
||||
fun triggerManualSync(source: String = "manual") {
|
||||
// 🌟 v1.6.0: Block sync in offline mode
|
||||
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
|
||||
Logger.d(TAG, "⏭️ $source Sync blocked: Offline mode enabled")
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi")
|
||||
SyncStateManager.markError(getString(R.string.sync_wifi_only_hint))
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
||||
if (!SyncStateManager.tryStartSync(source)) {
|
||||
if (SyncStateManager.isSyncing) {
|
||||
Logger.d(TAG, "⏭️ $source Sync blocked: Another sync in progress")
|
||||
viewModelScope.launch {
|
||||
_showSnackbar.emit(SnackbarData(
|
||||
message = getString(R.string.sync_already_running),
|
||||
actionLabel = "",
|
||||
onAction = {}
|
||||
))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
|
||||
// Check for unsynced changes
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
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()
|
||||
return@launch
|
||||
}
|
||||
@@ -544,6 +580,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* Only runs if server is configured and interval has passed
|
||||
* v1.5.0: Silent-Sync - kein Banner während des Syncs, Fehler werden trotzdem angezeigt
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_ON_RESUME
|
||||
* v1.7.0: Uses central canSync() gate for WiFi-only check
|
||||
*/
|
||||
fun triggerAutoSync(source: String = "auto") {
|
||||
// 🌟 v1.6.0: Check if onResume trigger is enabled
|
||||
@@ -557,10 +594,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if server is configured
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
Logger.d(TAG, "⏭️ Offline mode - skipping onResume sync")
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: WiFi-only mode, not on WiFi")
|
||||
} else {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source) blocked: ${gateResult.blockReason ?: "offline/no server"}")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -577,8 +619,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
|
||||
// Check for unsynced changes
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||
|
||||
@@ -72,7 +72,7 @@ fun NoteCard(
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
// 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.List
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.CloudDone
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.CloudSync
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Compact Note Card for Grid Layout
|
||||
*
|
||||
* COMPACT DESIGN für kleine Notizen:
|
||||
* - Reduzierter Padding (12dp statt 16dp)
|
||||
* - Kleinere Icons (24dp statt 32dp)
|
||||
* - Kompakte Typography (titleSmall)
|
||||
* - Max 3 Zeilen Preview
|
||||
* - Optimiert für Grid-Ansicht
|
||||
*/
|
||||
@Composable
|
||||
fun NoteCardCompact(
|
||||
note: Note,
|
||||
showSyncStatus: Boolean,
|
||||
isSelected: Boolean = false,
|
||||
isSelectionMode: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.pointerInput(note.id, isSelectionMode) {
|
||||
detectTapGestures(
|
||||
onTap = { onClick() },
|
||||
onLongPress = { onLongClick() }
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp)
|
||||
) {
|
||||
// Header row - COMPACT
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Type icon - SMALLER
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (note.noteType == NoteType.TEXT)
|
||||
Icons.Outlined.Description
|
||||
else
|
||||
Icons.AutoMirrored.Outlined.List,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Title - COMPACT Typography
|
||||
Text(
|
||||
text = note.title.ifEmpty { stringResource(R.string.untitled) },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Preview - MAX 3 ZEILEN
|
||||
Text(
|
||||
text = when (note.noteType) {
|
||||
NoteType.TEXT -> note.content
|
||||
NoteType.CHECKLIST -> {
|
||||
note.checklistItems
|
||||
?.joinToString("\n") { item ->
|
||||
val prefix = if (item.isChecked) "✅" else "☐"
|
||||
"$prefix ${item.text}"
|
||||
} ?: ""
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Bottom row - KOMPAKT
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Timestamp - SMALLER
|
||||
Text(
|
||||
text = note.updatedAt.toReadableTime(context),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
// Sync Status - KOMPAKT
|
||||
if (showSyncStatus) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> Icons.Outlined.CloudDone
|
||||
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
||||
SyncStatus.CONFLICT -> Icons.Default.Warning
|
||||
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
},
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection indicator checkbox (top-right)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isSelectionMode,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
}
|
||||
)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.selection_count, 1),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.List
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material.icons.outlined.CloudDone
|
||||
import androidx.compose.material.icons.outlined.CloudOff
|
||||
import androidx.compose.material.icons.outlined.CloudSync
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteSize
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.models.getSize
|
||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Unified Note Card for Grid Layout
|
||||
*
|
||||
* Einheitliche Card für ALLE Notizen im Grid:
|
||||
* - Dynamische maxLines basierend auf NoteSize
|
||||
* - LARGE notes: 6 Zeilen Preview
|
||||
* - SMALL notes: 3 Zeilen Preview
|
||||
* - Kein externes Padding - Grid steuert Abstände
|
||||
* - Optimiert für Pinterest-style dynamisches Layout
|
||||
*/
|
||||
@Composable
|
||||
fun NoteCardGrid(
|
||||
note: Note,
|
||||
showSyncStatus: Boolean,
|
||||
isSelected: Boolean = false,
|
||||
isSelectionMode: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val noteSize = note.getSize()
|
||||
|
||||
// Dynamische maxLines basierend auf Größe
|
||||
val previewMaxLines = if (noteSize == NoteSize.LARGE) 6 else 3
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Kein externes Padding - Grid steuert alles
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(
|
||||
width = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
} else Modifier
|
||||
)
|
||||
.pointerInput(note.id, isSelectionMode) {
|
||||
detectTapGestures(
|
||||
onTap = { onClick() },
|
||||
onLongPress = { onLongClick() }
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
)
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp) // Einheitliches internes Padding
|
||||
) {
|
||||
// Header row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Type icon
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (note.noteType == NoteType.TEXT)
|
||||
Icons.Outlined.Description
|
||||
else
|
||||
Icons.AutoMirrored.Outlined.List,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// Title
|
||||
Text(
|
||||
text = note.title.ifEmpty { stringResource(R.string.untitled) },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Preview - Dynamische Zeilen basierend auf NoteSize
|
||||
Text(
|
||||
text = when (note.noteType) {
|
||||
NoteType.TEXT -> note.content
|
||||
NoteType.CHECKLIST -> {
|
||||
note.checklistItems
|
||||
?.joinToString("\n") { item ->
|
||||
val prefix = if (item.isChecked) "✅" else "☐"
|
||||
"$prefix ${item.text}"
|
||||
} ?: ""
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = previewMaxLines, // 🎯 Dynamisch: LARGE=6, SMALL=3
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
// Footer
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = note.updatedAt.toReadableTime(context),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (showSyncStatus) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Icon(
|
||||
imageVector = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> Icons.Outlined.CloudDone
|
||||
SyncStatus.PENDING -> Icons.Outlined.CloudSync
|
||||
SyncStatus.CONFLICT -> Icons.Default.Warning
|
||||
SyncStatus.LOCAL_ONLY -> Icons.Outlined.CloudOff
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> MaterialTheme.colorScheme.primary
|
||||
SyncStatus.CONFLICT -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.outline
|
||||
},
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection indicator checkbox (top-right)
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isSelectionMode,
|
||||
enter = fadeIn() + scaleIn(),
|
||||
exit = fadeOut() + scaleOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
}
|
||||
)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.selection_count, 1),
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -49,6 +50,8 @@ fun NotesList(
|
||||
showSyncStatus = showSyncStatus,
|
||||
isSelected = isSelected,
|
||||
isSelectionMode = isSelectionMode,
|
||||
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
onClick = {
|
||||
if (isSelectionMode) {
|
||||
// In selection mode, tap toggles selection
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Staggered Grid Layout - OPTIMIERT
|
||||
*
|
||||
* Pinterest-style Grid:
|
||||
* - ALLE Items als SingleLane (halbe Breite)
|
||||
* - Dynamische Höhe basierend auf NoteSize (LARGE=6 Zeilen, SMALL=3 Zeilen)
|
||||
* - Keine Lücken mehr durch FullLine-Items
|
||||
* - Selection mode support
|
||||
* - Efficient LazyVerticalStaggeredGrid
|
||||
*/
|
||||
@Composable
|
||||
fun NotesStaggeredGrid(
|
||||
notes: List<Note>,
|
||||
gridState: LazyStaggeredGridState,
|
||||
showSyncStatus: Boolean,
|
||||
selectedNoteIds: Set<String>,
|
||||
isSelectionMode: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onNoteClick: (Note) -> Unit,
|
||||
onNoteLongClick: (Note) -> Unit
|
||||
) {
|
||||
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(Constants.GRID_COLUMNS),
|
||||
modifier = modifier.fillMaxSize(),
|
||||
state = gridState,
|
||||
// 🎨 v1.7.0: Konsistente Abstände - 16dp horizontal wie Liste, mehr Platz für FAB
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp, // Wie Liste, war 8dp
|
||||
end = 16.dp,
|
||||
top = 8.dp,
|
||||
bottom = 80.dp // Mehr Platz für FAB, war 16dp
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp), // War 8dp
|
||||
verticalItemSpacing = 12.dp // War Constants.GRID_SPACING_DP (8dp)
|
||||
) {
|
||||
items(
|
||||
items = notes,
|
||||
key = { it.id }
|
||||
// 🎨 v1.7.0: KEIN span mehr - alle Items sind SingleLane (halbe Breite)
|
||||
) { note ->
|
||||
val isSelected = selectedNoteIds.contains(note.id)
|
||||
|
||||
// 🎉 Einheitliche Card für alle Größen - dynamische maxLines intern
|
||||
NoteCardGrid(
|
||||
note = note,
|
||||
showSyncStatus = showSyncStatus,
|
||||
isSelected = isSelected,
|
||||
isSelectionMode = isSelectionMode,
|
||||
onClick = { onNoteClick(note) },
|
||||
onLongClick = { onNoteLongClick(note) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.navigation.compose.composable
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.AboutScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.DisplaySettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.LanguageSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
|
||||
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
|
||||
@@ -95,5 +96,13 @@ fun SettingsNavHost(
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
// 🎨 v1.7.0: Display Settings
|
||||
composable(SettingsRoute.Display.route) {
|
||||
DisplaySettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ sealed class SettingsRoute(val route: String) {
|
||||
data object Backup : SettingsRoute("settings_backup")
|
||||
data object About : SettingsRoute("settings_about")
|
||||
data object Debug : SettingsRoute("settings_debug")
|
||||
data object Display : SettingsRoute("settings_display") // 🎨 v1.7.0
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.backup.BackupManager
|
||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
@@ -33,6 +34,7 @@ import java.net.URL
|
||||
*
|
||||
* Manages all settings state and actions across the Settings navigation graph.
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // v1.7.0: 35 Funktionen durch viele kleine Setter (setTrigger*, set*)
|
||||
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
companion object {
|
||||
@@ -42,6 +44,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val backupManager = BackupManager(application)
|
||||
private val notesStorage = NotesStorage(application) // v1.7.0: For server change detection
|
||||
|
||||
// 🔧 v1.7.0 Hotfix: Track last confirmed server URL for change detection
|
||||
// This prevents false-positive "server changed" toasts during text input
|
||||
private var confirmedServerUrl: String = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Server Settings State
|
||||
@@ -154,6 +161,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
)
|
||||
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
|
||||
|
||||
// 🎉 v1.7.0: WiFi-Only Sync Toggle
|
||||
private val _wifiOnlySync = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
|
||||
)
|
||||
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Markdown Settings State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -173,6 +186,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
)
|
||||
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Settings State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _displayMode = MutableStateFlow(
|
||||
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
|
||||
)
|
||||
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UI State
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -216,41 +238,130 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
/**
|
||||
* 🌟 v1.6.0: Update only the host part of the server URL
|
||||
* The protocol prefix is handled separately by updateProtocol()
|
||||
* 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
||||
* 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
* but WITHOUT server-change detection (detection happens only on screen exit)
|
||||
*/
|
||||
fun updateServerHost(host: String) {
|
||||
_serverHost.value = host
|
||||
saveServerSettings()
|
||||
|
||||
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||
val fullUrl = if (host.isEmpty()) "" else prefix + host
|
||||
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||
}
|
||||
|
||||
fun updateProtocol(useHttps: Boolean) {
|
||||
_isHttps.value = useHttps
|
||||
// 🌟 v1.6.0: Host stays the same, only prefix changes
|
||||
saveServerSettings()
|
||||
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
|
||||
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||
val prefix = if (useHttps) "https://" else "http://"
|
||||
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||
}
|
||||
|
||||
fun updateUsername(value: String) {
|
||||
_username.value = value
|
||||
saveServerSettings()
|
||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
prefs.edit().putString(Constants.KEY_USERNAME, value).apply()
|
||||
}
|
||||
|
||||
fun updatePassword(value: String) {
|
||||
_password.value = value
|
||||
saveServerSettings()
|
||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
|
||||
}
|
||||
|
||||
private fun saveServerSettings() {
|
||||
/**
|
||||
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
|
||||
* This prevents false "server changed" detection during text input
|
||||
* 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions.
|
||||
* This function now ONLY handles server-change detection and sync reset.
|
||||
*/
|
||||
fun saveServerSettingsManually() {
|
||||
// 🌟 v1.6.0: Construct full URL from prefix + host
|
||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||
|
||||
prefs.edit().apply {
|
||||
putString(Constants.KEY_SERVER_URL, fullUrl)
|
||||
putString(Constants.KEY_USERNAME, _username.value)
|
||||
putString(Constants.KEY_PASSWORD, _password.value)
|
||||
apply()
|
||||
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
|
||||
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
|
||||
|
||||
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
|
||||
// This function now ONLY handles server-change detection
|
||||
|
||||
// Reset sync status if server actually changed
|
||||
if (serverChanged) {
|
||||
viewModelScope.launch {
|
||||
val count = notesStorage.resetAllSyncStatusToPending()
|
||||
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
|
||||
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
|
||||
}
|
||||
// Update confirmed state after reset
|
||||
confirmedServerUrl = fullUrl
|
||||
} else {
|
||||
Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <20> v1.7.0 Hotfix: Improved server change detection
|
||||
*
|
||||
* Only returns true if the server URL actually changed in a meaningful way.
|
||||
* Handles edge cases:
|
||||
* - First setup (empty → filled) = NOT a change
|
||||
* - Protocol only (http → https) = NOT a change
|
||||
* - Server removed (filled → empty) = NOT a change
|
||||
* - Trailing slashes, case differences = NOT a change
|
||||
* - Different hostname/port/path = IS a change ✓
|
||||
*/
|
||||
private fun isServerReallyChanged(confirmedUrl: String, newUrl: String): Boolean {
|
||||
// Empty → Non-empty = First setup, NOT a change
|
||||
if (confirmedUrl.isEmpty() && newUrl.isNotEmpty()) {
|
||||
Logger.d(TAG, "First server setup detected (no reset needed)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Both empty = No change
|
||||
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Non-empty → Empty = Server removed (keep notes local, no reset)
|
||||
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
|
||||
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Same URL = No change
|
||||
if (confirmedUrl == newUrl) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
|
||||
val normalize = { url: String ->
|
||||
url.trim()
|
||||
.removePrefix("http://")
|
||||
.removePrefix("https://")
|
||||
.removeSuffix("/")
|
||||
.lowercase()
|
||||
}
|
||||
|
||||
val confirmedNormalized = normalize(confirmedUrl)
|
||||
val newNormalized = normalize(newUrl)
|
||||
|
||||
// Check if normalized URLs differ
|
||||
val changed = confirmedNormalized != newNormalized
|
||||
|
||||
if (changed) {
|
||||
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
viewModelScope.launch {
|
||||
_serverStatus.value = ServerStatus.Checking
|
||||
@@ -318,9 +429,21 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
viewModelScope.launch {
|
||||
_isSyncing.value = true
|
||||
try {
|
||||
emitToast(getString(R.string.toast_syncing))
|
||||
val syncService = WebDavSyncService(getApplication())
|
||||
|
||||
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
|
||||
val gateResult = syncService.canSync()
|
||||
if (!gateResult.canSync) {
|
||||
if (gateResult.isBlockedByWifiOnly) {
|
||||
emitToast(getString(R.string.sync_wifi_only_hint))
|
||||
} else {
|
||||
emitToast(getString(R.string.toast_sync_failed, "Offline mode"))
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
emitToast(getString(R.string.toast_syncing))
|
||||
|
||||
if (!syncService.hasUnsyncedChanges()) {
|
||||
emitToast(getString(R.string.toast_already_synced))
|
||||
return@launch
|
||||
@@ -412,6 +535,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
Logger.d(TAG, "Trigger Boot: $enabled")
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎉 v1.7.0: Set WiFi-only sync mode
|
||||
* When enabled, sync only happens when connected to WiFi
|
||||
*/
|
||||
fun setWifiOnlySync(enabled: Boolean) {
|
||||
_wifiOnlySync.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
|
||||
Logger.d(TAG, "📡 WiFi-only sync: $enabled")
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Markdown Settings Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -519,11 +652,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
// Backup Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
fun createBackup(uri: Uri) {
|
||||
fun createBackup(uri: Uri, password: String? = null) {
|
||||
viewModelScope.launch {
|
||||
_isBackupInProgress.value = true
|
||||
try {
|
||||
val result = backupManager.createBackup(uri)
|
||||
val result = backupManager.createBackup(uri, password)
|
||||
val message = if (result.success) {
|
||||
getString(R.string.toast_backup_success, result.message ?: "")
|
||||
} else {
|
||||
@@ -538,11 +671,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromFile(uri: Uri, mode: RestoreMode) {
|
||||
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
||||
viewModelScope.launch {
|
||||
_isBackupInProgress.value = true
|
||||
try {
|
||||
val result = backupManager.restoreBackup(uri, mode)
|
||||
val result = backupManager.restoreBackup(uri, mode, password)
|
||||
val message = if (result.success) {
|
||||
getString(R.string.toast_restore_success, result.importedNotes)
|
||||
} else {
|
||||
@@ -557,6 +690,29 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
|
||||
*/
|
||||
fun checkBackupEncryption(
|
||||
uri: Uri,
|
||||
onEncrypted: () -> Unit,
|
||||
onPlaintext: () -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val isEncrypted = backupManager.isBackupEncrypted(uri)
|
||||
if (isEncrypted) {
|
||||
onEncrypted()
|
||||
} else {
|
||||
onPlaintext()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to check encryption status", e)
|
||||
onPlaintext() // Assume plaintext on error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromServer(mode: RestoreMode) {
|
||||
viewModelScope.launch {
|
||||
_isBackupInProgress.value = true
|
||||
@@ -624,10 +780,42 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
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 =
|
||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||
private fun getString(resId: Int, vararg formatArgs: Any): 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, *formatArgs)
|
||||
}
|
||||
|
||||
private suspend fun emitToast(message: String) {
|
||||
_showToast.emit(message)
|
||||
@@ -664,4 +852,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
val total: Int,
|
||||
val isComplete: Boolean = false
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🎨 v1.7.0: Display Mode Functions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Set display mode (list or grid)
|
||||
*/
|
||||
fun setDisplayMode(mode: String) {
|
||||
_displayMode.value = mode
|
||||
prefs.edit().putString(Constants.KEY_DISPLAY_MODE, mode).apply()
|
||||
Logger.d(TAG, "Display mode changed to: $mode")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
|
||||
private const val MIN_PASSWORD_LENGTH = 8
|
||||
|
||||
/**
|
||||
* 🔒 v1.7.0: Password input dialog for backup encryption/decryption
|
||||
*/
|
||||
@Composable
|
||||
fun BackupPasswordDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (password: String) -> Unit,
|
||||
requireConfirmation: Boolean = true
|
||||
) {
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column {
|
||||
// Password field
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = {
|
||||
password = it
|
||||
errorMessage = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.backup_encryption_password)) },
|
||||
placeholder = { Text(stringResource(R.string.backup_encryption_password_hint)) },
|
||||
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = if (requireConfirmation) ImeAction.Next else ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = if (!requireConfirmation) {
|
||||
{ validateAndConfirm(password, null, onConfirm) { errorMessage = it } }
|
||||
} else null
|
||||
),
|
||||
singleLine = true,
|
||||
isError = errorMessage != null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
// Confirm password field (only for encryption, not decryption)
|
||||
if (requireConfirmation) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = {
|
||||
confirmPassword = it
|
||||
errorMessage = null
|
||||
},
|
||||
label = { Text(stringResource(R.string.backup_encryption_confirm)) },
|
||||
placeholder = { Text(stringResource(R.string.backup_encryption_confirm_hint)) },
|
||||
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (confirmPasswordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { validateAndConfirm(password, confirmPassword, onConfirm) { errorMessage = it } }
|
||||
),
|
||||
singleLine = true,
|
||||
isError = errorMessage != null,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Error message
|
||||
if (errorMessage != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = errorMessage!!,
|
||||
color = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||
style = androidx.compose.material3.MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
validateAndConfirm(
|
||||
password,
|
||||
if (requireConfirmation) confirmPassword else null,
|
||||
onConfirm
|
||||
) { errorMessage = it }
|
||||
}
|
||||
) {
|
||||
Text("OK")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password and call onConfirm if valid
|
||||
*/
|
||||
private fun validateAndConfirm(
|
||||
password: String,
|
||||
confirmPassword: String?,
|
||||
onConfirm: (String) -> Unit,
|
||||
onError: (String) -> Unit
|
||||
) {
|
||||
when {
|
||||
password.length < MIN_PASSWORD_LENGTH -> {
|
||||
onError("Password too short (min. $MIN_PASSWORD_LENGTH characters)")
|
||||
}
|
||||
confirmPassword != null && password != confirmPassword -> {
|
||||
onError("Passwords don't match")
|
||||
}
|
||||
else -> {
|
||||
onConfirm(password)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.BackupPasswordDialog
|
||||
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
||||
@@ -34,6 +35,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsOutlinedButton
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -58,11 +60,25 @@ fun BackupSettingsScreen(
|
||||
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
||||
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
|
||||
|
||||
// 🔐 v1.7.0: Encryption state
|
||||
var encryptBackup by remember { mutableStateOf(false) }
|
||||
var showEncryptionPasswordDialog by remember { mutableStateOf(false) }
|
||||
var showDecryptionPasswordDialog by remember { mutableStateOf(false) }
|
||||
var pendingBackupUri by remember { mutableStateOf<Uri?>(null) }
|
||||
|
||||
// File picker launchers
|
||||
val createBackupLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||
) { uri ->
|
||||
uri?.let { viewModel.createBackup(it) }
|
||||
uri?.let {
|
||||
// 🔐 v1.7.0: If encryption enabled, show password dialog first
|
||||
if (encryptBackup) {
|
||||
pendingBackupUri = it
|
||||
showEncryptionPasswordDialog = true
|
||||
} else {
|
||||
viewModel.createBackup(it, password = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val restoreFileLauncher = rememberLauncherForActivityResult(
|
||||
@@ -99,6 +115,16 @@ fun BackupSettingsScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 🔐 v1.7.0: Encryption toggle
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.backup_encryption_title),
|
||||
subtitle = stringResource(R.string.backup_encryption_subtitle),
|
||||
checked = encryptBackup,
|
||||
onCheckedChange = { encryptBackup = it }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SettingsButton(
|
||||
text = stringResource(R.string.backup_create),
|
||||
onClick = {
|
||||
@@ -156,6 +182,47 @@ fun BackupSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 v1.7.0: Encryption password dialog (for backup creation)
|
||||
if (showEncryptionPasswordDialog) {
|
||||
BackupPasswordDialog(
|
||||
title = stringResource(R.string.backup_encryption_title),
|
||||
onDismiss = {
|
||||
showEncryptionPasswordDialog = false
|
||||
pendingBackupUri = null
|
||||
},
|
||||
onConfirm = { password ->
|
||||
showEncryptionPasswordDialog = false
|
||||
pendingBackupUri?.let { uri ->
|
||||
viewModel.createBackup(uri, password)
|
||||
}
|
||||
pendingBackupUri = null
|
||||
},
|
||||
requireConfirmation = true
|
||||
)
|
||||
}
|
||||
|
||||
// 🔐 v1.7.0: Decryption password dialog (for restore)
|
||||
if (showDecryptionPasswordDialog) {
|
||||
BackupPasswordDialog(
|
||||
title = stringResource(R.string.backup_decryption_required),
|
||||
onDismiss = {
|
||||
showDecryptionPasswordDialog = false
|
||||
pendingRestoreUri = null
|
||||
},
|
||||
onConfirm = { password ->
|
||||
showDecryptionPasswordDialog = false
|
||||
pendingRestoreUri?.let { uri ->
|
||||
when (restoreSource) {
|
||||
RestoreSource.LocalFile -> viewModel.restoreFromFile(uri, selectedRestoreMode, password)
|
||||
RestoreSource.Server -> { /* Server restore doesn't support encryption */ }
|
||||
}
|
||||
}
|
||||
pendingRestoreUri = null
|
||||
},
|
||||
requireConfirmation = false
|
||||
)
|
||||
}
|
||||
|
||||
// Restore Mode Dialog
|
||||
if (showRestoreDialog) {
|
||||
RestoreModeDialog(
|
||||
@@ -167,7 +234,17 @@ fun BackupSettingsScreen(
|
||||
when (restoreSource) {
|
||||
RestoreSource.LocalFile -> {
|
||||
pendingRestoreUri?.let { uri ->
|
||||
viewModel.restoreFromFile(uri, selectedRestoreMode)
|
||||
// 🔐 v1.7.0: Check if backup is encrypted
|
||||
viewModel.checkBackupEncryption(
|
||||
uri = uri,
|
||||
onEncrypted = {
|
||||
showDecryptionPasswordDialog = true
|
||||
},
|
||||
onPlaintext = {
|
||||
viewModel.restoreFromFile(uri, selectedRestoreMode, password = null)
|
||||
pendingRestoreUri = null
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
RestoreSource.Server -> {
|
||||
|
||||
@@ -82,6 +82,9 @@ fun DebugSettingsScreen(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Export Logs Button
|
||||
val logsSubject = stringResource(R.string.debug_logs_subject)
|
||||
val logsShareVia = stringResource(R.string.debug_logs_share_via)
|
||||
|
||||
SettingsButton(
|
||||
text = stringResource(R.string.debug_export_logs),
|
||||
onClick = {
|
||||
@@ -96,11 +99,11 @@ fun DebugSettingsScreen(
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, logUri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.debug_logs_subject))
|
||||
putExtra(Intent.EXTRA_SUBJECT, logsSubject)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
|
||||
context.startActivity(Intent.createChooser(shareIntent, context.getString(R.string.debug_logs_share_via)))
|
||||
context.startActivity(Intent.createChooser(shareIntent, logsShareVia))
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Display Settings Screen
|
||||
*
|
||||
* Allows switching between List and Grid view modes.
|
||||
*/
|
||||
@Composable
|
||||
fun DisplaySettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.display_settings_title),
|
||||
onBack = onBack
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.display_mode_title))
|
||||
|
||||
SettingsRadioGroup(
|
||||
options = listOf(
|
||||
RadioOption(
|
||||
value = "list",
|
||||
title = stringResource(R.string.display_mode_list),
|
||||
subtitle = null
|
||||
),
|
||||
RadioOption(
|
||||
value = "grid",
|
||||
title = stringResource(R.string.display_mode_grid),
|
||||
subtitle = null
|
||||
)
|
||||
),
|
||||
selectedValue = displayMode,
|
||||
onValueSelected = { viewModel.setDisplayMode(it) }
|
||||
)
|
||||
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.display_mode_info)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -56,6 +57,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
* Server configuration settings screen
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
* v1.6.0: Offline Mode Toggle
|
||||
* v1.7.0 Hotfix: Save settings on screen exit (not on every keystroke)
|
||||
*/
|
||||
@Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values
|
||||
@Composable
|
||||
@@ -74,6 +76,14 @@ fun ServerSettingsScreen(
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
// 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen
|
||||
// This prevents false "server changed" detection during text input
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.saveServerSettingsManually()
|
||||
}
|
||||
}
|
||||
|
||||
// Check server status on load (only if not in offline mode)
|
||||
LaunchedEffect(offlineMode) {
|
||||
if (!offlineMode) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Backup
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Description
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
@@ -90,6 +91,22 @@ fun SettingsMainScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// 🎨 v1.7.0: Display Settings
|
||||
item {
|
||||
val displayMode by viewModel.displayMode.collectAsState()
|
||||
val displaySubtitle = when (displayMode) {
|
||||
"grid" -> stringResource(R.string.display_mode_grid)
|
||||
else -> stringResource(R.string.display_mode_list)
|
||||
}
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.GridView,
|
||||
title = stringResource(R.string.display_settings_title),
|
||||
subtitle = displaySubtitle,
|
||||
onClick = { onNavigate(SettingsRoute.Display) }
|
||||
)
|
||||
}
|
||||
|
||||
// Server-Einstellungen
|
||||
item {
|
||||
// 🌟 v1.6.0: Check if server is configured (host is not empty)
|
||||
|
||||
@@ -50,6 +50,9 @@ fun SyncSettingsScreen(
|
||||
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||
val syncInterval by viewModel.syncInterval.collectAsState()
|
||||
|
||||
// 🆕 v1.7.0: WiFi-only sync
|
||||
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
|
||||
|
||||
// Check if server is configured
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
|
||||
@@ -82,6 +85,31 @@ fun SyncSettingsScreen(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 🆕 v1.7.0: NETZWERK-EINSCHRÄNKUNG Section (Global für alle Trigger)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_network))
|
||||
|
||||
// WiFi-Only Sync Toggle - Gilt für ALLE Trigger außer WiFi-Connect
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_wifi_only_title),
|
||||
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
|
||||
checked = wifiOnlySync,
|
||||
onCheckedChange = { viewModel.setWifiOnlySync(it) },
|
||||
icon = Icons.Default.Wifi,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
// Info-Hinweis dass WiFi-Connect davon ausgenommen ist
|
||||
if (wifiOnlySync && isServerConfigured) {
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.sync_wifi_only_hint)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SOFORT-SYNC Section
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -32,6 +32,10 @@ object Constants {
|
||||
// 🔥 v1.6.0: Offline Mode Toggle
|
||||
const val KEY_OFFLINE_MODE = "offline_mode_enabled"
|
||||
|
||||
// 🔥 v1.7.0: WiFi-Only Sync Toggle
|
||||
const val KEY_WIFI_ONLY_SYNC = "wifi_only_sync_enabled"
|
||||
const val DEFAULT_WIFI_ONLY_SYNC = false // Standardmäßig auch mobil syncen
|
||||
|
||||
// 🔥 v1.6.0: Configurable Sync Triggers
|
||||
const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save"
|
||||
const val KEY_SYNC_TRIGGER_ON_RESUME = "sync_trigger_on_resume"
|
||||
@@ -57,4 +61,10 @@ object Constants {
|
||||
// Notifications
|
||||
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
|
||||
const val NOTIFICATION_ID = 1001
|
||||
|
||||
// 🎨 v1.7.0: Staggered Grid Layout
|
||||
const val KEY_DISPLAY_MODE = "display_mode" // "list" or "grid"
|
||||
const val DEFAULT_DISPLAY_MODE = "list"
|
||||
const val GRID_COLUMNS = 2
|
||||
const val GRID_SPACING_DP = 8
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.dettmer.simplenotes.MainActivity
|
||||
import dev.dettmer.simplenotes.ui.main.ComposeMainActivity
|
||||
|
||||
object NotificationHelper {
|
||||
|
||||
@@ -19,6 +19,7 @@ object NotificationHelper {
|
||||
private const val CHANNEL_ID = "notes_sync_channel"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -54,11 +55,31 @@ object NotificationHelper {
|
||||
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
|
||||
*/
|
||||
fun showSyncSuccessNotification(context: Context, syncedCount: Int) {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
|
||||
@@ -154,7 +175,7 @@ object NotificationHelper {
|
||||
* Zeigt Notification bei erkanntem Konflikt
|
||||
*/
|
||||
fun showConflictNotification(context: Context, conflictCount: Int) {
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
val intent = Intent(context, ComposeMainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
@@ -229,7 +250,7 @@ object NotificationHelper {
|
||||
*/
|
||||
fun showSyncSuccess(context: Context, count: Int) {
|
||||
// PendingIntent für App-Öffnung
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
@@ -260,7 +281,7 @@ object NotificationHelper {
|
||||
*/
|
||||
fun showSyncError(context: Context, message: String) {
|
||||
// PendingIntent für App-Öffnung
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
@@ -297,7 +318,7 @@ object NotificationHelper {
|
||||
*/
|
||||
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
|
||||
// PendingIntent für App-Öffnung
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
val intent = Intent(context, ComposeMainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<!-- ============================= -->
|
||||
<!-- EMPTY STATE -->
|
||||
<!-- ============================= -->
|
||||
<string name="empty_state_emoji">📝</string>
|
||||
<string name="empty_state_title">Noch keine Notizen</string>
|
||||
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
|
||||
|
||||
@@ -92,9 +93,21 @@
|
||||
<string name="snackbar_server_error">Server-Fehler: %s</string>
|
||||
<string name="snackbar_already_synced">Bereits synchronisiert</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_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 -->
|
||||
<!-- ============================= -->
|
||||
@@ -196,19 +209,22 @@
|
||||
<string name="sync_interval_section">Sync-Intervall</string>
|
||||
<string name="sync_interval_info">Legt fest, wie oft die App im Hintergrund synchronisiert. Kürzere Intervalle bedeuten aktuellere Daten, verbrauchen aber etwas mehr Akku.\n\n⏱️ Hinweis: Wenn dein Smartphone im Standby ist, kann Android die Synchronisation verzögern (bis zu 60 Min.), um Akku zu sparen. Das ist normal und betrifft alle Hintergrund-Apps.</string>
|
||||
<string name="sync_interval_15min_title">⚡ Alle 15 Minuten</string>
|
||||
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8% Akku/Tag (~23 mAh)</string>
|
||||
<string name="sync_interval_15min_subtitle">Schnellste Synchronisation • ~0.8%% Akku/Tag (~23 mAh)</string>
|
||||
<string name="sync_interval_30min_title">✓ Alle 30 Minuten (Empfohlen)</string>
|
||||
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4% Akku/Tag (~12 mAh)</string>
|
||||
<string name="sync_interval_30min_subtitle">Ausgewogenes Verhältnis • ~0.4%% Akku/Tag (~12 mAh)</string>
|
||||
<string name="sync_interval_60min_title">🔋 Alle 60 Minuten</string>
|
||||
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2% Akku/Tag (~6 mAh geschätzt)</string>
|
||||
<string name="sync_interval_60min_subtitle">Maximale Akkulaufzeit • ~0.2%% Akku/Tag (~6 mAh geschätzt)</string>
|
||||
<!-- Legacy -->
|
||||
<string name="auto_sync_info">ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string>
|
||||
|
||||
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
|
||||
<string name="sync_section_network">📶 Netzwerk-Einschränkung</string>
|
||||
<string name="sync_section_instant">📲 Sofort-Sync</string>
|
||||
<string name="sync_section_background">📡 Hintergrund-Sync</string>
|
||||
<string name="sync_section_advanced">⚙️ Erweitert</string>
|
||||
|
||||
<string name="sync_wifi_only_hint">💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird.</string>
|
||||
|
||||
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
|
||||
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
|
||||
|
||||
@@ -224,6 +240,11 @@
|
||||
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
|
||||
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</string>
|
||||
|
||||
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
|
||||
<string name="sync_wifi_only_title">Sync nur im WLAN</string>
|
||||
<string name="sync_wifi_only_subtitle">Sync wird nur durchgeführt wenn WLAN verbunden ist. Spart mobiles Datenvolumen und verhindert lange Wartezeit.</string>
|
||||
<string name="sync_wifi_only_blocked">Sync nur im WLAN möglich</string>
|
||||
|
||||
<string name="sync_manual_hint">Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar.</string>
|
||||
<string name="sync_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
|
||||
|
||||
@@ -253,6 +274,20 @@
|
||||
<string name="backup_local_section">Lokales Backup</string>
|
||||
<string name="backup_create">💾 Backup erstellen</string>
|
||||
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</string>
|
||||
|
||||
<!-- 🔐 v1.7.0: Verschlüsselung -->
|
||||
<string name="backup_encryption_title">Backup verschlüsseln</string>
|
||||
<string name="backup_encryption_subtitle">Schütze deine Backup-Datei mit Passwort</string>
|
||||
<string name="backup_encryption_password">Passwort</string>
|
||||
<string name="backup_encryption_password_hint">Passwort eingeben (min. 8 Zeichen)</string>
|
||||
<string name="backup_encryption_confirm">Passwort bestätigen</string>
|
||||
<string name="backup_encryption_confirm_hint">Passwort erneut eingeben</string>
|
||||
<string name="backup_encryption_error_mismatch">Passwörter stimmen nicht überein</string>
|
||||
<string name="backup_encryption_error_too_short">Passwort zu kurz (min. 8 Zeichen)</string>
|
||||
<string name="backup_decryption_required">🔒 Verschlüsseltes Backup</string>
|
||||
<string name="backup_decryption_password">Passwort zum Entschlüsseln eingeben</string>
|
||||
<string name="backup_decryption_error">❌ Entschlüsselung fehlgeschlagen. Falsches Passwort?</string>
|
||||
|
||||
<string name="backup_server_section">Server-Backup</string>
|
||||
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
|
||||
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
|
||||
@@ -308,6 +343,15 @@
|
||||
<string name="language_info">ℹ️ Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
|
||||
<string name="language_changed_restart">Sprache geändert. Neustart…</string>
|
||||
|
||||
<!-- ============================= -->
|
||||
<!-- SETTINGS - DISPLAY -->
|
||||
<!-- ============================= -->
|
||||
<string name="display_settings_title">Anzeige</string>
|
||||
<string name="display_mode_title">Notizen-Ansicht</string>
|
||||
<string name="display_mode_list">📋 Listen-Ansicht</string>
|
||||
<string name="display_mode_grid">🎨 Raster-Ansicht</string>
|
||||
<string name="display_mode_info">Die Raster-Ansicht zeigt Notizen im Pinterest-Stil. Kurze Notizen erscheinen nebeneinander, lange Notizen nehmen die volle Breite ein.</string>
|
||||
|
||||
<!-- ============================= -->
|
||||
<!-- SETTINGS - ABOUT -->
|
||||
<!-- ============================= -->
|
||||
@@ -357,6 +401,8 @@
|
||||
<string name="toast_logs_deleted">🗑️ Logs gelöscht</string>
|
||||
<string name="toast_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
|
||||
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</string>
|
||||
<!-- 🔄 v1.7.0: Server change notification -->
|
||||
<string name="toast_server_changed_sync_reset">🔄 Server geändert. %d Notizen werden beim nächsten Sync hochgeladen.</string>
|
||||
<string name="toast_link_error">❌ Fehler beim Öffnen des Links</string>
|
||||
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
|
||||
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
|
||||
@@ -392,6 +438,8 @@
|
||||
<!-- ============================= -->
|
||||
<string name="notification_channel_name">Notizen Synchronisierung</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_message">%d Notiz(en) synchronisiert</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_already_synced">Already synced</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_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 -->
|
||||
<!-- ============================= -->
|
||||
@@ -197,19 +209,22 @@
|
||||
<string name="sync_interval_section">Sync Interval</string>
|
||||
<string name="sync_interval_info">Determines how often the app syncs in the background. Shorter intervals mean more up-to-date data, but use slightly more battery.\n\n⏱️ Note: When your phone is in standby, Android may delay syncs (up to 60 min) to save battery. This is normal and affects all background apps.</string>
|
||||
<string name="sync_interval_15min_title">⚡ Every 15 minutes</string>
|
||||
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8% battery/day (~23 mAh)</string>
|
||||
<string name="sync_interval_15min_subtitle">Fastest sync • ~0.8%% battery/day (~23 mAh)</string>
|
||||
<string name="sync_interval_30min_title">✓ Every 30 minutes (Recommended)</string>
|
||||
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4% battery/day (~12 mAh)</string>
|
||||
<string name="sync_interval_30min_subtitle">Balanced ratio • ~0.4%% battery/day (~12 mAh)</string>
|
||||
<string name="sync_interval_60min_title">🔋 Every 60 minutes</string>
|
||||
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2% battery/day (~6 mAh est.)</string>
|
||||
<string name="sync_interval_60min_subtitle">Maximum battery life • ~0.2%% battery/day (~6 mAh est.)</string>
|
||||
<!-- Legacy -->
|
||||
<string name="auto_sync_info">ℹ️ Auto-Sync:\n\n• Checks every 30 min if server is reachable\n• Works on any WiFi connection\n• Runs in background\n• Minimal battery usage (~0.4%%/day)</string>
|
||||
|
||||
<!-- 🌟 v1.6.0: Configurable Sync Triggers -->
|
||||
<string name="sync_section_network">📶 Network Restriction</string>
|
||||
<string name="sync_section_instant">📲 Instant Sync</string>
|
||||
<string name="sync_section_background">📡 Background Sync</string>
|
||||
<string name="sync_section_advanced">⚙️ Advanced</string>
|
||||
|
||||
<string name="sync_wifi_only_hint">💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected.</string>
|
||||
|
||||
<string name="sync_trigger_on_save_title">After Saving</string>
|
||||
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
|
||||
|
||||
@@ -225,6 +240,11 @@
|
||||
<string name="sync_trigger_boot_title">After Device Restart</string>
|
||||
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</string>
|
||||
|
||||
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
|
||||
<string name="sync_wifi_only_title">WiFi-only sync</string>
|
||||
<string name="sync_wifi_only_subtitle">Sync only when connected to WiFi. Saves mobile data and prevents long wait times.</string>
|
||||
<string name="sync_wifi_only_blocked">Sync only works when connected to WiFi</string>
|
||||
|
||||
<string name="sync_manual_hint">Manual sync (toolbar/pull-to-refresh) is also available.</string>
|
||||
<string name="sync_manual_hint_disabled">Sync is not available in offline mode.</string>
|
||||
|
||||
@@ -254,6 +274,20 @@
|
||||
<string name="backup_local_section">Local Backup</string>
|
||||
<string name="backup_create">💾 Create Backup</string>
|
||||
<string name="backup_restore_file">📂 Restore from File</string>
|
||||
|
||||
<!-- 🔐 v1.7.0: Encryption -->
|
||||
<string name="backup_encryption_title">Encrypt Backup</string>
|
||||
<string name="backup_encryption_subtitle">Password-protect your backup file</string>
|
||||
<string name="backup_encryption_password">Password</string>
|
||||
<string name="backup_encryption_password_hint">Enter password (min. 8 characters)</string>
|
||||
<string name="backup_encryption_confirm">Confirm Password</string>
|
||||
<string name="backup_encryption_confirm_hint">Re-enter password</string>
|
||||
<string name="backup_encryption_error_mismatch">Passwords don\'t match</string>
|
||||
<string name="backup_encryption_error_too_short">Password too short (min. 8 characters)</string>
|
||||
<string name="backup_decryption_required">🔒 Encrypted Backup</string>
|
||||
<string name="backup_decryption_password">Enter password to decrypt</string>
|
||||
<string name="backup_decryption_error">❌ Decryption failed. Wrong password?</string>
|
||||
|
||||
<string name="backup_server_section">Server Backup</string>
|
||||
<string name="backup_restore_server">☁️ Restore from Server</string>
|
||||
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
|
||||
@@ -309,6 +343,15 @@
|
||||
<string name="language_info">ℹ️ Choose your preferred language. The app will restart to apply the change.</string>
|
||||
<string name="language_changed_restart">Language changed. Restarting…</string>
|
||||
|
||||
<!-- ============================= -->
|
||||
<!-- SETTINGS - DISPLAY -->
|
||||
<!-- ============================= -->
|
||||
<string name="display_settings_title">Display</string>
|
||||
<string name="display_mode_title">Note Display Mode</string>
|
||||
<string name="display_mode_list">📋 List View</string>
|
||||
<string name="display_mode_grid">🎨 Grid View</string>
|
||||
<string name="display_mode_info">Grid view shows notes in a staggered Pinterest-style layout. Small notes appear side-by-side, large notes take full width.</string>
|
||||
|
||||
<!-- ============================= -->
|
||||
<!-- SETTINGS - ABOUT -->
|
||||
<!-- ============================= -->
|
||||
@@ -358,6 +401,8 @@
|
||||
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
|
||||
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
|
||||
<string name="toast_logs_delete_error">❌ Error deleting: %s</string>
|
||||
<!-- 🔄 v1.7.0: Server change notification -->
|
||||
<string name="toast_server_changed_sync_reset">🔄 Server changed. %d notes will be uploaded on next sync.</string>
|
||||
<string name="toast_link_error">❌ Error opening link</string>
|
||||
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
|
||||
<string name="toast_file_logging_disabled">📝 File logging disabled</string>
|
||||
@@ -393,6 +438,8 @@
|
||||
<!-- ============================= -->
|
||||
<string name="notification_channel_name">Notes Synchronization</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_message">%d note(s) synchronized</string>
|
||||
<string name="notification_sync_failed_title">Sync failed</string>
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<!-- 🔐 v1.7.0: Trust user-installed CA certificates for self-signed SSL support -->
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package dev.dettmer.simplenotes.models
|
||||
|
||||
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_CHECKLIST_THRESHOLD
|
||||
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_TEXT_THRESHOLD
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 🎨 v1.7.0: Tests for Note Size Classification (Staggered Grid Layout)
|
||||
*/
|
||||
class NoteSizeTest {
|
||||
|
||||
@Test
|
||||
fun `text note with less than 80 chars is SMALL`() {
|
||||
val note = Note(
|
||||
id = "test1",
|
||||
title = "Test",
|
||||
content = "Short content", // 13 chars
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.TEXT
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.SMALL, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text note with exactly 79 chars is SMALL`() {
|
||||
val content = "x".repeat(79) // Exactly threshold - 1
|
||||
val note = Note(
|
||||
id = "test2",
|
||||
title = "Test",
|
||||
content = content,
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.TEXT
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.SMALL, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text note with exactly 80 chars is LARGE`() {
|
||||
val content = "x".repeat(SMALL_TEXT_THRESHOLD) // Exactly at threshold
|
||||
val note = Note(
|
||||
id = "test3",
|
||||
title = "Test",
|
||||
content = content,
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.TEXT
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.LARGE, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `text note with more than 80 chars is LARGE`() {
|
||||
val content = "This is a long note with more than 80 characters. " +
|
||||
"It should be classified as LARGE for grid layout display."
|
||||
val note = Note(
|
||||
id = "test4",
|
||||
title = "Test",
|
||||
content = content,
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.TEXT
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.LARGE, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checklist with 1 item is SMALL`() {
|
||||
val note = Note(
|
||||
id = "test5",
|
||||
title = "Shopping",
|
||||
content = "",
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = listOf(
|
||||
ChecklistItem("id1", "Milk", false)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.SMALL, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checklist with 4 items is SMALL`() {
|
||||
val note = Note(
|
||||
id = "test6",
|
||||
title = "Shopping",
|
||||
content = "",
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = listOf(
|
||||
ChecklistItem("id1", "Milk", false),
|
||||
ChecklistItem("id2", "Bread", false),
|
||||
ChecklistItem("id3", "Eggs", false),
|
||||
ChecklistItem("id4", "Butter", false)
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.SMALL, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checklist with 5 items is LARGE`() {
|
||||
val note = Note(
|
||||
id = "test7",
|
||||
title = "Shopping",
|
||||
content = "",
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = listOf(
|
||||
ChecklistItem("id1", "Milk", false),
|
||||
ChecklistItem("id2", "Bread", false),
|
||||
ChecklistItem("id3", "Eggs", false),
|
||||
ChecklistItem("id4", "Butter", false),
|
||||
ChecklistItem("id5", "Cheese", false) // 5th item -> LARGE
|
||||
)
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.LARGE, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checklist with many items is LARGE`() {
|
||||
val items = (1..10).map { ChecklistItem("id$it", "Item $it", false) }
|
||||
val note = Note(
|
||||
id = "test8",
|
||||
title = "Long List",
|
||||
content = "",
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = items
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.LARGE, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty checklist is SMALL`() {
|
||||
val note = Note(
|
||||
id = "test9",
|
||||
title = "Empty",
|
||||
content = "",
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = emptyList()
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.SMALL, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `checklist with null items is SMALL`() {
|
||||
val note = Note(
|
||||
id = "test10",
|
||||
title = "Null Items",
|
||||
content = "",
|
||||
deviceId = "test-device",
|
||||
noteType = NoteType.CHECKLIST,
|
||||
checklistItems = null
|
||||
)
|
||||
|
||||
assertEquals(NoteSize.SMALL, note.getSize())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `constants have expected values`() {
|
||||
assertEquals(80, SMALL_TEXT_THRESHOLD)
|
||||
assertEquals(4, SMALL_CHECKLIST_THRESHOLD)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package dev.dettmer.simplenotes.utils
|
||||
|
||||
import dev.dettmer.simplenotes.backup.EncryptionException
|
||||
import dev.dettmer.simplenotes.backup.EncryptionManager
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.security.SecureRandom
|
||||
import kotlin.text.Charsets.UTF_8
|
||||
|
||||
/**
|
||||
* 🔒 v1.7.0: Tests for Local Backup Encryption
|
||||
*/
|
||||
class EncryptionManagerTest {
|
||||
|
||||
private val encryptionManager = EncryptionManager()
|
||||
|
||||
@Test
|
||||
fun `encrypt and decrypt roundtrip preserves data`() {
|
||||
val originalData = "This is a test backup with UTF-8: äöü 🔒".toByteArray(UTF_8)
|
||||
val password = "TestPassword123"
|
||||
|
||||
val encrypted = encryptionManager.encrypt(originalData, password)
|
||||
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||
|
||||
assertArrayEquals(originalData, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypted data has correct header format`() {
|
||||
val data = "Test data".toByteArray(UTF_8)
|
||||
val password = "password123"
|
||||
|
||||
val encrypted = encryptionManager.encrypt(data, password)
|
||||
|
||||
// Check magic bytes "SNE1"
|
||||
val magic = encrypted.copyOfRange(0, 4)
|
||||
assertArrayEquals("SNE1".toByteArray(UTF_8), magic)
|
||||
|
||||
// Check version (1 byte = 0x01)
|
||||
assertEquals(1, encrypted[4].toInt())
|
||||
|
||||
// Check minimum size: magic(4) + version(1) + salt(32) + iv(12) + ciphertext + tag(16)
|
||||
assertTrue("Encrypted data too small: ${encrypted.size}", encrypted.size >= 4 + 1 + 32 + 12 + 16)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEncrypted returns true for encrypted data`() {
|
||||
val data = "Test".toByteArray(UTF_8)
|
||||
val encrypted = encryptionManager.encrypt(data, "password")
|
||||
|
||||
assertTrue(encryptionManager.isEncrypted(encrypted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEncrypted returns false for plaintext data`() {
|
||||
val plaintext = "This is not encrypted".toByteArray(UTF_8)
|
||||
|
||||
assertFalse(encryptionManager.isEncrypted(plaintext))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEncrypted returns false for short data`() {
|
||||
val shortData = "SNE".toByteArray(UTF_8) // Less than 4 bytes
|
||||
|
||||
assertFalse(encryptionManager.isEncrypted(shortData))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isEncrypted returns false for wrong magic bytes`() {
|
||||
val wrongMagic = "FAKE1234567890".toByteArray(UTF_8)
|
||||
|
||||
assertFalse(encryptionManager.isEncrypted(wrongMagic))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt with wrong password throws EncryptionException`() {
|
||||
val data = "Sensitive data".toByteArray(UTF_8)
|
||||
val correctPassword = "correct123"
|
||||
val wrongPassword = "wrong123"
|
||||
|
||||
val encrypted = encryptionManager.encrypt(data, correctPassword)
|
||||
|
||||
val exception = assertThrows(EncryptionException::class.java) {
|
||||
encryptionManager.decrypt(encrypted, wrongPassword)
|
||||
}
|
||||
|
||||
assertTrue(exception.message?.contains("Decryption failed") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt corrupted data throws EncryptionException`() {
|
||||
val data = "Test".toByteArray(UTF_8)
|
||||
val encrypted = encryptionManager.encrypt(data, "password")
|
||||
|
||||
// Corrupt the ciphertext (skip header: 4 + 1 + 32 + 12 = 49 bytes)
|
||||
val corrupted = encrypted.copyOf()
|
||||
if (corrupted.size > 50) {
|
||||
corrupted[50] = (corrupted[50] + 1).toByte() // Flip one bit
|
||||
}
|
||||
|
||||
assertThrows(EncryptionException::class.java) {
|
||||
encryptionManager.decrypt(corrupted, "password")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt data with invalid header throws EncryptionException`() {
|
||||
val invalidData = "This is not encrypted at all".toByteArray(UTF_8)
|
||||
|
||||
assertThrows(EncryptionException::class.java) {
|
||||
encryptionManager.decrypt(invalidData, "password")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt truncated data throws EncryptionException`() {
|
||||
val data = "Test".toByteArray(UTF_8)
|
||||
val encrypted = encryptionManager.encrypt(data, "password")
|
||||
|
||||
// Truncate to only header
|
||||
val truncated = encrypted.copyOfRange(0, 20)
|
||||
|
||||
assertThrows(EncryptionException::class.java) {
|
||||
encryptionManager.decrypt(truncated, "password")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypt with different passwords produces different ciphertexts`() {
|
||||
val data = "Same data".toByteArray(UTF_8)
|
||||
|
||||
val encrypted1 = encryptionManager.encrypt(data, "password1")
|
||||
val encrypted2 = encryptionManager.encrypt(data, "password2")
|
||||
|
||||
// Different passwords should produce different ciphertexts
|
||||
assertFalse(encrypted1.contentEquals(encrypted2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypt same data twice produces different ciphertexts (different IV)`() {
|
||||
val data = "Same data".toByteArray(UTF_8)
|
||||
val password = "same-password"
|
||||
|
||||
val encrypted1 = encryptionManager.encrypt(data, password)
|
||||
val encrypted2 = encryptionManager.encrypt(data, password)
|
||||
|
||||
// Different IVs should produce different ciphertexts
|
||||
assertFalse(encrypted1.contentEquals(encrypted2))
|
||||
|
||||
// But both should decrypt to same original data
|
||||
val decrypted1 = encryptionManager.decrypt(encrypted1, password)
|
||||
val decrypted2 = encryptionManager.decrypt(encrypted2, password)
|
||||
assertArrayEquals(decrypted1, decrypted2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypt large data (1MB) succeeds`() {
|
||||
val random = SecureRandom()
|
||||
val largeData = ByteArray(1024 * 1024) // 1 MB
|
||||
random.nextBytes(largeData)
|
||||
val password = "password123"
|
||||
|
||||
val encrypted = encryptionManager.encrypt(largeData, password)
|
||||
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||
|
||||
assertArrayEquals(largeData, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypt empty data succeeds`() {
|
||||
val emptyData = ByteArray(0)
|
||||
val password = "password"
|
||||
|
||||
val encrypted = encryptionManager.encrypt(emptyData, password)
|
||||
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||
|
||||
assertArrayEquals(emptyData, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypt with empty password succeeds but is unsafe`() {
|
||||
val data = "Test".toByteArray(UTF_8)
|
||||
|
||||
// Crypto library accepts empty passwords (UI prevents this with validation)
|
||||
val encrypted = encryptionManager.encrypt(data, "")
|
||||
val decrypted = encryptionManager.decrypt(encrypted, "")
|
||||
|
||||
assertArrayEquals(data, decrypted)
|
||||
assertTrue("Empty password should still produce encrypted data", encrypted.size > data.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decrypt with unsupported version throws EncryptionException`() {
|
||||
val data = "Test".toByteArray(UTF_8)
|
||||
val encrypted = encryptionManager.encrypt(data, "password")
|
||||
|
||||
// Change version byte to unsupported value (99)
|
||||
val invalidVersion = encrypted.copyOf()
|
||||
invalidVersion[4] = 99.toByte()
|
||||
|
||||
assertThrows(EncryptionException::class.java) {
|
||||
encryptionManager.decrypt(invalidVersion, "password")
|
||||
}
|
||||
}
|
||||
}
|
||||
116
docs/DEBUG_APK.md
Normal file
116
docs/DEBUG_APK.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Debug APK für Issue-Testing
|
||||
|
||||
Für Bug-Reports und Testing von Fixes brauchst du eine **Debug-APK**. Diese wird automatisch gebaut, wenn du auf speziellen Branches pushst.
|
||||
|
||||
## 🔧 Branch-Struktur für Debug-APKs
|
||||
|
||||
Debug-APKs werden **automatisch** gebaut für diese Branches:
|
||||
|
||||
| Branch-Typ | Zweck | Beispiel |
|
||||
|-----------|-------|---------|
|
||||
| `debug/*` | Allgemeines Testing | `debug/wifi-only-sync` |
|
||||
| `fix/*` | Bug-Fixes testen | `fix/vpn-connection` |
|
||||
| `feature/*` | Neue Features | `feature/grid-layout` |
|
||||
|
||||
**Andere Branches (main, develop, etc.) bauen KEINE Debug-APKs!**
|
||||
|
||||
## 📥 Debug-APK downloaden
|
||||
|
||||
### 1️⃣ Push zu einem Debug-Branch
|
||||
|
||||
```bash
|
||||
# Neuen Fix-Branch erstellen
|
||||
git checkout -b fix/my-bug
|
||||
|
||||
# Deine Änderungen machen
|
||||
# ...
|
||||
|
||||
# Commit und Push
|
||||
git add .
|
||||
git commit -m "fix: beschreibung"
|
||||
git push origin fix/my-bug
|
||||
```
|
||||
|
||||
### 2️⃣ GitHub Actions Workflow starten
|
||||
|
||||
- GitHub → **Actions** Tab
|
||||
- **Build Debug APK** Workflow sehen
|
||||
- Warten bis Workflow grün ist ✅
|
||||
|
||||
### 3️⃣ APK herunterladen
|
||||
|
||||
1. Auf den grünen Workflow-Erfolg warten
|
||||
2. **Artifacts** Section oben (oder unten im Workflow)
|
||||
3. `simple-notes-sync-debug-*` herunterladen
|
||||
4. ZIP-Datei entpacken
|
||||
|
||||
**Wichtig:** Artifacts sind nur **30 Tage** verfügbar!
|
||||
|
||||
## 📱 Installation auf Gerät
|
||||
|
||||
## 📱 Installation auf Gerät
|
||||
|
||||
### Mit ADB (Empfohlen - sauberes Testing)
|
||||
```bash
|
||||
# Gerät verbinden
|
||||
adb devices
|
||||
|
||||
# Debug-APK installieren (alte Version wird nicht gelöscht)
|
||||
adb install simple-notes-sync-debug.apk
|
||||
|
||||
# Aus dem Gerät entfernen später:
|
||||
adb uninstall dev.dettmer.simplenotes
|
||||
```
|
||||
|
||||
### Manuell auf Gerät
|
||||
1. Datei auf Android-Gerät kopieren
|
||||
2. **Einstellungen → Sicherheit → "Unbekannte Quellen" aktivieren**
|
||||
3. Dateimanager öffnen und APK antippen
|
||||
4. "Installieren" auswählen
|
||||
|
||||
## ⚠️ Debug-APK vs. Release-APK
|
||||
|
||||
| Feature | Debug | Release |
|
||||
|---------|-------|---------|
|
||||
| **Logging** | Voll | Minimal |
|
||||
| **Signatur** | Debug-Key | Release-Key |
|
||||
| **Performance** | Langsamer | Schneller |
|
||||
| **Debugging** | ✅ Möglich | ❌ Nein |
|
||||
| **Installation** | Mehrmals | Kann Probleme geben |
|
||||
|
||||
## 📊 Was zu testen ist
|
||||
|
||||
1. **Neue Features** - Funktionieren wie beschrieben?
|
||||
2. **Bug Fixes** - Ist der Bug wirklich behoben?
|
||||
3. **Kompatibilität** - Funktioniert auf deinem Gerät?
|
||||
4. **Performance** - Läuft die App flüssig?
|
||||
|
||||
## 📝 Feedback geben
|
||||
|
||||
Bitte schreibe einen Kommentar im **Pull Request** oder **GitHub Issue**:
|
||||
- ✅ Was funktioniert
|
||||
- ❌ Was nicht funktioniert
|
||||
- 📋 Fehler-Logs (adb logcat falls relevant)
|
||||
- 📱 Gerät/Android-Version
|
||||
|
||||
## 🐛 Logs sammeln
|
||||
|
||||
Falls der App-Entwickler Debug-Logs braucht:
|
||||
|
||||
```bash
|
||||
# Terminal öffnen mit adb
|
||||
adb shell pm grant dev.dettmer.simplenotes android.permission.READ_LOGS
|
||||
|
||||
# Logs anschauen (live)
|
||||
adb logcat | grep simplenotes
|
||||
|
||||
# Logs speichern (Datei)
|
||||
adb logcat > debug-log.txt
|
||||
|
||||
# Nach Fehler filtern
|
||||
adb logcat | grep -E "ERROR|Exception|CRASH"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Danke fürs Testing! Dein Feedback hilft uns, die App zu verbessern.** 🙏
|
||||
166
docs/SELF_SIGNED_SSL.md
Normal file
166
docs/SELF_SIGNED_SSL.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Self-Signed SSL Certificate Support
|
||||
|
||||
**Since:** v1.7.0
|
||||
**Status:** ✅ Supported
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Simple Notes Sync now supports connecting to WebDAV servers with self-signed SSL certificates, such as:
|
||||
- ownCloud/Nextcloud with self-signed certificates
|
||||
- Synology NAS with default certificates
|
||||
- Raspberry Pi or home servers
|
||||
- Internal corporate servers with private CAs
|
||||
|
||||
## How to Use
|
||||
|
||||
### Step 1: Export Your Server's CA Certificate
|
||||
|
||||
**On your server:**
|
||||
|
||||
1. Locate your certificate file (usually `.crt`, `.pem`, or `.der` format)
|
||||
2. If you created the certificate yourself, you already have it
|
||||
3. For Synology NAS: Control Panel → Security → Certificate → Export
|
||||
4. For ownCloud/Nextcloud: Usually in `/etc/ssl/certs/` on the server
|
||||
|
||||
### Step 2: Install Certificate on Android
|
||||
|
||||
**On your Android device:**
|
||||
|
||||
1. **Transfer** the `.crt` or `.pem` file to your phone (via email, USB, etc.)
|
||||
|
||||
2. **Open Settings** → Security → More security settings (or Encryption & credentials)
|
||||
|
||||
3. **Install from storage** / "Install a certificate"
|
||||
- Choose "CA certificate"
|
||||
- **Warning:** Android will display a security warning. This is normal.
|
||||
- Tap "Install anyway"
|
||||
|
||||
4. **Browse** to your certificate file and select it
|
||||
|
||||
5. **Name** it something recognizable (e.g., "My ownCloud CA")
|
||||
|
||||
6. ✅ **Done!** The certificate is now trusted system-wide
|
||||
|
||||
### Step 3: Connect Simple Notes Sync
|
||||
|
||||
1. Open Simple Notes Sync
|
||||
2. Go to **Settings** → **Server Settings**
|
||||
3. Enter your **`https://` server URL** as usual
|
||||
4. The app will now trust your self-signed certificate ✅
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
### ⚠️ Important
|
||||
|
||||
- Installing a CA certificate grants trust to **all** certificates signed by that CA
|
||||
- Only install certificates from sources you trust
|
||||
- Android will warn you before installation – read the warning carefully
|
||||
|
||||
### 🔒 Why This is Safe
|
||||
|
||||
- You **manually** install the certificate (conscious decision)
|
||||
- The app uses Android's native trust store (no custom validation)
|
||||
- You can remove the certificate anytime from Android Settings
|
||||
- F-Droid and Google Play compliant (no "trust all" hack)
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Certificate Not Trusted
|
||||
|
||||
**Problem:** App still shows SSL error after installing certificate
|
||||
|
||||
**Solutions:**
|
||||
1. **Verify installation:** Settings → Security → Trusted credentials → User tab
|
||||
2. **Check certificate type:** Must be a CA certificate, not a server certificate
|
||||
3. **Restart app:** Close and reopen Simple Notes Sync
|
||||
4. **Check URL:** Must use `https://` (not `http://`)
|
||||
|
||||
### "Network Security Policy" Error
|
||||
|
||||
**Problem:** Android 7+ restricts user certificates for apps
|
||||
|
||||
**Solution:** This app is configured to trust user certificates ✅
|
||||
If the problem persists, check:
|
||||
- Certificate is installed in "User" tab (not "System")
|
||||
- Certificate is not expired
|
||||
- Server URL matches certificate's Common Name (CN) or Subject Alternative Name (SAN)
|
||||
|
||||
### Self-Signed vs. CA-Signed
|
||||
|
||||
| Type | Installation Required | Security |
|
||||
|------|---------------------|----------|
|
||||
| **Self-Signed** | ✅ Yes | Manual trust |
|
||||
| **Let's Encrypt** | ❌ No | Automatic |
|
||||
| **Private CA** | ✅ Yes (CA root) | Automatic for all CA-signed certs |
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Use Let's Encrypt (Recommended)
|
||||
|
||||
If your server is publicly accessible, consider using **Let's Encrypt** for free, automatically-renewed SSL certificates:
|
||||
|
||||
- No manual certificate installation needed
|
||||
- Trusted by all devices automatically
|
||||
- Easier for end users
|
||||
|
||||
**Setup guides:**
|
||||
- [ownCloud Let's Encrypt](https://doc.owncloud.com/server/admin_manual/installation/letsencrypt/)
|
||||
- [Nextcloud Let's Encrypt](https://docs.nextcloud.com/server/latest/admin_manual/installation/letsencrypt.html)
|
||||
- [Synology Let's Encrypt](https://kb.synology.com/en-us/DSM/tutorial/How_to_enable_HTTPS_and_create_a_certificate_signing_request_on_your_Synology_NAS)
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Implementation
|
||||
|
||||
- Uses Android's **Network Security Config**
|
||||
- Trusts both system and user CA certificates
|
||||
- No custom TrustManager or hostname verifier
|
||||
- F-Droid and Play Store compliant
|
||||
|
||||
### Configuration
|
||||
|
||||
File: `android/app/src/main/res/xml/network_security_config.xml`
|
||||
|
||||
```xml
|
||||
<base-config>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" /> <!-- ← Enables self-signed support -->
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Do I need to reinstall the certificate after app updates?**
|
||||
A: No, certificates are stored system-wide, not per-app.
|
||||
|
||||
**Q: Can I use the same certificate for multiple apps?**
|
||||
A: Yes, once installed, it works for all apps that trust user certificates.
|
||||
|
||||
**Q: How do I remove a certificate?**
|
||||
A: Settings → Security → Trusted credentials → User tab → Tap certificate → Remove
|
||||
|
||||
**Q: Does this work on Android 14+?**
|
||||
A: Yes, tested on Android 7 through 15 (API 24-35).
|
||||
|
||||
---
|
||||
|
||||
## Related Issues
|
||||
|
||||
- [GitHub Issue #X](link) - User request for ownCloud support
|
||||
- [Feature Analysis](../project-docs/simple-notes-sync/features/SELF_SIGNED_SSL_CERTIFICATES_ANALYSIS.md) - Technical analysis
|
||||
|
||||
---
|
||||
|
||||
**Need help?** Open an issue on [GitHub](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
2
fastlane/metadata/android/de-DE/changelogs/16.txt
Normal file
2
fastlane/metadata/android/de-DE/changelogs/16.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
• Behebt Offline-Modus Problem nach Update von v1.5.0
|
||||
• Nutzer mit konfiguriertem Server werden nicht mehr fälschlicherweise als offline angezeigt
|
||||
7
fastlane/metadata/android/de-DE/changelogs/17.txt
Normal file
7
fastlane/metadata/android/de-DE/changelogs/17.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
• Neu: Raster-Ansicht - Danke an freemen
|
||||
• Neu: Nur-WLAN Sync Toggle in Einstellungen
|
||||
• Neu: Verschlüsselung bei lokalen Backups - Danke an @SilentCoderHere (#9)
|
||||
• Behoben: Sync funktioniert korrekt bei aktivem VPN - Danke an @roughnecks (#11)
|
||||
• Verbessert: Server-Wechsel - Sync-Status wird für alle Notizen zurückgesetzt
|
||||
• Verbessert: "Sync läuft bereits" Feedback bei weiteren Ausführungen
|
||||
• Verschiedene Fixes und UI-Verbesserungen
|
||||
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
|
||||
|
||||
@@ -1,62 +1,32 @@
|
||||
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation.
|
||||
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features.
|
||||
|
||||
HAUPTFUNKTIONEN:
|
||||
|
||||
• Text-Notizen und Checklisten erstellen
|
||||
• Checklisten mit Tap-to-Check und Drag & Drop
|
||||
• Auswahlmodus: Long-Press zur Mehrfachauswahl für Batch-Aktionen
|
||||
• WebDAV-Synchronisation mit eigenem Server
|
||||
Hauptfunktionen:
|
||||
• Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop)
|
||||
• NEU: Raster-Ansicht (Grid View) für Notizen
|
||||
• Multi-Device Sync (Handy, Tablet, Desktop)
|
||||
• Markdown-Export für Obsidian/Desktop-Editoren
|
||||
• Checklisten als GitHub-Style Task-Listen exportieren
|
||||
• Automatische Synchronisation im Heim-WLAN
|
||||
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
|
||||
• Material Design 3 mit Dynamic Colors (Android 12+)
|
||||
• Jetpack Compose UI - modern, schnell und flüssig
|
||||
• WebDAV-Synchronisation mit eigenem Server (Nextcloud, ownCloud, etc.)
|
||||
• Markdown-Export und Import für Desktop-Editoren (Obsidian, VS Code)
|
||||
• NEU: WiFi-only Sync, VPN-Unterstützung, Verschlüsselung für lokale Backups
|
||||
• Konfigurierbare Sync-Trigger: onSave, onResume, WiFi, periodisch, Boot
|
||||
• Komplett offline nutzbar
|
||||
• Keine Werbung, keine Tracker
|
||||
|
||||
MEHRSPRACHIG:
|
||||
Datenschutz & Sicherheit:
|
||||
• Alle Daten bleiben bei dir – keine Cloud, keine Tracking-Bibliotheken
|
||||
• Unterstützung für selbstsignierte SSL-Zertifikate (Self-signed SSL)
|
||||
• SHA-256 Hash des Signaturzertifikats in App und Releases sichtbar
|
||||
|
||||
• Englische und deutsche Sprachunterstützung
|
||||
• Per-App Sprachauswahl (Android 13+)
|
||||
• Automatische Systemsprachen-Erkennung
|
||||
• Über 400 übersetzte Strings
|
||||
Synchronisation:
|
||||
• Automatisch oder manuell, optimierte Performance, periodischer Sync optional
|
||||
• Intelligente Konfliktlösung, Lösch-Tracking, Batch-Aktionen
|
||||
|
||||
DATENSCHUTZ:
|
||||
UI & Design:
|
||||
• Moderne Jetpack Compose Oberfläche
|
||||
• Material Design 3, Dynamic Colors, Dark Mode
|
||||
• Animationen und Live Sync-Status
|
||||
|
||||
Deine Daten bleiben bei dir! Die App kommuniziert nur mit deinem eigenen WebDAV-Server. Keine Cloud-Dienste, keine Tracking-Bibliotheken, keine Analysetools.
|
||||
|
||||
MULTI-DEVICE SYNC:
|
||||
|
||||
• Notizen synchronisieren automatisch zwischen allen Geräten
|
||||
• Lösch-Tracking verhindert "Zombie-Notizen"
|
||||
• Intelligente Konfliktlösung durch Timestamps
|
||||
• Markdown-Dateien für Desktop-Bearbeitung (Obsidian, VS Code, etc.)
|
||||
• Änderungen von Desktop-Editoren werden automatisch importiert
|
||||
|
||||
SYNCHRONISATION:
|
||||
|
||||
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
|
||||
• Konfigurierbare Sync-Trigger: Wähle einzeln, wann synchronisiert wird
|
||||
• 5 Trigger: onSave (nach dem Speichern), onResume (beim Öffnen), WiFi-Connect, Periodic (15/30/60 Min), Boot
|
||||
• Offline-Modus: Alle Netzwerkfunktionen mit einem Schalter deaktivieren
|
||||
• Smarte Defaults: nur ereignisbasierte Trigger aktiv (~0.2%/Tag Akku)
|
||||
• Periodischer Sync optional (Standard: AUS)
|
||||
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
|
||||
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
|
||||
• Silent-Sync Modus: kein Banner bei Auto-Sync
|
||||
• Doze Mode optimiert für zuverlässige Background-Syncs
|
||||
• Manuelle Synchronisation jederzeit möglich
|
||||
|
||||
MATERIAL DESIGN 3:
|
||||
|
||||
• Moderne Jetpack Compose Benutzeroberfläche
|
||||
• Dynamic Colors (Material You) auf Android 12+
|
||||
• Dark Mode Support
|
||||
• Auswahlmodus mit Batch-Löschen
|
||||
• Live Sync-Status Anzeige
|
||||
• Flüssige Slide-Animationen
|
||||
Mehrsprachig:
|
||||
• Deutsch und Englisch, automatische Erkennung, App-Sprachauswahl
|
||||
|
||||
Open Source unter MIT-Lizenz
|
||||
Quellcode: https://github.com/inventory69/simple-notes-sync
|
||||
2
fastlane/metadata/android/en-US/changelogs/16.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/16.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
• Fixes offline mode issue after updating from v1.5.0
|
||||
• Users with configured servers are no longer incorrectly shown as offline
|
||||
7
fastlane/metadata/android/en-US/changelogs/17.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/17.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
• New: Grid view - Thanks to freemen
|
||||
• New: WiFi-only sync toggle in settings
|
||||
• New: Encryption for local backups - Thanks to @SilentCoderHere (#9)
|
||||
• Fixed: Sync works correctly when VPN is active - Thanks to @roughnecks (#11)
|
||||
• Improved: Server change - Sync status resets for all notes
|
||||
• Improved: "Sync already running" feedback for additional executions
|
||||
• Various fixes and UI improvements
|
||||
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
|
||||
@@ -1,62 +1,32 @@
|
||||
Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization.
|
||||
Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features.
|
||||
|
||||
KEY FEATURES:
|
||||
|
||||
• Create text notes and checklists
|
||||
• Checklists with tap-to-check, drag & drop reordering
|
||||
• Selection mode: long-press to select multiple notes for batch actions
|
||||
• WebDAV synchronization with your own server
|
||||
Key Features:
|
||||
• Text notes and checklists (tap-to-check, drag & drop)
|
||||
• NEW: Grid view for notes
|
||||
• Multi-device sync (phone, tablet, desktop)
|
||||
• Markdown export for Obsidian/desktop editors
|
||||
• Checklists export as GitHub-style task lists
|
||||
• Automatic synchronization on home WiFi
|
||||
• Configurable sync interval (15/30/60 minutes)
|
||||
• Material Design 3 with Dynamic Colors (Android 12+)
|
||||
• Jetpack Compose UI - modern, fast, and smooth
|
||||
• WebDAV sync with your own server (Nextcloud, ownCloud, etc.)
|
||||
• Markdown export/import for desktop editors (Obsidian, VS Code)
|
||||
• NEW: WiFi-only sync, VPN support, encryption for local backups
|
||||
• Configurable sync triggers: onSave, onResume, WiFi, periodic, boot
|
||||
• Fully usable offline
|
||||
• No ads, no trackers
|
||||
|
||||
MULTILINGUAL:
|
||||
Privacy & Security:
|
||||
• Your data stays with you – no cloud, no tracking libraries
|
||||
• Support for self-signed SSL certificates
|
||||
• SHA-256 hash of signing certificate shown in app and releases
|
||||
|
||||
• English and German language support
|
||||
• Per-App Language selector (Android 13+)
|
||||
• Automatic system language detection
|
||||
• 400+ translated strings
|
||||
Synchronization:
|
||||
• Automatic or manual, optimized performance, optional periodic sync
|
||||
• Smart conflict resolution, deletion tracking, batch actions
|
||||
|
||||
PRIVACY:
|
||||
UI & Design:
|
||||
• Modern Jetpack Compose interface
|
||||
• Material Design 3, dynamic colors, dark mode
|
||||
• Animations and live sync status
|
||||
|
||||
Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools.
|
||||
|
||||
MULTI-DEVICE SYNC:
|
||||
|
||||
• Notes sync automatically between all your devices
|
||||
• Deletion tracking prevents "zombie notes"
|
||||
• Smart conflict resolution through timestamps
|
||||
• Markdown files for desktop editing (Obsidian, VS Code, etc.)
|
||||
• Changes from desktop editors are auto-imported
|
||||
|
||||
SYNCHRONIZATION:
|
||||
|
||||
• Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
|
||||
• Configurable Sync Triggers: Choose individually when to sync
|
||||
• 5 triggers: onSave (after saving), onResume (on open), WiFi-Connect, Periodic (15/30/60 min), Boot
|
||||
• Offline Mode: Disable all network features with one switch
|
||||
• Smart defaults: event-driven triggers only (~0.2%/day battery)
|
||||
• Periodic sync optional (default: OFF)
|
||||
• Optimized performance: skips unchanged files (~2-3s sync time)
|
||||
• E-Tag caching for 20x faster "no changes" checks
|
||||
• Silent-Sync mode: no banner during auto-sync
|
||||
• Doze Mode optimized for reliable background syncs
|
||||
• Manual synchronization available anytime
|
||||
|
||||
MATERIAL DESIGN 3:
|
||||
|
||||
• Modern Jetpack Compose user interface
|
||||
• Dynamic Colors (Material You) on Android 12+
|
||||
• Dark Mode support
|
||||
• Selection mode with batch delete
|
||||
• Live sync status indicator
|
||||
• Smooth slide animations
|
||||
Multilingual:
|
||||
• English and German, automatic detection, in-app language selector
|
||||
|
||||
Open Source under MIT License
|
||||
Source code: https://github.com/inventory69/simple-notes-sync
|
||||
|
||||
Reference in New Issue
Block a user