Compare commits
28 Commits
v1.5.0
...
fix/connec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b143e5f0d | ||
|
|
cf9695844c | ||
|
|
24ea7ec59a | ||
|
|
df4ee4bed0 | ||
|
|
68e8490db8 | ||
|
|
614650e37d | ||
|
|
785a6c011a | ||
|
|
a96d373e78 | ||
|
|
a59e89fe91 | ||
|
|
91beee0f8b | ||
|
|
c536ad3177 | ||
|
|
6dba091c03 | ||
|
|
5135c711a5 | ||
|
|
ebab347d4b | ||
|
|
cb63aa1220 | ||
|
|
0df8282eb4 | ||
|
|
b70bc4d8f6 | ||
|
|
217a174478 | ||
|
|
d58d9036cb | ||
|
|
dfdccfe6c7 | ||
|
|
d524bc715d | ||
|
|
2a22e7d88e | ||
|
|
b5cb4e1d96 | ||
|
|
80a35da3ff | ||
|
|
6254758a03 | ||
|
|
ff6510af90 | ||
|
|
ea5c6dae70 | ||
|
|
1d010d0034 |
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
@@ -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
@@ -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
@@ -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 }}
|
||||
|
||||
25
.github/workflows/pr-build-check.yml
vendored
@@ -33,6 +33,31 @@ jobs:
|
||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
||||
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
|
||||
|
||||
# 🔍 Code Quality Checks (v1.6.1)
|
||||
- name: Run detekt (Code Quality)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew detekt --no-daemon
|
||||
continue-on-error: false
|
||||
|
||||
- name: Run ktlint (Code Style)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew ktlintCheck --no-daemon
|
||||
continue-on-error: true # Parser-Probleme in Legacy-Code
|
||||
|
||||
- name: Upload Lint Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lint-reports-pr-${{ github.event.pull_request.number }}
|
||||
path: |
|
||||
android/app/build/reports/detekt/
|
||||
android/app/build/reports/ktlint/
|
||||
android/app/build/reports/lint-results*.html
|
||||
retention-days: 7
|
||||
|
||||
- name: Debug Build erstellen (ohne Signing)
|
||||
run: |
|
||||
cd android
|
||||
|
||||
6
.gitignore
vendored
@@ -43,3 +43,9 @@ Thumbs.db
|
||||
*.swp
|
||||
*~
|
||||
test-apks/
|
||||
server-test/
|
||||
|
||||
# F-Droid metadata (managed in fdroiddata repo)
|
||||
# Exclude fastlane metadata (we want to track those screenshots)
|
||||
metadata/
|
||||
!fastlane/metadata/
|
||||
|
||||
164
CHANGELOG.de.md
@@ -8,6 +8,170 @@ 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
|
||||
|
||||
- **detekt: 0 Issues** - Alle 29 Code-Qualitäts-Issues behoben
|
||||
- Triviale Fixes: Unused Imports, MaxLineLength
|
||||
- Datei umbenannt: DragDropState.kt → DragDropListState.kt
|
||||
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
|
||||
- SwallowedException: Logger.w() für besseres Error-Tracking hinzugefügt
|
||||
- LongParameterList: ChecklistEditorCallbacks data class erstellt
|
||||
- LongMethod: ServerSettingsScreen in Komponenten aufgeteilt
|
||||
- @Suppress Annotationen für Legacy-Code (WebDavSyncService, SettingsActivity)
|
||||
|
||||
- **Zero Build Warnings** - Alle 21 Deprecation Warnings eliminiert
|
||||
- File-level @Suppress für deprecated Imports
|
||||
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
|
||||
- onActivityResult, onRequestPermissionsResult
|
||||
- Gradle Compose Config bereinigt (StrongSkipping ist jetzt Standard)
|
||||
|
||||
- **ktlint reaktiviert** - Linting mit Compose-spezifischen Regeln wieder aktiviert
|
||||
- .editorconfig mit Compose Formatierungsregeln erstellt
|
||||
- Legacy-Dateien ausgeschlossen: WebDavSyncService.kt, build.gradle.kts
|
||||
- ignoreFailures=true für graduelle Migration
|
||||
|
||||
- **CI/CD Verbesserungen** - GitHub Actions Lint-Checks integriert
|
||||
- detekt + ktlint + Android Lint laufen vor Build in pr-build-check.yml
|
||||
- Stellt Code-Qualität bei jedem Pull Request sicher
|
||||
|
||||
### 🔧 Technische Verbesserungen
|
||||
|
||||
- **Constants Refactoring** - Bessere Code-Organisation
|
||||
- ui/theme/Dimensions.kt: UI-bezogene Konstanten
|
||||
- utils/SyncConstants.kt: Sync-Operations Konstanten
|
||||
|
||||
- **Vorbereitung für v2.0.0** - Legacy-Code für Entfernung markiert
|
||||
- SettingsActivity und MainActivity (ersetzt durch Compose-Versionen)
|
||||
- Alle deprecated APIs mit Removal-Plan dokumentiert
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-01-19
|
||||
|
||||
### 🎉 Major: Konfigurierbare Sync-Trigger
|
||||
|
||||
Feingranulare Kontrolle darüber, wann deine Notizen synchronisiert werden - wähle die Trigger, die am besten zu deinem Workflow passen!
|
||||
|
||||
### ⚙️ Sync-Trigger System
|
||||
|
||||
- **Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln in den Einstellungen aktivieren/deaktivieren
|
||||
- **5 Unabhängige Trigger:**
|
||||
- **onSave Sync** - Sync sofort nach dem Speichern einer Notiz (5s Throttle)
|
||||
- **onResume Sync** - Sync beim Öffnen der App (60s Throttle)
|
||||
- **WiFi-Connect Sync** - Sync bei WiFi-Verbindung
|
||||
- **Periodischer Sync** - Hintergrund-Sync alle 15/30/60 Minuten (konfigurierbar)
|
||||
- **Boot Sync** - Startet Hintergrund-Sync nach Geräteneustart
|
||||
|
||||
- **Smarte Defaults** - Nur ereignisbasierte Trigger standardmäßig aktiv (onSave, onResume, WiFi-Connect)
|
||||
- **Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit aktiviertem periodischen Sync
|
||||
- **Offline-Modus UI** - Ausgegraute Sync-Toggles wenn kein Server konfiguriert
|
||||
- **Dynamischer Settings-Subtitle** - Zeigt Anzahl aktiver Trigger im Haupteinstellungs-Screen
|
||||
|
||||
### 🔧 Server-Konfiguration Verbesserungen
|
||||
|
||||
- **Offline-Modus Toggle** - Alle Netzwerkfunktionen mit einem Schalter deaktivieren
|
||||
- **Getrennte Protokoll & Host Eingabe** - Protokoll (http/https) als nicht-editierbares Präfix angezeigt
|
||||
- **Klickbare Settings-Cards** - Gesamte Card klickbar für bessere UX
|
||||
- **Klickbare Toggle-Zeilen** - Text/Icon klicken um Switches zu bedienen (nicht nur der Switch selbst)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fix:** Fehlender 5. Sync-Trigger (Boot) in der Haupteinstellungs-Screen Subtitle-Zählung
|
||||
- **Fix:** Offline-Modus Status wird nicht aktualisiert beim Zurückkehren aus Einstellungen
|
||||
- **Fix:** Pull-to-Refresh funktioniert auch im Offline-Modus
|
||||
|
||||
### 🔧 Technische Verbesserungen
|
||||
|
||||
- **Reaktiver Offline-Modus Status** - StateFlow stellt sicher, dass UI korrekt aktualisiert wird
|
||||
- **Getrennte Server-Config Checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
|
||||
- **Verbesserte Konstanten** - Alle Sync-Trigger Keys und Defaults in Constants.kt
|
||||
- **Bessere Code-Organisation** - Settings-Screens für Klarheit refactored
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.7.0** wird Server-Ordner Prüfung und weitere Community-Features bringen.
|
||||
> Feature-Requests sind willkommen als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-01-15
|
||||
|
||||
### 🎉 Major: Jetpack Compose UI Redesign
|
||||
|
||||
163
CHANGELOG.md
@@ -8,6 +8,169 @@ 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
|
||||
|
||||
- **detekt: 0 issues** - All 29 code quality issues resolved
|
||||
- Trivial fixes: Unused imports, MaxLineLength
|
||||
- File rename: DragDropState.kt → DragDropListState.kt
|
||||
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
|
||||
- SwallowedException: Logger.w() added for better error tracking
|
||||
- LongParameterList: ChecklistEditorCallbacks data class created
|
||||
- LongMethod: ServerSettingsScreen split into components
|
||||
- @Suppress annotations for legacy code (WebDavSyncService, SettingsActivity)
|
||||
|
||||
- **Zero build warnings** - All 21 deprecation warnings eliminated
|
||||
- File-level @Suppress for deprecated imports
|
||||
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
|
||||
- onActivityResult, onRequestPermissionsResult
|
||||
- Gradle Compose config cleaned up (StrongSkipping is now default)
|
||||
|
||||
- **ktlint reactivated** - Linting re-enabled with Compose-specific rules
|
||||
- .editorconfig created with Compose formatting rules
|
||||
- Legacy files excluded: WebDavSyncService.kt, build.gradle.kts
|
||||
- ignoreFailures=true for gradual migration
|
||||
|
||||
- **CI/CD improvements** - GitHub Actions lint checks integrated
|
||||
- detekt + ktlint + Android Lint run before build in pr-build-check.yml
|
||||
- Ensures code quality on every pull request
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
|
||||
- **Constants refactoring** - Better code organization
|
||||
- ui/theme/Dimensions.kt: UI-related constants
|
||||
- utils/SyncConstants.kt: Sync operation constants
|
||||
|
||||
- **Preparation for v2.0.0** - Legacy code marked for removal
|
||||
- SettingsActivity and MainActivity (replaced by Compose versions)
|
||||
- All deprecated APIs documented with removal plan
|
||||
|
||||
---
|
||||
|
||||
## [1.6.0] - 2026-01-19
|
||||
|
||||
### 🎉 Major: Configurable Sync Triggers
|
||||
|
||||
Fine-grained control over when your notes sync - choose which triggers fit your workflow best!
|
||||
|
||||
### ⚙️ Sync Trigger System
|
||||
|
||||
- **Individual trigger control** - Enable/disable each sync trigger separately in settings
|
||||
- **5 Independent Triggers:**
|
||||
- **onSave Sync** - Sync immediately after saving a note (5s throttle)
|
||||
- **onResume Sync** - Sync when app is opened (60s throttle)
|
||||
- **WiFi-Connect Sync** - Sync when WiFi is connected
|
||||
- **Periodic Sync** - Background sync every 15/30/60 minutes (configurable)
|
||||
- **Boot Sync** - Start background sync after device restart
|
||||
|
||||
- **Smart Defaults** - Only event-driven triggers active by default (onSave, onResume, WiFi-Connect)
|
||||
- **Battery Optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic sync enabled
|
||||
- **Offline Mode UI** - Grayed-out sync toggles when no server configured
|
||||
- **Dynamic Settings Subtitle** - Shows count of active triggers on main settings screen
|
||||
|
||||
### 🔧 Server Configuration Improvements
|
||||
|
||||
- **Offline Mode Toggle** - Disable all network features with one switch
|
||||
- **Split Protocol & Host** - Protocol (http/https) shown as non-editable prefix
|
||||
- **Clickable Settings Cards** - Full card clickable for better UX
|
||||
- **Clickable Toggle Rows** - Click text/icon to toggle switches (not just the switch itself)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fixed:** Missing 5th sync trigger (Boot) in main settings screen subtitle count
|
||||
- **Various fixes** - UI improvements and stability enhancements
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
|
||||
- **Reactive offline mode state** - StateFlow ensures UI updates correctly
|
||||
- **Separated server config checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
|
||||
- **Improved constants** - All sync trigger keys and defaults in Constants.kt
|
||||
- **Better code organization** - Settings screens refactored for clarity
|
||||
|
||||
### Looking Ahead
|
||||
|
||||
> 🚀 **v1.7.0** will bring server folder checking and additional community features.
|
||||
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-01-15
|
||||
|
||||
### 🎉 Major: Jetpack Compose UI Redesign
|
||||
|
||||
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">
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.de.md)** · **🚀 [Quick Start](QUICKSTART.de.md)**
|
||||
</div>
|
||||
|
||||
**🌍 Sprachen:** **Deutsch** · [English](README.md)
|
||||
<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="Notizliste">
|
||||
<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/6.png" width="250" alt="Sync-Status">
|
||||
<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
|
||||
- 🔄 **Auto-Sync** - WLAN-Verbindung, regelmäßige Intervalle (15/30/60 Min) & Multi-Geräte-Sync
|
||||
- 🔒 **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-0.8% pro Tag
|
||||
- 🎨 **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.4.1** · Built with ❤️ using Kotlin + Material Design 3
|
||||
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
|
||||
98
README.md
@@ -1,49 +1,81 @@
|
||||
# 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">
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://developer.android.com/compose/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
||||
</div>
|
||||
|
||||
**🌍 Languages:** [Deutsch](README.de.md) · **English**
|
||||
<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
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Notes list">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="250" alt="Sync status">
|
||||
<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
|
||||
- 🔄 **Auto-sync** - WiFi reconnect, periodic intervals (15/30/60 min) & multi-device sync
|
||||
- 📊 **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-0.8% per day
|
||||
- 🎨 **Material Design 3** - Dark mode & dynamic colors
|
||||
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
|
||||
- 🎨 **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.5.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,7 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
|
||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
|
||||
// alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.ktlint) // ✅ v1.6.1: Reaktiviert nach Code-Cleanup
|
||||
alias(libs.plugins.detekt)
|
||||
}
|
||||
|
||||
@@ -21,8 +20,8 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 13 // 🔧 v1.5.0: Jetpack Compose Settings Redesign
|
||||
versionName = "1.5.0" // 🔧 v1.5.0: Jetpack Compose Settings Redesign
|
||||
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"
|
||||
}
|
||||
@@ -100,10 +99,14 @@ android {
|
||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||
}
|
||||
|
||||
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
||||
composeCompiler {
|
||||
enableStrongSkippingMode = true
|
||||
// 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 { }
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
@@ -142,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
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -162,18 +168,21 @@ dependencies {
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
|
||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
|
||||
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
|
||||
// ktlint {
|
||||
// android = true
|
||||
// outputToConsole = true
|
||||
// ignoreFailures = true
|
||||
// enableExperimentalRules = false
|
||||
// filter {
|
||||
// exclude("**/generated/**")
|
||||
// exclude("**/build/**")
|
||||
// }
|
||||
// }
|
||||
// ✅ v1.6.1: ktlint reaktiviert nach Code-Cleanup
|
||||
ktlint {
|
||||
android = true
|
||||
outputToConsole = true
|
||||
ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
|
||||
enableExperimentalRules = false
|
||||
|
||||
filter {
|
||||
exclude("**/generated/**")
|
||||
exclude("**/build/**")
|
||||
// Legacy adapters with ktlint parser issues
|
||||
exclude("**/adapters/NotesAdapter.kt")
|
||||
exclude("**/SettingsActivity.kt")
|
||||
}
|
||||
}
|
||||
|
||||
// ⚡ v1.3.1: detekt-Konfiguration
|
||||
detekt {
|
||||
|
||||
6
android/app/proguard-rules.pro
vendored
@@ -60,4 +60,8 @@
|
||||
-keep class * implements com.google.gson.JsonDeserializer
|
||||
|
||||
# Keep your app's data classes
|
||||
-keep class dev.dettmer.simplenotes.** { *; }
|
||||
-keep class dev.dettmer.simplenotes.** { *; }
|
||||
|
||||
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
|
||||
# This class only exists on API 35+ but Compose handles the fallback gracefully
|
||||
-dontwarn android.text.Layout$TextInclusionStrategy
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
<!-- Battery Optimization (for WorkManager background sync) -->
|
||||
<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>
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.Manifest
|
||||
@@ -48,6 +50,11 @@ import android.view.Gravity
|
||||
import android.widget.PopupMenu
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
|
||||
/**
|
||||
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
|
||||
* Ersetzt durch ComposeMainActivity
|
||||
*/
|
||||
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var recyclerViewNotes: RecyclerView
|
||||
@@ -126,6 +133,9 @@ class MainActivity : AppCompatActivity() {
|
||||
requestNotificationPermission()
|
||||
}
|
||||
|
||||
// 🌍 v1.7.2: Debug Locale für Fehlersuche
|
||||
logLocaleInfo()
|
||||
|
||||
findViews()
|
||||
setupToolbar()
|
||||
setupRecyclerView()
|
||||
@@ -385,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
|
||||
}
|
||||
|
||||
@@ -399,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)
|
||||
@@ -665,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
|
||||
}
|
||||
|
||||
@@ -676,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
|
||||
}
|
||||
|
||||
@@ -807,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, "╚═══════════════════════════════════════════════════")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes
|
||||
|
||||
import android.app.ProgressDialog
|
||||
@@ -42,6 +44,7 @@ import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
|
||||
class SettingsActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
@@ -596,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
|
||||
}
|
||||
@@ -605,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
|
||||
|
||||
@@ -123,6 +123,26 @@ class NotesStorage(private val context: Context) {
|
||||
saveDeletionTracker(DeletionTracker())
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.utils.Logger
|
||||
/**
|
||||
* BootReceiver: Startet WorkManager nach Device Reboot
|
||||
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_BOOT
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@@ -24,16 +25,22 @@ class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
Logger.d(TAG, "📱 BOOT_COMPLETED received")
|
||||
|
||||
// Prüfe ob Auto-Sync aktiviert ist
|
||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||
|
||||
if (!autoSyncEnabled) {
|
||||
Logger.d(TAG, "❌ Auto-sync disabled - not starting WorkManager")
|
||||
// 🌟 v1.6.0: Check if Boot trigger is enabled
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)) {
|
||||
Logger.d(TAG, "⏭️ Boot sync disabled - not starting WorkManager")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Auto-sync enabled - starting WorkManager")
|
||||
// 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 - not starting WorkManager")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🚀 Boot sync enabled - starting WorkManager")
|
||||
|
||||
// WorkManager neu starten
|
||||
val networkMonitor = NetworkMonitor(context.applicationContext)
|
||||
|
||||
@@ -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)")
|
||||
@@ -102,8 +107,22 @@ class NetworkMonitor(private val context: Context) {
|
||||
/**
|
||||
* Triggert WiFi-Connect Sync via WorkManager
|
||||
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_WIFI_CONNECT
|
||||
*/
|
||||
private fun triggerWifiConnectSync() {
|
||||
// 🌟 v1.6.0: Check if WiFi-Connect trigger is enabled
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
|
||||
Logger.d(TAG, "⏭️ WiFi-Connect sync disabled - skipping")
|
||||
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 WiFi-Connect sync")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
|
||||
|
||||
// 🔥 WICHTIG: NetworkType.UNMETERED constraint!
|
||||
@@ -126,30 +145,80 @@ 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()
|
||||
|
||||
// 2. NetworkCallback für WiFi-Connect Detection
|
||||
startWifiMonitoring()
|
||||
// 3. Logging für Debug
|
||||
if (!autoSyncEnabled && !wifiConnectEnabled) {
|
||||
Logger.d(TAG, "🛑 No background triggers active")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet WorkManager periodic sync
|
||||
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
|
||||
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_PERIODIC
|
||||
*/
|
||||
private fun startPeriodicSync() {
|
||||
// 🌟 v1.6.0: Check if Periodic trigger is enabled
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)) {
|
||||
Logger.d(TAG, "⏭️ Periodic sync disabled - skipping")
|
||||
// Cancel existing periodic work if disabled
|
||||
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||
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 Periodic sync")
|
||||
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// 🔥 Interval aus SharedPrefs lesen
|
||||
val intervalMinutes = prefs.getLong(
|
||||
Constants.PREF_SYNC_INTERVAL_MINUTES,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
|
||||
|
||||
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
|
||||
@@ -23,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.
|
||||
@@ -86,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)")
|
||||
}
|
||||
@@ -255,6 +311,7 @@ class SyncWorker(
|
||||
/**
|
||||
* Sendet Broadcast an MainActivity für UI Refresh
|
||||
*/
|
||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but still functional, will migrate in v2.0.0
|
||||
private fun broadcastSyncCompleted(success: Boolean, count: Int) {
|
||||
val intent = Intent(ACTION_SYNC_COMPLETED).apply {
|
||||
putExtra("success", success)
|
||||
|
||||
@@ -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
|
||||
@@ -35,11 +34,13 @@ data class ManualMarkdownSyncResult(
|
||||
val importedCount: Int
|
||||
)
|
||||
|
||||
@Suppress("LargeClass")
|
||||
// TODO v2.0.0: Split into SyncOrchestrator, NoteUploader, NoteDownloader, ConflictResolver
|
||||
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
|
||||
@@ -54,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) {
|
||||
@@ -89,121 +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...")
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -225,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,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")
|
||||
@@ -384,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
|
||||
}
|
||||
|
||||
@@ -514,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,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(
|
||||
@@ -581,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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -756,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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -780,6 +751,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||
// Sync logic requires nested conditions for comprehensive error handling and state management
|
||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
||||
var uploadedCount = 0
|
||||
val localNotes = storage.loadAllNotes()
|
||||
@@ -948,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)
|
||||
|
||||
@@ -1022,6 +984,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
val conflictCount: Int
|
||||
)
|
||||
|
||||
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
|
||||
private fun downloadRemoteNotes(
|
||||
sardine: Sardine,
|
||||
serverUrl: String,
|
||||
@@ -1075,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)
|
||||
@@ -1086,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"
|
||||
@@ -1109,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
|
||||
@@ -1473,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)
|
||||
|
||||
@@ -1541,6 +1515,8 @@ class WebDavSyncService(private val context: Context) {
|
||||
*
|
||||
* ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien
|
||||
*/
|
||||
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
|
||||
// Import logic requires nested conditions for file validation and duplicate handling
|
||||
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
|
||||
return try {
|
||||
Logger.d(TAG, "📝 Importing Markdown files...")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("DEPRECATION") // AbstractSavedStateViewModelFactory deprecated, will migrate to viewModelFactory in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import android.os.Bundle
|
||||
|
||||
@@ -76,10 +76,18 @@ fun NoteEditorScreen(
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val checklistItems by viewModel.checklistItems.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Offline mode state
|
||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
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() }
|
||||
@@ -108,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)
|
||||
}
|
||||
@@ -233,6 +241,7 @@ fun NoteEditorScreen(
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
noteCount = 1,
|
||||
isOfflineMode = isOfflineMode,
|
||||
onDismiss = { showDeleteDialog = false },
|
||||
onDeleteLocal = {
|
||||
showDeleteDialog = false
|
||||
@@ -287,6 +296,7 @@ private fun TextNoteContent(
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
|
||||
@Composable
|
||||
private fun ChecklistEditor(
|
||||
items: List<ChecklistItemState>,
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
package dev.dettmer.simplenotes.ui.editor
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -42,6 +47,7 @@ class NoteEditorViewModel(
|
||||
}
|
||||
|
||||
private val storage = NotesStorage(application)
|
||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// State
|
||||
@@ -53,6 +59,12 @@ class NoteEditorViewModel(
|
||||
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
|
||||
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Offline Mode State
|
||||
private val _isOfflineMode = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
)
|
||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Events
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -108,7 +120,7 @@ class NoteEditorViewModel(
|
||||
currentNoteType = try {
|
||||
NoteType.valueOf(noteTypeString)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT")
|
||||
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT: ${e.message}")
|
||||
NoteType.TEXT
|
||||
}
|
||||
|
||||
@@ -284,6 +296,10 @@ class NoteEditorViewModel(
|
||||
}
|
||||
|
||||
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
|
||||
|
||||
// 🌟 v1.6.0: Trigger onSave Sync
|
||||
triggerOnSaveSync()
|
||||
|
||||
_events.emit(NoteEditorEvent.NavigateBack)
|
||||
}
|
||||
}
|
||||
@@ -331,6 +347,58 @@ class NoteEditorViewModel(
|
||||
}
|
||||
|
||||
fun canDelete(): Boolean = existingNote != null
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 🌟 v1.6.0: Sync Trigger - onSave
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
private fun triggerOnSaveSync() {
|
||||
// Check 1: Trigger enabled?
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)) {
|
||||
Logger.d(TAG, "⏭️ onSave sync disabled - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// 🆕 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 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
|
||||
|
||||
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
|
||||
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
|
||||
Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s")
|
||||
return
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
|
||||
|
||||
// Trigger sync via WorkManager
|
||||
Logger.d(TAG, "📤 Triggering onSave sync")
|
||||
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||
.addTag(Constants.SYNC_WORK_TAG)
|
||||
.build()
|
||||
WorkManager.getInstance(getApplication()).enqueue(syncRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -83,6 +83,7 @@ fun ChecklistItemRow(
|
||||
val alpha = if (item.isChecked) 0.6f else 1.0f
|
||||
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
|
||||
|
||||
@Suppress("MagicNumber") // UI padding values are self-explanatory
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("DEPRECATION") // LocalBroadcastManager & deprecated lifecycle methods, will migrate in v2.0.0
|
||||
|
||||
package dev.dettmer.simplenotes.ui.main
|
||||
|
||||
import android.Manifest
|
||||
@@ -177,7 +179,15 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
|
||||
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
|
||||
|
||||
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
|
||||
// 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(
|
||||
syncCompletedReceiver,
|
||||
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
|
||||
@@ -203,6 +213,7 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
super.onPause()
|
||||
|
||||
// Unregister BroadcastReceiver
|
||||
@Suppress("DEPRECATION")
|
||||
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
|
||||
Logger.d(TAG, "📡 BroadcastReceiver unregistered")
|
||||
}
|
||||
@@ -211,6 +222,7 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
SyncStateManager.syncStatus.observe(this) { status ->
|
||||
viewModel.updateSyncState(status)
|
||||
|
||||
@Suppress("MagicNumber") // UI timing delays for banner visibility
|
||||
// Hide banner after delay for completed/error states
|
||||
when (status.state) {
|
||||
SyncStateManager.SyncState.COMPLETED -> {
|
||||
@@ -330,6 +342,8 @@ class ComposeMainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
|
||||
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,16 +81,31 @@ fun MainScreen(
|
||||
val selectedNotes by viewModel.selectedNotes.collectAsState()
|
||||
val isSelectionMode by viewModel.isSelectionMode.collectAsState()
|
||||
|
||||
// 🌟 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
|
||||
|
||||
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
|
||||
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
|
||||
// which is called in ComposeMainActivity.onResume() when returning from Settings
|
||||
val hasServerConfig = viewModel.hasServerConfig()
|
||||
val isSyncAvailable = !isOfflineMode && hasServerConfig
|
||||
val canSync = isSyncAvailable && !isSyncing
|
||||
|
||||
// Handle snackbar events from ViewModel
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.showSnackbar.collect { data ->
|
||||
@@ -106,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()
|
||||
}
|
||||
}
|
||||
@@ -136,7 +158,7 @@ fun MainScreen(
|
||||
exit = slideOutVertically() + fadeOut()
|
||||
) {
|
||||
MainTopBar(
|
||||
syncEnabled = !isSyncing,
|
||||
syncEnabled = canSync,
|
||||
onSyncClick = { viewModel.triggerManualSync("toolbar") },
|
||||
onSettingsClick = onOpenSettings
|
||||
)
|
||||
@@ -146,10 +168,10 @@ fun MainScreen(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) { paddingValues ->
|
||||
// PullToRefreshBox wraps the content with pull-to-refresh capability
|
||||
// 🌟 v1.6.0: PullToRefreshBox only enabled when sync available
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isSyncing,
|
||||
onRefresh = { viewModel.triggerManualSync("pullToRefresh") },
|
||||
onRefresh = { if (isSyncAvailable) viewModel.triggerManualSync("pullToRefresh") },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
@@ -167,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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +251,7 @@ fun MainScreen(
|
||||
if (showBatchDeleteDialog) {
|
||||
DeleteConfirmationDialog(
|
||||
noteCount = selectedNotes.size,
|
||||
isOfflineMode = isOfflineMode,
|
||||
onDismiss = { showBatchDeleteDialog = false },
|
||||
onDeleteLocal = {
|
||||
viewModel.deleteSelectedNotes(deleteFromServer = false)
|
||||
|
||||
@@ -62,6 +62,45 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🌟 v1.6.0: Offline Mode State (reactive)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private val _isOfflineMode = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
)
|
||||
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
|
||||
|
||||
/**
|
||||
* Refresh offline mode state from SharedPreferences
|
||||
* Called when returning from Settings screen (in onResume)
|
||||
*/
|
||||
fun refreshOfflineModeState() {
|
||||
val oldValue = _isOfflineMode.value
|
||||
val newValue = prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
_isOfflineMode.value = newValue
|
||||
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)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -251,6 +290,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
))
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing coordination
|
||||
// If delete from server, actually delete after a short delay
|
||||
// (to allow undo action before server deletion)
|
||||
if (deleteFromServer) {
|
||||
@@ -350,6 +390,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
))
|
||||
|
||||
@Suppress("MagicNumber") // Snackbar timing
|
||||
// If delete from server, actually delete after snackbar timeout
|
||||
if (deleteFromServer) {
|
||||
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
|
||||
@@ -420,6 +461,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
if (success) successCount++ else failCount++
|
||||
} catch (e: Exception) {
|
||||
Logger.w(TAG, "Failed to delete note $noteId from server: ${e.message}")
|
||||
failCount++
|
||||
} finally {
|
||||
_pendingDeletions.value = _pendingDeletions.value - noteId
|
||||
@@ -458,20 +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.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
|
||||
}
|
||||
@@ -513,16 +579,30 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* Trigger auto-sync (onResume)
|
||||
* 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
|
||||
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)) {
|
||||
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
|
||||
return
|
||||
}
|
||||
|
||||
// Throttling check
|
||||
if (!canTriggerAutoSync()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if server is configured
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
// 🆕 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
|
||||
}
|
||||
|
||||
@@ -539,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")
|
||||
@@ -607,6 +685,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||
|
||||
fun isServerConfigured(): Boolean {
|
||||
// 🌟 v1.6.0: Use reactive offline mode state
|
||||
if (_isOfflineMode.value) {
|
||||
return false
|
||||
}
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
|
||||
* Used for determining if sync would be available when offline mode is disabled
|
||||
*/
|
||||
fun hasServerConfig(): Boolean {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
|
||||
}
|
||||
|
||||
@@ -2,15 +2,24 @@ package dev.dettmer.simplenotes.ui.main.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -19,10 +28,12 @@ import dev.dettmer.simplenotes.R
|
||||
/**
|
||||
* Delete confirmation dialog with server/local options
|
||||
* v1.5.0: Multi-Select Feature
|
||||
* v1.6.0: Offline mode support - disables server deletion option
|
||||
*/
|
||||
@Composable
|
||||
fun DeleteConfirmationDialog(
|
||||
noteCount: Int = 1,
|
||||
isOfflineMode: Boolean = false,
|
||||
onDismiss: () -> Unit,
|
||||
onDeleteLocal: () -> Unit,
|
||||
onDeleteEverywhere: () -> Unit
|
||||
@@ -59,16 +70,56 @@ fun DeleteConfirmationDialog(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Delete everywhere (server + local) - primary action
|
||||
// 🌟 v1.6.0: Disabled in offline mode with visual hint
|
||||
TextButton(
|
||||
onClick = onDeleteEverywhere,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !isOfflineMode,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
contentColor = MaterialTheme.colorScheme.error,
|
||||
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
)
|
||||
) {
|
||||
Text(stringResource(R.string.delete_everywhere))
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Show offline hint in a subtle Surface container
|
||||
if (isOfflineMode) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
shape = MaterialTheme.shapes.small
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.delete_everywhere_offline_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Delete local only
|
||||
TextButton(
|
||||
onClick = onDeleteLocal,
|
||||
|
||||
@@ -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
|
||||
@@ -55,7 +56,13 @@ fun SettingsNavHost(
|
||||
composable(SettingsRoute.Sync.route) {
|
||||
SyncSettingsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
onNavigateToServerSettings = {
|
||||
navController.navigate(SettingsRoute.Server.route) {
|
||||
// Avoid multiple copies of server settings in back stack
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,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
|
||||
@@ -16,9 +17,12 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.net.HttpURLConnection
|
||||
@@ -30,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 {
|
||||
@@ -39,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
|
||||
@@ -46,10 +56,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
// v1.5.0 Fix: Initialize URL with protocol prefix if empty
|
||||
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||
private val initialUrl = if (storedUrl.isEmpty()) "http://" else storedUrl
|
||||
|
||||
private val _serverUrl = MutableStateFlow(initialUrl)
|
||||
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow()
|
||||
// 🌟 v1.6.0: Separate host from prefix for better UX
|
||||
// isHttps determines the prefix, serverHost is the editable part
|
||||
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
|
||||
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
|
||||
|
||||
// Extract host part (everything after http:// or https://)
|
||||
private fun extractHostFromUrl(url: String): String {
|
||||
return when {
|
||||
url.startsWith("https://") -> url.removePrefix("https://")
|
||||
url.startsWith("http://") -> url.removePrefix("http://")
|
||||
else -> url
|
||||
}
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Only the host part is editable (without protocol prefix)
|
||||
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
|
||||
val serverHost: StateFlow<String> = _serverHost.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
|
||||
val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host ->
|
||||
val prefix = if (https) "https://" else "http://"
|
||||
if (host.isEmpty()) "" else prefix + host
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
|
||||
|
||||
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
|
||||
val username: StateFlow<String> = _username.asStateFlow()
|
||||
@@ -57,13 +87,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
|
||||
val password: StateFlow<String> = _password.asStateFlow()
|
||||
|
||||
// v1.5.0 Fix: isHttps based on stored URL (false = HTTP if empty)
|
||||
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
|
||||
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
|
||||
|
||||
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
|
||||
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Offline Mode Toggle
|
||||
// Default: true for new users (no server), false for existing users (has server config)
|
||||
private val _offlineMode = MutableStateFlow(
|
||||
if (prefs.contains(Constants.KEY_OFFLINE_MODE)) {
|
||||
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
|
||||
} else {
|
||||
// Migration: auto-detect based on existing server config
|
||||
!hasExistingServerConfig()
|
||||
}
|
||||
)
|
||||
val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow()
|
||||
|
||||
private fun hasExistingServerConfig(): Boolean {
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Events (for Activity-level actions like dialogs, intents)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -90,6 +135,38 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
)
|
||||
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
|
||||
|
||||
// 🌟 v1.6.0: Configurable Sync Triggers
|
||||
private val _triggerOnSave = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
|
||||
)
|
||||
val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow()
|
||||
|
||||
private val _triggerOnResume = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
|
||||
)
|
||||
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
|
||||
|
||||
private val _triggerWifiConnect = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
||||
)
|
||||
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
|
||||
|
||||
private val _triggerPeriodic = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
||||
)
|
||||
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
|
||||
|
||||
private val _triggerBoot = MutableStateFlow(
|
||||
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
|
||||
)
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -109,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
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -126,54 +212,156 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
// Server Settings Actions
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* v1.6.0: Set offline mode on/off
|
||||
* When enabled, all network features are disabled
|
||||
*/
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
_offlineMode.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
|
||||
|
||||
if (enabled) {
|
||||
_serverStatus.value = ServerStatus.OfflineMode
|
||||
} else {
|
||||
// Re-check server status when disabling offline mode
|
||||
checkServerStatus()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServerUrl(url: String) {
|
||||
_serverUrl.value = url
|
||||
saveServerSettings()
|
||||
// 🌟 v1.6.0: Deprecated - use updateServerHost instead
|
||||
// This function is kept for compatibility but now delegates to updateServerHost
|
||||
val host = extractHostFromUrl(url)
|
||||
updateServerHost(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🌟 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
|
||||
|
||||
// ✅ 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
|
||||
val currentUrl = _serverUrl.value
|
||||
// 🌟 v1.6.0: Host stays the same, only prefix changes
|
||||
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
||||
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||
|
||||
// v1.5.0 Fix: Automatisch Prefix setzen, auch bei leerem Feld
|
||||
val newUrl = if (useHttps) {
|
||||
when {
|
||||
currentUrl.isEmpty() || currentUrl == "http://" -> "https://"
|
||||
currentUrl.startsWith("http://") -> currentUrl.replace("http://", "https://")
|
||||
!currentUrl.startsWith("https://") -> "https://$currentUrl"
|
||||
else -> currentUrl
|
||||
}
|
||||
} else {
|
||||
when {
|
||||
currentUrl.isEmpty() || currentUrl == "https://" -> "http://"
|
||||
currentUrl.startsWith("https://") -> currentUrl.replace("https://", "http://")
|
||||
!currentUrl.startsWith("http://") -> "http://$currentUrl"
|
||||
else -> currentUrl
|
||||
}
|
||||
}
|
||||
_serverUrl.value = newUrl
|
||||
saveServerSettings()
|
||||
// ✅ 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() {
|
||||
prefs.edit().apply {
|
||||
putString(Constants.KEY_SERVER_URL, _serverUrl.value)
|
||||
putString(Constants.KEY_USERNAME, _username.value)
|
||||
putString(Constants.KEY_PASSWORD, _password.value)
|
||||
apply()
|
||||
/**
|
||||
* 🔧 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
|
||||
|
||||
// 🔄 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
|
||||
@@ -199,13 +387,23 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
|
||||
fun checkServerStatus() {
|
||||
val serverUrl = _serverUrl.value
|
||||
// v1.5.0 Fix: URL mit nur Prefix gilt als "nicht konfiguriert"
|
||||
if (serverUrl.isEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
||||
// 🌟 v1.6.0: Respect offline mode first
|
||||
if (_offlineMode.value) {
|
||||
_serverStatus.value = ServerStatus.OfflineMode
|
||||
return
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Check if host is configured
|
||||
val serverHost = _serverHost.value
|
||||
if (serverHost.isEmpty()) {
|
||||
_serverStatus.value = ServerStatus.NotConfigured
|
||||
return
|
||||
}
|
||||
|
||||
// Construct full URL
|
||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||
val serverUrl = prefix + serverHost
|
||||
|
||||
viewModelScope.launch {
|
||||
_serverStatus.value = ServerStatus.Checking
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
@@ -231,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
|
||||
@@ -287,6 +497,54 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
}
|
||||
|
||||
// 🌟 v1.6.0: Configurable Sync Triggers Setters
|
||||
|
||||
fun setTriggerOnSave(enabled: Boolean) {
|
||||
_triggerOnSave.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
|
||||
Logger.d(TAG, "Trigger onSave: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerOnResume(enabled: Boolean) {
|
||||
_triggerOnResume.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
|
||||
Logger.d(TAG, "Trigger onResume: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerWifiConnect(enabled: Boolean) {
|
||||
_triggerWifiConnect.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
|
||||
viewModelScope.launch {
|
||||
_events.emit(SettingsEvent.RestartNetworkMonitor)
|
||||
}
|
||||
Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerPeriodic(enabled: Boolean) {
|
||||
_triggerPeriodic.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
|
||||
viewModelScope.launch {
|
||||
_events.emit(SettingsEvent.RestartNetworkMonitor)
|
||||
}
|
||||
Logger.d(TAG, "Trigger Periodic: $enabled")
|
||||
}
|
||||
|
||||
fun setTriggerBoot(enabled: Boolean) {
|
||||
_triggerBoot.value = enabled
|
||||
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -337,6 +595,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
|
||||
emitToast(getString(R.string.toast_markdown_exported, exportedCount))
|
||||
|
||||
@Suppress("MagicNumber") // UI progress delay
|
||||
// Clear progress after short delay
|
||||
kotlinx.coroutines.delay(500)
|
||||
_markdownExportProgress.value = null
|
||||
@@ -371,6 +630,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
|
||||
fun performManualMarkdownSync() {
|
||||
// 🌟 v1.6.0: Block in offline mode
|
||||
if (_offlineMode.value) {
|
||||
Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled")
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
emitToast(getString(R.string.toast_markdown_syncing))
|
||||
@@ -387,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 {
|
||||
@@ -406,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 {
|
||||
@@ -425,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
|
||||
@@ -478,10 +766,56 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
// Helper
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId)
|
||||
/**
|
||||
* Check if server is configured AND not in offline mode
|
||||
* v1.6.0: Returns false if offline mode is enabled
|
||||
*/
|
||||
fun isServerConfigured(): Boolean {
|
||||
// Offline mode takes priority
|
||||
if (_offlineMode.value) return false
|
||||
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
return !serverUrl.isNullOrEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
}
|
||||
|
||||
private fun getString(resId: Int, vararg formatArgs: Any): String =
|
||||
getApplication<android.app.Application>().getString(resId, *formatArgs)
|
||||
/**
|
||||
* 🌍 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 {
|
||||
// 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)
|
||||
@@ -489,9 +823,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
||||
|
||||
/**
|
||||
* Server status states
|
||||
* v1.6.0: Added OfflineMode state
|
||||
*/
|
||||
sealed class ServerStatus {
|
||||
data object Unknown : ServerStatus()
|
||||
data object OfflineMode : ServerStatus() // 🌟 v1.6.0
|
||||
data object NotConfigured : ServerStatus()
|
||||
data object Checking : ServerStatus()
|
||||
data object Reachable : ServerStatus()
|
||||
@@ -516,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,24 +95,34 @@ fun SettingsDangerButton(
|
||||
|
||||
/**
|
||||
* Info card with description text
|
||||
* v1.6.0: Added isWarning parameter for offline mode warning
|
||||
*/
|
||||
@Composable
|
||||
fun SettingsInfoCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
isWarning: Boolean = false
|
||||
) {
|
||||
androidx.compose.material3.Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
containerColor = if (isWarning) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
}
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (isWarning) {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.padding(16.dp),
|
||||
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@file:Suppress("MatchingDeclarationName")
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -34,6 +35,7 @@ fun SettingsSwitch(
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled) { onCheckedChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
@@ -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
|
||||
@@ -49,17 +51,34 @@ fun BackupSettingsScreen(
|
||||
) {
|
||||
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Check if server restore is available
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
|
||||
// Restore dialog state
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) }
|
||||
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(
|
||||
@@ -96,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 = {
|
||||
@@ -126,6 +155,7 @@ fun BackupSettingsScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 🌟 v1.6.0: Disabled when offline mode active
|
||||
SettingsOutlinedButton(
|
||||
text = stringResource(R.string.backup_restore_server),
|
||||
onClick = {
|
||||
@@ -133,13 +163,66 @@ fun BackupSettingsScreen(
|
||||
showRestoreDialog = true
|
||||
},
|
||||
isLoading = isBackupInProgress,
|
||||
enabled = isServerConfigured,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
// 🌟 v1.6.0: Show hint when offline
|
||||
if (!isServerConfigured) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_sync_offline_mode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
// 🔐 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(
|
||||
@@ -151,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ fun MarkdownSettingsScreen(
|
||||
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
||||
val exportProgress by viewModel.markdownExportProgress.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Check offline mode
|
||||
val offlineMode by viewModel.offlineMode.collectAsState()
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
|
||||
// v1.5.0 Fix: Progress Dialog for initial export
|
||||
exportProgress?.let { progress ->
|
||||
AlertDialog(
|
||||
@@ -96,15 +100,22 @@ fun MarkdownSettingsScreen(
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Markdown Auto-Sync Toggle
|
||||
// 🌟 v1.6.0: Disabled when offline mode active
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.markdown_auto_sync_title),
|
||||
subtitle = stringResource(R.string.markdown_auto_sync_subtitle),
|
||||
subtitle = if (!isServerConfigured) {
|
||||
stringResource(R.string.settings_sync_offline_mode)
|
||||
} else {
|
||||
stringResource(R.string.markdown_auto_sync_subtitle)
|
||||
},
|
||||
checked = markdownAutoSync,
|
||||
onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
|
||||
icon = Icons.Default.Description
|
||||
icon = Icons.Default.Description,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
// Manual sync button (only visible when auto-sync is off)
|
||||
// 🌟 v1.6.0: Also disabled in offline mode
|
||||
if (!markdownAutoSync) {
|
||||
SettingsDivider()
|
||||
|
||||
@@ -117,8 +128,20 @@ fun MarkdownSettingsScreen(
|
||||
SettingsButton(
|
||||
text = stringResource(R.string.markdown_manual_sync_button),
|
||||
onClick = { viewModel.performManualMarkdownSync() },
|
||||
enabled = isServerConfigured,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
// 🌟 v1.6.0: Show hint when offline
|
||||
if (!isServerConfigured) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_sync_offline_mode),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.ui.settings.screens
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -29,8 +30,10 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
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
|
||||
@@ -39,6 +42,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -52,13 +56,18 @@ 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
|
||||
fun ServerSettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val serverUrl by viewModel.serverUrl.collectAsState()
|
||||
val offlineMode by viewModel.offlineMode.collectAsState()
|
||||
val serverHost by viewModel.serverHost.collectAsState() // 🌟 v1.6.0: Only host part
|
||||
val serverUrl by viewModel.serverUrl.collectAsState() // Full URL for display
|
||||
val username by viewModel.username.collectAsState()
|
||||
val password by viewModel.password.collectAsState()
|
||||
val isHttps by viewModel.isHttps.collectAsState()
|
||||
@@ -67,9 +76,19 @@ fun ServerSettingsScreen(
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
// Check server status on load
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.checkServerStatus()
|
||||
// 🔧 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) {
|
||||
viewModel.checkServerStatus()
|
||||
}
|
||||
}
|
||||
|
||||
SettingsScaffold(
|
||||
@@ -83,99 +102,168 @@ fun ServerSettingsScreen(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
) {
|
||||
// Verbindungstyp
|
||||
Text(
|
||||
text = stringResource(R.string.server_connection_type),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 🌟 v1.6.0: Offline-Modus Toggle (TOP)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { viewModel.setOfflineMode(!offlineMode) },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (offlineMode) {
|
||||
MaterialTheme.colorScheme.tertiaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
}
|
||||
)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !isHttps,
|
||||
onClick = { viewModel.updateProtocol(false) },
|
||||
label = { Text(stringResource(R.string.server_connection_http)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
FilterChip(
|
||||
selected = isHttps,
|
||||
onClick = { viewModel.updateProtocol(true) },
|
||||
label = { Text(stringResource(R.string.server_connection_https)) },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (!isHttps) {
|
||||
stringResource(R.string.server_connection_http_hint)
|
||||
} else {
|
||||
stringResource(R.string.server_connection_https_hint)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
|
||||
)
|
||||
|
||||
// Server-Adresse
|
||||
OutlinedTextField(
|
||||
value = serverUrl,
|
||||
onValueChange = { viewModel.updateServerUrl(it) },
|
||||
label = { Text(stringResource(R.string.server_address)) },
|
||||
supportingText = { Text(stringResource(R.string.server_address_hint)) },
|
||||
leadingIcon = { Icon(Icons.Default.Language, null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Benutzername
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { viewModel.updateUsername(it) },
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Passwort
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { viewModel.updatePassword(it) },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) {
|
||||
Icons.Default.VisibilityOff
|
||||
} else {
|
||||
Icons.Default.Visibility
|
||||
},
|
||||
contentDescription = if (passwordVisible) {
|
||||
stringResource(R.string.server_password_hide)
|
||||
} else {
|
||||
stringResource(R.string.server_password_show)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.server_offline_mode_title),
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.server_offline_mode_subtitle),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
Switch(
|
||||
checked = offlineMode,
|
||||
onCheckedChange = { viewModel.setOfflineMode(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Server Configuration (grayed out when offline mode)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
val fieldsEnabled = !offlineMode
|
||||
val fieldsAlpha = if (offlineMode) 0.5f else 1f
|
||||
|
||||
Column(modifier = Modifier.alpha(fieldsAlpha)) {
|
||||
// Verbindungstyp
|
||||
Text(
|
||||
text = stringResource(R.string.server_connection_type),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !isHttps,
|
||||
onClick = { viewModel.updateProtocol(false) },
|
||||
label = { Text(stringResource(R.string.server_connection_http)) },
|
||||
enabled = fieldsEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
FilterChip(
|
||||
selected = isHttps,
|
||||
onClick = { viewModel.updateProtocol(true) },
|
||||
label = { Text(stringResource(R.string.server_connection_https)) },
|
||||
enabled = fieldsEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = if (!isHttps) {
|
||||
stringResource(R.string.server_connection_http_hint)
|
||||
} else {
|
||||
stringResource(R.string.server_connection_https_hint)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
|
||||
)
|
||||
|
||||
// 🌟 v1.6.0: Server-Adresse with non-editable prefix
|
||||
OutlinedTextField(
|
||||
value = serverHost, // Only host part is editable
|
||||
onValueChange = { viewModel.updateServerHost(it) },
|
||||
label = { Text(stringResource(R.string.server_address)) },
|
||||
supportingText = { Text(stringResource(R.string.server_address_hint)) },
|
||||
prefix = {
|
||||
// Protocol prefix is displayed but not editable
|
||||
Text(
|
||||
text = if (isHttps) "https://" else "http://",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (fieldsEnabled) {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
}
|
||||
)
|
||||
},
|
||||
leadingIcon = { Icon(Icons.Default.Language, null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = fieldsEnabled,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Benutzername
|
||||
OutlinedTextField(
|
||||
value = username,
|
||||
onValueChange = { viewModel.updateUsername(it) },
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
leadingIcon = { Icon(Icons.Default.Person, null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = fieldsEnabled
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Passwort
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { viewModel.updatePassword(it) },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
leadingIcon = { Icon(Icons.Default.Lock, null) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) {
|
||||
Icons.Default.VisibilityOff
|
||||
} else {
|
||||
Icons.Default.Visibility
|
||||
},
|
||||
contentDescription = if (passwordVisible) {
|
||||
stringResource(R.string.server_password_hide)
|
||||
} else {
|
||||
stringResource(R.string.server_password_show)
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = fieldsEnabled,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -196,16 +284,18 @@ fun ServerSettingsScreen(
|
||||
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
text = when (serverStatus) {
|
||||
is SettingsViewModel.ServerStatus.OfflineMode -> stringResource(R.string.server_status_offline_mode)
|
||||
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.server_status_reachable)
|
||||
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
|
||||
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_not_configured)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_offline_mode)
|
||||
else -> stringResource(R.string.server_status_unknown)
|
||||
},
|
||||
color = when (serverStatus) {
|
||||
is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
|
||||
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
|
||||
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
@@ -214,13 +304,16 @@ fun ServerSettingsScreen(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Action Buttons
|
||||
// Action Buttons (disabled in offline mode)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(fieldsAlpha),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.testConnection() },
|
||||
enabled = fieldsEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(stringResource(R.string.test_connection))
|
||||
@@ -228,7 +321,7 @@ fun ServerSettingsScreen(
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.syncNow() },
|
||||
enabled = !isSyncing,
|
||||
enabled = fieldsEnabled && !isSyncing,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
if (isSyncing) {
|
||||
|
||||
@@ -10,9 +10,11 @@ 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
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -32,6 +34,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||
* Main Settings overview screen with clickable group cards
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
*/
|
||||
@Suppress("MagicNumber") // Color hex values
|
||||
@Composable
|
||||
fun SettingsMainScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
@@ -45,6 +48,14 @@ fun SettingsMainScreen(
|
||||
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
|
||||
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
|
||||
|
||||
// 🌟 v1.6.0: Collect offline mode and trigger states
|
||||
val offlineMode by viewModel.offlineMode.collectAsState()
|
||||
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
|
||||
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
|
||||
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
|
||||
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
|
||||
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||
|
||||
// Check server status on first load
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.checkServerStatus()
|
||||
@@ -80,28 +91,56 @@ 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.5.0 Fix: Nur Prefix-URLs gelten als "nicht konfiguriert"
|
||||
val isConfigured = serverUrl.isNotEmpty() &&
|
||||
serverUrl != "http://" &&
|
||||
serverUrl != "https://"
|
||||
// 🌟 v1.6.0: Check if server is configured (host is not empty)
|
||||
val isConfigured = serverUrl.isNotEmpty()
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Cloud,
|
||||
title = stringResource(R.string.settings_server),
|
||||
subtitle = if (isConfigured) serverUrl else null,
|
||||
statusText = when (serverStatus) {
|
||||
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.settings_server_status_reachable)
|
||||
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.settings_server_status_unreachable)
|
||||
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.settings_server_status_checking)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.settings_server_status_not_configured)
|
||||
subtitle = if (!offlineMode && isConfigured) serverUrl else null,
|
||||
statusText = when {
|
||||
offlineMode ->
|
||||
stringResource(R.string.settings_server_status_offline_mode)
|
||||
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
|
||||
stringResource(R.string.settings_server_status_offline_mode)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
|
||||
stringResource(R.string.settings_server_status_reachable)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
|
||||
stringResource(R.string.settings_server_status_unreachable)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Checking ->
|
||||
stringResource(R.string.settings_server_status_checking)
|
||||
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
|
||||
stringResource(R.string.settings_server_status_offline_mode)
|
||||
else -> null
|
||||
},
|
||||
statusColor = when (serverStatus) {
|
||||
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
|
||||
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
|
||||
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800)
|
||||
statusColor = when {
|
||||
offlineMode -> MaterialTheme.colorScheme.tertiary
|
||||
serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
|
||||
Color(0xFF4CAF50)
|
||||
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
|
||||
Color(0xFFF44336)
|
||||
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
|
||||
MaterialTheme.colorScheme.tertiary
|
||||
else -> Color.Gray
|
||||
},
|
||||
onClick = { onNavigate(SettingsRoute.Server) }
|
||||
@@ -110,33 +149,52 @@ fun SettingsMainScreen(
|
||||
|
||||
// Sync-Einstellungen
|
||||
item {
|
||||
val intervalText = when (syncInterval) {
|
||||
15L -> stringResource(R.string.settings_interval_15min)
|
||||
60L -> stringResource(R.string.settings_interval_60min)
|
||||
else -> stringResource(R.string.settings_interval_30min)
|
||||
}
|
||||
// 🌟 v1.6.0: Build dynamic subtitle based on active triggers
|
||||
val isServerConfigured = viewModel.isServerConfigured()
|
||||
val activeTriggersCount = listOf(
|
||||
triggerOnSave,
|
||||
triggerOnResume,
|
||||
triggerWifiConnect,
|
||||
triggerPeriodic,
|
||||
triggerBoot
|
||||
).count { it }
|
||||
|
||||
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
|
||||
val syncSubtitle = if (isServerConfigured) {
|
||||
if (activeTriggersCount == 0) {
|
||||
stringResource(R.string.settings_sync_manual_only)
|
||||
} else {
|
||||
stringResource(R.string.settings_sync_triggers_active, activeTriggersCount)
|
||||
}
|
||||
} else null
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Sync,
|
||||
title = stringResource(R.string.settings_sync),
|
||||
subtitle = if (autoSyncEnabled) {
|
||||
stringResource(R.string.settings_sync_auto_on, intervalText)
|
||||
} else {
|
||||
stringResource(R.string.settings_sync_auto_off)
|
||||
},
|
||||
subtitle = syncSubtitle,
|
||||
statusText = if (!isServerConfigured) stringResource(R.string.settings_sync_offline_mode) else null,
|
||||
statusColor = if (!isServerConfigured) MaterialTheme.colorScheme.tertiary else Color.Gray,
|
||||
onClick = { onNavigate(SettingsRoute.Sync) }
|
||||
)
|
||||
}
|
||||
|
||||
// Markdown-Integration
|
||||
item {
|
||||
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
|
||||
val isServerConfiguredForMarkdown = viewModel.isServerConfigured()
|
||||
|
||||
SettingsCard(
|
||||
icon = Icons.Default.Description,
|
||||
title = stringResource(R.string.settings_markdown),
|
||||
subtitle = if (markdownAutoSync) {
|
||||
stringResource(R.string.settings_markdown_auto_on)
|
||||
} else {
|
||||
stringResource(R.string.settings_markdown_auto_off)
|
||||
},
|
||||
subtitle = if (isServerConfiguredForMarkdown) {
|
||||
if (markdownAutoSync) {
|
||||
stringResource(R.string.settings_markdown_auto_on)
|
||||
} else {
|
||||
stringResource(R.string.settings_markdown_auto_off)
|
||||
}
|
||||
} else null,
|
||||
statusText = if (!isServerConfiguredForMarkdown) stringResource(R.string.settings_sync_offline_mode) else null,
|
||||
statusColor = if (!isServerConfiguredForMarkdown) MaterialTheme.colorScheme.tertiary else Color.Gray,
|
||||
onClick = { onNavigate(SettingsRoute.Markdown) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,13 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Sync
|
||||
import androidx.compose.material.icons.filled.PhonelinkRing
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material.icons.filled.SettingsInputAntenna
|
||||
import androidx.compose.material.icons.filled.Wifi
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,17 +32,30 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||
|
||||
/**
|
||||
* Sync settings screen (Auto-Sync toggle and interval selection)
|
||||
* Sync settings screen - Configurable Sync Triggers
|
||||
* v1.5.0: Jetpack Compose Settings Redesign
|
||||
* v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot)
|
||||
*/
|
||||
@Composable
|
||||
fun SyncSettingsScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit
|
||||
onBack: () -> Unit,
|
||||
onNavigateToServerSettings: () -> Unit
|
||||
) {
|
||||
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState()
|
||||
// Collect all trigger states
|
||||
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
|
||||
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
|
||||
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
|
||||
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
|
||||
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()
|
||||
|
||||
SettingsScaffold(
|
||||
title = stringResource(R.string.sync_settings_title),
|
||||
onBack = onBack
|
||||
@@ -49,55 +68,162 @@ fun SyncSettingsScreen(
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Auto-Sync Info
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.sync_auto_sync_info)
|
||||
// 🌟 v1.6.0: Offline Mode Warning if server not configured
|
||||
if (!isServerConfigured) {
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.sync_offline_mode_message),
|
||||
isWarning = true
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onNavigateToServerSettings,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.sync_offline_mode_button))
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
// Info-Hinweis dass WiFi-Connect davon ausgenommen ist
|
||||
if (wifiOnlySync && isServerConfigured) {
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.sync_wifi_only_hint)
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-Sync Toggle
|
||||
SettingsDivider()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SOFORT-SYNC Section
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
|
||||
|
||||
// onSave Trigger
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_auto_sync_enabled),
|
||||
checked = autoSyncEnabled,
|
||||
onCheckedChange = { viewModel.setAutoSync(it) },
|
||||
icon = Icons.Default.Sync
|
||||
title = stringResource(R.string.sync_trigger_on_save_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
|
||||
checked = triggerOnSave,
|
||||
onCheckedChange = { viewModel.setTriggerOnSave(it) },
|
||||
icon = Icons.Default.Save,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
// onResume Trigger
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_on_resume_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
|
||||
checked = triggerOnResume,
|
||||
onCheckedChange = { viewModel.setTriggerOnResume(it) },
|
||||
icon = Icons.Default.PhonelinkRing,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// Sync Interval Section
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_interval_section))
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HINTERGRUND-SYNC Section
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
|
||||
|
||||
// WiFi-Connect Trigger
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_wifi_connect_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
|
||||
checked = triggerWifiConnect,
|
||||
onCheckedChange = { viewModel.setTriggerWifiConnect(it) },
|
||||
icon = Icons.Default.Wifi,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
// Periodic Trigger
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_periodic_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
|
||||
checked = triggerPeriodic,
|
||||
onCheckedChange = { viewModel.setTriggerPeriodic(it) },
|
||||
icon = Icons.Default.Schedule,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
// Periodic Interval Selection (only visible if periodic trigger is enabled)
|
||||
if (triggerPeriodic && isServerConfigured) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
val intervalOptions = listOf(
|
||||
RadioOption(
|
||||
value = 15L,
|
||||
title = stringResource(R.string.sync_interval_15min_title),
|
||||
subtitle = null
|
||||
),
|
||||
RadioOption(
|
||||
value = 30L,
|
||||
title = stringResource(R.string.sync_interval_30min_title),
|
||||
subtitle = null
|
||||
),
|
||||
RadioOption(
|
||||
value = 60L,
|
||||
title = stringResource(R.string.sync_interval_60min_title),
|
||||
subtitle = null
|
||||
)
|
||||
)
|
||||
|
||||
SettingsRadioGroup(
|
||||
options = intervalOptions,
|
||||
selectedValue = syncInterval,
|
||||
onValueSelected = { viewModel.setSyncInterval(it) }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ADVANCED Section (Boot Sync)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
|
||||
|
||||
// Boot Trigger
|
||||
SettingsSwitch(
|
||||
title = stringResource(R.string.sync_trigger_boot_title),
|
||||
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
|
||||
checked = triggerBoot,
|
||||
onCheckedChange = { viewModel.setTriggerBoot(it) },
|
||||
icon = Icons.Default.SettingsInputAntenna,
|
||||
enabled = isServerConfigured
|
||||
)
|
||||
|
||||
SettingsDivider()
|
||||
|
||||
// Manual Sync Info
|
||||
val manualHintText = if (isServerConfigured) {
|
||||
stringResource(R.string.sync_manual_hint)
|
||||
} else {
|
||||
stringResource(R.string.sync_manual_hint_disabled)
|
||||
}
|
||||
|
||||
SettingsInfoCard(
|
||||
text = stringResource(R.string.sync_interval_info)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Interval Radio Group
|
||||
val intervalOptions = listOf(
|
||||
RadioOption(
|
||||
value = 15L,
|
||||
title = stringResource(R.string.sync_interval_15min_title),
|
||||
subtitle = stringResource(R.string.sync_interval_15min_subtitle)
|
||||
),
|
||||
RadioOption(
|
||||
value = 30L,
|
||||
title = stringResource(R.string.sync_interval_30min_title),
|
||||
subtitle = stringResource(R.string.sync_interval_30min_subtitle)
|
||||
),
|
||||
RadioOption(
|
||||
value = 60L,
|
||||
title = stringResource(R.string.sync_interval_60min_title),
|
||||
subtitle = stringResource(R.string.sync_interval_60min_subtitle)
|
||||
)
|
||||
)
|
||||
|
||||
SettingsRadioGroup(
|
||||
options = intervalOptions,
|
||||
selectedValue = syncInterval,
|
||||
onValueSelected = { viewModel.setSyncInterval(it) }
|
||||
text = manualHintText
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.dettmer.simplenotes.ui.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Zentrale UI-Dimensionen für konsistentes Design
|
||||
*/
|
||||
object Dimensions {
|
||||
// Padding & Spacing
|
||||
val SpacingSmall = 4.dp
|
||||
val SpacingMedium = 8.dp
|
||||
val SpacingLarge = 16.dp
|
||||
val SpacingXLarge = 24.dp
|
||||
|
||||
// Icon Sizes
|
||||
val IconSizeSmall = 16.dp
|
||||
val IconSizeMedium = 24.dp
|
||||
val IconSizeLarge = 32.dp
|
||||
|
||||
// Minimum Touch Target (Material Design: 48dp)
|
||||
val MinTouchTarget = 48.dp
|
||||
|
||||
// Checklist
|
||||
val ChecklistItemMinHeight = 48.dp
|
||||
|
||||
// Status Bar Heights
|
||||
val StatusBarHeightDefault = 56.dp
|
||||
}
|
||||
@@ -29,6 +29,31 @@ object Constants {
|
||||
// 🔥 v1.3.1: Debug & Logging
|
||||
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
|
||||
|
||||
// 🔥 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"
|
||||
const val KEY_SYNC_TRIGGER_WIFI_CONNECT = "sync_trigger_wifi_connect"
|
||||
const val KEY_SYNC_TRIGGER_PERIODIC = "sync_trigger_periodic"
|
||||
const val KEY_SYNC_TRIGGER_BOOT = "sync_trigger_boot"
|
||||
|
||||
// Sync Trigger Defaults (active after server configuration)
|
||||
const val DEFAULT_TRIGGER_ON_SAVE = true
|
||||
const val DEFAULT_TRIGGER_ON_RESUME = true
|
||||
const val DEFAULT_TRIGGER_WIFI_CONNECT = true
|
||||
const val DEFAULT_TRIGGER_PERIODIC = false
|
||||
const val DEFAULT_TRIGGER_BOOT = false
|
||||
|
||||
// Throttling for onSave sync (5 seconds)
|
||||
const val MIN_ON_SAVE_SYNC_INTERVAL_MS = 5_000L
|
||||
const val PREF_LAST_ON_SAVE_SYNC_TIME = "last_on_save_sync_time"
|
||||
|
||||
// WorkManager
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
@@ -36,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(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.dettmer.simplenotes.utils
|
||||
|
||||
/**
|
||||
* Konstanten für Sync-Operationen
|
||||
*/
|
||||
object SyncConstants {
|
||||
// Debounce Delays
|
||||
const val SEARCH_DEBOUNCE_MS = 300L
|
||||
const val SYNC_DEBOUNCE_MS = 500L
|
||||
|
||||
// Connection Timeouts
|
||||
const val CONNECTION_TEST_TIMEOUT_MS = 5000L
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -65,6 +66,7 @@
|
||||
<string name="delete_note_message">Wie möchtest du diese Notiz löschen?</string>
|
||||
<string name="delete_notes_message">Wie möchtest du diese %d Notizen löschen?</string>
|
||||
<string name="delete_everywhere">Überall löschen (auch Server)</string>
|
||||
<string name="delete_everywhere_offline_hint">Nicht verfügbar im Offline-Modus</string>
|
||||
<string name="delete_local_only">Nur lokal löschen</string>
|
||||
<string name="delete">Löschen</string>
|
||||
<string name="cancel">Abbrechen</string>
|
||||
@@ -91,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 -->
|
||||
<!-- ============================= -->
|
||||
@@ -135,9 +149,13 @@
|
||||
<string name="settings_server_status_unreachable">❌ Nicht erreichbar</string>
|
||||
<string name="settings_server_status_checking">🔍 Prüfe…</string>
|
||||
<string name="settings_server_status_not_configured">⚠️ Nicht konfiguriert</string>
|
||||
<string name="settings_server_status_offline_mode">📴 Offline-Modus</string>
|
||||
<string name="settings_sync">Sync-Einstellungen</string>
|
||||
<string name="settings_sync_auto_on">Auto-Sync: An • %s</string>
|
||||
<string name="settings_sync_auto_off">Auto-Sync: Aus</string>
|
||||
<string name="settings_sync_offline_mode">📴 Offline-Modus</string>
|
||||
<string name="settings_sync_manual_only">Nur manueller Sync</string>
|
||||
<string name="settings_sync_triggers_active">%d Trigger aktiv</string>
|
||||
<string name="settings_interval_15min">15 Min</string>
|
||||
<string name="settings_interval_30min">30 Min</string>
|
||||
<string name="settings_interval_60min">60 Min</string>
|
||||
@@ -173,7 +191,10 @@
|
||||
<string name="server_status_unreachable">❌ Nicht erreichbar</string>
|
||||
<string name="server_status_checking">🔍 Prüfe…</string>
|
||||
<string name="server_status_not_configured">⚠️ Nicht konfiguriert</string>
|
||||
<string name="server_status_offline_mode">📴 Offline-Modus aktiv</string>
|
||||
<string name="server_status_unknown">❓ Unbekannt</string>
|
||||
<string name="server_offline_mode_title">📴 Offline-Modus</string>
|
||||
<string name="server_offline_mode_subtitle">Alle Netzwerkfunktionen deaktivieren</string>
|
||||
<string name="test_connection">Verbindung testen</string>
|
||||
<string name="sync_now">Jetzt synchronisieren</string>
|
||||
|
||||
@@ -188,14 +209,49 @@
|
||||
<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>
|
||||
|
||||
<string name="sync_trigger_on_resume_title">Beim App-Start</string>
|
||||
<string name="sync_trigger_on_resume_subtitle">Sync wenn die App geöffnet wird</string>
|
||||
|
||||
<string name="sync_trigger_wifi_connect_title">Bei WiFi-Verbindung</string>
|
||||
<string name="sync_trigger_wifi_connect_subtitle">Sync wenn WiFi verbunden wird</string>
|
||||
|
||||
<string name="sync_trigger_periodic_title">Automatisch alle X Minuten</string>
|
||||
<string name="sync_trigger_periodic_subtitle">Regelmäßiger Hintergrund-Sync</string>
|
||||
|
||||
<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>
|
||||
|
||||
<string name="sync_offline_mode_title">Offline-Modus</string>
|
||||
<string name="sync_offline_mode_message">Du nutzt die App im Offline-Modus. Richte einen Server ein, um Notizen zu synchronisieren.</string>
|
||||
<string name="sync_offline_mode_button">Server einrichten</string>
|
||||
|
||||
<!-- ============================= -->
|
||||
<!-- SETTINGS - MARKDOWN -->
|
||||
<!-- ============================= -->
|
||||
@@ -218,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>
|
||||
@@ -273,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 -->
|
||||
<!-- ============================= -->
|
||||
@@ -322,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>
|
||||
@@ -357,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>
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
<string name="delete_note_message">How do you want to delete this note?</string>
|
||||
<string name="delete_notes_message">How do you want to delete these %d notes?</string>
|
||||
<string name="delete_everywhere">Delete everywhere (also server)</string>
|
||||
<string name="delete_everywhere_offline_hint">Not available in offline mode</string>
|
||||
<string name="delete_local_only">Delete local only</string>
|
||||
<string name="delete">Delete</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
@@ -92,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 -->
|
||||
<!-- ============================= -->
|
||||
@@ -136,9 +149,13 @@
|
||||
<string name="settings_server_status_unreachable">❌ Not reachable</string>
|
||||
<string name="settings_server_status_checking">🔍 Checking…</string>
|
||||
<string name="settings_server_status_not_configured">⚠️ Not configured</string>
|
||||
<string name="settings_server_status_offline_mode">📴 Offline Mode</string>
|
||||
<string name="settings_sync">Sync Settings</string>
|
||||
<string name="settings_sync_auto_on">Auto-Sync: On • %s</string>
|
||||
<string name="settings_sync_auto_off">Auto-Sync: Off</string>
|
||||
<string name="settings_sync_offline_mode">📴 Offline Mode</string>
|
||||
<string name="settings_sync_manual_only">Manual sync only</string>
|
||||
<string name="settings_sync_triggers_active">%d triggers active</string>
|
||||
<string name="settings_interval_15min">15 min</string>
|
||||
<string name="settings_interval_30min">30 min</string>
|
||||
<string name="settings_interval_60min">60 min</string>
|
||||
@@ -174,7 +191,10 @@
|
||||
<string name="server_status_unreachable">❌ Not reachable</string>
|
||||
<string name="server_status_checking">🔍 Checking…</string>
|
||||
<string name="server_status_not_configured">⚠️ Not configured</string>
|
||||
<string name="server_status_offline_mode">📴 Offline mode active</string>
|
||||
<string name="server_status_unknown">❓ Unknown</string>
|
||||
<string name="server_offline_mode_title">📴 Offline Mode</string>
|
||||
<string name="server_offline_mode_subtitle">Disable all network features</string>
|
||||
<string name="test_connection">Test Connection</string>
|
||||
<string name="sync_now">Sync now</string>
|
||||
|
||||
@@ -189,14 +209,49 @@
|
||||
<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>
|
||||
|
||||
<string name="sync_trigger_on_resume_title">On App Start</string>
|
||||
<string name="sync_trigger_on_resume_subtitle">Sync when the app is opened</string>
|
||||
|
||||
<string name="sync_trigger_wifi_connect_title">On WiFi Connection</string>
|
||||
<string name="sync_trigger_wifi_connect_subtitle">Sync when WiFi is connected</string>
|
||||
|
||||
<string name="sync_trigger_periodic_title">Automatically every X minutes</string>
|
||||
<string name="sync_trigger_periodic_subtitle">Regular background sync</string>
|
||||
|
||||
<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>
|
||||
|
||||
<string name="sync_offline_mode_title">Offline Mode</string>
|
||||
<string name="sync_offline_mode_message">You are using the app in offline mode. Set up a server to synchronize notes.</string>
|
||||
<string name="sync_offline_mode_button">Set Up Server</string>
|
||||
|
||||
<!-- ============================= -->
|
||||
<!-- SETTINGS - MARKDOWN -->
|
||||
<!-- ============================= -->
|
||||
@@ -219,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>
|
||||
@@ -274,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 -->
|
||||
<!-- ============================= -->
|
||||
@@ -323,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>
|
||||
@@ -358,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
@@ -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.** 🙏
|
||||
@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
||||
|
||||
## 🔋 Akku-Optimierung
|
||||
|
||||
### Verbrauchsanalyse
|
||||
### v1.6.0: Konfigurierbare Sync-Trigger
|
||||
|
||||
Seit v1.6.0 kann jeder Sync-Trigger einzeln aktiviert/deaktiviert werden. Das gibt Nutzern feine Kontrolle über den Akkuverbrauch.
|
||||
|
||||
#### Sync-Trigger Übersicht
|
||||
|
||||
| Trigger | Standard | Akku-Impact | Beschreibung |
|
||||
|---------|----------|-------------|--------------|
|
||||
| **Manueller Sync** | Immer an | 0 (nutzer-getriggert) | Toolbar-Button / Pull-to-Refresh |
|
||||
| **onSave Sync** | ✅ AN | ~0.5 mAh/Speichern | Sync sofort nach Speichern einer Notiz |
|
||||
| **onResume Sync** | ✅ AN | ~0.3 mAh/Öffnen | Sync beim App-Öffnen (60s Throttle) |
|
||||
| **WiFi-Connect** | ✅ AN | ~0.5 mAh/Verbindung | Sync bei WiFi-Verbindung |
|
||||
| **Periodic Sync** | ❌ AUS | 0.2-0.8%/Tag | Hintergrund-Sync alle 15/30/60 Min |
|
||||
| **Boot Sync** | ❌ AUS | ~0.1 mAh/Boot | Start Hintergrund-Sync nach Neustart |
|
||||
|
||||
#### Akku-Verbrauchsberechnung
|
||||
|
||||
**Typisches Nutzungsszenario (Standardeinstellungen):**
|
||||
- onSave: ~5 Speichern/Tag × 0.5 mAh = **~2.5 mAh**
|
||||
- onResume: ~10 Öffnen/Tag × 0.3 mAh = **~3 mAh**
|
||||
- WiFi-Connect: ~2 Verbindungen/Tag × 0.5 mAh = **~1 mAh**
|
||||
- **Gesamt: ~6.5 mAh/Tag (~0.2% bei 3000mAh Akku)**
|
||||
|
||||
**Mit aktiviertem Periodic Sync (15/30/60 Min):**
|
||||
|
||||
| Intervall | Syncs/Tag | Akku/Tag | Gesamt (mit Standards) |
|
||||
|-----------|-----------|----------|------------------------|
|
||||
| **15 Min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
|
||||
| **30 Min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
|
||||
| **60 Min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
|
||||
|
||||
#### Komponenten-Aufschlüsselung
|
||||
|
||||
| Komponente | Frequenz | Verbrauch | Details |
|
||||
|------------|----------|-----------|---------|
|
||||
| WorkManager Wakeup | Alle 30 Min | ~0.15 mAh | System wacht auf |
|
||||
| Network Check | 48x/Tag | ~0.03 mAh | Gateway IP check |
|
||||
| WebDAV Sync | 2-3x/Tag | ~1.5 mAh | Nur bei Änderungen |
|
||||
| **Total** | - | **~12 mAh/Tag** | **~0.4%** bei 3000mAh |
|
||||
| WorkManager Wakeup | Pro Sync | ~0.15 mAh | System wacht auf |
|
||||
| Network Check | Pro Sync | ~0.03 mAh | Gateway IP Check |
|
||||
| WebDAV Sync | Nur bei Änderungen | ~0.25 mAh | HTTP PUT/GET |
|
||||
| **Pro-Sync Gesamt** | - | **~0.25 mAh** | Optimiert |
|
||||
|
||||
### Optimierungen
|
||||
|
||||
1. **IP Caching**
|
||||
1. **Pre-Checks vor Sync**
|
||||
```kotlin
|
||||
// Reihenfolge wichtig! Günstigste Checks zuerst
|
||||
if (!hasUnsyncedChanges()) return // Lokaler Check (günstig)
|
||||
if (!isServerReachable()) return // Netzwerk Check (teuer)
|
||||
performSync() // Nur wenn beide bestehen
|
||||
```
|
||||
|
||||
2. **Throttling**
|
||||
- onResume: 60 Sekunden Mindestabstand
|
||||
- onSave: 5 Sekunden Mindestabstand
|
||||
- Periodic: 15/30/60 Minuten Intervalle
|
||||
|
||||
3. **IP Caching**
|
||||
```kotlin
|
||||
private var cachedServerIP: String? = null
|
||||
// DNS lookup nur 1x beim Start, nicht bei jedem Check
|
||||
```
|
||||
|
||||
2. **Throttling**
|
||||
```kotlin
|
||||
private var lastSyncTime = 0L
|
||||
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 Sync/Min
|
||||
```
|
||||
|
||||
3. **Conditional Logging**
|
||||
4. **Conditional Logging**
|
||||
```kotlin
|
||||
object Logger {
|
||||
fun d(tag: String, msg: String) {
|
||||
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
||||
}
|
||||
```
|
||||
|
||||
4. **Network Constraints**
|
||||
5. **Network Constraints**
|
||||
- Nur WiFi (nicht mobile Daten)
|
||||
- Nur wenn Server erreichbar
|
||||
- Keine permanenten Listeners
|
||||
|
||||
68
docs/DOCS.md
@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
||||
|
||||
## 🔋 Battery Optimization
|
||||
|
||||
### Usage Analysis
|
||||
### v1.6.0: Configurable Sync Triggers
|
||||
|
||||
Since v1.6.0, each sync trigger can be individually enabled/disabled. This gives users fine-grained control over battery usage.
|
||||
|
||||
#### Sync Trigger Overview
|
||||
|
||||
| Trigger | Default | Battery Impact | Description |
|
||||
|---------|---------|----------------|-------------|
|
||||
| **Manual Sync** | Always on | 0 (user-triggered) | Toolbar button / Pull-to-refresh |
|
||||
| **onSave Sync** | ✅ ON | ~0.5 mAh/save | Sync immediately after saving a note |
|
||||
| **onResume Sync** | ✅ ON | ~0.3 mAh/resume | Sync when app is opened (60s throttle) |
|
||||
| **WiFi-Connect** | ✅ ON | ~0.5 mAh/connect | Sync when WiFi is connected |
|
||||
| **Periodic Sync** | ❌ OFF | 0.2-0.8%/day | Background sync every 15/30/60 min |
|
||||
| **Boot Sync** | ❌ OFF | ~0.1 mAh/boot | Start background sync after reboot |
|
||||
|
||||
#### Battery Usage Calculation
|
||||
|
||||
**Typical usage scenario (defaults):**
|
||||
- onSave: ~5 saves/day × 0.5 mAh = **~2.5 mAh**
|
||||
- onResume: ~10 opens/day × 0.3 mAh = **~3 mAh**
|
||||
- WiFi-Connect: ~2 connects/day × 0.5 mAh = **~1 mAh**
|
||||
- **Total: ~6.5 mAh/day (~0.2% on 3000mAh battery)**
|
||||
|
||||
**With Periodic Sync enabled (15/30/60 min):**
|
||||
|
||||
| Interval | Syncs/day | Battery/day | Total (with defaults) |
|
||||
|----------|-----------|-------------|----------------------|
|
||||
| **15 min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
|
||||
| **30 min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
|
||||
| **60 min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
|
||||
|
||||
#### Component Breakdown
|
||||
|
||||
| Component | Frequency | Usage | Details |
|
||||
|------------|----------|-----------|---------|
|
||||
| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up |
|
||||
| Network Check | 48x/day | ~0.03 mAh | Gateway IP check |
|
||||
| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes |
|
||||
| **Total** | - | **~12 mAh/day** | **~0.4%** at 3000mAh |
|
||||
|-----------|-----------|-------|---------|
|
||||
| WorkManager Wakeup | Per sync | ~0.15 mAh | System wakes up |
|
||||
| Network Check | Per sync | ~0.03 mAh | Gateway IP check |
|
||||
| WebDAV Sync | Only if changes | ~0.25 mAh | HTTP PUT/GET |
|
||||
| **Per-Sync Total** | - | **~0.25 mAh** | Optimized |
|
||||
|
||||
### Optimizations
|
||||
|
||||
1. **IP Caching**
|
||||
1. **Pre-Checks before Sync**
|
||||
```kotlin
|
||||
// Order matters! Cheapest checks first
|
||||
if (!hasUnsyncedChanges()) return // Local check (cheap)
|
||||
if (!isServerReachable()) return // Network check (expensive)
|
||||
performSync() // Only if both pass
|
||||
```
|
||||
|
||||
2. **Throttling**
|
||||
- onResume: 60 second minimum interval
|
||||
- onSave: 5 second minimum interval
|
||||
- Periodic: 15/30/60 minute intervals
|
||||
|
||||
3. **IP Caching**
|
||||
```kotlin
|
||||
private var cachedServerIP: String? = null
|
||||
// DNS lookup only once at start, not every check
|
||||
```
|
||||
|
||||
2. **Throttling**
|
||||
```kotlin
|
||||
private var lastSyncTime = 0L
|
||||
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min
|
||||
```
|
||||
|
||||
3. **Conditional Logging**
|
||||
4. **Conditional Logging**
|
||||
```kotlin
|
||||
object Logger {
|
||||
fun d(tag: String, msg: String) {
|
||||
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
||||
}
|
||||
```
|
||||
|
||||
4. **Network Constraints**
|
||||
5. **Network Constraints**
|
||||
- WiFi only (not mobile data)
|
||||
- Only when server is reachable
|
||||
- No permanent listeners
|
||||
|
||||
@@ -169,16 +169,19 @@
|
||||
|
||||
## 🔋 Performance & Optimierung
|
||||
|
||||
### Akku-Effizienz
|
||||
- ✅ **Optimierte Sync-Intervalle** - 15/30/60 Min
|
||||
### Akku-Effizienz (v1.6.0)
|
||||
- ✅ **Konfigurierbare Sync-Trigger** - Jeden Trigger einzeln aktivieren/deaktivieren
|
||||
- ✅ **Smarte Defaults** - Nur ereignisbasierte Trigger standardmäßig aktiv
|
||||
- ✅ **Optimierte Periodische Intervalle** - 15/30/60 Min (Standard: AUS)
|
||||
- ✅ **WiFi-Only** - Kein Mobile Data Sync
|
||||
- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar
|
||||
- ✅ **WorkManager** - System-optimierte Ausführung
|
||||
- ✅ **Doze Mode kompatibel** - Sync läuft auch im Standby
|
||||
- ✅ **Gemessener Verbrauch:**
|
||||
- 15 Min: ~0.8% / Tag (~23 mAh)
|
||||
- 30 Min: ~0.4% / Tag (~12 mAh) ⭐ _Empfohlen_
|
||||
- 60 Min: ~0.2% / Tag (~6 mAh)
|
||||
- Standard (nur ereignisbasiert): ~0.2%/Tag (~6.5 mAh) ⭐ _Optimal_
|
||||
- Mit Periodic 15 Min: ~1.0%/Tag (~30 mAh)
|
||||
- Mit Periodic 30 Min: ~0.6%/Tag (~19 mAh)
|
||||
- Mit Periodic 60 Min: ~0.4%/Tag (~13 mAh)
|
||||
|
||||
### App-Performance
|
||||
- ✅ **Offline-First** - Funktioniert ohne Internet
|
||||
|
||||
@@ -169,16 +169,19 @@
|
||||
|
||||
## 🔋 Performance & Optimization
|
||||
|
||||
### Battery Efficiency
|
||||
- ✅ **Optimized sync intervals** - 15/30/60 min
|
||||
### Battery Efficiency (v1.6.0)
|
||||
- ✅ **Configurable sync triggers** - Enable/disable each trigger individually
|
||||
- ✅ **Smart defaults** - Only event-driven triggers active by default
|
||||
- ✅ **Optimized periodic intervals** - 15/30/60 min (default: OFF)
|
||||
- ✅ **WiFi-only** - No mobile data sync
|
||||
- ✅ **Smart server check** - Sync only when server is reachable
|
||||
- ✅ **WorkManager** - System-optimized execution
|
||||
- ✅ **Doze mode compatible** - Sync runs even in standby
|
||||
- ✅ **Measured consumption:**
|
||||
- 15 min: ~0.8% / day (~23 mAh)
|
||||
- 30 min: ~0.4% / day (~12 mAh) ⭐ _Recommended_
|
||||
- 60 min: ~0.2% / day (~6 mAh)
|
||||
- Default (event-driven only): ~0.2%/day (~6.5 mAh) ⭐ _Optimal_
|
||||
- With periodic 15 min: ~1.0%/day (~30 mAh)
|
||||
- With periodic 30 min: ~0.6%/day (~19 mAh)
|
||||
- With periodic 60 min: ~0.4%/day (~13 mAh)
|
||||
|
||||
### App Performance
|
||||
- ✅ **Offline-first** - Works without internet
|
||||
|
||||
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)
|
||||
@@ -31,9 +31,46 @@
|
||||
|
||||
---
|
||||
|
||||
## v1.6.0 - Technische Modernisierung
|
||||
## v1.6.0 - Technische Modernisierung ✅
|
||||
|
||||
> **Status:** In Planung 📋
|
||||
> **Status:** Released 🎉 (Januar 2026)
|
||||
|
||||
### ⚙️ Konfigurierbare Sync-Trigger
|
||||
|
||||
- ✅ **Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln aktivieren/deaktivieren
|
||||
- ✅ **Ereignisbasierte Defaults** - onSave, onResume, WiFi-Connect standardmäßig aktiv
|
||||
- ✅ **Periodischer Sync optional** - 15/30/60 Min Intervalle (Standard: AUS)
|
||||
- ✅ **Boot Sync optional** - Periodischen Sync nach Geräteneustart starten (Standard: AUS)
|
||||
- ✅ **Offline-Modus UI** - Ausgegraute Toggles wenn kein Server konfiguriert
|
||||
- ✅ **Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit Periodic
|
||||
|
||||
---
|
||||
|
||||
## v1.6.1 - Clean Code ✅
|
||||
|
||||
> **Status:** Released 🎉 (Januar 2026)
|
||||
|
||||
### 🧹 Code-Qualität
|
||||
|
||||
- ✅ **detekt: 0 Issues** - Alle 29 Code-Qualitäts-Issues behoben
|
||||
- ✅ **Zero Build Warnings** - Alle 21 Deprecation Warnings eliminiert
|
||||
- ✅ **ktlint reaktiviert** - Mit Compose-spezifischen Regeln
|
||||
- ✅ **CI/CD Lint-Checks** - In PR Build Workflow integriert
|
||||
- ✅ **Constants Refactoring** - Dimensions.kt, SyncConstants.kt
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 - Staggered Grid Layout
|
||||
|
||||
> **Status:** Geplant 📝
|
||||
|
||||
### 🎨 Adaptives Layout
|
||||
|
||||
- **Staggered Grid** - Pinterest-artiges Layout mit `LazyVerticalStaggeredGrid`
|
||||
- **Intelligente Größen** - Kleine Notizen (kurzer Text, wenige Checklist-Items) kompakt dargestellt
|
||||
- **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht in Einstellungen wechseln
|
||||
- **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
|
||||
- **120 FPS optimiert** - Lazy Loading für flüssiges Scrollen bei vielen Notizen
|
||||
|
||||
### 🔧 Server-Ordner Prüfung
|
||||
|
||||
@@ -43,22 +80,43 @@
|
||||
|
||||
### 🔧 Technische Verbesserungen
|
||||
|
||||
- **Code-Refactoring** - LongMethod und LargeClass Warnings beheben
|
||||
- **Modernere Background-Sync Architektur** - Noch zuverlässiger
|
||||
- **Code-Refactoring** - LargeClass Komponenten aufteilen (WebDavSyncService, SettingsActivity)
|
||||
- **Verbesserte Progress-Dialoge** - Material Design 3 konform
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 - Community Features
|
||||
## v2.0.0 - Legacy Cleanup
|
||||
|
||||
> **Status:** Ideen-Sammlung 💡
|
||||
> **Status:** Geplant 📝
|
||||
|
||||
### Mögliche Features
|
||||
### 🗑️ Legacy Code Entfernung
|
||||
|
||||
- **Zusätzliche Sprachen** - Community-Übersetzungen (FR, ES, IT, ...)
|
||||
- **SettingsActivity entfernen** - Ersetzt durch ComposeSettingsActivity
|
||||
- **MainActivity entfernen** - Ersetzt durch ComposeMainActivity
|
||||
- **LocalBroadcastManager → SharedFlow** - Moderne Event-Architektur
|
||||
- **ProgressDialog → Material Dialog** - Volle Material 3 Konformität
|
||||
- **AbstractSavedStateViewModelFactory → viewModelFactory** - Moderne ViewModel-Erstellung
|
||||
|
||||
---
|
||||
|
||||
## 📋 Backlog
|
||||
|
||||
> Features für zukünftige Überlegungen
|
||||
|
||||
### 🔐 Sicherheits-Verbesserungen
|
||||
|
||||
- **Passwortgeschützte lokale Backups** - Backup-ZIP mit Passwort verschlüsseln
|
||||
- **Biometrische Entsperrung** - Fingerabdruck/Gesichtserkennung für App
|
||||
|
||||
### 🎨 UI Features
|
||||
|
||||
- **Widget** - Schnellzugriff vom Homescreen
|
||||
- **Kategorien/Tags** - Notizen organisieren
|
||||
- **Suche** - Volltextsuche in Notizen
|
||||
- **Widget** - Schnellzugriff vom Homescreen
|
||||
|
||||
### 🌍 Community
|
||||
|
||||
- **Zusätzliche Sprachen** - Community-Übersetzungen (FR, ES, IT, ...)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,9 +31,46 @@
|
||||
|
||||
---
|
||||
|
||||
## v1.6.0 - Technical Modernization
|
||||
## v1.6.0 - Technical Modernization ✅
|
||||
|
||||
> **Status:** Planned 📋
|
||||
> **Status:** Released 🎉 (January 2026)
|
||||
|
||||
### ⚙️ Configurable Sync Triggers
|
||||
|
||||
- ✅ **Individual trigger control** - Enable/disable each sync trigger separately
|
||||
- ✅ **Event-driven defaults** - onSave, onResume, WiFi-Connect active by default
|
||||
- ✅ **Periodic sync optional** - 15/30/60 min intervals (default: OFF)
|
||||
- ✅ **Boot sync optional** - Start periodic sync after device restart (default: OFF)
|
||||
- ✅ **Offline mode UI** - Grayed-out toggles when no server configured
|
||||
- ✅ **Battery optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic
|
||||
|
||||
---
|
||||
|
||||
## v1.6.1 - Clean Code ✅
|
||||
|
||||
> **Status:** Released 🎉 (January 2026)
|
||||
|
||||
### 🧹 Code Quality
|
||||
|
||||
- ✅ **detekt: 0 issues** - All 29 code quality issues fixed
|
||||
- ✅ **Zero build warnings** - All 21 deprecation warnings eliminated
|
||||
- ✅ **ktlint reactivated** - With Compose-specific rules
|
||||
- ✅ **CI/CD lint checks** - Integrated into PR build workflow
|
||||
- ✅ **Constants refactoring** - Dimensions.kt, SyncConstants.kt
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 - Staggered Grid Layout
|
||||
|
||||
> **Status:** Planned 📝
|
||||
|
||||
### 🎨 Adaptive Layout
|
||||
|
||||
- **Staggered Grid** - Pinterest-style layout using `LazyVerticalStaggeredGrid`
|
||||
- **Smart sizing** - Small notes (short text, few checklist items) displayed compactly
|
||||
- **Layout toggle** - Switch between List and Grid view in settings
|
||||
- **Adaptive columns** - 2-3 columns based on screen size
|
||||
- **120 FPS optimized** - Lazy loading for smooth scrolling with many notes
|
||||
|
||||
### 🔧 Server Folder Check
|
||||
|
||||
@@ -43,22 +80,43 @@
|
||||
|
||||
### 🔧 Technical Improvements
|
||||
|
||||
- **Code refactoring** - Fix LongMethod and LargeClass warnings
|
||||
- **Modern background sync architecture** - Even more reliable
|
||||
- **Code refactoring** - Split LargeClass components (WebDavSyncService, SettingsActivity)
|
||||
- **Improved progress dialogs** - Material Design 3 compliant
|
||||
|
||||
---
|
||||
|
||||
## v1.7.0 - Community Features
|
||||
## v2.0.0 - Legacy Cleanup
|
||||
|
||||
> **Status:** Idea Collection 💡
|
||||
> **Status:** Planned 📝
|
||||
|
||||
### Potential Features
|
||||
### 🗑️ Legacy Code Removal
|
||||
|
||||
- **Additional languages** - Community translations (FR, ES, IT, ...)
|
||||
- **Remove SettingsActivity** - Replaced by ComposeSettingsActivity
|
||||
- **Remove MainActivity** - Replaced by ComposeMainActivity
|
||||
- **LocalBroadcastManager → SharedFlow** - Modern event architecture
|
||||
- **ProgressDialog → Material Dialog** - Full Material 3 compliance
|
||||
- **AbstractSavedStateViewModelFactory → viewModelFactory** - Modern ViewModel creation
|
||||
|
||||
---
|
||||
|
||||
## 📋 Backlog
|
||||
|
||||
> Features for future consideration
|
||||
|
||||
### 🔐 Security Enhancements
|
||||
|
||||
- **Password-protected local backups** - Encrypt backup ZIP with password
|
||||
- **Biometric unlock option** - Fingerprint/Face unlock for app
|
||||
|
||||
### 🎨 UI Features
|
||||
|
||||
- **Widget** - Quick access from homescreen
|
||||
- **Categories/Tags** - Organize notes
|
||||
- **Search** - Full-text search in notes
|
||||
- **Widget** - Quick access from homescreen
|
||||
|
||||
### 🌍 Community
|
||||
|
||||
- **Additional languages** - Community translations (FR, ES, IT, ...)
|
||||
|
||||
---
|
||||
|
||||
|
||||
6
fastlane/metadata/android/de-DE/changelogs/14.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
• NEU: Konfigurierbare Sync-Trigger - Jeden einzeln aktivieren/deaktivieren
|
||||
• NEU: Offline-Modus - Alle Netzwerkfunktionen mit einem Schalter aus
|
||||
• 5 Trigger: onSave, onResume, WiFi, Periodic (15/30/60 Min), Boot
|
||||
• Smarte Defaults: Nur ereignisbasiert aktiv (~0.2%/Tag Akku)
|
||||
• Periodischer Sync optional (Standard: AUS)
|
||||
• Verschiedene Fixes und UI-Verbesserungen
|
||||
2
fastlane/metadata/android/de-DE/changelogs/15.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
• Code Quality Verbesserungen
|
||||
• Bessere Vorbereitung für zukünftige Updates
|
||||
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
@@ -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
|
||||
6
fastlane/metadata/android/de-DE/changelogs/18.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
• Behoben: App-Absturz auf Android 9 (Issue #15) - Danke an @roughnecks
|
||||
- WorkManager Expedited Work Kompatibilität (getForegroundInfo)
|
||||
- Kernel-VPN-Kompatibilität (Wireguard tun/wg Interfaces)
|
||||
• Verbessert: Stabilität und Verbindungsverwaltung
|
||||
• Technisch: Optimierter HTTP-Connection-Lebenszyklus
|
||||
|
||||
@@ -1,60 +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.)
|
||||
• Automatische WiFi-Sync: Synchronisiert automatisch wenn du ein beliebiges WLAN betrittst (wenn Server erreichbar ist)
|
||||
• Konfigurierbares Interval: 15, 30 oder 60 Minuten
|
||||
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
|
||||
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
|
||||
• Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
|
||||
• 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
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 100 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
6
fastlane/metadata/android/en-US/changelogs/14.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
• NEW: Configurable Sync Triggers - Enable/disable each individually
|
||||
• NEW: Offline Mode - Disable all network features with one switch
|
||||
• 5 triggers: onSave, onResume, WiFi, Periodic (15/30/60 min), Boot
|
||||
• Smart defaults: Event-driven only (~0.2%/day battery)
|
||||
• Periodic sync optional (default: OFF)
|
||||
• Various fixes and UI improvements
|
||||
2
fastlane/metadata/android/en-US/changelogs/15.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
• Code quality improvements
|
||||
• Better preparation for future updates
|
||||
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
@@ -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
|
||||
5
fastlane/metadata/android/en-US/changelogs/18.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
• Fixed: App crash on Android 9 (Issue #15) - Thanks to @roughnecks
|
||||
- WorkManager expedited work compatibility (getForegroundInfo)
|
||||
- Kernel-VPN compatibility (Wireguard tun/wg interfaces)
|
||||
• Improved: Stability and connection management
|
||||
• Technical: Optimized HTTP connection lifecycle
|
||||
@@ -1,60 +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.)
|
||||
• Automatic WiFi sync: synchronizes whenever you join any WiFi network (if server is reachable)
|
||||
• Configurable interval: 15, 30, or 60 minutes
|
||||
• Optimized performance: skips unchanged files (~2-3s sync time)
|
||||
• E-Tag caching for 20x faster "no changes" checks
|
||||
• Measured battery consumption: only ~0.4% per day (at 30min)
|
||||
• 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
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 100 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/7.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
@@ -1,30 +0,0 @@
|
||||
Categories:
|
||||
- Writing
|
||||
License: MIT
|
||||
AuthorName: inventory69
|
||||
AuthorEmail: admin@dettmer.dev
|
||||
AuthorWebSite: https://dettmer.dev
|
||||
SourceCode: https://github.com/inventory69/simple-notes-sync
|
||||
IssueTracker: https://github.com/inventory69/simple-notes-sync/issues
|
||||
Changelog: https://github.com/inventory69/simple-notes-sync/releases
|
||||
|
||||
AutoName: Simple Notes
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/inventory69/simple-notes-sync.git
|
||||
Binaries: https://github.com/inventory69/simple-notes-sync/releases/download/v%v/simple-notes-sync-v%v-fdroid.apk
|
||||
|
||||
Builds:
|
||||
- versionName: 1.5.0
|
||||
versionCode: 13
|
||||
commit: 65395142fab487e0a286cc5dfe3cf8b76652379d
|
||||
subdir: android/app
|
||||
gradle:
|
||||
- fdroid
|
||||
|
||||
AllowedAPKSigningKeys: 42a1c613bbc673045af3dc8191bf9cb6456ee44c7dce40c7cfb566facb69f16a
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: 1.5.0
|
||||
CurrentVersionCode: 13
|
||||