10 Commits

Author SHA1 Message Date
inventory69
a96d373e78 Merge branch: Release v1.7.0 – Major Improvements & Features
- New: Grid view for notes – thanks to freemen
- New: WiFi-only sync toggle in settings
- New: Encryption for local backups – thanks to @SilentCoderHere (ref #9)
- Fixed: Sync now works correctly when VPN is active – thanks to @roughnecks (closes #11)
- Improved: Server change now resets sync status for all notes
- Improved: 'Sync already running' feedback for additional executions
- Various bug fixes and UI improvements
- Added support for self-signed SSL certificates; documentation updated – thanks to Stefan L.
- SHA-256 hash of the signing certificate is now shown in the README and on release pages – thanks to @isawaway (ref #10)

This release brings enhanced security, better sync reliability, and improved usability for self-hosted and private server setups.
2026-01-27 14:33:47 +01:00
inventory69
a59e89fe91 fix: add server-test directory to .gitignore [skip ci] 2026-01-27 14:03:38 +01:00
inventory69
91beee0f8b docs: fix badge layout finally [skip ci] 2026-01-27 13:53:44 +01:00
inventory69
c536ad3177 fix: badges aligned and underline removed [skip ci] 2026-01-27 13:30:17 +01:00
inventory69
6dba091c03 Unify and streamline documentation, changelogs, and app descriptions (DE/EN). Improved clarity, removed redundancies, and updated feature highlights for v1.7.0. [skip ci] 2026-01-27 13:20:14 +01:00
inventory69
5135c711a5 chore: Suppress SwallowedException in stopWifiMonitoring()
The exception is intentionally swallowed - it's OK if the callback is already unregistered.
2026-01-26 23:25:13 +01:00
inventory69
ebab347d4b fix: Notification opens ComposeMainActivity, WiFi-Only toggle in own section
Fixes:
1. Notification click now opens ComposeMainActivity instead of legacy MainActivity
2. WiFi-Only toggle moved to its own 'Network Restriction' section at top of sync settings
3. Added hint explaining WiFi-Connect trigger is not affected by WiFi-Only setting

UI Changes:
- New section header: 'Network Restriction' / 'Netzwerk-Einschränkung'
- WiFi-Only toggle now clearly separated from sync triggers
- Info card shows when WiFi-Only is enabled explaining the exception
2026-01-26 23:21:13 +01:00
inventory69
cb63aa1220 fix(sync): Implement central canSync() gate for WiFi-only check
- Add WebDavSyncService.canSync() as single source of truth
- Add SyncGateResult data class for structured response
- Update MainViewModel.triggerManualSync() to use canSync()
- Update MainViewModel.triggerAutoSync() to use canSync() - FIXES onResume bug
- Update NoteEditorViewModel.triggerOnSaveSync() to use canSync()
- Update SettingsViewModel.syncNow() to use canSync()
- Update SyncWorker to use canSync() instead of direct prefs check

All 9 sync paths now respect WiFi-only setting through one central gate.
2026-01-26 22:41:00 +01:00
inventory69
0df8282eb4 fix(sync): Add WiFi-only check for onSave and background sync
- SyncWorker: Add central WiFi-only guard before all sync operations
- NoteEditorViewModel: Add WiFi-only check before onSave sync trigger
- Prevents notes from syncing over 5G/mobile when WiFi-only is enabled
- Fixes: onSave sync ignored WiFi-only setting completely
2026-01-26 21:42:03 +01:00
inventory69
b70bc4d8f6 debug: v1.7.0 Features - Grid Layout, WiFi-only Sync, VPN Support 2026-01-26 21:19:46 +01:00
48 changed files with 2813 additions and 313 deletions

87
.github/workflows/build-debug-apk.yml vendored Normal file
View 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

View File

@@ -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
View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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">
[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) [![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) [![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/)
![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white) [![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/)
[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) [![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](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 &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Akkuschonend 📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 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
@@ -78,17 +95,15 @@ docker compose up -d
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest) 1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren & öffnen 2. Installieren & öffnen
3. ⚙️ Einstellungen → Server konfigurieren: 3. ⚙️ Einstellungen → Server konfigurieren:
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_ - **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
- **User:** `noteuser` - **User:** `noteuser`
- **Passwort:** _(aus .env)_ - **Passwort:** _(aus .env)_
- **WLAN:** _(dein Netzwerk-Name)_ - **WLAN:** _(dein Netzwerk-Name)_
4. **Verbindung testen** → Auto-Sync aktivieren 4. **Verbindung testen** → Auto-Sync aktivieren
5. Fertig! 🎉 5. Fertig! 🎉
➡️ **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,19 @@ 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>

View File

@@ -1,29 +1,49 @@
<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">
[![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/) [![Android](https://img.shields.io/badge/Android-8.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/) [![Kotlin](https://img.shields.io/badge/Kotlin-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white)](https://kotlinlang.org/)
![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white) [![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?style=for-the-badge&logo=jetpackcompose&logoColor=white)](https://developer.android.com/compose/)
[![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/) [![Material 3](https://img.shields.io/badge/Material_3-6750A4?style=for-the-badge&logo=material-design&logoColor=white)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](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>
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid" align="center" height="80" /></a>
</div>
<div align="center">
<strong>SHA-256 hash of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
</div>
<div align="center">
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
**🌍** [Deutsch](README.de.md) · **English** **🌍** [Deutsch](README.de.md) · **English**
</div> </div>
---
## 📱 Screenshots ## 📱 Screenshots
<p align="center"> <p align="center">
@@ -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 &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Battery-friendly 📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 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,17 @@ 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>

View File

@@ -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
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════

View File

@@ -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
*/ */

View File

@@ -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)

View File

@@ -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

View File

@@ -123,6 +123,26 @@ class NotesStorage(private val context: Context) {
saveDeletionTracker(DeletionTracker()) saveDeletionTracker(DeletionTracker())
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

View File

@@ -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) {
Logger.d(TAG, "📶 Starting WiFi monitoring...")
startWifiMonitoring()
} else {
stopWifiMonitoring()
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
}
// 1. WorkManager für periodic sync // 3. Logging für Debug
startPeriodicSync() if (!autoSyncEnabled && !wifiConnectEnabled) {
Logger.d(TAG, "🛑 No background triggers active")
// 2. NetworkCallback für WiFi-Connect Detection }
startWifiMonitoring() }
/**
* 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor
*/
@Suppress("SwallowedException")
private fun stopWifiMonitoring() {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered")
} catch (e: Exception) {
// Already unregistered - das ist OK
Logger.d(TAG, " WiFi callback already unregistered")
}
} }
/** /**

View File

@@ -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)")
} }

View File

@@ -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(

View File

@@ -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)
} }

View File

@@ -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

View File

@@ -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(

View File

@@ -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) {
listState.animateScrollToItem(0) if (displayMode == "grid") {
gridState.animateScrollToItem(0)
} else {
listState.animateScrollToItem(0)
}
viewModel.resetScrollToTop() viewModel.resetScrollToTop()
} }
} }
@@ -177,22 +189,44 @@ fun MainScreen(
if (notes.isEmpty()) { if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f)) EmptyState(modifier = Modifier.weight(1f))
} else { } else {
NotesList( // 🎨 v1.7.0: Switch between List and Grid based on display mode
notes = notes, if (displayMode == "grid") {
showSyncStatus = viewModel.isServerConfigured(), NotesStaggeredGrid(
selectedNotes = selectedNotes, notes = notes,
isSelectionMode = isSelectionMode, gridState = gridState,
listState = listState, showSyncStatus = viewModel.isServerConfigured(),
modifier = Modifier.weight(1f), selectedNoteIds = selectedNotes,
onNoteClick = { note -> onOpenNote(note.id) }, isSelectionMode = isSelectionMode,
onNoteLongPress = { note -> modifier = Modifier.weight(1f),
// Long-press starts selection mode onNoteClick = { note ->
viewModel.startSelectionMode(note.id) if (isSelectionMode) {
}, viewModel.toggleNoteSelection(note.id)
onNoteSelectionToggle = { note -> } else {
viewModel.toggleNoteSelection(note.id) onOpenNote(note.id)
} }
) },
onNoteLongClick = { note ->
viewModel.startSelectionMode(note.id)
}
)
} else {
NotesList(
notes = notes,
showSyncStatus = viewModel.isServerConfigured(),
selectedNotes = selectedNotes,
isSelectionMode = isSelectionMode,
listState = listState,
modifier = Modifier.weight(1f),
onNoteClick = { note -> onOpenNote(note.id) },
onNoteLongPress = { note ->
// Long-press starts selection mode
viewModel.startSelectionMode(note.id)
},
onNoteSelectionToggle = { note ->
viewModel.toggleNoteSelection(note.id)
}
)
}
} }
} }

View File

@@ -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")

View File

@@ -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(

View File

@@ -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)
)
}
}
}
}
}
}

View File

@@ -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)
)
}
}
}
}
}
}

View File

@@ -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

View File

@@ -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) }
)
}
}
}

View File

@@ -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() }
)
}
} }
} }

View File

@@ -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
} }

View File

@@ -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,41 +238,130 @@ 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() {
viewModelScope.launch { viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking _serverStatus.value = ServerStatus.Checking
@@ -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")
}
} }

View File

@@ -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)
}
}
}

View File

@@ -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 -> {

View File

@@ -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)

View File

@@ -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))
}
}
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════

View File

@@ -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
} }

View File

@@ -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(

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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
View 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
View 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)

View 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

View File

@@ -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

View 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

View File

@@ -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