Compare commits
8 Commits
v1.6.2
...
debug/v1.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91beee0f8b | ||
|
|
c536ad3177 | ||
|
|
6dba091c03 | ||
|
|
5135c711a5 | ||
|
|
ebab347d4b | ||
|
|
cb63aa1220 | ||
|
|
0df8282eb4 | ||
|
|
b70bc4d8f6 |
87
.github/workflows/build-debug-apk.yml
vendored
Normal file
87
.github/workflows/build-debug-apk.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: Build Debug APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'debug/**'
|
||||||
|
- 'fix/**'
|
||||||
|
- 'feature/**'
|
||||||
|
workflow_dispatch: # Manueller Trigger möglich
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-debug:
|
||||||
|
name: Build Debug APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Java
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '17'
|
||||||
|
|
||||||
|
- name: Extract version info
|
||||||
|
run: |
|
||||||
|
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||||
|
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
|
BRANCH_NAME=${GITHUB_REF#refs/heads/}
|
||||||
|
COMMIT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||||
|
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
||||||
|
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
|
||||||
|
echo "COMMIT_SHA=$COMMIT_SHA" >> $GITHUB_ENV
|
||||||
|
echo "BUILD_TIME=$(date +'%Y-%m-%d_%H-%M-%S')" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build Debug APK (Standard + F-Droid)
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
|
- name: Prepare Debug APK artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p debug-apks
|
||||||
|
|
||||||
|
cp android/app/build/outputs/apk/standard/debug/app-standard-debug.apk \
|
||||||
|
debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-standard-debug.apk
|
||||||
|
|
||||||
|
cp android/app/build/outputs/apk/fdroid/debug/app-fdroid-debug.apk \
|
||||||
|
debug-apks/simple-notes-sync-v${{ env.VERSION_NAME }}-${{ env.COMMIT_SHA }}-fdroid-debug.apk
|
||||||
|
|
||||||
|
echo "✅ Debug APK Files ready:"
|
||||||
|
ls -lh debug-apks/
|
||||||
|
|
||||||
|
- name: Upload Debug APK Artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: simple-notes-sync-debug-v${{ env.VERSION_NAME }}-${{ env.BUILD_TIME }}
|
||||||
|
path: debug-apks/*.apk
|
||||||
|
retention-days: 30 # Debug Builds länger aufbewahren
|
||||||
|
compression-level: 0 # APK ist bereits komprimiert
|
||||||
|
|
||||||
|
- name: Create summary
|
||||||
|
run: |
|
||||||
|
echo "## 🐛 Debug APK Build" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Build Info" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Version:** v${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Branch:** ${{ env.BRANCH_NAME }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commit:** ${{ env.COMMIT_SHA }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Built:** ${{ env.BUILD_TIME }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Download" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Debug APK available in the Artifacts section above (expires in 30 days)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### Installation" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "# Enable unknown sources" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "adb install simple-notes-sync-*-debug.apk" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### What's included?" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Full Logging enabled" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Not production signed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- May have performance impact" >> $GITHUB_STEP_SUMMARY
|
||||||
75
.github/workflows/build-production-apk.yml
vendored
75
.github/workflows/build-production-apk.yml
vendored
@@ -2,11 +2,11 @@ name: Build Android Production APK
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ] # Nur bei Push/Merge auf main triggern
|
branches: [ main ] # Only trigger on push/merge to main
|
||||||
workflow_dispatch: # Ermöglicht manuellen Trigger
|
workflow_dispatch: # Enables manual trigger
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write # Fuer Release-Erstellung erforderlich
|
contents: write # Required for release creation
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -14,50 +14,50 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Code auschecken
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Java einrichten
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Semantic Versionsnummer aus build.gradle.kts extrahieren
|
- name: Extract semantic version from build.gradle.kts
|
||||||
run: |
|
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_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/')
|
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"
|
BUILD_NUMBER="$VERSION_CODE"
|
||||||
|
|
||||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||||
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
|
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
|
||||||
echo "VERSION_TAG=v$VERSION_NAME" >> $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: |
|
run: |
|
||||||
echo "✅ Verwende Version aus build.gradle.kts:"
|
echo "✅ Using version from build.gradle.kts:"
|
||||||
grep -E "versionCode|versionName" android/app/build.gradle.kts
|
grep -E "versionCode|versionName" android/app/build.gradle.kts
|
||||||
|
|
||||||
- name: Android Signing konfigurieren
|
- name: Configure Android signing
|
||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
|
||||||
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
|
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
|
||||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
||||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
||||||
echo "storeFile=simple-notes-release.jks" >> 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: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
|
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: APK-Varianten mit Versionsnamen kopieren
|
- name: Copy APK variants with version names
|
||||||
run: |
|
run: |
|
||||||
mkdir -p apk-output
|
mkdir -p apk-output
|
||||||
|
|
||||||
@@ -69,34 +69,34 @@ jobs:
|
|||||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||||
|
|
||||||
echo "✅ APK-Dateien vorbereitet:"
|
echo "✅ APK files prepared:"
|
||||||
ls -lh apk-output/
|
ls -lh apk-output/
|
||||||
|
|
||||||
- name: APK-Artefakte hochladen
|
- name: Upload APK artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
|
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
|
||||||
path: apk-output/*.apk
|
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: |
|
run: |
|
||||||
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||||
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $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: |
|
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
|
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")
|
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||||
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||||
else
|
else
|
||||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
echo "CHANGELOG_DE=No German release notes available." >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Lese englische Changelog (optional)
|
# Read English changelog (optional)
|
||||||
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
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")
|
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||||
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
@@ -127,25 +127,30 @@ jobs:
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Downloads
|
## 📦 Downloads
|
||||||
|
|
||||||
| Variante | Datei | Info |
|
| Variant | File | Info |
|
||||||
|----------|-------|------|
|
|---------|------|------|
|
||||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
| **🏆 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` | Fuer F-Droid Store |
|
| 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 }})
|
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
||||||
- **Datum:** ${{ env.COMMIT_DATE }}
|
- **Date:** ${{ env.COMMIT_DATE }}
|
||||||
- **Commit:** ${{ env.SHORT_SHA }}
|
- **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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ Thumbs.db
|
|||||||
*.swp
|
*.swp
|
||||||
*~
|
*~
|
||||||
test-apks/
|
test-apks/
|
||||||
|
server-test/
|
||||||
|
|
||||||
# F-Droid metadata (managed in fdroiddata repo)
|
# F-Droid metadata (managed in fdroiddata repo)
|
||||||
# Exclude fastlane metadata (we want to track those screenshots)
|
# Exclude fastlane metadata (we want to track those screenshots)
|
||||||
|
|||||||
@@ -8,6 +8,42 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [1.6.1] - 2026-01-20
|
||||||
|
|
||||||
### 🧹 Code-Qualität & Build-Verbesserungen
|
### 🧹 Code-Qualität & Build-Verbesserungen
|
||||||
|
|||||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -8,6 +8,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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
|
## [1.6.1] - 2026-01-20
|
||||||
|
|
||||||
### 🧹 Code Quality & Build Improvements
|
### 🧹 Code Quality & Build Improvements
|
||||||
|
|||||||
98
README.de.md
98
README.de.md
@@ -1,63 +1,80 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
# Simple Notes Sync
|
<h1 align="center">Simple Notes Sync</h1>
|
||||||
|
|
||||||
**Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server**
|
<h4 align="center">Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.</h4>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
[](https://www.android.com/)
|
[](https://www.android.com/)
|
||||||
[](https://kotlinlang.org/)
|
[](https://kotlinlang.org/)
|
||||||

|
[](https://developer.android.com/compose/)
|
||||||
[](https://m3.material.io/)
|
[](https://m3.material.io/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="60">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
|
</div>
|
||||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="60">](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync)
|
|
||||||
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="60">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
|
|
||||||
|
|
||||||
[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Quick Start](QUICKSTART.de.md)
|
<div align="center">
|
||||||
|
|
||||||
**🌍** **Deutsch** · [English](README.md)
|
<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>
|
||||||
|
|
||||||
---
|
<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
|
## 📱 Screenshots
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync-Status">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Sync status">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Notiz bearbeiten">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.png" width="250" alt="Edit note">
|
||||||
<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/3.png" width="250" alt="Edit checklist">
|
||||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/4.png" width="250" alt="Einstellungen">
|
<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-Einstellungen">
|
<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-Einstellungen">
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Akkuschonend
|
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Akkuschonend
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Highlights
|
## ✨ Highlights
|
||||||
|
|
||||||
- ✅ **NEU: Checklisten** - Tap-to-Check, Drag & Drop
|
- 📝 **Offline-first** – Funktioniert ohne Internet
|
||||||
- 🌍 **NEU: Mehrsprachig** - Deutsch/Englisch mit Sprachauswahl
|
- 📊 **Flexible Ansichten** – Listen- und Grid-Layout
|
||||||
- 📝 **Offline-First** - Funktioniert ohne Internet
|
- ✅ **Checklisten** – Tap-to-Check, Drag & Drop
|
||||||
- 🔄 **Konfigurierbare Sync-Trigger** - onSave, onResume, WiFi-Verbindung, periodisch (15/30/60 Min), Boot
|
- 🌍 **Mehrsprachig** – Deutsch/Englisch mit Sprachauswahl
|
||||||
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
|
- 🔄 **Konfigurierbare Sync-Trigger** – onSave, onResume, WiFi, periodisch (15/30/60 Min), Boot
|
||||||
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
|
- 🔒 **Self-hosted** – Deine Daten bleiben bei dir (WebDAV)
|
||||||
- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
|
- 💾 **Lokales Backup** – Export/Import als JSON-Datei (optional verschlüsselt)
|
||||||
- 🔋 **Akkuschonend** - ~0.2% mit Defaults, bis zu ~1.0% mit Periodic Sync
|
- 🖥️ **Desktop-Integration** – Markdown-Export für Obsidian, VS Code, Typora
|
||||||
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
|
- 🔋 **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
|
## 🚀 Schnellstart
|
||||||
|
|
||||||
@@ -87,8 +104,6 @@ docker compose up -d
|
|||||||
|
|
||||||
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
|
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Dokumentation
|
## 📚 Dokumentation
|
||||||
|
|
||||||
| Dokument | Inhalt |
|
| Dokument | Inhalt |
|
||||||
@@ -97,13 +112,12 @@ docker compose up -d
|
|||||||
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
|
| **[FEATURES.de.md](docs/FEATURES.de.md)** | Vollständige Feature-Liste |
|
||||||
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
|
| **[BACKUP.de.md](docs/BACKUP.de.md)** | Backup & Wiederherstellung |
|
||||||
| **[DESKTOP.de.md](docs/DESKTOP.de.md)** | Desktop-Integration (Markdown) |
|
| **[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 |
|
| **[DOCS.de.md](docs/DOCS.de.md)** | Technische Details & Troubleshooting |
|
||||||
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
|
| **[CHANGELOG.de.md](CHANGELOG.de.md)** | Versionshistorie |
|
||||||
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
|
| **[UPCOMING.de.md](docs/UPCOMING.de.md)** | Geplante Features 🚀 |
|
||||||
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
|
| **[ÜBERSETZEN.md](docs/TRANSLATING.de.md)** | Übersetzungsanleitung 🌍 |
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Entwicklung
|
## 🛠️ Entwicklung
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -111,24 +125,18 @@ cd android
|
|||||||
./gradlew assembleStandardRelease
|
./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
|
## 🤝 Contributing
|
||||||
|
|
||||||
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 Lizenz
|
## 📄 Lizenz
|
||||||
|
|
||||||
MIT License - siehe [LICENSE](LICENSE)
|
MIT License – siehe [LICENSE](LICENSE)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
<br /><br />
|
||||||
**v1.6.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
66
README.md
66
README.md
@@ -1,28 +1,48 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
|
||||||
|
</div>
|
||||||
|
|
||||||
# Simple Notes Sync
|
<h1 align="center">Simple Notes Sync</h1>
|
||||||
|
|
||||||
**Minimalist offline notes with auto-sync to your own server**
|
<h4 align="center">Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.</h4>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
[](https://www.android.com/)
|
[](https://www.android.com/)
|
||||||
[](https://kotlinlang.org/)
|
[](https://kotlinlang.org/)
|
||||||

|
[](https://developer.android.com/compose/)
|
||||||
[](https://m3.material.io/)
|
[](https://m3.material.io/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="60">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
|
</div>
|
||||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="60">](http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync)
|
|
||||||
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="60">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
|
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)
|
<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>
|
||||||
|
|
||||||
**🌍** [Deutsch](README.de.md) · **English**
|
<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>
|
||||||
|
|
||||||
---
|
<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
|
## 📱 Screenshots
|
||||||
|
|
||||||
@@ -35,32 +55,27 @@
|
|||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Battery-friendly
|
📝 Offline-first • 🔄 Smart Sync • 🔒 Self-hosted • 🔋 Battery-friendly
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ Highlights
|
## ✨ Highlights
|
||||||
|
|
||||||
- ✅ **NEW: Checklists** - Tap-to-check, drag & drop
|
|
||||||
- 🌍 **NEW: Multilingual** - English/German with language selector
|
|
||||||
- 📝 **Offline-first** - Works without internet
|
- 📝 **Offline-first** - Works without internet
|
||||||
|
- 📊 **Flexible views** - Switch between list and grid layout
|
||||||
|
- ✅ **Checklists** - Tap-to-check, drag & drop
|
||||||
|
- 🌍 **Multilingual** - English/German with language selector
|
||||||
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
|
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
|
||||||
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
- 🔒 **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
|
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
||||||
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
|
- 🔋 **Battery-friendly** - ~0.2% with defaults, up to ~1.0% with periodic sync
|
||||||
- 🎨 **Material Design 3** - Dark mode & dynamic colors
|
- 🎨 **Material Design 3** - Dynamic dark/light mode & colors based on system settings
|
||||||
|
|
||||||
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
➡️ **Complete feature list:** [FEATURES.md](docs/FEATURES.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### 1. Server Setup (5 minutes)
|
### 1. Server Setup (5 minutes)
|
||||||
@@ -89,8 +104,6 @@ docker compose up -d
|
|||||||
|
|
||||||
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
|
➡️ **Detailed guide:** [QUICKSTART.md](QUICKSTART.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
| Document | Content |
|
| Document | Content |
|
||||||
@@ -99,6 +112,7 @@ docker compose up -d
|
|||||||
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
|
| **[FEATURES.md](docs/FEATURES.md)** | Complete feature list |
|
||||||
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
|
| **[BACKUP.md](docs/BACKUP.md)** | Backup & restore guide |
|
||||||
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop integration (Markdown) |
|
| **[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 |
|
| **[DOCS.md](docs/DOCS.md)** | Technical details & troubleshooting |
|
||||||
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
|
| **[CHANGELOG.md](CHANGELOG.md)** | Version history |
|
||||||
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
|
| **[UPCOMING.md](docs/UPCOMING.md)** | Upcoming features 🚀 |
|
||||||
@@ -111,22 +125,16 @@ cd android
|
|||||||
|
|
||||||
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
MIT License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
<br /><br />
|
||||||
**v1.6.1** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
**v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 16 // 🔧 v1.6.2: Hotfix offline mode migration bug
|
versionCode = 17 // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
||||||
versionName = "1.6.2" // 🔧 v1.6.2: Hotfix offline mode migration bug
|
versionName = "1.7.0" // 🎨 v1.7.0: Grid Layout + Backup Encryption
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -99,6 +99,11 @@ android {
|
|||||||
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
compose = true // v1.5.0: Jetpack Compose für Settings Redesign
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
|
||||||
|
testOptions {
|
||||||
|
unitTests.isReturnDefaultValues = true
|
||||||
|
}
|
||||||
|
|
||||||
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
|
||||||
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
|
||||||
// composeCompiler { }
|
// composeCompiler { }
|
||||||
@@ -140,6 +145,9 @@ dependencies {
|
|||||||
// SwipeRefreshLayout für Pull-to-Refresh
|
// SwipeRefreshLayout für Pull-to-Refresh
|
||||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
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
|
// v1.5.0: Jetpack Compose für Settings Redesign
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -31,20 +31,24 @@ class BackupManager(private val context: Context) {
|
|||||||
private const val BACKUP_VERSION = 1
|
private const val BACKUP_VERSION = 1
|
||||||
private const val AUTO_BACKUP_DIR = "auto_backups"
|
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||||
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
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 storage = NotesStorage(context)
|
||||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
private val encryptionManager = EncryptionManager() // 🔐 v1.7.0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt Backup aller Notizen
|
* Erstellt Backup aller Notizen
|
||||||
*
|
*
|
||||||
* @param uri Output-URI (via Storage Access Framework)
|
* @param uri Output-URI (via Storage Access Framework)
|
||||||
|
* @param password Optional password for encryption (null = unencrypted)
|
||||||
* @return BackupResult mit Erfolg/Fehler Info
|
* @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 {
|
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()
|
val allNotes = storage.loadAllNotes()
|
||||||
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||||
@@ -59,15 +63,22 @@ class BackupManager(private val context: Context) {
|
|||||||
|
|
||||||
val jsonString = gson.toJson(backupData)
|
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 ->
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
outputStream.write(jsonString.toByteArray())
|
outputStream.write(dataToWrite)
|
||||||
Logger.d(TAG, "✅ Backup created successfully")
|
Logger.d(TAG, "✅ Backup created successfully$encryptedSuffix")
|
||||||
}
|
}
|
||||||
|
|
||||||
BackupResult(
|
BackupResult(
|
||||||
success = true,
|
success = true,
|
||||||
notesCount = allNotes.size,
|
notesCount = allNotes.size,
|
||||||
message = "Backup erstellt: ${allNotes.size} Notizen"
|
message = "Backup erstellt: ${allNotes.size} Notizen$encryptedSuffix"
|
||||||
)
|
)
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -126,20 +137,42 @@ class BackupManager(private val context: Context) {
|
|||||||
*
|
*
|
||||||
* @param uri Backup-Datei URI
|
* @param uri Backup-Datei URI
|
||||||
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||||
|
* @param password Optional password if backup is encrypted
|
||||||
* @return RestoreResult mit Details
|
* @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 {
|
return@withContext try {
|
||||||
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||||
|
|
||||||
// 1. Backup-Datei lesen
|
// 1. Backup-Datei lesen
|
||||||
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
val fileData = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
inputStream.bufferedReader().use { it.readText() }
|
inputStream.readBytes()
|
||||||
} ?: return@withContext RestoreResult(
|
} ?: return@withContext RestoreResult(
|
||||||
success = false,
|
success = false,
|
||||||
error = "Datei konnte nicht gelesen werden"
|
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
|
// 2. Backup validieren & parsen
|
||||||
val validationResult = validateBackup(jsonString)
|
val validationResult = validateBackup(jsonString)
|
||||||
if (!validationResult.isValid) {
|
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
|
* 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
|
// Extension für JSON-Escaping
|
||||||
fun String.escapeJson(): String {
|
fun String.escapeJson(): String {
|
||||||
return this
|
return this
|
||||||
|
|||||||
@@ -124,6 +124,26 @@ class NotesStorage(private val context: Context) {
|
|||||||
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
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
|
fun getNotesDir(): File = notesDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,15 +68,20 @@ class NetworkMonitor(private val context: Context) {
|
|||||||
|
|
||||||
lastConnectedNetworkId = currentNetworkId
|
lastConnectedNetworkId = currentNetworkId
|
||||||
|
|
||||||
// Auto-Sync check
|
// WiFi-Connect Trigger prüfen - NICHT KEY_AUTO_SYNC!
|
||||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
// Der Callback ist registriert WEIL KEY_SYNC_TRIGGER_WIFI_CONNECT = true
|
||||||
Logger.d(TAG, " Auto-Sync enabled: $autoSyncEnabled")
|
// 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) {
|
if (wifiConnectEnabled) {
|
||||||
Logger.d(TAG, " ✅ Triggering WorkManager...")
|
Logger.d(TAG, " ✅ Triggering WiFi-Connect sync...")
|
||||||
triggerWifiConnectSync()
|
triggerWifiConnectSync()
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, " ❌ Auto-sync disabled - not triggering")
|
Logger.d(TAG, " ⏭️ WiFi-Connect trigger disabled in settings")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
|
Logger.d(TAG, " ⚠️ Same WiFi network as before - ignoring (no network change)")
|
||||||
@@ -140,23 +145,56 @@ class NetworkMonitor(private val context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Startet WorkManager mit Network Constraints + NetworkCallback
|
* 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() {
|
fun startMonitoring() {
|
||||||
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called")
|
||||||
|
|
||||||
if (!autoSyncEnabled) {
|
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||||
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring")
|
val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
|
||||||
stopMonitoring()
|
val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
|
||||||
return
|
|
||||||
|
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) {
|
||||||
// 1. WorkManager für periodic sync
|
Logger.d(TAG, "📶 Starting WiFi monitoring...")
|
||||||
startPeriodicSync()
|
|
||||||
|
|
||||||
// 2. NetworkCallback für WiFi-Connect Detection
|
|
||||||
startWifiMonitoring()
|
startWifiMonitoring()
|
||||||
|
} else {
|
||||||
|
stopWifiMonitoring()
|
||||||
|
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
|||||||
import androidx.work.CoroutineWorker
|
import androidx.work.CoroutineWorker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -88,6 +89,27 @@ class SyncWorker(
|
|||||||
return@withContext Result.success()
|
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) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "WebDavSyncService"
|
private const val TAG = "WebDavSyncService"
|
||||||
private const val SOCKET_TIMEOUT_MS = 2000
|
private const val SOCKET_TIMEOUT_MS = 1000 // 🆕 v1.7.0: Reduziert von 2s auf 1s
|
||||||
private const val MAX_FILENAME_LENGTH = 200
|
private const val MAX_FILENAME_LENGTH = 200
|
||||||
private const val ETAG_PREVIEW_LENGTH = 8
|
private const val ETAG_PREVIEW_LENGTH = 8
|
||||||
private const val CONTENT_PREVIEW_LENGTH = 50
|
private const val CONTENT_PREVIEW_LENGTH = 50
|
||||||
@@ -130,7 +130,14 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur wenn WiFi aktiv
|
// 🔒 v1.7.0: VPN-Detection - Skip WiFi binding when VPN is active
|
||||||
|
// When VPN is active, traffic should route through VPN, not directly via WiFi
|
||||||
|
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) {
|
||||||
|
Logger.d(TAG, "🔒 VPN detected - using default routing (traffic will go through VPN)")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur wenn WiFi aktiv (und kein VPN)
|
||||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||||
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
|
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
|
||||||
return null
|
return null
|
||||||
@@ -556,6 +563,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) {
|
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
|
||||||
return@withContext try {
|
return@withContext try {
|
||||||
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
|
val sardine = getOrCreateSardine() ?: return@withContext SyncResult(
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ fun NoteEditorScreen(
|
|||||||
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
var focusNewItemId by remember { mutableStateOf<String?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
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
|
// v1.5.0: Auto-keyboard support
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
val titleFocusRequester = remember { FocusRequester() }
|
val titleFocusRequester = remember { FocusRequester() }
|
||||||
@@ -111,9 +116,9 @@ fun NoteEditorScreen(
|
|||||||
when (event) {
|
when (event) {
|
||||||
is NoteEditorEvent.ShowToast -> {
|
is NoteEditorEvent.ShowToast -> {
|
||||||
val message = when (event.message) {
|
val message = when (event.message) {
|
||||||
ToastMessage.NOTE_IS_EMPTY -> context.getString(R.string.note_is_empty)
|
ToastMessage.NOTE_IS_EMPTY -> msgNoteIsEmpty
|
||||||
ToastMessage.NOTE_SAVED -> context.getString(R.string.note_saved)
|
ToastMessage.NOTE_SAVED -> msgNoteSaved
|
||||||
ToastMessage.NOTE_DELETED -> context.getString(R.string.note_deleted)
|
ToastMessage.NOTE_DELETED -> msgNoteDeleted
|
||||||
}
|
}
|
||||||
context.showToast(message)
|
context.showToast(message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -355,6 +355,7 @@ class NoteEditorViewModel(
|
|||||||
/**
|
/**
|
||||||
* Triggers sync after saving a note (if enabled and server configured)
|
* Triggers sync after saving a note (if enabled and server configured)
|
||||||
* v1.6.0: New configurable sync trigger
|
* 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
|
* Separate throttling (5 seconds) to prevent spam when saving multiple times
|
||||||
*/
|
*/
|
||||||
@@ -365,14 +366,19 @@ class NoteEditorViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 2: Server configured?
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val syncService = WebDavSyncService(getApplication())
|
||||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
val gateResult = syncService.canSync()
|
||||||
Logger.d(TAG, "⏭️ Offline mode - skipping onSave sync")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 3: Throttling (5 seconds) to prevent spam
|
// Check 2: Throttling (5 seconds) to prevent spam
|
||||||
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
|
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val timeSinceLastSync = now - lastOnSaveSyncTime
|
val timeSinceLastSync = now - lastOnSaveSyncTime
|
||||||
|
|||||||
@@ -183,6 +183,9 @@ class ComposeMainActivity : ComponentActivity() {
|
|||||||
// This ensures UI reflects current offline mode when returning from Settings
|
// This ensures UI reflects current offline mode when returning from Settings
|
||||||
viewModel.refreshOfflineModeState()
|
viewModel.refreshOfflineModeState()
|
||||||
|
|
||||||
|
// 🎨 v1.7.0: Refresh display mode when returning from Settings
|
||||||
|
viewModel.refreshDisplayMode()
|
||||||
|
|
||||||
// Register BroadcastReceiver for Background-Sync
|
// Register BroadcastReceiver for Background-Sync
|
||||||
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
|
||||||
LocalBroadcastManager.getInstance(this).registerReceiver(
|
LocalBroadcastManager.getInstance(this).registerReceiver(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.EmptyState
|
||||||
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
import dev.dettmer.simplenotes.ui.main.components.NoteTypeFAB
|
||||||
import dev.dettmer.simplenotes.ui.main.components.NotesList
|
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 dev.dettmer.simplenotes.ui.main.components.SyncStatusBanner
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -82,12 +84,17 @@ fun MainScreen(
|
|||||||
// 🌟 v1.6.0: Reactive offline mode state
|
// 🌟 v1.6.0: Reactive offline mode state
|
||||||
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
|
||||||
|
|
||||||
|
// 🎨 v1.7.0: Display mode (list or grid)
|
||||||
|
val displayMode by viewModel.displayMode.collectAsState()
|
||||||
|
|
||||||
// Delete confirmation dialog state
|
// Delete confirmation dialog state
|
||||||
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
var showBatchDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
// 🎨 v1.7.0: gridState für Staggered Grid Layout
|
||||||
|
val gridState = rememberLazyStaggeredGridState()
|
||||||
|
|
||||||
// Compute isSyncing once
|
// Compute isSyncing once
|
||||||
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
val isSyncing = syncState == SyncStateManager.SyncState.SYNCING
|
||||||
@@ -116,9 +123,14 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3: Scroll to top when new note created
|
// Phase 3: Scroll to top when new note created
|
||||||
|
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
|
||||||
LaunchedEffect(scrollToTop) {
|
LaunchedEffect(scrollToTop) {
|
||||||
if (scrollToTop) {
|
if (scrollToTop) {
|
||||||
|
if (displayMode == "grid") {
|
||||||
|
gridState.animateScrollToItem(0)
|
||||||
|
} else {
|
||||||
listState.animateScrollToItem(0)
|
listState.animateScrollToItem(0)
|
||||||
|
}
|
||||||
viewModel.resetScrollToTop()
|
viewModel.resetScrollToTop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +188,27 @@ fun MainScreen(
|
|||||||
// Content: Empty state or notes list
|
// Content: Empty state or notes list
|
||||||
if (notes.isEmpty()) {
|
if (notes.isEmpty()) {
|
||||||
EmptyState(modifier = Modifier.weight(1f))
|
EmptyState(modifier = Modifier.weight(1f))
|
||||||
|
} else {
|
||||||
|
// 🎨 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 {
|
} else {
|
||||||
NotesList(
|
NotesList(
|
||||||
notes = notes,
|
notes = notes,
|
||||||
@@ -195,6 +228,7 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import dev.dettmer.simplenotes.sync.SyncStateManager
|
|||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.SyncConstants
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -83,6 +82,25 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue → $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)
|
// Sync State (derived from SyncStateManager)
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -482,22 +500,39 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger manual sync (from toolbar button or pull-to-refresh)
|
* 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") {
|
fun triggerManualSync(source: String = "manual") {
|
||||||
// 🌟 v1.6.0: Block sync in offline mode
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||||
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
|
val syncService = WebDavSyncService(getApplication())
|
||||||
Logger.d(TAG, "⏭️ $source Sync blocked: Offline mode enabled")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
|
||||||
if (!SyncStateManager.tryStartSync(source)) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val syncService = WebDavSyncService(getApplication())
|
|
||||||
|
|
||||||
// Check for unsynced changes
|
// Check for unsynced changes
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
Logger.d(TAG, "⏭️ $source Sync: No unsynced changes")
|
||||||
@@ -544,6 +579,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
* Only runs if server is configured and interval has passed
|
* 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.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.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") {
|
fun triggerAutoSync(source: String = "auto") {
|
||||||
// 🌟 v1.6.0: Check if onResume trigger is enabled
|
// 🌟 v1.6.0: Check if onResume trigger is enabled
|
||||||
@@ -557,10 +593,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if server is configured
|
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val syncService = WebDavSyncService(getApplication())
|
||||||
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
|
val gateResult = syncService.canSync()
|
||||||
Logger.d(TAG, "⏭️ Offline mode - skipping onResume sync")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,8 +618,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
val syncService = WebDavSyncService(getApplication())
|
|
||||||
|
|
||||||
// Check for unsynced changes
|
// Check for unsynced changes
|
||||||
if (!syncService.hasUnsyncedChanges()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ fun NoteCard(
|
|||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
// 🎨 v1.7.0: Externes Padding entfernt - Grid/Liste steuert Abstände
|
||||||
.then(
|
.then(
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
Modifier.border(
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -49,6 +50,8 @@ fun NotesList(
|
|||||||
showSyncStatus = showSyncStatus,
|
showSyncStatus = showSyncStatus,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isSelectionMode = isSelectionMode,
|
isSelectionMode = isSelectionMode,
|
||||||
|
// 🎨 v1.7.0: Padding hier in Liste (nicht in Card selbst)
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSelectionMode) {
|
if (isSelectionMode) {
|
||||||
// In selection mode, tap toggles selection
|
// 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.AboutScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.BackupSettingsScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.DebugSettingsScreen
|
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.LanguageSettingsScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.MarkdownSettingsScreen
|
||||||
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
|
import dev.dettmer.simplenotes.ui.settings.screens.ServerSettingsScreen
|
||||||
@@ -95,5 +96,13 @@ fun SettingsNavHost(
|
|||||||
onBack = { navController.popBackStack() }
|
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 Backup : SettingsRoute("settings_backup")
|
||||||
data object About : SettingsRoute("settings_about")
|
data object About : SettingsRoute("settings_about")
|
||||||
data object Debug : SettingsRoute("settings_debug")
|
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.R
|
||||||
import dev.dettmer.simplenotes.backup.BackupManager
|
import dev.dettmer.simplenotes.backup.BackupManager
|
||||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
@@ -33,6 +34,7 @@ import java.net.URL
|
|||||||
*
|
*
|
||||||
* Manages all settings state and actions across the Settings navigation graph.
|
* 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) {
|
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -42,6 +44,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
|
|
||||||
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val backupManager = BackupManager(application)
|
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
|
// Server Settings State
|
||||||
@@ -154,6 +161,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
)
|
)
|
||||||
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
|
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
|
// Markdown Settings State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -173,6 +186,15 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
)
|
)
|
||||||
val fileLoggingEnabled: StateFlow<Boolean> = _fileLoggingEnabled.asStateFlow()
|
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
|
// UI State
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -216,39 +238,128 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
/**
|
/**
|
||||||
* 🌟 v1.6.0: Update only the host part of the server URL
|
* 🌟 v1.6.0: Update only the host part of the server URL
|
||||||
* The protocol prefix is handled separately by updateProtocol()
|
* 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) {
|
fun updateServerHost(host: String) {
|
||||||
_serverHost.value = host
|
_serverHost.value = host
|
||||||
saveServerSettings()
|
|
||||||
|
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||||
|
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||||
|
val fullUrl = if (host.isEmpty()) "" else prefix + host
|
||||||
|
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateProtocol(useHttps: Boolean) {
|
fun updateProtocol(useHttps: Boolean) {
|
||||||
_isHttps.value = useHttps
|
_isHttps.value = useHttps
|
||||||
// 🌟 v1.6.0: Host stays the same, only prefix changes
|
// 🌟 v1.6.0: Host stays the same, only prefix changes
|
||||||
saveServerSettings()
|
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
|
||||||
|
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||||
|
|
||||||
|
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
|
||||||
|
val prefix = if (useHttps) "https://" else "http://"
|
||||||
|
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||||
|
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateUsername(value: String) {
|
fun updateUsername(value: String) {
|
||||||
_username.value = value
|
_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) {
|
fun updatePassword(value: String) {
|
||||||
_password.value = value
|
_password.value = value
|
||||||
saveServerSettings()
|
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
|
||||||
|
prefs.edit().putString(Constants.KEY_PASSWORD, value).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveServerSettings() {
|
/**
|
||||||
|
* 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
|
||||||
|
* This prevents false "server changed" detection during text input
|
||||||
|
* 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions.
|
||||||
|
* This function now ONLY handles server-change detection and sync reset.
|
||||||
|
*/
|
||||||
|
fun saveServerSettingsManually() {
|
||||||
// 🌟 v1.6.0: Construct full URL from prefix + host
|
// 🌟 v1.6.0: Construct full URL from prefix + host
|
||||||
val prefix = if (_isHttps.value) "https://" else "http://"
|
val prefix = if (_isHttps.value) "https://" else "http://"
|
||||||
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
|
||||||
|
|
||||||
prefs.edit().apply {
|
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
|
||||||
putString(Constants.KEY_SERVER_URL, fullUrl)
|
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
|
||||||
putString(Constants.KEY_USERNAME, _username.value)
|
|
||||||
putString(Constants.KEY_PASSWORD, _password.value)
|
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
|
||||||
apply()
|
// 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() {
|
fun testConnection() {
|
||||||
@@ -318,9 +429,21 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isSyncing.value = true
|
_isSyncing.value = true
|
||||||
try {
|
try {
|
||||||
emitToast(getString(R.string.toast_syncing))
|
|
||||||
val syncService = WebDavSyncService(getApplication())
|
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()) {
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
emitToast(getString(R.string.toast_already_synced))
|
emitToast(getString(R.string.toast_already_synced))
|
||||||
return@launch
|
return@launch
|
||||||
@@ -412,6 +535,16 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
Logger.d(TAG, "Trigger Boot: $enabled")
|
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
|
// Markdown Settings Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -519,11 +652,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
// Backup Actions
|
// Backup Actions
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
fun createBackup(uri: Uri) {
|
fun createBackup(uri: Uri, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
try {
|
try {
|
||||||
val result = backupManager.createBackup(uri)
|
val result = backupManager.createBackup(uri, password)
|
||||||
val message = if (result.success) {
|
val message = if (result.success) {
|
||||||
getString(R.string.toast_backup_success, result.message ?: "")
|
getString(R.string.toast_backup_success, result.message ?: "")
|
||||||
} else {
|
} else {
|
||||||
@@ -538,11 +671,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun restoreFromFile(uri: Uri, mode: RestoreMode) {
|
fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
try {
|
try {
|
||||||
val result = backupManager.restoreBackup(uri, mode)
|
val result = backupManager.restoreBackup(uri, mode, password)
|
||||||
val message = if (result.success) {
|
val message = if (result.success) {
|
||||||
getString(R.string.toast_restore_success, result.importedNotes)
|
getString(R.string.toast_restore_success, result.importedNotes)
|
||||||
} else {
|
} else {
|
||||||
@@ -557,6 +690,29 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
|
||||||
|
*/
|
||||||
|
fun checkBackupEncryption(
|
||||||
|
uri: Uri,
|
||||||
|
onEncrypted: () -> Unit,
|
||||||
|
onPlaintext: () -> Unit
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val isEncrypted = backupManager.isBackupEncrypted(uri)
|
||||||
|
if (isEncrypted) {
|
||||||
|
onEncrypted()
|
||||||
|
} else {
|
||||||
|
onPlaintext()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to check encryption status", e)
|
||||||
|
onPlaintext() // Assume plaintext on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun restoreFromServer(mode: RestoreMode) {
|
fun restoreFromServer(mode: RestoreMode) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_isBackupInProgress.value = true
|
_isBackupInProgress.value = true
|
||||||
@@ -664,4 +820,17 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
|
|||||||
val total: Int,
|
val total: Int,
|
||||||
val isComplete: Boolean = false
|
val isComplete: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 🎨 v1.7.0: Display Mode Functions
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set display mode (list or grid)
|
||||||
|
*/
|
||||||
|
fun setDisplayMode(mode: String) {
|
||||||
|
_displayMode.value = mode
|
||||||
|
prefs.edit().putString(Constants.KEY_DISPLAY_MODE, mode).apply()
|
||||||
|
Logger.d(TAG, "Display mode changed to: $mode")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
|
||||||
|
private const val MIN_PASSWORD_LENGTH = 8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 v1.7.0: Password input dialog for backup encryption/decryption
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BackupPasswordDialog(
|
||||||
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (password: String) -> Unit,
|
||||||
|
requireConfirmation: Boolean = true
|
||||||
|
) {
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var confirmPassword by remember { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
var confirmPasswordVisible by remember { mutableStateOf(false) }
|
||||||
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
// Password field
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = {
|
||||||
|
password = it
|
||||||
|
errorMessage = null
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.backup_encryption_password)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.backup_encryption_password_hint)) },
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = if (requireConfirmation) ImeAction.Next else ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = if (!requireConfirmation) {
|
||||||
|
{ validateAndConfirm(password, null, onConfirm) { errorMessage = it } }
|
||||||
|
} else null
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
isError = errorMessage != null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Confirm password field (only for encryption, not decryption)
|
||||||
|
if (requireConfirmation) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = confirmPassword,
|
||||||
|
onValueChange = {
|
||||||
|
confirmPassword = it
|
||||||
|
errorMessage = null
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.backup_encryption_confirm)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.backup_encryption_confirm_hint)) },
|
||||||
|
visualTransformation = if (confirmPasswordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { confirmPasswordVisible = !confirmPasswordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (confirmPasswordVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = { validateAndConfirm(password, confirmPassword, onConfirm) { errorMessage = it } }
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
isError = errorMessage != null,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if (errorMessage != null) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = errorMessage!!,
|
||||||
|
color = androidx.compose.material3.MaterialTheme.colorScheme.error,
|
||||||
|
style = androidx.compose.material3.MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
validateAndConfirm(
|
||||||
|
password,
|
||||||
|
if (requireConfirmation) confirmPassword else null,
|
||||||
|
onConfirm
|
||||||
|
) { errorMessage = it }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("OK")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate password and call onConfirm if valid
|
||||||
|
*/
|
||||||
|
private fun validateAndConfirm(
|
||||||
|
password: String,
|
||||||
|
confirmPassword: String?,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
password.length < MIN_PASSWORD_LENGTH -> {
|
||||||
|
onError("Password too short (min. $MIN_PASSWORD_LENGTH characters)")
|
||||||
|
}
|
||||||
|
confirmPassword != null && password != confirmPassword -> {
|
||||||
|
onError("Passwords don't match")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
onConfirm(password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.backup.RestoreMode
|
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||||
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
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.RadioOption
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsButton
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsDivider
|
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.SettingsRadioGroup
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||||
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -58,11 +60,25 @@ fun BackupSettingsScreen(
|
|||||||
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
var pendingRestoreUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
var selectedRestoreMode by remember { mutableStateOf(RestoreMode.MERGE) }
|
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
|
// File picker launchers
|
||||||
val createBackupLauncher = rememberLauncherForActivityResult(
|
val createBackupLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.CreateDocument("application/json")
|
contract = ActivityResultContracts.CreateDocument("application/json")
|
||||||
) { uri ->
|
) { 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(
|
val restoreFileLauncher = rememberLauncherForActivityResult(
|
||||||
@@ -99,6 +115,16 @@ fun BackupSettingsScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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(
|
SettingsButton(
|
||||||
text = stringResource(R.string.backup_create),
|
text = stringResource(R.string.backup_create),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -156,6 +182,47 @@ fun BackupSettingsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Encryption password dialog (for backup creation)
|
||||||
|
if (showEncryptionPasswordDialog) {
|
||||||
|
BackupPasswordDialog(
|
||||||
|
title = stringResource(R.string.backup_encryption_title),
|
||||||
|
onDismiss = {
|
||||||
|
showEncryptionPasswordDialog = false
|
||||||
|
pendingBackupUri = null
|
||||||
|
},
|
||||||
|
onConfirm = { password ->
|
||||||
|
showEncryptionPasswordDialog = false
|
||||||
|
pendingBackupUri?.let { uri ->
|
||||||
|
viewModel.createBackup(uri, password)
|
||||||
|
}
|
||||||
|
pendingBackupUri = null
|
||||||
|
},
|
||||||
|
requireConfirmation = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔐 v1.7.0: Decryption password dialog (for restore)
|
||||||
|
if (showDecryptionPasswordDialog) {
|
||||||
|
BackupPasswordDialog(
|
||||||
|
title = stringResource(R.string.backup_decryption_required),
|
||||||
|
onDismiss = {
|
||||||
|
showDecryptionPasswordDialog = false
|
||||||
|
pendingRestoreUri = null
|
||||||
|
},
|
||||||
|
onConfirm = { password ->
|
||||||
|
showDecryptionPasswordDialog = false
|
||||||
|
pendingRestoreUri?.let { uri ->
|
||||||
|
when (restoreSource) {
|
||||||
|
RestoreSource.LocalFile -> viewModel.restoreFromFile(uri, selectedRestoreMode, password)
|
||||||
|
RestoreSource.Server -> { /* Server restore doesn't support encryption */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingRestoreUri = null
|
||||||
|
},
|
||||||
|
requireConfirmation = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Restore Mode Dialog
|
// Restore Mode Dialog
|
||||||
if (showRestoreDialog) {
|
if (showRestoreDialog) {
|
||||||
RestoreModeDialog(
|
RestoreModeDialog(
|
||||||
@@ -167,7 +234,17 @@ fun BackupSettingsScreen(
|
|||||||
when (restoreSource) {
|
when (restoreSource) {
|
||||||
RestoreSource.LocalFile -> {
|
RestoreSource.LocalFile -> {
|
||||||
pendingRestoreUri?.let { uri ->
|
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 -> {
|
RestoreSource.Server -> {
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ fun DebugSettingsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Export Logs Button
|
// Export Logs Button
|
||||||
|
val logsSubject = stringResource(R.string.debug_logs_subject)
|
||||||
|
val logsShareVia = stringResource(R.string.debug_logs_share_via)
|
||||||
|
|
||||||
SettingsButton(
|
SettingsButton(
|
||||||
text = stringResource(R.string.debug_export_logs),
|
text = stringResource(R.string.debug_export_logs),
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -96,11 +99,11 @@ fun DebugSettingsScreen(
|
|||||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
type = "text/plain"
|
type = "text/plain"
|
||||||
putExtra(Intent.EXTRA_STREAM, logUri)
|
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)
|
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)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package dev.dettmer.simplenotes.ui.settings.screens
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.SettingsViewModel
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.RadioOption
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsInfoCard
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsRadioGroup
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
||||||
|
import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎨 v1.7.0: Display Settings Screen
|
||||||
|
*
|
||||||
|
* Allows switching between List and Grid view modes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DisplaySettingsScreen(
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
val displayMode by viewModel.displayMode.collectAsState()
|
||||||
|
|
||||||
|
SettingsScaffold(
|
||||||
|
title = stringResource(R.string.display_settings_title),
|
||||||
|
onBack = onBack
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.display_mode_title))
|
||||||
|
|
||||||
|
SettingsRadioGroup(
|
||||||
|
options = listOf(
|
||||||
|
RadioOption(
|
||||||
|
value = "list",
|
||||||
|
title = stringResource(R.string.display_mode_list),
|
||||||
|
subtitle = null
|
||||||
|
),
|
||||||
|
RadioOption(
|
||||||
|
value = "grid",
|
||||||
|
title = stringResource(R.string.display_mode_grid),
|
||||||
|
subtitle = null
|
||||||
|
)
|
||||||
|
),
|
||||||
|
selectedValue = displayMode,
|
||||||
|
onValueSelected = { viewModel.setDisplayMode(it) }
|
||||||
|
)
|
||||||
|
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.display_mode_info)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -56,6 +57,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
|
|||||||
* Server configuration settings screen
|
* Server configuration settings screen
|
||||||
* v1.5.0: Jetpack Compose Settings Redesign
|
* v1.5.0: Jetpack Compose Settings Redesign
|
||||||
* v1.6.0: Offline Mode Toggle
|
* 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
|
@Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values
|
||||||
@Composable
|
@Composable
|
||||||
@@ -74,6 +76,14 @@ fun ServerSettingsScreen(
|
|||||||
|
|
||||||
var passwordVisible by remember { mutableStateOf(false) }
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen
|
||||||
|
// This prevents false "server changed" detection during text input
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
viewModel.saveServerSettingsManually()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check server status on load (only if not in offline mode)
|
// Check server status on load (only if not in offline mode)
|
||||||
LaunchedEffect(offlineMode) {
|
LaunchedEffect(offlineMode) {
|
||||||
if (!offlineMode) {
|
if (!offlineMode) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Backup
|
|||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Description
|
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.Info
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.filled.Sync
|
||||||
@@ -90,6 +91,22 @@ fun SettingsMainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎨 v1.7.0: Display Settings
|
||||||
|
item {
|
||||||
|
val displayMode by viewModel.displayMode.collectAsState()
|
||||||
|
val displaySubtitle = when (displayMode) {
|
||||||
|
"grid" -> stringResource(R.string.display_mode_grid)
|
||||||
|
else -> stringResource(R.string.display_mode_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard(
|
||||||
|
icon = Icons.Default.GridView,
|
||||||
|
title = stringResource(R.string.display_settings_title),
|
||||||
|
subtitle = displaySubtitle,
|
||||||
|
onClick = { onNavigate(SettingsRoute.Display) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Server-Einstellungen
|
// Server-Einstellungen
|
||||||
item {
|
item {
|
||||||
// 🌟 v1.6.0: Check if server is configured (host is not empty)
|
// 🌟 v1.6.0: Check if server is configured (host is not empty)
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ fun SyncSettingsScreen(
|
|||||||
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
val triggerBoot by viewModel.triggerBoot.collectAsState()
|
||||||
val syncInterval by viewModel.syncInterval.collectAsState()
|
val syncInterval by viewModel.syncInterval.collectAsState()
|
||||||
|
|
||||||
|
// 🆕 v1.7.0: WiFi-only sync
|
||||||
|
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
|
||||||
|
|
||||||
// Check if server is configured
|
// Check if server is configured
|
||||||
val isServerConfigured = viewModel.isServerConfigured()
|
val isServerConfigured = viewModel.isServerConfigured()
|
||||||
|
|
||||||
@@ -82,6 +85,31 @@ fun SyncSettingsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// 🆕 v1.7.0: NETZWERK-EINSCHRÄNKUNG Section (Global für alle Trigger)
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SettingsSectionHeader(text = stringResource(R.string.sync_section_network))
|
||||||
|
|
||||||
|
// WiFi-Only Sync Toggle - Gilt für ALLE Trigger außer WiFi-Connect
|
||||||
|
SettingsSwitch(
|
||||||
|
title = stringResource(R.string.sync_wifi_only_title),
|
||||||
|
subtitle = stringResource(R.string.sync_wifi_only_subtitle),
|
||||||
|
checked = wifiOnlySync,
|
||||||
|
onCheckedChange = { viewModel.setWifiOnlySync(it) },
|
||||||
|
icon = Icons.Default.Wifi,
|
||||||
|
enabled = isServerConfigured
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info-Hinweis dass WiFi-Connect davon ausgenommen ist
|
||||||
|
if (wifiOnlySync && isServerConfigured) {
|
||||||
|
SettingsInfoCard(
|
||||||
|
text = stringResource(R.string.sync_wifi_only_hint)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDivider()
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// SOFORT-SYNC Section
|
// SOFORT-SYNC Section
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ object Constants {
|
|||||||
// 🔥 v1.6.0: Offline Mode Toggle
|
// 🔥 v1.6.0: Offline Mode Toggle
|
||||||
const val KEY_OFFLINE_MODE = "offline_mode_enabled"
|
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
|
// 🔥 v1.6.0: Configurable Sync Triggers
|
||||||
const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save"
|
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_ON_RESUME = "sync_trigger_on_resume"
|
||||||
@@ -57,4 +61,10 @@ object Constants {
|
|||||||
// Notifications
|
// Notifications
|
||||||
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
|
const val NOTIFICATION_CHANNEL_ID = "notes_sync_channel"
|
||||||
const val NOTIFICATION_ID = 1001
|
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 android.os.Looper
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import dev.dettmer.simplenotes.MainActivity
|
import dev.dettmer.simplenotes.ui.main.ComposeMainActivity
|
||||||
|
|
||||||
object NotificationHelper {
|
object NotificationHelper {
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ object NotificationHelper {
|
|||||||
* Zeigt Erfolgs-Notification nach Sync
|
* Zeigt Erfolgs-Notification nach Sync
|
||||||
*/
|
*/
|
||||||
fun showSyncSuccessNotification(context: Context, syncedCount: Int) {
|
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
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ object NotificationHelper {
|
|||||||
* Zeigt Notification bei erkanntem Konflikt
|
* Zeigt Notification bei erkanntem Konflikt
|
||||||
*/
|
*/
|
||||||
fun showConflictNotification(context: Context, conflictCount: Int) {
|
fun showConflictNotification(context: Context, conflictCount: Int) {
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
val intent = Intent(context, ComposeMainActivity::class.java)
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
context, 0, intent,
|
context, 0, intent,
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
@@ -229,7 +229,7 @@ object NotificationHelper {
|
|||||||
*/
|
*/
|
||||||
fun showSyncSuccess(context: Context, count: Int) {
|
fun showSyncSuccess(context: Context, count: Int) {
|
||||||
// PendingIntent für App-Öffnung
|
// 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
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
@@ -260,7 +260,7 @@ object NotificationHelper {
|
|||||||
*/
|
*/
|
||||||
fun showSyncError(context: Context, message: String) {
|
fun showSyncError(context: Context, message: String) {
|
||||||
// PendingIntent für App-Öffnung
|
// 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
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
@@ -297,7 +297,7 @@ object NotificationHelper {
|
|||||||
*/
|
*/
|
||||||
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
|
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
|
||||||
// PendingIntent für App-Öffnung
|
// 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
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
}
|
}
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
<!-- EMPTY STATE -->
|
<!-- EMPTY STATE -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
|
<string name="empty_state_emoji">📝</string>
|
||||||
<string name="empty_state_title">Noch keine Notizen</string>
|
<string name="empty_state_title">Noch keine Notizen</string>
|
||||||
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
|
<string name="empty_state_message">Tippe + um eine neue Notiz zu erstellen</string>
|
||||||
|
|
||||||
@@ -196,19 +197,22 @@
|
|||||||
<string name="sync_interval_section">Sync-Intervall</string>
|
<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_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_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_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_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 -->
|
<!-- 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>
|
<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 -->
|
<!-- 🌟 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_instant">📲 Sofort-Sync</string>
|
||||||
<string name="sync_section_background">📡 Hintergrund-Sync</string>
|
<string name="sync_section_background">📡 Hintergrund-Sync</string>
|
||||||
<string name="sync_section_advanced">⚙️ Erweitert</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_title">Nach dem Speichern</string>
|
||||||
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
|
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
|
||||||
|
|
||||||
@@ -224,6 +228,11 @@
|
|||||||
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
|
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
|
||||||
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</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">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_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
|
||||||
|
|
||||||
@@ -253,6 +262,20 @@
|
|||||||
<string name="backup_local_section">Lokales Backup</string>
|
<string name="backup_local_section">Lokales Backup</string>
|
||||||
<string name="backup_create">💾 Backup erstellen</string>
|
<string name="backup_create">💾 Backup erstellen</string>
|
||||||
<string name="backup_restore_file">📂 Aus Datei wiederherstellen</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_server_section">Server-Backup</string>
|
||||||
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
|
<string name="backup_restore_server">☁️ Vom Server wiederherstellen</string>
|
||||||
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
|
<string name="backup_restore_dialog_title">⚠️ Backup wiederherstellen?</string>
|
||||||
@@ -308,6 +331,15 @@
|
|||||||
<string name="language_info">ℹ️ Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string>
|
<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>
|
<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 -->
|
<!-- SETTINGS - ABOUT -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -357,6 +389,8 @@
|
|||||||
<string name="toast_logs_deleted">🗑️ Logs gelöscht</string>
|
<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_no_logs_to_delete">📭 Keine Logs zum Löschen</string>
|
||||||
<string name="toast_logs_delete_error">❌ Fehler beim Löschen: %s</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_link_error">❌ Fehler beim Öffnen des Links</string>
|
||||||
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
|
<string name="toast_file_logging_enabled">📝 Datei-Logging aktiviert</string>
|
||||||
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
|
<string name="toast_file_logging_disabled">📝 Datei-Logging deaktiviert</string>
|
||||||
|
|||||||
@@ -197,19 +197,22 @@
|
|||||||
<string name="sync_interval_section">Sync Interval</string>
|
<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_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_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_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_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 -->
|
<!-- 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>
|
<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 -->
|
<!-- 🌟 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_instant">📲 Instant Sync</string>
|
||||||
<string name="sync_section_background">📡 Background Sync</string>
|
<string name="sync_section_background">📡 Background Sync</string>
|
||||||
<string name="sync_section_advanced">⚙️ Advanced</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_title">After Saving</string>
|
||||||
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
|
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
|
||||||
|
|
||||||
@@ -225,6 +228,11 @@
|
|||||||
<string name="sync_trigger_boot_title">After Device Restart</string>
|
<string name="sync_trigger_boot_title">After Device Restart</string>
|
||||||
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</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">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_manual_hint_disabled">Sync is not available in offline mode.</string>
|
||||||
|
|
||||||
@@ -254,6 +262,20 @@
|
|||||||
<string name="backup_local_section">Local Backup</string>
|
<string name="backup_local_section">Local Backup</string>
|
||||||
<string name="backup_create">💾 Create Backup</string>
|
<string name="backup_create">💾 Create Backup</string>
|
||||||
<string name="backup_restore_file">📂 Restore from File</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_server_section">Server Backup</string>
|
||||||
<string name="backup_restore_server">☁️ Restore from Server</string>
|
<string name="backup_restore_server">☁️ Restore from Server</string>
|
||||||
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
|
<string name="backup_restore_dialog_title">⚠️ Restore Backup?</string>
|
||||||
@@ -309,6 +331,15 @@
|
|||||||
<string name="language_info">ℹ️ Choose your preferred language. The app will restart to apply the change.</string>
|
<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>
|
<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 -->
|
<!-- SETTINGS - ABOUT -->
|
||||||
<!-- ============================= -->
|
<!-- ============================= -->
|
||||||
@@ -358,6 +389,8 @@
|
|||||||
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
|
<string name="toast_logs_deleted">🗑️ Logs deleted</string>
|
||||||
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
|
<string name="toast_no_logs_to_delete">📭 No logs to delete</string>
|
||||||
<string name="toast_logs_delete_error">❌ Error deleting: %s</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_link_error">❌ Error opening link</string>
|
||||||
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
|
<string name="toast_file_logging_enabled">📝 File logging enabled</string>
|
||||||
<string name="toast_file_logging_disabled">📝 File logging disabled</string>
|
<string name="toast_file_logging_disabled">📝 File logging disabled</string>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<base-config cleartextTrafficPermitted="true">
|
<base-config cleartextTrafficPermitted="true">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
|
<!-- 🔐 v1.7.0: Trust user-installed CA certificates for self-signed SSL support -->
|
||||||
|
<certificates src="user" />
|
||||||
</trust-anchors>
|
</trust-anchors>
|
||||||
</base-config>
|
</base-config>
|
||||||
</network-security-config>
|
</network-security-config>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_CHECKLIST_THRESHOLD
|
||||||
|
import dev.dettmer.simplenotes.models.NoteSize.Companion.SMALL_TEXT_THRESHOLD
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🎨 v1.7.0: Tests for Note Size Classification (Staggered Grid Layout)
|
||||||
|
*/
|
||||||
|
class NoteSizeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with less than 80 chars is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test1",
|
||||||
|
title = "Test",
|
||||||
|
content = "Short content", // 13 chars
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with exactly 79 chars is SMALL`() {
|
||||||
|
val content = "x".repeat(79) // Exactly threshold - 1
|
||||||
|
val note = Note(
|
||||||
|
id = "test2",
|
||||||
|
title = "Test",
|
||||||
|
content = content,
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with exactly 80 chars is LARGE`() {
|
||||||
|
val content = "x".repeat(SMALL_TEXT_THRESHOLD) // Exactly at threshold
|
||||||
|
val note = Note(
|
||||||
|
id = "test3",
|
||||||
|
title = "Test",
|
||||||
|
content = content,
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `text note with more than 80 chars is LARGE`() {
|
||||||
|
val content = "This is a long note with more than 80 characters. " +
|
||||||
|
"It should be classified as LARGE for grid layout display."
|
||||||
|
val note = Note(
|
||||||
|
id = "test4",
|
||||||
|
title = "Test",
|
||||||
|
content = content,
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.TEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with 1 item is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test5",
|
||||||
|
title = "Shopping",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = listOf(
|
||||||
|
ChecklistItem("id1", "Milk", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with 4 items is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test6",
|
||||||
|
title = "Shopping",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = listOf(
|
||||||
|
ChecklistItem("id1", "Milk", false),
|
||||||
|
ChecklistItem("id2", "Bread", false),
|
||||||
|
ChecklistItem("id3", "Eggs", false),
|
||||||
|
ChecklistItem("id4", "Butter", false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with 5 items is LARGE`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test7",
|
||||||
|
title = "Shopping",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = listOf(
|
||||||
|
ChecklistItem("id1", "Milk", false),
|
||||||
|
ChecklistItem("id2", "Bread", false),
|
||||||
|
ChecklistItem("id3", "Eggs", false),
|
||||||
|
ChecklistItem("id4", "Butter", false),
|
||||||
|
ChecklistItem("id5", "Cheese", false) // 5th item -> LARGE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with many items is LARGE`() {
|
||||||
|
val items = (1..10).map { ChecklistItem("id$it", "Item $it", false) }
|
||||||
|
val note = Note(
|
||||||
|
id = "test8",
|
||||||
|
title = "Long List",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = items
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.LARGE, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty checklist is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test9",
|
||||||
|
title = "Empty",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checklist with null items is SMALL`() {
|
||||||
|
val note = Note(
|
||||||
|
id = "test10",
|
||||||
|
title = "Null Items",
|
||||||
|
content = "",
|
||||||
|
deviceId = "test-device",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = null
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(NoteSize.SMALL, note.getSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `constants have expected values`() {
|
||||||
|
assertEquals(80, SMALL_TEXT_THRESHOLD)
|
||||||
|
assertEquals(4, SMALL_CHECKLIST_THRESHOLD)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package dev.dettmer.simplenotes.utils
|
||||||
|
|
||||||
|
import dev.dettmer.simplenotes.backup.EncryptionException
|
||||||
|
import dev.dettmer.simplenotes.backup.EncryptionManager
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import kotlin.text.Charsets.UTF_8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 v1.7.0: Tests for Local Backup Encryption
|
||||||
|
*/
|
||||||
|
class EncryptionManagerTest {
|
||||||
|
|
||||||
|
private val encryptionManager = EncryptionManager()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt and decrypt roundtrip preserves data`() {
|
||||||
|
val originalData = "This is a test backup with UTF-8: äöü 🔒".toByteArray(UTF_8)
|
||||||
|
val password = "TestPassword123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(originalData, password)
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||||
|
|
||||||
|
assertArrayEquals(originalData, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypted data has correct header format`() {
|
||||||
|
val data = "Test data".toByteArray(UTF_8)
|
||||||
|
val password = "password123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(data, password)
|
||||||
|
|
||||||
|
// Check magic bytes "SNE1"
|
||||||
|
val magic = encrypted.copyOfRange(0, 4)
|
||||||
|
assertArrayEquals("SNE1".toByteArray(UTF_8), magic)
|
||||||
|
|
||||||
|
// Check version (1 byte = 0x01)
|
||||||
|
assertEquals(1, encrypted[4].toInt())
|
||||||
|
|
||||||
|
// Check minimum size: magic(4) + version(1) + salt(32) + iv(12) + ciphertext + tag(16)
|
||||||
|
assertTrue("Encrypted data too small: ${encrypted.size}", encrypted.size >= 4 + 1 + 32 + 12 + 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns true for encrypted data`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
assertTrue(encryptionManager.isEncrypted(encrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns false for plaintext data`() {
|
||||||
|
val plaintext = "This is not encrypted".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
assertFalse(encryptionManager.isEncrypted(plaintext))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns false for short data`() {
|
||||||
|
val shortData = "SNE".toByteArray(UTF_8) // Less than 4 bytes
|
||||||
|
|
||||||
|
assertFalse(encryptionManager.isEncrypted(shortData))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `isEncrypted returns false for wrong magic bytes`() {
|
||||||
|
val wrongMagic = "FAKE1234567890".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
assertFalse(encryptionManager.isEncrypted(wrongMagic))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt with wrong password throws EncryptionException`() {
|
||||||
|
val data = "Sensitive data".toByteArray(UTF_8)
|
||||||
|
val correctPassword = "correct123"
|
||||||
|
val wrongPassword = "wrong123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(data, correctPassword)
|
||||||
|
|
||||||
|
val exception = assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(encrypted, wrongPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(exception.message?.contains("Decryption failed") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt corrupted data throws EncryptionException`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
// Corrupt the ciphertext (skip header: 4 + 1 + 32 + 12 = 49 bytes)
|
||||||
|
val corrupted = encrypted.copyOf()
|
||||||
|
if (corrupted.size > 50) {
|
||||||
|
corrupted[50] = (corrupted[50] + 1).toByte() // Flip one bit
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(corrupted, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt data with invalid header throws EncryptionException`() {
|
||||||
|
val invalidData = "This is not encrypted at all".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(invalidData, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt truncated data throws EncryptionException`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
// Truncate to only header
|
||||||
|
val truncated = encrypted.copyOfRange(0, 20)
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(truncated, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt with different passwords produces different ciphertexts`() {
|
||||||
|
val data = "Same data".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
val encrypted1 = encryptionManager.encrypt(data, "password1")
|
||||||
|
val encrypted2 = encryptionManager.encrypt(data, "password2")
|
||||||
|
|
||||||
|
// Different passwords should produce different ciphertexts
|
||||||
|
assertFalse(encrypted1.contentEquals(encrypted2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt same data twice produces different ciphertexts (different IV)`() {
|
||||||
|
val data = "Same data".toByteArray(UTF_8)
|
||||||
|
val password = "same-password"
|
||||||
|
|
||||||
|
val encrypted1 = encryptionManager.encrypt(data, password)
|
||||||
|
val encrypted2 = encryptionManager.encrypt(data, password)
|
||||||
|
|
||||||
|
// Different IVs should produce different ciphertexts
|
||||||
|
assertFalse(encrypted1.contentEquals(encrypted2))
|
||||||
|
|
||||||
|
// But both should decrypt to same original data
|
||||||
|
val decrypted1 = encryptionManager.decrypt(encrypted1, password)
|
||||||
|
val decrypted2 = encryptionManager.decrypt(encrypted2, password)
|
||||||
|
assertArrayEquals(decrypted1, decrypted2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt large data (1MB) succeeds`() {
|
||||||
|
val random = SecureRandom()
|
||||||
|
val largeData = ByteArray(1024 * 1024) // 1 MB
|
||||||
|
random.nextBytes(largeData)
|
||||||
|
val password = "password123"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(largeData, password)
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||||
|
|
||||||
|
assertArrayEquals(largeData, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt empty data succeeds`() {
|
||||||
|
val emptyData = ByteArray(0)
|
||||||
|
val password = "password"
|
||||||
|
|
||||||
|
val encrypted = encryptionManager.encrypt(emptyData, password)
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, password)
|
||||||
|
|
||||||
|
assertArrayEquals(emptyData, decrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `encrypt with empty password succeeds but is unsafe`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
|
||||||
|
// Crypto library accepts empty passwords (UI prevents this with validation)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "")
|
||||||
|
val decrypted = encryptionManager.decrypt(encrypted, "")
|
||||||
|
|
||||||
|
assertArrayEquals(data, decrypted)
|
||||||
|
assertTrue("Empty password should still produce encrypted data", encrypted.size > data.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `decrypt with unsupported version throws EncryptionException`() {
|
||||||
|
val data = "Test".toByteArray(UTF_8)
|
||||||
|
val encrypted = encryptionManager.encrypt(data, "password")
|
||||||
|
|
||||||
|
// Change version byte to unsupported value (99)
|
||||||
|
val invalidVersion = encrypted.copyOf()
|
||||||
|
invalidVersion[4] = 99.toByte()
|
||||||
|
|
||||||
|
assertThrows(EncryptionException::class.java) {
|
||||||
|
encryptionManager.decrypt(invalidVersion, "password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
docs/DEBUG_APK.md
Normal file
116
docs/DEBUG_APK.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Debug APK für Issue-Testing
|
||||||
|
|
||||||
|
Für Bug-Reports und Testing von Fixes brauchst du eine **Debug-APK**. Diese wird automatisch gebaut, wenn du auf speziellen Branches pushst.
|
||||||
|
|
||||||
|
## 🔧 Branch-Struktur für Debug-APKs
|
||||||
|
|
||||||
|
Debug-APKs werden **automatisch** gebaut für diese Branches:
|
||||||
|
|
||||||
|
| Branch-Typ | Zweck | Beispiel |
|
||||||
|
|-----------|-------|---------|
|
||||||
|
| `debug/*` | Allgemeines Testing | `debug/wifi-only-sync` |
|
||||||
|
| `fix/*` | Bug-Fixes testen | `fix/vpn-connection` |
|
||||||
|
| `feature/*` | Neue Features | `feature/grid-layout` |
|
||||||
|
|
||||||
|
**Andere Branches (main, develop, etc.) bauen KEINE Debug-APKs!**
|
||||||
|
|
||||||
|
## 📥 Debug-APK downloaden
|
||||||
|
|
||||||
|
### 1️⃣ Push zu einem Debug-Branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Neuen Fix-Branch erstellen
|
||||||
|
git checkout -b fix/my-bug
|
||||||
|
|
||||||
|
# Deine Änderungen machen
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Commit und Push
|
||||||
|
git add .
|
||||||
|
git commit -m "fix: beschreibung"
|
||||||
|
git push origin fix/my-bug
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2️⃣ GitHub Actions Workflow starten
|
||||||
|
|
||||||
|
- GitHub → **Actions** Tab
|
||||||
|
- **Build Debug APK** Workflow sehen
|
||||||
|
- Warten bis Workflow grün ist ✅
|
||||||
|
|
||||||
|
### 3️⃣ APK herunterladen
|
||||||
|
|
||||||
|
1. Auf den grünen Workflow-Erfolg warten
|
||||||
|
2. **Artifacts** Section oben (oder unten im Workflow)
|
||||||
|
3. `simple-notes-sync-debug-*` herunterladen
|
||||||
|
4. ZIP-Datei entpacken
|
||||||
|
|
||||||
|
**Wichtig:** Artifacts sind nur **30 Tage** verfügbar!
|
||||||
|
|
||||||
|
## 📱 Installation auf Gerät
|
||||||
|
|
||||||
|
## 📱 Installation auf Gerät
|
||||||
|
|
||||||
|
### Mit ADB (Empfohlen - sauberes Testing)
|
||||||
|
```bash
|
||||||
|
# Gerät verbinden
|
||||||
|
adb devices
|
||||||
|
|
||||||
|
# Debug-APK installieren (alte Version wird nicht gelöscht)
|
||||||
|
adb install simple-notes-sync-debug.apk
|
||||||
|
|
||||||
|
# Aus dem Gerät entfernen später:
|
||||||
|
adb uninstall dev.dettmer.simplenotes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manuell auf Gerät
|
||||||
|
1. Datei auf Android-Gerät kopieren
|
||||||
|
2. **Einstellungen → Sicherheit → "Unbekannte Quellen" aktivieren**
|
||||||
|
3. Dateimanager öffnen und APK antippen
|
||||||
|
4. "Installieren" auswählen
|
||||||
|
|
||||||
|
## ⚠️ Debug-APK vs. Release-APK
|
||||||
|
|
||||||
|
| Feature | Debug | Release |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| **Logging** | Voll | Minimal |
|
||||||
|
| **Signatur** | Debug-Key | Release-Key |
|
||||||
|
| **Performance** | Langsamer | Schneller |
|
||||||
|
| **Debugging** | ✅ Möglich | ❌ Nein |
|
||||||
|
| **Installation** | Mehrmals | Kann Probleme geben |
|
||||||
|
|
||||||
|
## 📊 Was zu testen ist
|
||||||
|
|
||||||
|
1. **Neue Features** - Funktionieren wie beschrieben?
|
||||||
|
2. **Bug Fixes** - Ist der Bug wirklich behoben?
|
||||||
|
3. **Kompatibilität** - Funktioniert auf deinem Gerät?
|
||||||
|
4. **Performance** - Läuft die App flüssig?
|
||||||
|
|
||||||
|
## 📝 Feedback geben
|
||||||
|
|
||||||
|
Bitte schreibe einen Kommentar im **Pull Request** oder **GitHub Issue**:
|
||||||
|
- ✅ Was funktioniert
|
||||||
|
- ❌ Was nicht funktioniert
|
||||||
|
- 📋 Fehler-Logs (adb logcat falls relevant)
|
||||||
|
- 📱 Gerät/Android-Version
|
||||||
|
|
||||||
|
## 🐛 Logs sammeln
|
||||||
|
|
||||||
|
Falls der App-Entwickler Debug-Logs braucht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal öffnen mit adb
|
||||||
|
adb shell pm grant dev.dettmer.simplenotes android.permission.READ_LOGS
|
||||||
|
|
||||||
|
# Logs anschauen (live)
|
||||||
|
adb logcat | grep simplenotes
|
||||||
|
|
||||||
|
# Logs speichern (Datei)
|
||||||
|
adb logcat > debug-log.txt
|
||||||
|
|
||||||
|
# Nach Fehler filtern
|
||||||
|
adb logcat | grep -E "ERROR|Exception|CRASH"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Danke fürs Testing! Dein Feedback hilft uns, die App zu verbessern.** 🙏
|
||||||
166
docs/SELF_SIGNED_SSL.md
Normal file
166
docs/SELF_SIGNED_SSL.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Self-Signed SSL Certificate Support
|
||||||
|
|
||||||
|
**Since:** v1.7.0
|
||||||
|
**Status:** ✅ Supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Simple Notes Sync now supports connecting to WebDAV servers with self-signed SSL certificates, such as:
|
||||||
|
- ownCloud/Nextcloud with self-signed certificates
|
||||||
|
- Synology NAS with default certificates
|
||||||
|
- Raspberry Pi or home servers
|
||||||
|
- Internal corporate servers with private CAs
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
### Step 1: Export Your Server's CA Certificate
|
||||||
|
|
||||||
|
**On your server:**
|
||||||
|
|
||||||
|
1. Locate your certificate file (usually `.crt`, `.pem`, or `.der` format)
|
||||||
|
2. If you created the certificate yourself, you already have it
|
||||||
|
3. For Synology NAS: Control Panel → Security → Certificate → Export
|
||||||
|
4. For ownCloud/Nextcloud: Usually in `/etc/ssl/certs/` on the server
|
||||||
|
|
||||||
|
### Step 2: Install Certificate on Android
|
||||||
|
|
||||||
|
**On your Android device:**
|
||||||
|
|
||||||
|
1. **Transfer** the `.crt` or `.pem` file to your phone (via email, USB, etc.)
|
||||||
|
|
||||||
|
2. **Open Settings** → Security → More security settings (or Encryption & credentials)
|
||||||
|
|
||||||
|
3. **Install from storage** / "Install a certificate"
|
||||||
|
- Choose "CA certificate"
|
||||||
|
- **Warning:** Android will display a security warning. This is normal.
|
||||||
|
- Tap "Install anyway"
|
||||||
|
|
||||||
|
4. **Browse** to your certificate file and select it
|
||||||
|
|
||||||
|
5. **Name** it something recognizable (e.g., "My ownCloud CA")
|
||||||
|
|
||||||
|
6. ✅ **Done!** The certificate is now trusted system-wide
|
||||||
|
|
||||||
|
### Step 3: Connect Simple Notes Sync
|
||||||
|
|
||||||
|
1. Open Simple Notes Sync
|
||||||
|
2. Go to **Settings** → **Server Settings**
|
||||||
|
3. Enter your **`https://` server URL** as usual
|
||||||
|
4. The app will now trust your self-signed certificate ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
### ⚠️ Important
|
||||||
|
|
||||||
|
- Installing a CA certificate grants trust to **all** certificates signed by that CA
|
||||||
|
- Only install certificates from sources you trust
|
||||||
|
- Android will warn you before installation – read the warning carefully
|
||||||
|
|
||||||
|
### 🔒 Why This is Safe
|
||||||
|
|
||||||
|
- You **manually** install the certificate (conscious decision)
|
||||||
|
- The app uses Android's native trust store (no custom validation)
|
||||||
|
- You can remove the certificate anytime from Android Settings
|
||||||
|
- F-Droid and Google Play compliant (no "trust all" hack)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Certificate Not Trusted
|
||||||
|
|
||||||
|
**Problem:** App still shows SSL error after installing certificate
|
||||||
|
|
||||||
|
**Solutions:**
|
||||||
|
1. **Verify installation:** Settings → Security → Trusted credentials → User tab
|
||||||
|
2. **Check certificate type:** Must be a CA certificate, not a server certificate
|
||||||
|
3. **Restart app:** Close and reopen Simple Notes Sync
|
||||||
|
4. **Check URL:** Must use `https://` (not `http://`)
|
||||||
|
|
||||||
|
### "Network Security Policy" Error
|
||||||
|
|
||||||
|
**Problem:** Android 7+ restricts user certificates for apps
|
||||||
|
|
||||||
|
**Solution:** This app is configured to trust user certificates ✅
|
||||||
|
If the problem persists, check:
|
||||||
|
- Certificate is installed in "User" tab (not "System")
|
||||||
|
- Certificate is not expired
|
||||||
|
- Server URL matches certificate's Common Name (CN) or Subject Alternative Name (SAN)
|
||||||
|
|
||||||
|
### Self-Signed vs. CA-Signed
|
||||||
|
|
||||||
|
| Type | Installation Required | Security |
|
||||||
|
|------|---------------------|----------|
|
||||||
|
| **Self-Signed** | ✅ Yes | Manual trust |
|
||||||
|
| **Let's Encrypt** | ❌ No | Automatic |
|
||||||
|
| **Private CA** | ✅ Yes (CA root) | Automatic for all CA-signed certs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alternative: Use Let's Encrypt (Recommended)
|
||||||
|
|
||||||
|
If your server is publicly accessible, consider using **Let's Encrypt** for free, automatically-renewed SSL certificates:
|
||||||
|
|
||||||
|
- No manual certificate installation needed
|
||||||
|
- Trusted by all devices automatically
|
||||||
|
- Easier for end users
|
||||||
|
|
||||||
|
**Setup guides:**
|
||||||
|
- [ownCloud Let's Encrypt](https://doc.owncloud.com/server/admin_manual/installation/letsencrypt/)
|
||||||
|
- [Nextcloud Let's Encrypt](https://docs.nextcloud.com/server/latest/admin_manual/installation/letsencrypt.html)
|
||||||
|
- [Synology Let's Encrypt](https://kb.synology.com/en-us/DSM/tutorial/How_to_enable_HTTPS_and_create_a_certificate_signing_request_on_your_Synology_NAS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- Uses Android's **Network Security Config**
|
||||||
|
- Trusts both system and user CA certificates
|
||||||
|
- No custom TrustManager or hostname verifier
|
||||||
|
- F-Droid and Play Store compliant
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
File: `android/app/src/main/res/xml/network_security_config.xml`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<base-config>
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
<certificates src="user" /> <!-- ← Enables self-signed support -->
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**Q: Do I need to reinstall the certificate after app updates?**
|
||||||
|
A: No, certificates are stored system-wide, not per-app.
|
||||||
|
|
||||||
|
**Q: Can I use the same certificate for multiple apps?**
|
||||||
|
A: Yes, once installed, it works for all apps that trust user certificates.
|
||||||
|
|
||||||
|
**Q: How do I remove a certificate?**
|
||||||
|
A: Settings → Security → Trusted credentials → User tab → Tap certificate → Remove
|
||||||
|
|
||||||
|
**Q: Does this work on Android 14+?**
|
||||||
|
A: Yes, tested on Android 7 through 15 (API 24-35).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Issues
|
||||||
|
|
||||||
|
- [GitHub Issue #X](link) - User request for ownCloud support
|
||||||
|
- [Feature Analysis](../project-docs/simple-notes-sync/features/SELF_SIGNED_SSL_CERTIFICATES_ANALYSIS.md) - Technical analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Need help?** Open an issue on [GitHub](https://github.com/inventory69/simple-notes-sync/issues)
|
||||||
7
fastlane/metadata/android/de-DE/changelogs/17.txt
Normal file
7
fastlane/metadata/android/de-DE/changelogs/17.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
• Neu: Raster-Ansicht - Danke an freemen
|
||||||
|
• Neu: Nur-WLAN Sync Toggle in Einstellungen
|
||||||
|
• Neu: Verschlüsselung bei lokalen Backups - Danke an @SilentCoderHere (#9)
|
||||||
|
• Behoben: Sync funktioniert korrekt bei aktivem VPN - Danke an @roughnecks (#11)
|
||||||
|
• Verbessert: Server-Wechsel - Sync-Status wird für alle Notizen zurückgesetzt
|
||||||
|
• Verbessert: "Sync läuft bereits" Feedback bei weiteren Ausführungen
|
||||||
|
• Verschiedene Fixes und UI-Verbesserungen
|
||||||
@@ -1,62 +1,32 @@
|
|||||||
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation.
|
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features.
|
||||||
|
|
||||||
HAUPTFUNKTIONEN:
|
Hauptfunktionen:
|
||||||
|
• Text-Notizen und Checklisten (Tap-to-Check, Drag & Drop)
|
||||||
• Text-Notizen und Checklisten erstellen
|
• NEU: Raster-Ansicht (Grid View) für Notizen
|
||||||
• Checklisten mit Tap-to-Check und Drag & Drop
|
|
||||||
• Auswahlmodus: Long-Press zur Mehrfachauswahl für Batch-Aktionen
|
|
||||||
• WebDAV-Synchronisation mit eigenem Server
|
|
||||||
• Multi-Device Sync (Handy, Tablet, Desktop)
|
• Multi-Device Sync (Handy, Tablet, Desktop)
|
||||||
• Markdown-Export für Obsidian/Desktop-Editoren
|
• WebDAV-Synchronisation mit eigenem Server (Nextcloud, ownCloud, etc.)
|
||||||
• Checklisten als GitHub-Style Task-Listen exportieren
|
• Markdown-Export und Import für Desktop-Editoren (Obsidian, VS Code)
|
||||||
• Automatische Synchronisation im Heim-WLAN
|
• NEU: WiFi-only Sync, VPN-Unterstützung, Verschlüsselung für lokale Backups
|
||||||
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
|
• Konfigurierbare Sync-Trigger: onSave, onResume, WiFi, periodisch, Boot
|
||||||
• Material Design 3 mit Dynamic Colors (Android 12+)
|
|
||||||
• Jetpack Compose UI - modern, schnell und flüssig
|
|
||||||
• Komplett offline nutzbar
|
• Komplett offline nutzbar
|
||||||
• Keine Werbung, keine Tracker
|
• 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
|
Synchronisation:
|
||||||
• Per-App Sprachauswahl (Android 13+)
|
• Automatisch oder manuell, optimierte Performance, periodischer Sync optional
|
||||||
• Automatische Systemsprachen-Erkennung
|
• Intelligente Konfliktlösung, Lösch-Tracking, Batch-Aktionen
|
||||||
• Über 400 übersetzte Strings
|
|
||||||
|
|
||||||
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.
|
Mehrsprachig:
|
||||||
|
• Deutsch und Englisch, automatische Erkennung, App-Sprachauswahl
|
||||||
MULTI-DEVICE SYNC:
|
|
||||||
|
|
||||||
• Notizen synchronisieren automatisch zwischen allen Geräten
|
|
||||||
• Lösch-Tracking verhindert "Zombie-Notizen"
|
|
||||||
• Intelligente Konfliktlösung durch Timestamps
|
|
||||||
• Markdown-Dateien für Desktop-Bearbeitung (Obsidian, VS Code, etc.)
|
|
||||||
• Änderungen von Desktop-Editoren werden automatisch importiert
|
|
||||||
|
|
||||||
SYNCHRONISATION:
|
|
||||||
|
|
||||||
• Unterstützt alle WebDAV-Server (Nextcloud, ownCloud, etc.)
|
|
||||||
• Konfigurierbare Sync-Trigger: Wähle einzeln, wann synchronisiert wird
|
|
||||||
• 5 Trigger: onSave (nach dem Speichern), onResume (beim Öffnen), WiFi-Connect, Periodic (15/30/60 Min), Boot
|
|
||||||
• Offline-Modus: Alle Netzwerkfunktionen mit einem Schalter deaktivieren
|
|
||||||
• Smarte Defaults: nur ereignisbasierte Trigger aktiv (~0.2%/Tag Akku)
|
|
||||||
• Periodischer Sync optional (Standard: AUS)
|
|
||||||
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
|
|
||||||
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
|
|
||||||
• Silent-Sync Modus: kein Banner bei Auto-Sync
|
|
||||||
• Doze Mode optimiert für zuverlässige Background-Syncs
|
|
||||||
• Manuelle Synchronisation jederzeit möglich
|
|
||||||
|
|
||||||
MATERIAL DESIGN 3:
|
|
||||||
|
|
||||||
• Moderne Jetpack Compose Benutzeroberfläche
|
|
||||||
• Dynamic Colors (Material You) auf Android 12+
|
|
||||||
• Dark Mode Support
|
|
||||||
• Auswahlmodus mit Batch-Löschen
|
|
||||||
• Live Sync-Status Anzeige
|
|
||||||
• Flüssige Slide-Animationen
|
|
||||||
|
|
||||||
Open Source unter MIT-Lizenz
|
Open Source unter MIT-Lizenz
|
||||||
Quellcode: https://github.com/inventory69/simple-notes-sync
|
Quellcode: https://github.com/inventory69/simple-notes-sync
|
||||||
7
fastlane/metadata/android/en-US/changelogs/17.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/17.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
• New: Grid view - Thanks to freemen
|
||||||
|
• New: WiFi-only sync toggle in settings
|
||||||
|
• New: Encryption for local backups - Thanks to @SilentCoderHere (#9)
|
||||||
|
• Fixed: Sync works correctly when VPN is active - Thanks to @roughnecks (#11)
|
||||||
|
• Improved: Server change - Sync status resets for all notes
|
||||||
|
• Improved: "Sync already running" feedback for additional executions
|
||||||
|
• Various fixes and UI improvements
|
||||||
@@ -1,62 +1,32 @@
|
|||||||
Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization.
|
Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features.
|
||||||
|
|
||||||
KEY FEATURES:
|
Key Features:
|
||||||
|
• Text notes and checklists (tap-to-check, drag & drop)
|
||||||
• Create text notes and checklists
|
• NEW: Grid view for notes
|
||||||
• 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
|
|
||||||
• Multi-device sync (phone, tablet, desktop)
|
• Multi-device sync (phone, tablet, desktop)
|
||||||
• Markdown export for Obsidian/desktop editors
|
• WebDAV sync with your own server (Nextcloud, ownCloud, etc.)
|
||||||
• Checklists export as GitHub-style task lists
|
• Markdown export/import for desktop editors (Obsidian, VS Code)
|
||||||
• Automatic synchronization on home WiFi
|
• NEW: WiFi-only sync, VPN support, encryption for local backups
|
||||||
• Configurable sync interval (15/30/60 minutes)
|
• Configurable sync triggers: onSave, onResume, WiFi, periodic, boot
|
||||||
• Material Design 3 with Dynamic Colors (Android 12+)
|
|
||||||
• Jetpack Compose UI - modern, fast, and smooth
|
|
||||||
• Fully usable offline
|
• Fully usable offline
|
||||||
• No ads, no trackers
|
• 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
|
Synchronization:
|
||||||
• Per-App Language selector (Android 13+)
|
• Automatic or manual, optimized performance, optional periodic sync
|
||||||
• Automatic system language detection
|
• Smart conflict resolution, deletion tracking, batch actions
|
||||||
• 400+ translated strings
|
|
||||||
|
|
||||||
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.
|
Multilingual:
|
||||||
|
• English and German, automatic detection, in-app language selector
|
||||||
MULTI-DEVICE SYNC:
|
|
||||||
|
|
||||||
• Notes sync automatically between all your devices
|
|
||||||
• Deletion tracking prevents "zombie notes"
|
|
||||||
• Smart conflict resolution through timestamps
|
|
||||||
• Markdown files for desktop editing (Obsidian, VS Code, etc.)
|
|
||||||
• Changes from desktop editors are auto-imported
|
|
||||||
|
|
||||||
SYNCHRONIZATION:
|
|
||||||
|
|
||||||
• Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
|
|
||||||
• Configurable Sync Triggers: Choose individually when to sync
|
|
||||||
• 5 triggers: onSave (after saving), onResume (on open), WiFi-Connect, Periodic (15/30/60 min), Boot
|
|
||||||
• Offline Mode: Disable all network features with one switch
|
|
||||||
• Smart defaults: event-driven triggers only (~0.2%/day battery)
|
|
||||||
• Periodic sync optional (default: OFF)
|
|
||||||
• Optimized performance: skips unchanged files (~2-3s sync time)
|
|
||||||
• E-Tag caching for 20x faster "no changes" checks
|
|
||||||
• Silent-Sync mode: no banner during auto-sync
|
|
||||||
• Doze Mode optimized for reliable background syncs
|
|
||||||
• Manual synchronization available anytime
|
|
||||||
|
|
||||||
MATERIAL DESIGN 3:
|
|
||||||
|
|
||||||
• Modern Jetpack Compose user interface
|
|
||||||
• Dynamic Colors (Material You) on Android 12+
|
|
||||||
• Dark Mode support
|
|
||||||
• Selection mode with batch delete
|
|
||||||
• Live sync status indicator
|
|
||||||
• Smooth slide animations
|
|
||||||
|
|
||||||
Open Source under MIT License
|
Open Source under MIT License
|
||||||
Source code: https://github.com/inventory69/simple-notes-sync
|
Source code: https://github.com/inventory69/simple-notes-sync
|
||||||
|
|||||||
Reference in New Issue
Block a user