28 Commits

Author SHA1 Message Date
inventory69
0b143e5f0d fix: timeout increase (1s→10s) and locale hardcoded strings
## Changes:

### Timeout Fix (v1.7.2)
- SOCKET_TIMEOUT_MS: 1000ms → 10000ms for more stable connections
- Better error handling in hasUnsyncedChanges(): returns TRUE on error

### Locale Fix (v1.7.2)
- Replaced hardcoded German strings with getString(R.string.*)
- MainActivity, SettingsActivity, MainViewModel: 'Bereits synchronisiert' → getString()
- SettingsViewModel: Enhanced getString() with AppCompatDelegate locale support
- Added locale debug logging in MainActivity

### Code Cleanup
- Removed non-working VPN bypass code:
  - WiFiSocketFactory class
  - getWiFiInetAddressInternal() function
  - getOrCacheWiFiAddress() function
  - sessionWifiAddress cache variables
  - WiFi-binding logic in createSardineClient()
- Kept isVpnInterfaceActive() for logging/debugging

Note: VPN users should configure their VPN to exclude private IPs (e.g., 192.168.x.x)
for local server connectivity. App-level VPN bypass is not reliable on Android.
2026-02-02 17:14:23 +01:00
inventory69
cf9695844c chore: Add SystemForegroundService to manifest and Feature Requests link to issue template
- AndroidManifest.xml: Added WorkManager SystemForegroundService declaration
  with dataSync foregroundServiceType to fix lint error for Expedited Work
- .github/ISSUE_TEMPLATE/config.yml: Added Feature Requests & Ideas link
  pointing to GitHub Discussions for non-bug feature discussions
2026-02-02 13:45:16 +01:00
inventory69
24ea7ec59a fix: Android 9 crash - Implement getForegroundInfo() for WorkManager Expedited Work (Issue #15)
This commit fixes the critical crash on Android 9 (API 28) that occurred when using
WorkManager Expedited Work for background sync operations.

## Root Cause
When setExpedited() is used in WorkManager, the CoroutineWorker must implement
getForegroundInfo() to return a ForegroundInfo object with a Foreground Service
notification. On Android 9-11, WorkManager calls this method, but the default
implementation throws: IllegalStateException: Not implemented

## Solution
- Implemented getForegroundInfo() in SyncWorker
- Returns ForegroundInfo with sync progress notification
- Android 10+: Sets FOREGROUND_SERVICE_TYPE_DATA_SYNC for proper service typing
- Added required Foreground Service permissions to AndroidManifest.xml

## Technical Changes
- SyncWorker.kt: Added getForegroundInfo() override
- NotificationHelper.kt: Added createSyncProgressNotification() factory method
- strings.xml: Added sync_in_progress UI strings (EN + DE)
- AndroidManifest.xml: Added FOREGROUND_SERVICE permissions
- Version updated to 1.7.1 (versionCode 18)

## Previously Fixed (in this release)
- Kernel-VPN compatibility (Wireguard interface detection)
- HTTP connection lifecycle optimization (SafeSardineWrapper)
- Stability improvements for sync sessions

## Testing
- Tested on Android 9 (API 28) - No crash on second app start
- Tested on Android 15 (API 35) - No regressions
- WiFi-connect sync working correctly
- Expedited work notifications display properly

Fixes #15
Thanks to @roughnecks for detailed bug report and testing!
2026-02-02 13:09:12 +01:00
inventory69
df4ee4bed0 v1.7.1: Fix Android 9 crash and Kernel-VPN compatibility
- Fix connection leak on Android 9 (close() in finally block)
- Fix VPN detection for Kernel Wireguard (interface name patterns)
- Fix missing files after app data clear (local existence check)
- Update changelogs for v1.7.1 (versionCode 18)

Refs: #15
2026-01-30 16:21:04 +01:00
inventory69
68e8490db8 Fix connection leaks causing crash on Android 9
- Added SafeSardineWrapper to properly close HTTP responses
- Prevents resource exhaustion after extended use (30-45 min)
- Added preemptive authentication to reduce 401 round-trips
- Added ProGuard rule for TextInclusionStrategy warnings
- Updated version to 1.7.1

Refs: #15
2026-01-30 13:37:52 +01:00
inventory69
614650e37d delete: remove feature request issue template [skip ci] 2026-01-28 16:14:10 +01:00
Fabian Dettmer
785a6c011a Add feature requests section to README [skip ci]
Added a section for feature requests and ideas with guidelines.
2026-01-28 15:24:17 +01:00
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
inventory69
217a174478 Merge feature/v1.6.2-offline-mode-hotfix: Fix offline mode migration bug 2026-01-23 21:39:30 +01:00
inventory69
d58d9036cb fix: offline mode migration bug for v1.5.0 → v1.6.2 updates
- Fixes offline mode incorrectly enabled after updating from v1.5.0
- Users with existing server configuration no longer appear as offline
- Root cause: KEY_OFFLINE_MODE didn't exist in v1.5.0
- MainViewModel/NoteEditorViewModel used hardcoded default 'true'
- Fix: Migration in SimpleNotesApplication.onCreate() detects server config
- Version bumped to v1.6.2 (versionCode 16)
- F-Droid changelogs added

Tested: v1.5.0 → v1.6.2 update successful
Migration log: hasServer=true → offlineMode=false ✓
2026-01-23 21:39:04 +01:00
inventory69
dfdccfe6c7 chore: restructured README and added Obtanium badge
credits: https://github.com/ImranR98/Obtainium/issues/1287

[skip ci]
2026-01-21 23:16:16 +01:00
inventory69
d524bc715d Merge branch 'feature/v1.6.1-clean-code'
v1.6.1 - Clean Code Release

 detekt: 29 → 0 issues
 Build warnings: 21 → 0
 ktlint reactivated with Compose rules
 CI/CD lint checks integrated in pr workflow
 Constants refactoring
 Preparation for v2.0.0 legacy cleanup

Commits:
- ea5c6da: feat: v1.6.1 Clean Code implementation
- ff6510a: docs: update UPCOMING for v1.6.1
- 80a35da: chore: prepare v1.6.1 release changelogs
- b5cb4e1: chore: update v1.6.1 screenshot path in README
2026-01-20 22:00:09 +01:00
inventory69
2a22e7d88e chore: update F-Droid changelogs for v1.6.1
- User-friendly descriptions focusing on performance and stability
- Remove technical implementation details
- Emphasize user-visible improvements and future readiness
2026-01-20 21:59:09 +01:00
inventory69
b5cb4e1d96 chore: update v1.6.1 screenshot path in README 2026-01-20 21:40:59 +01:00
inventory69
80a35da3ff chore: prepare v1.6.1 release changelogs
- Add F-Droid changelogs (versionCode 15)
  - de-DE: Code-Qualität, Zero Warnings, ktlint, CI/CD
  - en-US: Code quality, Zero warnings, ktlint, CI/CD
  - Both under 500 characters as required

- Update CHANGELOG.md / CHANGELOG.de.md
  - detekt: 29 → 0 issues
  - Build warnings: 21 → 0
  - ktlint reactivated with .editorconfig
  - CI/CD lint checks integrated
  - Constants refactoring (Dimensions, SyncConstants)
  - Preparation for v2.0.0 legacy cleanup
2026-01-20 15:11:35 +01:00
inventory69
6254758a03 chore: update screenshots for fdroid metadata in both de-DE and en-US 2026-01-20 15:06:53 +01:00
inventory69
ff6510af90 docs: update UPCOMING for v1.6.1 release and v1.7.0 planning
- Mark v1.6.0 and v1.6.1 as Released
- Add v1.6.1 Clean Code section (detekt 0 issues, zero warnings)
- Restructure v1.7.0 as Staggered Grid Layout release
  - LazyVerticalStaggeredGrid for 120 FPS performance
  - Server Folder Check feature
  - Technical improvements (MD3 dialogs, code refactoring)
- Add v2.0.0 Legacy Cleanup section
- Add Backlog section with future features:
  - Password-protected local backups
  - Biometric unlock
  - Widget, Categories/Tags, Search
- Remove 'Modern background sync' (already implemented with WorkManager)
2026-01-20 14:59:10 +01:00
inventory69
ea5c6dae70 feat: v1.6.1 Clean Code - detekt 0 issues, zero build warnings
- detekt: 29 → 0 issues 
  - Triviale Fixes: Unused imports, MaxLineLength
  - DragDropState.kt → DragDropListState.kt umbenennen
  - MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
  - SwallowedException: Logger.w() hinzugefügt
  - LongParameterList: ChecklistEditorCallbacks data class
  - LongMethod: ServerSettingsScreen in Komponenten aufgeteilt
  - @Suppress für komplexe Legacy-Code (WebDavSyncService, SettingsActivity)

- Deprecation Warnings: 21 → 0 
  - File-level @Suppress für alle deprecated Imports
  - ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
  - onActivityResult, onRequestPermissionsResult
  - Vorbereitung für v2.0.0 Legacy Cleanup

- ktlint: Reaktiviert mit .editorconfig 
  - Compose-spezifische Regeln konfiguriert
  - WebDavSyncService.kt, build.gradle.kts in Exclusions
  - ignoreFailures=true für graduelle Migration

- CI/CD: GitHub Actions erweitert 
  - Lint-Checks in pr-build-check.yml integriert
  - Detekt + ktlint + Android Lint vor Build
2026-01-20 14:35:22 +01:00
inventory69
1d010d0034 Release v1.6.0: Configurable Sync Triggers + Offline Mode
- NEW: Configurable sync triggers (onSave, onResume, WiFi, Periodic, Boot)
- NEW: Offline mode toggle to disable all network features
- Various fixes and UI improvements
- Version bumped to 1.6.0 (code 14)
2026-01-19 23:31:25 +01:00
93 changed files with 4894 additions and 899 deletions

View File

@@ -9,3 +9,6 @@ contact_links:
- name: "🐛 Troubleshooting" - name: "🐛 Troubleshooting"
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
about: Häufige Probleme und Lösungen / Common issues and solutions about: Häufige Probleme und Lösungen / Common issues and solutions
- name: "✨ Feature Requests & Ideas"
url: https://github.com/inventory69/simple-notes-sync/discussions/categories/ideas
about: Diskutiere neue Features in Discussions / Discuss new features in Discussions

View File

@@ -1,84 +0,0 @@
name: "💡 Feature Request / Feature-Wunsch"
description: Schlage eine neue Funktion vor / Suggest a new feature
title: "[FEATURE] "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Danke für deinen Feature-Vorschlag! / Thanks for your feature suggestion!
- type: textarea
id: feature-description
attributes:
label: "💡 Feature-Beschreibung / Feature Description"
description: Was möchtest du hinzugefügt haben? / What would you like to be added?
placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting"
validations:
required: true
- type: textarea
id: problem
attributes:
label: "🎯 Problem / Motivation"
description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve?
placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes"
validations:
required: true
- type: textarea
id: solution
attributes:
label: "📝 Vorgeschlagene Lösung / Proposed Solution"
description: Wie könnte das Feature funktionieren? / How could the feature work?
placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview"
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: "🔄 Alternativen / Alternatives"
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
validations:
required: false
- type: dropdown
id: platform
attributes:
label: "📱 Plattform / Platform"
description: Für welche Komponente ist das Feature? / For which component is the feature?
options:
- Android App
- WebDAV Server
- Dokumentation / Documentation
- Andere / Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: "⭐ Priorität (aus deiner Sicht) / Priority (from your perspective)"
options:
- Nice to have
- Wichtig / Important
- Sehr wichtig / Very important
validations:
required: false
- type: checkboxes
id: willing-to-contribute
attributes:
label: "🤝 Beitragen / Contribute"
options:
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
required: false
- type: textarea
id: additional
attributes:
label: "🔧 Zusätzliche Informationen / Additional Context"
description: Screenshots, Mockups, Links, ähnliche Apps, etc.
validations:
required: false

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

View File

@@ -33,6 +33,31 @@ jobs:
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)" echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
# 🔍 Code Quality Checks (v1.6.1)
- name: Run detekt (Code Quality)
run: |
cd android
./gradlew detekt --no-daemon
continue-on-error: false
- name: Run ktlint (Code Style)
run: |
cd android
./gradlew ktlintCheck --no-daemon
continue-on-error: true # Parser-Probleme in Legacy-Code
- name: Upload Lint Reports
if: always()
uses: actions/upload-artifact@v4
with:
name: lint-reports-pr-${{ github.event.pull_request.number }}
path: |
android/app/build/reports/detekt/
android/app/build/reports/ktlint/
android/app/build/reports/lint-results*.html
retention-days: 7
- name: Debug Build erstellen (ohne Signing) - name: Debug Build erstellen (ohne Signing)
run: | run: |
cd android cd android

6
.gitignore vendored
View File

@@ -43,3 +43,9 @@ Thumbs.db
*.swp *.swp
*~ *~
test-apks/ test-apks/
server-test/
# F-Droid metadata (managed in fdroiddata repo)
# Exclude fastlane metadata (we want to track those screenshots)
metadata/
!fastlane/metadata/

View File

@@ -8,6 +8,170 @@ Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [1.7.1] - 2026-02-02
### 🐛 Kritische Fehlerbehebungen
#### Android 9 App-Absturz Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
**Problem:** App stürzte auf Android 9 (API 28) ab wenn WorkManager Expedited Work für Hintergrund-Sync verwendet wurde.
**Root Cause:** Wenn `setExpedited()` in WorkManager verwendet wird, muss die `CoroutineWorker` die Methode `getForegroundInfo()` implementieren um eine Foreground Service Notification zurückzugeben. Auf Android 9-11 ruft WorkManager diese Methode auf, aber die Standard-Implementierung wirft `IllegalStateException: Not implemented`.
**Lösung:** `getForegroundInfo()` in `SyncWorker` implementiert um eine korrekte `ForegroundInfo` mit Sync-Progress-Notification zurückzugeben.
**Details:**
- `ForegroundInfo` mit Sync-Progress-Notification für Android 9-11 hinzugefügt
- Android 10+: Setzt `FOREGROUND_SERVICE_TYPE_DATA_SYNC` für korrekte Service-Typisierung
- Foreground Service Permissions in AndroidManifest.xml hinzugefügt
- Notification zeigt Sync-Progress mit indeterminiertem Progress Bar
- Danke an [@roughnecks](https://github.com/roughnecks) für das detaillierte Debugging!
#### VPN-Kompatibilitäts-Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
- WiFi Socket-Binding erkennt jetzt korrekt Wireguard VPN-Interfaces (tun*, wg*, *-wg-*)
- Traffic wird korrekt durch VPN-Tunnel geleitet statt direkt über WiFi
- Behebt "Verbindungs-Timeout" beim Sync zu externen Servern über VPN
### 🔧 Technische Änderungen
- Neue `SafeSardineWrapper` Klasse stellt korrektes HTTP-Connection-Cleanup sicher
- Weniger unnötige 401-Authentifizierungs-Challenges durch preemptive Auth-Header
- ProGuard-Regel hinzugefügt um harmlose TextInclusionStrategy-Warnungen zu unterdrücken
- VPN-Interface-Erkennung via `NetworkInterface.getNetworkInterfaces()` Pattern-Matching
- Foreground Service Erkennung und Notification-System für Hintergrund-Sync-Tasks
### 🌍 Lokalisierung
- Hardcodierte deutsche Fehlermeldungen behoben - jetzt String-Resources für korrekte Lokalisierung
- Deutsche und englische Strings für Sync-Progress-Notifications hinzugefügt
---
## [1.7.0] - 2026-01-26
### 🎉 Major: Grid-Ansicht, Nur-WLAN Sync & VPN-Unterstützung
Pinterest-Style Grid, Nur-WLAN Sync-Modus und korrekte VPN-Unterstützung!
### 🎨 Grid-Layout
- Pinterest-Style Staggered Grid ohne Lücken
- Konsistente 12dp Abstände zwischen Cards
- Scroll-Position bleibt erhalten nach Einstellungen
- Neue einheitliche `NoteCardGrid` mit dynamischen Vorschauzeilen (3 klein, 6 groß)
### 📡 Sync-Verbesserungen
- **Nur-WLAN Sync Toggle** - Sync nur wenn WLAN verbunden
- **VPN-Unterstützung** - Sync funktioniert korrekt bei aktivem VPN (Traffic über VPN)
- **Server-Wechsel Erkennung** - Alle Notizen auf PENDING zurückgesetzt bei Server-URL Änderung
- **Schnellere Server-Prüfung** - Socket-Timeout von 2s auf 1s reduziert
- **"Sync läuft bereits" Feedback** - Zeigt Snackbar wenn Sync bereits läuft
### 🔒 Self-Signed SSL Unterstützung
- **Dokumentation hinzugefügt** - Anleitung für selbst-signierte Zertifikate
- Nutzt Android's eingebauten CA Trust Store
- Funktioniert mit ownCloud, Nextcloud, Synology, Home-Servern
### 🔧 Technisch
- `NoteCardGrid` Komponente mit dynamischen maxLines
- FullLine Spans entfernt für lückenloses Layout
- `resetAllSyncStatusToPending()` in NotesStorage
- VPN-Erkennung in `getOrCacheWiFiAddress()`
---
## [1.6.1] - 2026-01-20
### 🧹 Code-Qualität & Build-Verbesserungen
- **detekt: 0 Issues** - Alle 29 Code-Qualitäts-Issues behoben
- Triviale Fixes: Unused Imports, MaxLineLength
- Datei umbenannt: DragDropState.kt → DragDropListState.kt
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
- SwallowedException: Logger.w() für besseres Error-Tracking hinzugefügt
- LongParameterList: ChecklistEditorCallbacks data class erstellt
- LongMethod: ServerSettingsScreen in Komponenten aufgeteilt
- @Suppress Annotationen für Legacy-Code (WebDavSyncService, SettingsActivity)
- **Zero Build Warnings** - Alle 21 Deprecation Warnings eliminiert
- File-level @Suppress für deprecated Imports
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
- onActivityResult, onRequestPermissionsResult
- Gradle Compose Config bereinigt (StrongSkipping ist jetzt Standard)
- **ktlint reaktiviert** - Linting mit Compose-spezifischen Regeln wieder aktiviert
- .editorconfig mit Compose Formatierungsregeln erstellt
- Legacy-Dateien ausgeschlossen: WebDavSyncService.kt, build.gradle.kts
- ignoreFailures=true für graduelle Migration
- **CI/CD Verbesserungen** - GitHub Actions Lint-Checks integriert
- detekt + ktlint + Android Lint laufen vor Build in pr-build-check.yml
- Stellt Code-Qualität bei jedem Pull Request sicher
### 🔧 Technische Verbesserungen
- **Constants Refactoring** - Bessere Code-Organisation
- ui/theme/Dimensions.kt: UI-bezogene Konstanten
- utils/SyncConstants.kt: Sync-Operations Konstanten
- **Vorbereitung für v2.0.0** - Legacy-Code für Entfernung markiert
- SettingsActivity und MainActivity (ersetzt durch Compose-Versionen)
- Alle deprecated APIs mit Removal-Plan dokumentiert
---
## [1.6.0] - 2026-01-19
### 🎉 Major: Konfigurierbare Sync-Trigger
Feingranulare Kontrolle darüber, wann deine Notizen synchronisiert werden - wähle die Trigger, die am besten zu deinem Workflow passen!
### ⚙️ Sync-Trigger System
- **Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln in den Einstellungen aktivieren/deaktivieren
- **5 Unabhängige Trigger:**
- **onSave Sync** - Sync sofort nach dem Speichern einer Notiz (5s Throttle)
- **onResume Sync** - Sync beim Öffnen der App (60s Throttle)
- **WiFi-Connect Sync** - Sync bei WiFi-Verbindung
- **Periodischer Sync** - Hintergrund-Sync alle 15/30/60 Minuten (konfigurierbar)
- **Boot Sync** - Startet Hintergrund-Sync nach Geräteneustart
- **Smarte Defaults** - Nur ereignisbasierte Trigger standardmäßig aktiv (onSave, onResume, WiFi-Connect)
- **Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit aktiviertem periodischen Sync
- **Offline-Modus UI** - Ausgegraute Sync-Toggles wenn kein Server konfiguriert
- **Dynamischer Settings-Subtitle** - Zeigt Anzahl aktiver Trigger im Haupteinstellungs-Screen
### 🔧 Server-Konfiguration Verbesserungen
- **Offline-Modus Toggle** - Alle Netzwerkfunktionen mit einem Schalter deaktivieren
- **Getrennte Protokoll & Host Eingabe** - Protokoll (http/https) als nicht-editierbares Präfix angezeigt
- **Klickbare Settings-Cards** - Gesamte Card klickbar für bessere UX
- **Klickbare Toggle-Zeilen** - Text/Icon klicken um Switches zu bedienen (nicht nur der Switch selbst)
### 🐛 Bug Fixes
- **Fix:** Fehlender 5. Sync-Trigger (Boot) in der Haupteinstellungs-Screen Subtitle-Zählung
- **Fix:** Offline-Modus Status wird nicht aktualisiert beim Zurückkehren aus Einstellungen
- **Fix:** Pull-to-Refresh funktioniert auch im Offline-Modus
### 🔧 Technische Verbesserungen
- **Reaktiver Offline-Modus Status** - StateFlow stellt sicher, dass UI korrekt aktualisiert wird
- **Getrennte Server-Config Checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
- **Verbesserte Konstanten** - Alle Sync-Trigger Keys und Defaults in Constants.kt
- **Bessere Code-Organisation** - Settings-Screens für Klarheit refactored
### Looking Ahead
> 🚀 **v1.7.0** wird Server-Ordner Prüfung und weitere Community-Features bringen.
> Feature-Requests sind willkommen als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
---
## [1.5.0] - 2026-01-15 ## [1.5.0] - 2026-01-15
### 🎉 Major: Jetpack Compose UI Redesign ### 🎉 Major: Jetpack Compose UI Redesign

View File

@@ -8,6 +8,169 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [1.7.1] - 2026-02-02
### 🐛 Critical Bug Fixes
#### Android 9 App Crash Fix ([#15](https://github.com/inventory69/simple-notes-sync/issues/15))
**Problem:** App crashed on Android 9 (API 28) when using WorkManager Expedited Work for background sync.
**Root Cause:** When `setExpedited()` is used in WorkManager, the `CoroutineWorker` must implement `getForegroundInfo()` to return a Foreground Service notification. On Android 9-11, WorkManager calls this method, but the default implementation throws `IllegalStateException: Not implemented`.
**Solution:** Implemented `getForegroundInfo()` in `SyncWorker` to return a proper `ForegroundInfo` with sync progress notification.
**Details:**
- Added `ForegroundInfo` with sync progress notification for Android 9-11
- Android 10+: Sets `FOREGROUND_SERVICE_TYPE_DATA_SYNC` for proper service typing
- Added Foreground Service permissions to AndroidManifest.xml
- Notification shows sync progress with indeterminate progress bar
- Thanks to [@roughnecks](https://github.com/roughnecks) for the detailed debugging!
#### VPN Compatibility Fix ([#11](https://github.com/inventory69/simple-notes-sync/issues/11))
- WiFi socket binding now correctly detects Wireguard VPN interfaces (tun*, wg*, *-wg-*)
- Traffic routes through VPN tunnel instead of bypassing it directly to WiFi
- Fixes "Connection timeout" when syncing to external servers via VPN
### 🔧 Technical Changes
- New `SafeSardineWrapper` class ensures proper HTTP connection cleanup
- Reduced unnecessary 401 authentication challenges with preemptive auth headers
- Added ProGuard rule to suppress harmless TextInclusionStrategy warnings on older Android versions
- VPN interface detection via `NetworkInterface.getNetworkInterfaces()` pattern matching
- Foreground Service detection and notification system for background sync tasks
### 🌍 Localization
- Fixed hardcoded German error messages - now uses string resources for proper localization
- Added German and English strings for sync progress notifications
---
## [1.7.0] - 2026-01-26
### 🎉 Major: Grid View, WiFi-Only Sync & VPN Support
Pinterest-style grid, WiFi-only sync mode, and proper VPN support!
### 🎨 Grid Layout
- Pinterest-style staggered grid without gaps
- Consistent 12dp spacing between cards
- Scroll position preserved when returning from settings
- New unified `NoteCardGrid` with dynamic preview lines (3 small, 6 large)
### 📡 Sync Improvements
- **WiFi-only sync toggle** - Sync only when connected to WiFi
- **VPN support** - Sync works correctly when VPN is active (traffic routes through VPN)
- **Server change detection** - All notes reset to PENDING when server URL changes
- **Faster server check** - Socket timeout reduced from 2s to 1s
- **"Sync already running" feedback** - Shows snackbar when sync is in progress
### 🔒 Self-Signed SSL Support
- **Documentation added** - Guide for using self-signed certificates
- Uses Android's built-in CA trust store
- Works with ownCloud, Nextcloud, Synology, home servers
### 🔧 Technical
- `NoteCardGrid` component with dynamic maxLines
- Removed FullLine spans for gapless layout
- `resetAllSyncStatusToPending()` in NotesStorage
- VPN detection in `getOrCacheWiFiAddress()`
---
## [1.6.1] - 2026-01-20
### 🧹 Code Quality & Build Improvements
- **detekt: 0 issues** - All 29 code quality issues resolved
- Trivial fixes: Unused imports, MaxLineLength
- File rename: DragDropState.kt → DragDropListState.kt
- MagicNumbers → Constants (Dimensions.kt, SyncConstants.kt)
- SwallowedException: Logger.w() added for better error tracking
- LongParameterList: ChecklistEditorCallbacks data class created
- LongMethod: ServerSettingsScreen split into components
- @Suppress annotations for legacy code (WebDavSyncService, SettingsActivity)
- **Zero build warnings** - All 21 deprecation warnings eliminated
- File-level @Suppress for deprecated imports
- ProgressDialog, LocalBroadcastManager, AbstractSavedStateViewModelFactory
- onActivityResult, onRequestPermissionsResult
- Gradle Compose config cleaned up (StrongSkipping is now default)
- **ktlint reactivated** - Linting re-enabled with Compose-specific rules
- .editorconfig created with Compose formatting rules
- Legacy files excluded: WebDavSyncService.kt, build.gradle.kts
- ignoreFailures=true for gradual migration
- **CI/CD improvements** - GitHub Actions lint checks integrated
- detekt + ktlint + Android Lint run before build in pr-build-check.yml
- Ensures code quality on every pull request
### 🔧 Technical Improvements
- **Constants refactoring** - Better code organization
- ui/theme/Dimensions.kt: UI-related constants
- utils/SyncConstants.kt: Sync operation constants
- **Preparation for v2.0.0** - Legacy code marked for removal
- SettingsActivity and MainActivity (replaced by Compose versions)
- All deprecated APIs documented with removal plan
---
## [1.6.0] - 2026-01-19
### 🎉 Major: Configurable Sync Triggers
Fine-grained control over when your notes sync - choose which triggers fit your workflow best!
### ⚙️ Sync Trigger System
- **Individual trigger control** - Enable/disable each sync trigger separately in settings
- **5 Independent Triggers:**
- **onSave Sync** - Sync immediately after saving a note (5s throttle)
- **onResume Sync** - Sync when app is opened (60s throttle)
- **WiFi-Connect Sync** - Sync when WiFi is connected
- **Periodic Sync** - Background sync every 15/30/60 minutes (configurable)
- **Boot Sync** - Start background sync after device restart
- **Smart Defaults** - Only event-driven triggers active by default (onSave, onResume, WiFi-Connect)
- **Battery Optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic sync enabled
- **Offline Mode UI** - Grayed-out sync toggles when no server configured
- **Dynamic Settings Subtitle** - Shows count of active triggers on main settings screen
### 🔧 Server Configuration Improvements
- **Offline Mode Toggle** - Disable all network features with one switch
- **Split Protocol & Host** - Protocol (http/https) shown as non-editable prefix
- **Clickable Settings Cards** - Full card clickable for better UX
- **Clickable Toggle Rows** - Click text/icon to toggle switches (not just the switch itself)
### 🐛 Bug Fixes
- **Fixed:** Missing 5th sync trigger (Boot) in main settings screen subtitle count
- **Various fixes** - UI improvements and stability enhancements
### 🔧 Technical Improvements
- **Reactive offline mode state** - StateFlow ensures UI updates correctly
- **Separated server config checks** - `hasServerConfig()` vs `isServerConfigured()` (offline-aware)
- **Improved constants** - All sync trigger keys and defaults in Constants.kt
- **Better code organization** - Settings screens refactored for clarity
### Looking Ahead
> 🚀 **v1.7.0** will bring server folder checking and additional community features.
> Feature requests welcome as [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues).
---
## [1.5.0] - 2026-01-15 ## [1.5.0] - 2026-01-15
### 🎉 Major: Jetpack Compose UI Redesign ### 🎉 Major: Jetpack Compose UI Redesign

View File

@@ -1,48 +1,80 @@
# Simple Notes Sync 📝 <div align="center">
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
</div>
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server <h1 align="center">Simple Notes Sync</h1>
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/) <h4 align="center">Minimalistische Offline-Notizen mit intelligentem Sync - Einfachheit trifft smarte Synchronisation.</h4>
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) <div align="center">
[<img src="https://f-droid.org/badge/get-it-on-de.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.de.md)** · **🚀 [Quick Start](QUICKSTART.de.md)** [![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/)
[![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/)
[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE)
**🌍 Sprachen:** **Deutsch** · [English](README.md) </div>
--- <div align="center">
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
alt="Get it on Obtainium" align="center" height="54" />
</a>
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid" align="center" height="80" /></a>
</div>
<div align="center">
<strong>SHA-256 Hash des Signaturzertifikats:</strong><br /> 42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
</div>
<div align="center">
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Dokumentation](docs/DOCS.de.md) · [🚀 Schnellstart](QUICKSTART.de.md)<br />
**🌍** Deutsch · [English](README.md)
</div>
## 📱 Screenshots ## 📱 Screenshots
<p align="center"> <p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.png" width="250" alt="Notizliste"> <img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/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/6.png" width="250" alt="Sync-Status"> <img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
</p> </p>
--- <div align="center">
📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Akkuschonend
</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
- 🔄 **Auto-Sync** - WLAN-Verbindung, regelmäßige Intervalle (15/30/60 Min) & Multi-Geräte-Sync - 🌍 **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-0.8% pro Tag - 🖥️ **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
@@ -72,8 +104,6 @@ docker compose up -d
➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md) ➡️ **Ausführliche Anleitung:** [QUICKSTART.de.md](QUICKSTART.de.md)
---
## 📚 Dokumentation ## 📚 Dokumentation
| Dokument | Inhalt | | Dokument | Inhalt |
@@ -82,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
@@ -96,20 +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">
<br /><br />
**v1.4.1** · Built with ❤️ using Kotlin + Material Design 3 **v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

@@ -1,49 +1,81 @@
# Simple Notes Sync 📝 <div align="center">
<img src="android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png" alt="Logo" />
</div>
> Minimalist offline notes with auto-sync to your own server <h1 align="center">Simple Notes Sync</h1>
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/) <h4 align="center">Clean, offline-first notes with intelligent sync - simplicity meets smart synchronization.</h4>
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes) <div align="center">
[<img src="https://f-droid.org/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/dev.dettmer.simplenotes/)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)** [![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/)
[![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/)
[![License](https://img.shields.io/badge/License-MIT-F5C400?style=for-the-badge)](LICENSE)
**🌍 Languages:** [Deutsch](README.de.md) · **English** </div>
--- <div align="center">
<a href="https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes">
<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png"
alt="Get it on IzzyOnDroid" align="center" height="80" /></a>
<a href="https://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/inventory69/simple-notes-sync">
<img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png"
alt="Get it on Obtainium" align="center" height="54" />
</a>
<a href="https://f-droid.org/packages/dev.dettmer.simplenotes">
<img src="https://f-droid.org/badge/get-it-on.png"
alt="Get it on F-Droid" align="center" height="80" /></a>
</div>
<div align="center">
<strong>SHA-256 hash of the signing certificate:</strong><br />42:A1:C6:13:BB:C6:73:04:5A:F3:DC:81:91:BF:9C:B6:45:6E:E4:4C:7D:CE:40:C7:CF:B5:66:FA:CB:69:F1:6A
</div>
<div align="center">
<br />[📱 APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest) · [📖 Documentation](docs/DOCS.md) · [🚀 Quick Start](QUICKSTART.md)<br />
**🌍** [Deutsch](README.de.md) · **English**
</div>
## 📱 Screenshots ## 📱 Screenshots
<p align="center"> <p align="center">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Notes list"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="250" alt="Sync status">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="250" alt="Edit note">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="250" alt="Edit checklist">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="250" alt="Settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" width="250" alt="Server settings">
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" width="250" alt="Sync status"> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/7.png" width="250" alt="Sync settings">
</p> </p>
--- <div align="center">
📝 Offline-first &nbsp;&nbsp; 🔄 Smart Sync &nbsp;&nbsp; 🔒 Self-hosted &nbsp;&nbsp; 🔋 Battery-friendly
</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
- 🔄 **Auto-sync** - WiFi reconnect, periodic intervals (15/30/60 min) & multi-device sync - 📊 **Flexible views** - Switch between list and grid layout
-**Checklists** - Tap-to-check, drag & drop
- 🌍 **Multilingual** - English/German with language selector
- 🔄 **Configurable sync triggers** - onSave, onResume, WiFi-connect, periodic (15/30/60 min), boot
- 🔒 **Self-hosted** - Your data stays with you (WebDAV) - 🔒 **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-0.8% per day - 🔋 **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)
@@ -72,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 |
@@ -82,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 🚀 |
@@ -94,18 +125,29 @@ cd android
➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment) ➡️ **Build guide:** [DOCS.md](docs/DOCS.md#-build--deployment)
--- ## 💡 Feature Requests & Ideas
Have an idea for a new feature or improvement? We'd love to hear it!
➡️ **How to suggest features:**
1. Check [existing discussions](https://github.com/inventory69/simple-notes-sync/discussions) to see if someone already suggested it
2. If not, start a new discussion in the "Feature Requests / Ideas" category
3. Upvote (👍) features you'd like to see
Features with enough community support will be considered for implementation. Please keep in mind that this app is designed to stay simple and user-friendly.
## 🤝 Contributing ## 🤝 Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 License ## 📄 License
MIT License - see [LICENSE](LICENSE) MIT License - see [LICENSE](LICENSE)
--- <div align="center">
<br /><br />
**v1.5.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3 **v1.7.0** · Built with ❤️ using Kotlin + Jetpack Compose + Material Design 3
</div>

View File

@@ -2,8 +2,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler alias(libs.plugins.kotlin.compose) // v1.5.0: Jetpack Compose Compiler
// v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0 alias(libs.plugins.ktlint) // v1.6.1: Reaktiviert nach Code-Cleanup
// alias(libs.plugins.ktlint)
alias(libs.plugins.detekt) alias(libs.plugins.detekt)
} }
@@ -21,8 +20,8 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 13 // 🔧 v1.5.0: Jetpack Compose Settings Redesign versionCode = 18 // 🔧 v1.7.1: Android 9 getForegroundInfo Fix (Issue #15)
versionName = "1.5.0" // 🔧 v1.5.0: Jetpack Compose Settings Redesign versionName = "1.7.1" // 🔧 v1.7.1: Android 9 getForegroundInfo Fix
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -100,11 +99,15 @@ android {
compose = true // v1.5.0: Jetpack Compose für Settings Redesign compose = true // v1.5.0: Jetpack Compose für Settings Redesign
} }
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance // v1.7.0: Mock Android framework classes in unit tests (Log, etc.)
composeCompiler { testOptions {
enableStrongSkippingMode = true unitTests.isReturnDefaultValues = true
} }
// v1.5.0 Hotfix: Strong Skipping Mode für bessere 120Hz Performance
// v1.6.1: Feature ist ab dieser Kotlin/Compose Version bereits Standard
// composeCompiler { }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
@@ -142,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
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -162,18 +168,21 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }
// v1.3.1: ktlint deaktiviert wegen Parser-Problemen // v1.6.1: ktlint reaktiviert nach Code-Cleanup
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde ktlint {
// ktlint { android = true
// android = true outputToConsole = true
// outputToConsole = true ignoreFailures = true // Parser-Probleme in WebDavSyncService.kt und build.gradle.kts
// ignoreFailures = true enableExperimentalRules = false
// enableExperimentalRules = false
// filter { filter {
// exclude("**/generated/**") exclude("**/generated/**")
// exclude("**/build/**") exclude("**/build/**")
// } // Legacy adapters with ktlint parser issues
// } exclude("**/adapters/NotesAdapter.kt")
exclude("**/SettingsActivity.kt")
}
}
// ⚡ v1.3.1: detekt-Konfiguration // ⚡ v1.3.1: detekt-Konfiguration
detekt { detekt {

View File

@@ -61,3 +61,7 @@
# Keep your app's data classes # Keep your app's data classes
-keep class dev.dettmer.simplenotes.** { *; } -keep class dev.dettmer.simplenotes.** { *; }
# v1.7.1: Suppress TextInclusionStrategy warnings on older Android versions
# This class only exists on API 35+ but Compose handles the fallback gracefully
-dontwarn android.text.Layout$TextInclusionStrategy

View File

@@ -12,6 +12,11 @@
<!-- Battery Optimization (for WorkManager background sync) --> <!-- Battery Optimization (for WorkManager background sync) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- v1.7.1: Foreground Service for Expedited Work (Android 9-11) -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!-- v1.7.1: Foreground Service Type for Android 10+ -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! --> <!-- BOOT Permission - CRITICAL für Auto-Sync nach Neustart! -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -91,6 +96,12 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<!-- v1.7.1: WorkManager SystemForegroundService for Expedited Work -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application> </application>
</manifest> </manifest>

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.Manifest import android.Manifest
@@ -48,6 +50,11 @@ import android.view.Gravity
import android.widget.PopupMenu import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
/**
* Legacy MainActivity - DEPRECATED seit v1.5.0, wird entfernt in v2.0.0
* Ersetzt durch ComposeMainActivity
*/
@Suppress("DEPRECATION") // Legacy code using LocalBroadcastManager, will be removed in v2.0.0
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var recyclerViewNotes: RecyclerView private lateinit var recyclerViewNotes: RecyclerView
@@ -126,6 +133,9 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission() requestNotificationPermission()
} }
// 🌍 v1.7.2: Debug Locale für Fehlersuche
logLocaleInfo()
findViews() findViews()
setupToolbar() setupToolbar()
setupRecyclerView() setupRecyclerView()
@@ -385,13 +395,13 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check") Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
SyncStateManager.markCompleted("Bereits synchronisiert") SyncStateManager.markCompleted(getString(R.string.snackbar_already_synced))
return@launch return@launch
} }
// Check if server is reachable // Check if server is reachable
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
SyncStateManager.markError("Server nicht erreichbar") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch return@launch
} }
@@ -399,7 +409,7 @@ class MainActivity : AppCompatActivity() {
val result = syncService.syncNotes() val result = syncService.syncNotes()
if (result.isSuccess) { if (result.isSuccess) {
SyncStateManager.markCompleted("${result.syncedCount} Notizen") SyncStateManager.markCompleted(getString(R.string.snackbar_synced_count, result.syncedCount))
loadNotes() loadNotes()
} else { } else {
SyncStateManager.markError(result.errorMessage) SyncStateManager.markError(result.errorMessage)
@@ -665,7 +675,8 @@ class MainActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping") Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
SyncStateManager.markCompleted("Bereits synchronisiert") val message = getString(R.string.toast_already_synced)
SyncStateManager.markCompleted(message)
return@launch return@launch
} }
@@ -676,7 +687,7 @@ class MainActivity : AppCompatActivity() {
if (!isReachable) { if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting") Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError("Server nicht erreichbar") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
return@launch return@launch
} }
@@ -807,4 +818,39 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
/**
* 🌍 v1.7.2: Debug-Logging für Locale-Problem
* Hilft zu identifizieren warum deutsche Strings trotz englischer App angezeigt werden
*/
private fun logLocaleInfo() {
if (!BuildConfig.DEBUG) return
Logger.d(TAG, "╔═══════════════════════════════════════════════════")
Logger.d(TAG, "║ 🌍 LOCALE DEBUG INFO")
Logger.d(TAG, "╠═══════════════════════════════════════════════════")
// System Locale
val systemLocale = java.util.Locale.getDefault()
Logger.d(TAG, "║ System Locale (Locale.getDefault()): $systemLocale")
// Resources Locale
val resourcesLocale = resources.configuration.locales[0]
Logger.d(TAG, "║ Resources Locale: $resourcesLocale")
// Context Locale (API 24+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val contextLocales = resources.configuration.locales
Logger.d(TAG, "║ Context Locales (all): $contextLocales")
}
// Test String Loading
val testString = getString(R.string.toast_already_synced)
Logger.d(TAG, "║ Test: getString(R.string.toast_already_synced)")
Logger.d(TAG, "║ Result: '$testString'")
Logger.d(TAG, "║ Expected EN: '✅ Already synced'")
Logger.d(TAG, "║ Is German?: ${testString.contains("Bereits") || testString.contains("synchronisiert")}")
Logger.d(TAG, "╚═══════════════════════════════════════════════════")
}
} }

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
package dev.dettmer.simplenotes package dev.dettmer.simplenotes
import android.app.ProgressDialog import android.app.ProgressDialog
@@ -42,6 +44,7 @@ import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@Suppress("LargeClass", "DEPRECATION") // Legacy code using ProgressDialog & LocalBroadcastManager, will be removed in v2.0.0
class SettingsActivity : AppCompatActivity() { class SettingsActivity : AppCompatActivity() {
companion object { companion object {
@@ -596,7 +599,7 @@ class SettingsActivity : AppCompatActivity() {
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization) // 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) { if (!syncService.hasUnsyncedChanges()) {
showToast("✅ Bereits synchronisiert") showToast(getString(R.string.toast_already_synced))
SyncStateManager.markCompleted() SyncStateManager.markCompleted()
return@launch return@launch
} }
@@ -605,8 +608,8 @@ class SettingsActivity : AppCompatActivity() {
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern) // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) { if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar") showToast("⚠️ ${getString(R.string.snackbar_server_unreachable)}")
SyncStateManager.markError("Server nicht erreichbar") SyncStateManager.markError(getString(R.string.snackbar_server_unreachable))
checkServerStatus() // Server-Status aktualisieren checkServerStatus() // Server-Status aktualisieren
return@launch return@launch
} }

View File

@@ -2,6 +2,7 @@ package dev.dettmer.simplenotes
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
@@ -15,11 +16,29 @@ class SimpleNotesApplication : Application() {
lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity lateinit var networkMonitor: NetworkMonitor // Public access für SettingsActivity
/**
* 🌍 v1.7.1: Apply app locale to Application Context
*
* This ensures ViewModels and other components using Application Context
* get the correct locale-specific strings.
*/
override fun attachBaseContext(base: Context) {
// Apply the app locale before calling super
// This is handled by AppCompatDelegate which reads from system storage
super.attachBaseContext(base)
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// 🔧 Hotfix v1.6.2: Migrate offline mode setting BEFORE any ViewModel initialization
// This prevents the offline mode bug where users updating from v1.5.0 incorrectly
// appear as offline even though they have a configured server
migrateOfflineModeSetting(prefs)
// File-Logging ZUERST aktivieren (damit alle Logs geschrieben werden!)
if (prefs.getBoolean("file_logging_enabled", false)) { if (prefs.getBoolean("file_logging_enabled", false)) {
Logger.enableFileLogging(this) Logger.enableFileLogging(this)
Logger.d(TAG, "📝 File logging enabled at Application startup") Logger.d(TAG, "📝 File logging enabled at Application startup")
@@ -50,4 +69,30 @@ class SimpleNotesApplication : Application() {
// WorkManager läuft weiter auch nach onTerminate! // WorkManager läuft weiter auch nach onTerminate!
// Nur bei deaktiviertem Auto-Sync stoppen wir es // Nur bei deaktiviertem Auto-Sync stoppen wir es
} }
/**
* 🔧 Hotfix v1.6.2: Migrate offline mode setting for updates from v1.5.0
*
* Problem: KEY_OFFLINE_MODE didn't exist in v1.5.0, but MainViewModel
* and NoteEditorViewModel use `true` as default, causing existing users
* with configured servers to appear in offline mode after update.
*
* Fix: Set the key BEFORE any ViewModel is initialized based on whether
* a server was already configured.
*/
private fun migrateOfflineModeSetting(prefs: android.content.SharedPreferences) {
if (!prefs.contains(Constants.KEY_OFFLINE_MODE)) {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
val hasServerConfig = !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
// If server was configured → offlineMode = false (continue syncing)
// If no server → offlineMode = true (new users / offline users)
val offlineModeValue = !hasServerConfig
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, offlineModeValue).apply()
Logger.i(TAG, "🔄 Migrated offline_mode_enabled: hasServer=$hasServerConfig → offlineMode=$offlineModeValue")
}
}
} }

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

@@ -124,6 +124,26 @@ class NotesStorage(private val context: Context) {
Logger.d(TAG, "🗑️ Deletion tracker cleared") Logger.d(TAG, "🗑️ Deletion tracker cleared")
} }
/**
* 🔄 v1.7.0: Reset all sync statuses to PENDING when server changes
* This ensures notes are uploaded to the new server on next sync
*/
fun resetAllSyncStatusToPending(): Int {
val notes = loadAllNotes()
var updatedCount = 0
notes.forEach { note ->
if (note.syncStatus == dev.dettmer.simplenotes.models.SyncStatus.SYNCED) {
val updatedNote = note.copy(syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING)
saveNote(updatedNote)
updatedCount++
}
}
Logger.d(TAG, "🔄 Reset sync status for $updatedCount notes to PENDING")
return updatedCount
}
fun getNotesDir(): File = notesDir fun getNotesDir(): File = notesDir
} }

View File

@@ -9,6 +9,7 @@ import dev.dettmer.simplenotes.utils.Logger
/** /**
* BootReceiver: Startet WorkManager nach Device Reboot * BootReceiver: Startet WorkManager nach Device Reboot
* CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT! * CRITICAL: Ohne diesen Receiver funktioniert Auto-Sync nach Reboot NICHT!
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_BOOT
*/ */
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@@ -24,16 +25,22 @@ class BootReceiver : BroadcastReceiver() {
Logger.d(TAG, "📱 BOOT_COMPLETED received") Logger.d(TAG, "📱 BOOT_COMPLETED received")
// Prüfe ob Auto-Sync aktiviert ist
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
if (!autoSyncEnabled) { // 🌟 v1.6.0: Check if Boot trigger is enabled
Logger.d(TAG, "❌ Auto-sync disabled - not starting WorkManager") if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)) {
Logger.d(TAG, "⏭️ Boot sync disabled - not starting WorkManager")
return return
} }
Logger.d(TAG, "🚀 Auto-sync enabled - starting WorkManager") // Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
Logger.d(TAG, "⏭️ Offline mode - not starting WorkManager")
return
}
Logger.d(TAG, "🚀 Boot sync enabled - starting WorkManager")
// WorkManager neu starten // WorkManager neu starten
val networkMonitor = NetworkMonitor(context.applicationContext) val networkMonitor = NetworkMonitor(context.applicationContext)

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)")
@@ -102,8 +107,22 @@ class NetworkMonitor(private val context: Context) {
/** /**
* Triggert WiFi-Connect Sync via WorkManager * Triggert WiFi-Connect Sync via WorkManager
* WorkManager wacht App auf (funktioniert auch wenn App geschlossen!) * WorkManager wacht App auf (funktioniert auch wenn App geschlossen!)
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_WIFI_CONNECT
*/ */
private fun triggerWifiConnectSync() { private fun triggerWifiConnectSync() {
// 🌟 v1.6.0: Check if WiFi-Connect trigger is enabled
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)) {
Logger.d(TAG, "⏭️ WiFi-Connect sync disabled - skipping")
return
}
// Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
Logger.d(TAG, "⏭️ Offline mode - skipping WiFi-Connect sync")
return
}
Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager") Logger.d(TAG, "📡 Scheduling WiFi-Connect sync via WorkManager")
// 🔥 WICHTIG: NetworkType.UNMETERED constraint! // 🔥 WICHTIG: NetworkType.UNMETERED constraint!
@@ -126,30 +145,80 @@ class NetworkMonitor(private val context: Context) {
/** /**
* Startet WorkManager mit Network Constraints + NetworkCallback * Startet WorkManager mit Network Constraints + NetworkCallback
*
* 🆕 v1.7.0: Überarbeitete Logik - WiFi-Connect Trigger funktioniert UNABHÄNGIG von KEY_AUTO_SYNC
* - KEY_AUTO_SYNC + KEY_SYNC_TRIGGER_PERIODIC → Periodic Sync
* - KEY_SYNC_TRIGGER_WIFI_CONNECT → WiFi-Connect Trigger (unabhängig!)
*/ */
fun startMonitoring() { fun startMonitoring() {
val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) Logger.d(TAG, "🚀 NetworkMonitor.startMonitoring() called")
if (!autoSyncEnabled) { val autoSyncEnabled = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
Logger.d(TAG, "Auto-sync disabled - stopping all monitoring") val periodicEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
stopMonitoring() val wifiConnectEnabled = prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
return
Logger.d(TAG, " Settings: autoSync=$autoSyncEnabled, periodic=$periodicEnabled, wifiConnect=$wifiConnectEnabled")
// 1. Periodic Sync (nur wenn KEY_AUTO_SYNC UND KEY_SYNC_TRIGGER_PERIODIC aktiv)
if (autoSyncEnabled && periodicEnabled) {
Logger.d(TAG, "📅 Starting periodic sync...")
startPeriodicSync()
} else {
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
Logger.d(TAG, "⏭️ Periodic sync disabled (autoSync=$autoSyncEnabled, periodic=$periodicEnabled)")
} }
Logger.d(TAG, "🚀 Starting NetworkMonitor (WorkManager + WiFi Callback)") // 2. WiFi-Connect Trigger (🆕 UNABHÄNGIG von KEY_AUTO_SYNC!)
if (wifiConnectEnabled) {
// 1. WorkManager für periodic sync Logger.d(TAG, "📶 Starting WiFi monitoring...")
startPeriodicSync()
// 2. NetworkCallback für WiFi-Connect Detection
startWifiMonitoring() startWifiMonitoring()
} else {
stopWifiMonitoring()
Logger.d(TAG, "⏭️ WiFi-Connect trigger disabled")
}
// 3. Logging für Debug
if (!autoSyncEnabled && !wifiConnectEnabled) {
Logger.d(TAG, "🛑 No background triggers active")
}
}
/**
* 🆕 v1.7.0: Stoppt nur WiFi-Monitoring, nicht den gesamten NetworkMonitor
*/
@Suppress("SwallowedException")
private fun stopWifiMonitoring() {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
Logger.d(TAG, "🛑 WiFi NetworkCallback unregistered")
} catch (e: Exception) {
// Already unregistered - das ist OK
Logger.d(TAG, " WiFi callback already unregistered")
}
} }
/** /**
* Startet WorkManager periodic sync * Startet WorkManager periodic sync
* 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min) * 🔥 Interval aus SharedPrefs konfigurierbar (15/30/60 min)
* v1.6.0: Configurable trigger - checks KEY_SYNC_TRIGGER_PERIODIC
*/ */
private fun startPeriodicSync() { private fun startPeriodicSync() {
// 🌟 v1.6.0: Check if Periodic trigger is enabled
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)) {
Logger.d(TAG, "⏭️ Periodic sync disabled - skipping")
// Cancel existing periodic work if disabled
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
return
}
// Check if server is configured
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
Logger.d(TAG, "⏭️ Offline mode - skipping Periodic sync")
WorkManager.getInstance(context).cancelUniqueWork(AUTO_SYNC_WORK_NAME)
return
}
// 🔥 Interval aus SharedPrefs lesen // 🔥 Interval aus SharedPrefs lesen
val intervalMinutes = prefs.getLong( val intervalMinutes = prefs.getLong(
Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.PREF_SYNC_INTERVAL_MINUTES,

View File

@@ -0,0 +1,105 @@
package dev.dettmer.simplenotes.sync
import com.thegrizzlylabs.sardineandroid.DavResource
import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import dev.dettmer.simplenotes.utils.Logger
import okhttp3.Credentials
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
/**
* 🔧 v1.7.1: Wrapper für Sardine der Connection Leaks verhindert
*
* Hintergrund:
* - OkHttpSardine.exists() schließt den Response-Body nicht
* - Dies führt zu "connection leaked" Warnungen im Log
* - Kann bei vielen Requests zu Socket-Exhaustion führen
*
* Lösung:
* - Eigene exists()-Implementation mit korrektem Response-Cleanup
* - Preemptive Authentication um 401-Round-Trips zu vermeiden
*
* @see <a href="https://square.github.io/okhttp/4.x/okhttp/okhttp3/-response-body/">OkHttp Response Body Docs</a>
*/
class SafeSardineWrapper private constructor(
private val delegate: OkHttpSardine,
private val okHttpClient: OkHttpClient,
private val authHeader: String
) : Sardine by delegate {
companion object {
private const val TAG = "SafeSardine"
/**
* Factory-Methode für SafeSardineWrapper
*/
fun create(
okHttpClient: OkHttpClient,
username: String,
password: String
): SafeSardineWrapper {
val delegate = OkHttpSardine(okHttpClient).apply {
setCredentials(username, password)
}
val authHeader = Credentials.basic(username, password)
return SafeSardineWrapper(delegate, okHttpClient, authHeader)
}
}
/**
* ✅ Sichere exists()-Implementation mit Response Cleanup
*
* Im Gegensatz zu OkHttpSardine.exists() wird hier:
* 1. Preemptive Auth-Header gesendet (kein 401 Round-Trip)
* 2. Response.use{} für garantiertes Cleanup verwendet
*/
override fun exists(url: String): Boolean {
val request = Request.Builder()
.url(url)
.head()
.header("Authorization", authHeader)
.build()
return try {
okHttpClient.newCall(request).execute().use { response ->
val isSuccess = response.isSuccessful
Logger.d(TAG, "exists($url) → $isSuccess (${response.code})")
isSuccess
}
} catch (e: Exception) {
Logger.d(TAG, "exists($url) failed: ${e.message}")
false
}
}
/**
* ✅ Wrapper um get() mit Logging
*
* WICHTIG: Der zurückgegebene InputStream MUSS vom Caller geschlossen werden!
* Empfohlen: inputStream.bufferedReader().use { it.readText() }
*/
override fun get(url: String): InputStream {
Logger.d(TAG, "get($url)")
return delegate.get(url)
}
/**
* ✅ Wrapper um list() mit Logging
*/
override fun list(url: String): List<DavResource> {
Logger.d(TAG, "list($url)")
return delegate.list(url)
}
/**
* ✅ Wrapper um list(url, depth) mit Logging
*/
override fun list(url: String, depth: Int): List<DavResource> {
Logger.d(TAG, "list($url, depth=$depth)")
return delegate.list(url, depth)
}
// Alle anderen Methoden werden automatisch durch 'by delegate' weitergeleitet
}

View File

@@ -1,12 +1,18 @@
@file:Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional, will migrate in v2.0.0
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
import android.app.ActivityManager import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.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
@@ -23,6 +29,35 @@ class SyncWorker(
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED" const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
} }
/**
* 🔧 v1.7.2: Required for expedited work on Android 9-11
*
* WorkManager ruft diese Methode auf um die Foreground-Notification zu erstellen
* wenn der Worker als Expedited Work gestartet wird.
*
* Ab Android 12+ wird diese Methode NICHT aufgerufen (neue Expedited API).
* Auf Android 9-11 MUSS diese Methode implementiert sein!
*
* @see https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#foregroundinfo
*/
override suspend fun getForegroundInfo(): ForegroundInfo {
val notification = NotificationHelper.createSyncProgressNotification(applicationContext)
// Android 10+ benötigt foregroundServiceType
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
ForegroundInfo(
NotificationHelper.SYNC_PROGRESS_NOTIFICATION_ID,
notification
)
}
}
/** /**
* Prüft ob die App im Vordergrund ist. * Prüft ob die App im Vordergrund ist.
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt. * Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
@@ -86,6 +121,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)")
} }
@@ -255,6 +311,7 @@ class SyncWorker(
/** /**
* Sendet Broadcast an MainActivity für UI Refresh * Sendet Broadcast an MainActivity für UI Refresh
*/ */
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but still functional, will migrate in v2.0.0
private fun broadcastSyncCompleted(success: Boolean, count: Int) { private fun broadcastSyncCompleted(success: Boolean, count: Int) {
val intent = Intent(ACTION_SYNC_COMPLETED).apply { val intent = Intent(ACTION_SYNC_COMPLETED).apply {
putExtra("success", success) putExtra("success", success)

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import com.thegrizzlylabs.sardineandroid.Sardine import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.DeletionTracker import dev.dettmer.simplenotes.models.DeletionTracker
@@ -35,11 +34,13 @@ data class ManualMarkdownSyncResult(
val importedCount: Int val importedCount: Int
) )
@Suppress("LargeClass")
// TODO v2.0.0: Split into SyncOrchestrator, NoteUploader, NoteDownloader, ConflictResolver
class WebDavSyncService(private val context: Context) { 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 = 10000 // 🔧 v1.7.2: 10s für stabile Verbindungen (1s war zu kurz)
private const val MAX_FILENAME_LENGTH = 200 private const val MAX_FILENAME_LENGTH = 200
private const val ETAG_PREVIEW_LENGTH = 8 private const val ETAG_PREVIEW_LENGTH = 8
private const val CONTENT_PREVIEW_LENGTH = 50 private const val CONTENT_PREVIEW_LENGTH = 50
@@ -54,9 +55,7 @@ class WebDavSyncService(private val context: Context) {
private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz private var notesDirEnsured = false // ⚡ v1.3.1: Cache für /notes/ Ordner-Existenz
// ⚡ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert) // ⚡ v1.3.1 Performance: Session-Caches (werden am Ende von syncNotes() geleert)
private var sessionSardine: Sardine? = null private var sessionSardine: SafeSardineWrapper? = null
private var sessionWifiAddress: InetAddress? = null
private var sessionWifiAddressChecked = false // Flag ob WiFi-Check bereits durchgeführt
init { init {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
@@ -89,121 +88,37 @@ class WebDavSyncService(private val context: Context) {
} }
/** /**
* v1.3.1: Gecachte WiFi-Adresse zurückgeben oder berechnen * 🔒 v1.7.1: Checks if any VPN/Wireguard interface is active.
*
* Wireguard VPNs run as separate network interfaces (tun*, wg*, *-wg-*),
* and are NOT detected via NetworkCapabilities.TRANSPORT_VPN!
*
* @return true if VPN interface is detected
*/ */
private fun getOrCacheWiFiAddress(): InetAddress? { private fun isVpnInterfaceActive(): Boolean {
// Return cached if already checked this session
if (sessionWifiAddressChecked) {
return sessionWifiAddress
}
// Calculate and cache
sessionWifiAddress = getWiFiInetAddressInternal()
sessionWifiAddressChecked = true
return sessionWifiAddress
}
/**
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
*/
@Suppress("ReturnCount") // Early returns for network validation checks
private fun getWiFiInetAddressInternal(): InetAddress? {
try { try {
Logger.d(TAG, "🔍 getWiFiInetAddress() called") val interfaces = NetworkInterface.getNetworkInterfaces() ?: return false
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork
Logger.d(TAG, " Active network: $network")
if (network == null) {
Logger.d(TAG, "❌ No active network")
return null
}
val capabilities = connectivityManager.getNetworkCapabilities(network)
Logger.d(TAG, " Network capabilities: $capabilities")
if (capabilities == null) {
Logger.d(TAG, "❌ No network capabilities")
return null
}
// Nur wenn WiFi aktiv
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
Logger.d(TAG, "⚠️ Not on WiFi, using default routing")
return null
}
Logger.d(TAG, "✅ Network is WiFi, searching for interface...")
// Finde WiFi Interface
val interfaces = NetworkInterface.getNetworkInterfaces()
while (interfaces.hasMoreElements()) { while (interfaces.hasMoreElements()) {
val iface = interfaces.nextElement() val iface = interfaces.nextElement()
Logger.d(TAG, " Checking interface: ${iface.name}, isUp=${iface.isUp}")
// WiFi Interfaces: wlan0, wlan1, etc.
if (!iface.name.startsWith("wlan")) continue
if (!iface.isUp) continue if (!iface.isUp) continue
val addresses = iface.inetAddresses val name = iface.name.lowercase()
while (addresses.hasMoreElements()) { // Check for VPN/Wireguard interface patterns:
val addr = addresses.nextElement() // - tun0, tun1, etc. (OpenVPN, generic VPN)
// - wg0, wg1, etc. (Wireguard)
Logger.d( // - *-wg-* (Mullvad, ProtonVPN style: se-sto-wg-202)
TAG, if (name.startsWith("tun") ||
" Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, " + name.startsWith("wg") ||
"loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}" name.contains("-wg-") ||
) name.startsWith("ppp")) {
Logger.d(TAG, "🔒 VPN interface detected: ${iface.name}")
// Nur IPv4, nicht loopback, nicht link-local return true
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
Logger.d(TAG, "✅ Found WiFi IP: ${addr.hostAddress} on ${iface.name}")
return addr
} }
} }
}
Logger.w(TAG, "⚠️ No WiFi interface found, using default routing")
return null
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, " Failed to get WiFi interface", e) Logger.w(TAG, "⚠️ Failed to check VPN interfaces: ${e.message}")
return null
}
}
/**
* Custom SocketFactory die an WiFi-IP bindet (VPN Fix)
*/
private inner class WiFiSocketFactory(private val wifiAddress: InetAddress) : SocketFactory() {
override fun createSocket(): Socket {
val socket = Socket()
socket.bind(InetSocketAddress(wifiAddress, 0))
Logger.d(TAG, "🔌 Socket bound to WiFi IP: ${wifiAddress.hostAddress}")
return socket
}
override fun createSocket(host: String, port: Int): Socket {
val socket = createSocket()
socket.connect(InetSocketAddress(host, port))
return socket
}
override fun createSocket(host: String, port: Int, localHost: InetAddress, localPort: Int): Socket {
return createSocket(host, port)
}
override fun createSocket(host: InetAddress, port: Int): Socket {
val socket = createSocket()
socket.connect(InetSocketAddress(host, port))
return socket
}
override fun createSocket(address: InetAddress, port: Int, localAddress: InetAddress, localPort: Int): Socket {
return createSocket(address, port)
} }
return false
} }
/** /**
@@ -225,30 +140,26 @@ class WebDavSyncService(private val context: Context) {
/** /**
* Erstellt einen neuen Sardine-Client (intern) * Erstellt einen neuen Sardine-Client (intern)
*
* 🆕 v1.7.2: Intelligentes Routing basierend auf Ziel-Adresse
* - Lokale Server: WiFi-Binding (bypass VPN)
* - Externe Server: Default-Routing (nutzt VPN wenn aktiv)
*
* 🔧 v1.7.1: Verwendet SafeSardineWrapper statt OkHttpSardine
* - Verhindert Connection Leaks durch proper Response-Cleanup
* - Preemptive Authentication für weniger 401-Round-Trips
*/ */
private fun createSardineClient(): Sardine? { private fun createSardineClient(): SafeSardineWrapper? {
val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null val username = prefs.getString(Constants.KEY_USERNAME, null) ?: return null
val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null val password = prefs.getString(Constants.KEY_PASSWORD, null) ?: return null
Logger.d(TAG, "🔧 Creating OkHttpSardine with WiFi binding") Logger.d(TAG, "🔧 Creating SafeSardineWrapper")
Logger.d(TAG, " Context: ${context.javaClass.simpleName}")
// ⚡ v1.3.1: Verwende gecachte WiFi-Adresse val okHttpClient = OkHttpClient.Builder()
val wifiAddress = getOrCacheWiFiAddress() .connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
val okHttpClient = if (wifiAddress != null) {
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
OkHttpClient.Builder()
.socketFactory(WiFiSocketFactory(wifiAddress))
.build() .build()
} else {
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
OkHttpClient.Builder().build()
}
return OkHttpSardine(okHttpClient).apply { return SafeSardineWrapper.create(okHttpClient, username, password)
setCredentials(username, password)
}
} }
/** /**
@@ -256,8 +167,6 @@ class WebDavSyncService(private val context: Context) {
*/ */
private fun clearSessionCache() { private fun clearSessionCache() {
sessionSardine = null sessionSardine = null
sessionWifiAddress = null
sessionWifiAddressChecked = false
notesDirEnsured = false notesDirEnsured = false
markdownDirEnsured = false markdownDirEnsured = false
Logger.d(TAG, "🧹 Session caches cleared") Logger.d(TAG, "🧹 Session caches cleared")
@@ -384,8 +293,10 @@ class WebDavSyncService(private val context: Context) {
} }
val notesUrl = getNotesUrl(serverUrl) val notesUrl = getNotesUrl(serverUrl)
// 🔧 v1.7.2: Exception wird NICHT gefangen - muss nach oben propagieren!
// Wenn sardine.exists() timeout hat, soll hasUnsyncedChanges() das behandeln
if (!sardine.exists(notesUrl)) { if (!sardine.exists(notesUrl)) {
Logger.d(TAG, "📁 /notes/ doesn't exist - no server changes") Logger.d(TAG, "📁 /notes/ doesn't exist - assuming no server changes")
return false return false
} }
@@ -514,8 +425,11 @@ class WebDavSyncService(private val context: Context) {
hasServerChanges hasServerChanges
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to check for unsynced changes", e) // 🔧 v1.7.2 KRITISCH: Bei Server-Fehler (Timeout, etc.) return TRUE!
true // Safe default // Grund: Besser fälschlich synchen als "Already synced" zeigen obwohl Server nicht erreichbar
Logger.e(TAG, "❌ Failed to check server for changes: ${e.message}")
Logger.d(TAG, "⚠️ Returning TRUE (will attempt sync) - server check failed")
true // Sicherheitshalber TRUE → Sync wird versucht und gibt dann echte Fehlermeldung
} }
} }
@@ -553,6 +467,63 @@ class WebDavSyncService(private val context: Context) {
} }
} }
/**
* 🆕 v1.7.0: Prüft ob Gerät aktuell im WLAN ist
* Für schnellen Pre-Check VOR dem langsamen Socket-Check
*
* @return true wenn WLAN verbunden, false sonst (mobil oder kein Netzwerk)
*/
fun isOnWiFi(): Boolean {
return try {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as? ConnectivityManager ?: return false
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} catch (e: Exception) {
Logger.e(TAG, "Failed to check WiFi state", e)
false
}
}
/**
* 🆕 v1.7.0: Zentrale Sync-Gate Prüfung
* Prüft ALLE Voraussetzungen bevor ein Sync gestartet wird.
* Diese Funktion sollte VOR jedem syncNotes() Aufruf verwendet werden.
*
* @return SyncGateResult mit canSync flag und optionalem Blockierungsgrund
*/
fun canSync(): SyncGateResult {
// 1. Offline Mode Check
if (prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)) {
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
}
// 2. Server configured?
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty() || serverUrl == "http://" || serverUrl == "https://") {
return SyncGateResult(canSync = false, blockReason = null) // Silent skip
}
// 3. WiFi-Only Check
val wifiOnlySync = prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
if (wifiOnlySync && !isOnWiFi()) {
return SyncGateResult(canSync = false, blockReason = "wifi_only")
}
return SyncGateResult(canSync = true, blockReason = null)
}
/**
* 🆕 v1.7.0: Result-Klasse für canSync()
*/
data class SyncGateResult(
val canSync: Boolean,
val blockReason: String? = null
) {
val isBlockedByWifiOnly: Boolean get() = blockReason == "wifi_only"
}
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) { 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(
@@ -581,19 +552,19 @@ class WebDavSyncService(private val context: Context) {
SyncResult( SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = when (e) { errorMessage = when (e) {
is java.net.UnknownHostException -> "Server nicht erreichbar" is java.net.UnknownHostException -> context.getString(R.string.snackbar_server_unreachable)
is java.net.SocketTimeoutException -> "Verbindungs-Timeout" is java.net.SocketTimeoutException -> context.getString(R.string.snackbar_connection_timeout)
is javax.net.ssl.SSLException -> "SSL-Fehler" is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl)
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> { is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
when (e.statusCode) { when (e.statusCode) {
401 -> "Authentifizierung fehlgeschlagen" 401 -> context.getString(R.string.sync_error_auth_failed)
403 -> "Zugriff verweigert" 403 -> context.getString(R.string.sync_error_access_denied)
404 -> "Server-Pfad nicht gefunden" 404 -> context.getString(R.string.sync_error_path_not_found)
500 -> "Server-Fehler" 500 -> context.getString(R.string.sync_error_server)
else -> "HTTP-Fehler: ${e.statusCode}" else -> context.getString(R.string.sync_error_http, e.statusCode)
} }
} }
else -> e.message ?: "Unbekannter Fehler" else -> e.message ?: context.getString(R.string.sync_error_unknown)
} }
) )
} }
@@ -756,19 +727,19 @@ class WebDavSyncService(private val context: Context) {
SyncResult( SyncResult(
isSuccess = false, isSuccess = false,
errorMessage = when (e) { errorMessage = when (e) {
is java.net.UnknownHostException -> "Server nicht erreichbar: ${e.message}" is java.net.UnknownHostException -> "${context.getString(R.string.snackbar_server_unreachable)}: ${e.message}"
is java.net.SocketTimeoutException -> "Verbindungs-Timeout: ${e.message}" is java.net.SocketTimeoutException -> "${context.getString(R.string.snackbar_connection_timeout)}: ${e.message}"
is javax.net.ssl.SSLException -> "SSL-Fehler" is javax.net.ssl.SSLException -> context.getString(R.string.sync_error_ssl)
is com.thegrizzlylabs.sardineandroid.impl.SardineException -> { is com.thegrizzlylabs.sardineandroid.impl.SardineException -> {
when (e.statusCode) { when (e.statusCode) {
401 -> "Authentifizierung fehlgeschlagen" 401 -> context.getString(R.string.sync_error_auth_failed)
403 -> "Zugriff verweigert" 403 -> context.getString(R.string.sync_error_access_denied)
404 -> "Server-Pfad nicht gefunden" 404 -> context.getString(R.string.sync_error_path_not_found)
500 -> "Server-Fehler" 500 -> context.getString(R.string.sync_error_server)
else -> "HTTP-Fehler: ${e.statusCode}" else -> context.getString(R.string.sync_error_http, e.statusCode)
} }
} }
else -> e.message ?: "Unbekannter Fehler" else -> e.message ?: context.getString(R.string.sync_error_unknown)
} }
) )
} }
@@ -780,6 +751,8 @@ class WebDavSyncService(private val context: Context) {
} }
} }
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Sync logic requires nested conditions for comprehensive error handling and state management
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
var uploadedCount = 0 var uploadedCount = 0
val localNotes = storage.loadAllNotes() val localNotes = storage.loadAllNotes()
@@ -948,22 +921,11 @@ class WebDavSyncService(private val context: Context) {
): Int = withContext(Dispatchers.IO) { ): Int = withContext(Dispatchers.IO) {
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...") Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
// ⚡ v1.3.1: Use cached WiFi address val okHttpClient = OkHttpClient.Builder()
val wifiAddress = getOrCacheWiFiAddress() .connectTimeout(SOCKET_TIMEOUT_MS.toLong(), java.util.concurrent.TimeUnit.MILLISECONDS)
val okHttpClient = if (wifiAddress != null) {
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
OkHttpClient.Builder()
.socketFactory(WiFiSocketFactory(wifiAddress))
.build() .build()
} else {
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
OkHttpClient.Builder().build()
}
val sardine = OkHttpSardine(okHttpClient).apply { val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
setCredentials(username, password)
}
val mdUrl = getMarkdownUrl(serverUrl) val mdUrl = getMarkdownUrl(serverUrl)
@@ -1022,6 +984,8 @@ class WebDavSyncService(private val context: Context) {
val conflictCount: Int val conflictCount: Int
) )
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Sync logic requires nested conditions for comprehensive error handling and conflict resolution
private fun downloadRemoteNotes( private fun downloadRemoteNotes(
sardine: Sardine, sardine: Sardine,
serverUrl: String, serverUrl: String,
@@ -1075,9 +1039,32 @@ class WebDavSyncService(private val context: Context) {
"modified=$serverModified lastSync=$lastSyncTime" "modified=$serverModified lastSync=$lastSyncTime"
) )
// FIRST: Check deletion tracker - if locally deleted, skip unless re-created on server
if (deletionTracker.isDeleted(noteId)) {
val deletedAt = deletionTracker.getDeletionTimestamp(noteId)
// Smart check: Was note re-created on server after deletion?
if (deletedAt != null && serverModified > deletedAt) {
Logger.d(TAG, " 📝 Note re-created on server after deletion: $noteId")
deletionTracker.removeDeletion(noteId)
trackerModified = true
// Continue with download below
} else {
Logger.d(TAG, " ⏭️ Skipping deleted note: $noteId")
skippedDeleted++
processedIds.add(noteId)
continue
}
}
// Check if file exists locally
val localNote = storage.loadNote(noteId)
val fileExistsLocally = localNote != null
// PRIMARY: Timestamp check (works on first sync!) // PRIMARY: Timestamp check (works on first sync!)
// Same logic as Markdown sync - skip if not modified since last sync // Same logic as Markdown sync - skip if not modified since last sync
if (!forceOverwrite && lastSyncTime > 0 && serverModified <= lastSyncTime) { // BUT: Always download if file doesn't exist locally!
if (!forceOverwrite && fileExistsLocally && lastSyncTime > 0 && serverModified <= lastSyncTime) {
skippedUnchanged++ skippedUnchanged++
Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)") Logger.d(TAG, " ⏭️ Skipping $noteId: Not modified since last sync (timestamp)")
processedIds.add(noteId) processedIds.add(noteId)
@@ -1086,13 +1073,19 @@ class WebDavSyncService(private val context: Context) {
// SECONDARY: E-Tag check (for performance after first sync) // SECONDARY: E-Tag check (for performance after first sync)
// Catches cases where file was re-uploaded with same content // Catches cases where file was re-uploaded with same content
if (!forceOverwrite && serverETag != null && serverETag == cachedETag) { // BUT: Always download if file doesn't exist locally!
if (!forceOverwrite && fileExistsLocally && serverETag != null && serverETag == cachedETag) {
skippedUnchanged++ skippedUnchanged++
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)") Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)")
processedIds.add(noteId) processedIds.add(noteId)
continue continue
} }
// If file doesn't exist locally, always download
if (!fileExistsLocally) {
Logger.d(TAG, " 📥 File missing locally - forcing download")
}
// 🐛 DEBUG: Log download reason // 🐛 DEBUG: Log download reason
val downloadReason = when { val downloadReason = when {
lastSyncTime == 0L -> "First sync ever" lastSyncTime == 0L -> "First sync ever"
@@ -1109,28 +1102,9 @@ class WebDavSyncService(private val context: Context) {
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() } val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val remoteNote = Note.fromJson(jsonContent) ?: continue val remoteNote = Note.fromJson(jsonContent) ?: continue
// NEW: Check if note was deleted locally
if (deletionTracker.isDeleted(remoteNote.id)) {
val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id)
// Smart check: Was note re-created on server after deletion?
if (deletedAt != null && remoteNote.updatedAt > deletedAt) {
Logger.d(TAG, " 📝 Note re-created on server after deletion: ${remoteNote.id}")
deletionTracker.removeDeletion(remoteNote.id)
trackerModified = true
// Continue with download below
} else {
Logger.d(TAG, " ⏭️ Skipping deleted note: ${remoteNote.id}")
skippedDeleted++
processedIds.add(remoteNote.id)
continue
}
}
processedIds.add(remoteNote.id) // 🆕 Mark as processed processedIds.add(remoteNote.id) // 🆕 Mark as processed
val localNote = storage.loadNote(remoteNote.id) // Note: localNote was already loaded above for existence check
when { when {
localNote == null -> { localNote == null -> {
// New note from server // New note from server
@@ -1473,8 +1447,8 @@ class WebDavSyncService(private val context: Context) {
return@withContext try { return@withContext try {
Logger.d(TAG, "📝 Starting Markdown sync...") Logger.d(TAG, "📝 Starting Markdown sync...")
val sardine = OkHttpSardine() val okHttpClient = OkHttpClient.Builder().build()
sardine.setCredentials(username, password) val sardine = SafeSardineWrapper.create(okHttpClient, username, password)
val mdUrl = getMarkdownUrl(serverUrl) val mdUrl = getMarkdownUrl(serverUrl)
@@ -1541,6 +1515,8 @@ class WebDavSyncService(private val context: Context) {
* *
* ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien * ⚡ v1.3.1: Performance-Optimierung - Skip unveränderte Dateien
*/ */
@Suppress("NestedBlockDepth", "LoopWithTooManyJumpStatements")
// Import logic requires nested conditions for file validation and duplicate handling
private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int { private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
return try { return try {
Logger.d(TAG, "📝 Importing Markdown files...") Logger.d(TAG, "📝 Importing Markdown files...")

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // AbstractSavedStateViewModelFactory deprecated, will migrate to viewModelFactory in v2.0.0
package dev.dettmer.simplenotes.ui.editor package dev.dettmer.simplenotes.ui.editor
import android.os.Bundle import android.os.Bundle

View File

@@ -76,10 +76,18 @@ fun NoteEditorScreen(
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
val checklistItems by viewModel.checklistItems.collectAsState() val checklistItems by viewModel.checklistItems.collectAsState()
// 🌟 v1.6.0: Offline mode state
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
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() }
@@ -108,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)
} }
@@ -233,6 +241,7 @@ fun NoteEditorScreen(
if (showDeleteDialog) { if (showDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
noteCount = 1, noteCount = 1,
isOfflineMode = isOfflineMode,
onDismiss = { showDeleteDialog = false }, onDismiss = { showDeleteDialog = false },
onDeleteLocal = { onDeleteLocal = {
showDeleteDialog = false showDeleteDialog = false
@@ -287,6 +296,7 @@ private fun TextNoteContent(
) )
} }
@Suppress("LongParameterList") // Compose functions commonly have many callback parameters
@Composable @Composable
private fun ChecklistEditor( private fun ChecklistEditor(
items: List<ChecklistItemState>, items: List<ChecklistItemState>,

View File

@@ -1,15 +1,20 @@
package dev.dettmer.simplenotes.ui.editor package dev.dettmer.simplenotes.ui.editor
import android.app.Application import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import dev.dettmer.simplenotes.models.ChecklistItem import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,6 +47,7 @@ class NoteEditorViewModel(
} }
private val storage = NotesStorage(application) private val storage = NotesStorage(application)
private val prefs = application.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// State // State
@@ -53,6 +59,12 @@ class NoteEditorViewModel(
private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList()) private val _checklistItems = MutableStateFlow<List<ChecklistItemState>>(emptyList())
val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow() val checklistItems: StateFlow<List<ChecklistItemState>> = _checklistItems.asStateFlow()
// 🌟 v1.6.0: Offline Mode State
private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
)
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Events // Events
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -108,7 +120,7 @@ class NoteEditorViewModel(
currentNoteType = try { currentNoteType = try {
NoteType.valueOf(noteTypeString) NoteType.valueOf(noteTypeString)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT") Logger.w(TAG, "Invalid note type '$noteTypeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT NoteType.TEXT
} }
@@ -284,6 +296,10 @@ class NoteEditorViewModel(
} }
_events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED)) _events.emit(NoteEditorEvent.ShowToast(ToastMessage.NOTE_SAVED))
// 🌟 v1.6.0: Trigger onSave Sync
triggerOnSaveSync()
_events.emit(NoteEditorEvent.NavigateBack) _events.emit(NoteEditorEvent.NavigateBack)
} }
} }
@@ -331,6 +347,58 @@ class NoteEditorViewModel(
} }
fun canDelete(): Boolean = existingNote != null fun canDelete(): Boolean = existingNote != null
// ═══════════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Sync Trigger - onSave
// ═══════════════════════════════════════════════════════════════════════════
/**
* Triggers sync after saving a note (if enabled and server configured)
* v1.6.0: New configurable sync trigger
* v1.7.0: Uses central canSync() gate for WiFi-only check
*
* Separate throttling (5 seconds) to prevent spam when saving multiple times
*/
private fun triggerOnSaveSync() {
// Check 1: Trigger enabled?
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)) {
Logger.d(TAG, "⏭️ onSave sync disabled - skipping")
return
}
// 🆕 v1.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ onSave sync blocked: WiFi-only mode, not on WiFi")
} else {
Logger.d(TAG, "⏭️ onSave sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
}
return
}
// Check 2: Throttling (5 seconds) to prevent spam
val lastOnSaveSyncTime = prefs.getLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, 0)
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastOnSaveSyncTime
if (timeSinceLastSync < Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS) {
val remainingSeconds = (Constants.MIN_ON_SAVE_SYNC_INTERVAL_MS - timeSinceLastSync) / 1000
Logger.d(TAG, "⏳ onSave sync throttled - wait ${remainingSeconds}s")
return
}
// Update last sync time
prefs.edit().putLong(Constants.PREF_LAST_ON_SAVE_SYNC_TIME, now).apply()
// Trigger sync via WorkManager
Logger.d(TAG, "📤 Triggering onSave sync")
val syncRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.addTag(Constants.SYNC_WORK_TAG)
.build()
WorkManager.getInstance(getApplication()).enqueue(syncRequest)
}
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════

View File

@@ -83,6 +83,7 @@ fun ChecklistItemRow(
val alpha = if (item.isChecked) 0.6f else 1.0f val alpha = if (item.isChecked) 0.6f else 1.0f
val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None val textDecoration = if (item.isChecked) TextDecoration.LineThrough else TextDecoration.None
@Suppress("MagicNumber") // UI padding values are self-explanatory
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()

View File

@@ -1,3 +1,5 @@
@file:Suppress("DEPRECATION") // LocalBroadcastManager & deprecated lifecycle methods, will migrate in v2.0.0
package dev.dettmer.simplenotes.ui.main package dev.dettmer.simplenotes.ui.main
import android.Manifest import android.Manifest
@@ -177,7 +179,15 @@ class ComposeMainActivity : ComponentActivity() {
Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers") Logger.d(TAG, "📱 ComposeMainActivity.onResume() - Registering receivers")
// 🌟 v1.6.0: Refresh offline mode state FIRST (before any sync checks)
// This ensures UI reflects current offline mode when returning from Settings
viewModel.refreshOfflineModeState()
// 🎨 v1.7.0: Refresh display mode when returning from Settings
viewModel.refreshDisplayMode()
// Register BroadcastReceiver for Background-Sync // Register BroadcastReceiver for Background-Sync
@Suppress("DEPRECATION") // LocalBroadcastManager deprecated but functional
LocalBroadcastManager.getInstance(this).registerReceiver( LocalBroadcastManager.getInstance(this).registerReceiver(
syncCompletedReceiver, syncCompletedReceiver,
IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED) IntentFilter(SyncWorker.ACTION_SYNC_COMPLETED)
@@ -203,6 +213,7 @@ class ComposeMainActivity : ComponentActivity() {
super.onPause() super.onPause()
// Unregister BroadcastReceiver // Unregister BroadcastReceiver
@Suppress("DEPRECATION")
LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(syncCompletedReceiver)
Logger.d(TAG, "📡 BroadcastReceiver unregistered") Logger.d(TAG, "📡 BroadcastReceiver unregistered")
} }
@@ -211,6 +222,7 @@ class ComposeMainActivity : ComponentActivity() {
SyncStateManager.syncStatus.observe(this) { status -> SyncStateManager.syncStatus.observe(this) { status ->
viewModel.updateSyncState(status) viewModel.updateSyncState(status)
@Suppress("MagicNumber") // UI timing delays for banner visibility
// Hide banner after delay for completed/error states // Hide banner after delay for completed/error states
when (status.state) { when (status.state) {
SyncStateManager.SyncState.COMPLETED -> { SyncStateManager.SyncState.COMPLETED -> {
@@ -330,6 +342,8 @@ class ComposeMainActivity : ComponentActivity() {
} }
} }
@Deprecated("Deprecated in API 23", ReplaceWith("Use ActivityResultContracts"))
@Suppress("DEPRECATION", "OVERRIDE_DEPRECATION")
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<out String>, permissions: Array<out String>,

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
@@ -79,16 +81,31 @@ fun MainScreen(
val selectedNotes by viewModel.selectedNotes.collectAsState() val selectedNotes by viewModel.selectedNotes.collectAsState()
val isSelectionMode by viewModel.isSelectionMode.collectAsState() val isSelectionMode by viewModel.isSelectionMode.collectAsState()
// 🌟 v1.6.0: Reactive offline mode state
val isOfflineMode by viewModel.isOfflineMode.collectAsState()
// 🎨 v1.7.0: Display mode (list or grid)
val displayMode by viewModel.displayMode.collectAsState()
// Delete confirmation dialog state // 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
// 🌟 v1.6.0: Reactive sync availability (recomposes when offline mode changes)
// Note: isOfflineMode is updated via StateFlow from MainViewModel.refreshOfflineModeState()
// which is called in ComposeMainActivity.onResume() when returning from Settings
val hasServerConfig = viewModel.hasServerConfig()
val isSyncAvailable = !isOfflineMode && hasServerConfig
val canSync = isSyncAvailable && !isSyncing
// Handle snackbar events from ViewModel // Handle snackbar events from ViewModel
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.showSnackbar.collect { data -> viewModel.showSnackbar.collect { data ->
@@ -106,9 +123,14 @@ fun MainScreen(
} }
// Phase 3: Scroll to top when new note created // Phase 3: Scroll to top when new note created
// 🎨 v1.7.0: Unterstützt beide Display-Modi (list & grid)
LaunchedEffect(scrollToTop) { LaunchedEffect(scrollToTop) {
if (scrollToTop) { if (scrollToTop) {
if (displayMode == "grid") {
gridState.animateScrollToItem(0)
} else {
listState.animateScrollToItem(0) listState.animateScrollToItem(0)
}
viewModel.resetScrollToTop() viewModel.resetScrollToTop()
} }
} }
@@ -136,7 +158,7 @@ fun MainScreen(
exit = slideOutVertically() + fadeOut() exit = slideOutVertically() + fadeOut()
) { ) {
MainTopBar( MainTopBar(
syncEnabled = !isSyncing, syncEnabled = canSync,
onSyncClick = { viewModel.triggerManualSync("toolbar") }, onSyncClick = { viewModel.triggerManualSync("toolbar") },
onSettingsClick = onOpenSettings onSettingsClick = onOpenSettings
) )
@@ -146,10 +168,10 @@ fun MainScreen(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) { paddingValues -> ) { paddingValues ->
// PullToRefreshBox wraps the content with pull-to-refresh capability // 🌟 v1.6.0: PullToRefreshBox only enabled when sync available
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isSyncing, isRefreshing = isSyncing,
onRefresh = { viewModel.triggerManualSync("pullToRefresh") }, onRefresh = { if (isSyncAvailable) viewModel.triggerManualSync("pullToRefresh") },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
@@ -166,6 +188,27 @@ fun MainScreen(
// Content: Empty state or notes list // Content: Empty state or notes list
if (notes.isEmpty()) { if (notes.isEmpty()) {
EmptyState(modifier = Modifier.weight(1f)) EmptyState(modifier = Modifier.weight(1f))
} else {
// 🎨 v1.7.0: Switch between List and Grid based on display mode
if (displayMode == "grid") {
NotesStaggeredGrid(
notes = notes,
gridState = gridState,
showSyncStatus = viewModel.isServerConfigured(),
selectedNoteIds = selectedNotes,
isSelectionMode = isSelectionMode,
modifier = Modifier.weight(1f),
onNoteClick = { note ->
if (isSelectionMode) {
viewModel.toggleNoteSelection(note.id)
} else {
onOpenNote(note.id)
}
},
onNoteLongClick = { note ->
viewModel.startSelectionMode(note.id)
}
)
} else { } else {
NotesList( NotesList(
notes = notes, notes = notes,
@@ -185,6 +228,7 @@ fun MainScreen(
) )
} }
} }
}
// FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode // FAB als TOP-LAYER - nur anzeigen wenn nicht im Selection Mode
AnimatedVisibility( AnimatedVisibility(
@@ -207,6 +251,7 @@ fun MainScreen(
if (showBatchDeleteDialog) { if (showBatchDeleteDialog) {
DeleteConfirmationDialog( DeleteConfirmationDialog(
noteCount = selectedNotes.size, noteCount = selectedNotes.size,
isOfflineMode = isOfflineMode,
onDismiss = { showBatchDeleteDialog = false }, onDismiss = { showBatchDeleteDialog = false },
onDeleteLocal = { onDeleteLocal = {
viewModel.deleteSelectedNotes(deleteFromServer = false) viewModel.deleteSelectedNotes(deleteFromServer = false)

View File

@@ -62,6 +62,45 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
.map { it.isNotEmpty() } .map { it.isNotEmpty() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
// ═══════════════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline Mode State (reactive)
// ═══════════════════════════════════════════════════════════════════════
private val _isOfflineMode = MutableStateFlow(
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
)
val isOfflineMode: StateFlow<Boolean> = _isOfflineMode.asStateFlow()
/**
* Refresh offline mode state from SharedPreferences
* Called when returning from Settings screen (in onResume)
*/
fun refreshOfflineModeState() {
val oldValue = _isOfflineMode.value
val newValue = prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
_isOfflineMode.value = newValue
Logger.d(TAG, "🔄 refreshOfflineModeState: offlineMode=$oldValue$newValue")
}
// ═══════════════════════════════════════════════════════════════════════
// 🎨 v1.7.0: Display Mode State
// ═══════════════════════════════════════════════════════════════════════
private val _displayMode = MutableStateFlow(
prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
)
val displayMode: StateFlow<String> = _displayMode.asStateFlow()
/**
* Refresh display mode from SharedPreferences
* Called when returning from Settings screen
*/
fun refreshDisplayMode() {
val newValue = prefs.getString(Constants.KEY_DISPLAY_MODE, Constants.DEFAULT_DISPLAY_MODE) ?: Constants.DEFAULT_DISPLAY_MODE
_displayMode.value = newValue
Logger.d(TAG, "🔄 refreshDisplayMode: displayMode=${_displayMode.value}$newValue")
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Sync State (derived from SyncStateManager) // Sync State (derived from SyncStateManager)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -251,6 +290,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
)) ))
@Suppress("MagicNumber") // Snackbar timing coordination
// If delete from server, actually delete after a short delay // If delete from server, actually delete after a short delay
// (to allow undo action before server deletion) // (to allow undo action before server deletion)
if (deleteFromServer) { if (deleteFromServer) {
@@ -350,6 +390,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
)) ))
@Suppress("MagicNumber") // Snackbar timing
// If delete from server, actually delete after snackbar timeout // If delete from server, actually delete after snackbar timeout
if (deleteFromServer) { if (deleteFromServer) {
kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s kotlinx.coroutines.delay(3500) // Snackbar shows for ~3s
@@ -420,6 +461,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} }
if (success) successCount++ else failCount++ if (success) successCount++ else failCount++
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to delete note $noteId from server: ${e.message}")
failCount++ failCount++
} finally { } finally {
_pendingDeletions.value = _pendingDeletions.value - noteId _pendingDeletions.value = _pendingDeletions.value - noteId
@@ -458,20 +500,44 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
/** /**
* Trigger manual sync (from toolbar button or pull-to-refresh) * 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.7.0: Zentrale Sync-Gate Prüfung (inkl. WiFi-Only, Offline Mode, Server Config)
val syncService = WebDavSyncService(getApplication())
val gateResult = syncService.canSync()
if (!gateResult.canSync) {
if (gateResult.isBlockedByWifiOnly) {
Logger.d(TAG, "⏭️ $source Sync blocked: WiFi-only mode, not on WiFi")
SyncStateManager.markError(getString(R.string.sync_wifi_only_hint))
} else {
Logger.d(TAG, "⏭️ $source Sync blocked: ${gateResult.blockReason ?: "offline/no server"}")
}
return
}
// 🆕 v1.7.0: Feedback wenn Sync bereits läuft
if (!SyncStateManager.tryStartSync(source)) { if (!SyncStateManager.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")
SyncStateManager.markCompleted("Bereits synchronisiert") val message = getApplication<Application>().getString(R.string.toast_already_synced)
SyncStateManager.markCompleted(message)
loadNotes() loadNotes()
return@launch return@launch
} }
@@ -513,16 +579,30 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* Trigger auto-sync (onResume) * Trigger auto-sync (onResume)
* 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.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
if (!prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)) {
Logger.d(TAG, "⏭️ onResume sync disabled - skipping")
return
}
// Throttling check // Throttling check
if (!canTriggerAutoSync()) { if (!canTriggerAutoSync()) {
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()
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
} }
@@ -539,8 +619,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")
@@ -607,6 +685,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
getApplication<android.app.Application>().getString(resId, *formatArgs) getApplication<android.app.Application>().getString(resId, *formatArgs)
fun isServerConfigured(): Boolean { fun isServerConfigured(): Boolean {
// 🌟 v1.6.0: Use reactive offline mode state
if (_isOfflineMode.value) {
return false
}
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
}
/**
* 🌟 v1.6.0: Check if server has a configured URL (ignores offline mode)
* Used for determining if sync would be available when offline mode is disabled
*/
fun hasServerConfig(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://" return !serverUrl.isNullOrEmpty() && serverUrl != "http://" && serverUrl != "https://"
} }

View File

@@ -2,15 +2,24 @@ package dev.dettmer.simplenotes.ui.main.components
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -19,10 +28,12 @@ import dev.dettmer.simplenotes.R
/** /**
* Delete confirmation dialog with server/local options * Delete confirmation dialog with server/local options
* v1.5.0: Multi-Select Feature * v1.5.0: Multi-Select Feature
* v1.6.0: Offline mode support - disables server deletion option
*/ */
@Composable @Composable
fun DeleteConfirmationDialog( fun DeleteConfirmationDialog(
noteCount: Int = 1, noteCount: Int = 1,
isOfflineMode: Boolean = false,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDeleteLocal: () -> Unit, onDeleteLocal: () -> Unit,
onDeleteEverywhere: () -> Unit onDeleteEverywhere: () -> Unit
@@ -59,16 +70,56 @@ fun DeleteConfirmationDialog(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// Delete everywhere (server + local) - primary action // Delete everywhere (server + local) - primary action
// 🌟 v1.6.0: Disabled in offline mode with visual hint
TextButton( TextButton(
onClick = onDeleteEverywhere, onClick = onDeleteEverywhere,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
enabled = !isOfflineMode,
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error contentColor = MaterialTheme.colorScheme.error,
disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
) )
) { ) {
Text(stringResource(R.string.delete_everywhere)) Text(stringResource(R.string.delete_everywhere))
} }
// 🌟 v1.6.0: Show offline hint in a subtle Surface container
if (isOfflineMode) {
Surface(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.CloudOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(R.string.delete_everywhere_offline_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
Spacer(modifier = Modifier.height(4.dp))
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant
)
Spacer(modifier = Modifier.height(4.dp))
}
// Delete local only // Delete local only
TextButton( TextButton(
onClick = onDeleteLocal, onClick = onDeleteLocal,

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
@@ -55,7 +56,13 @@ fun SettingsNavHost(
composable(SettingsRoute.Sync.route) { composable(SettingsRoute.Sync.route) {
SyncSettingsScreen( SyncSettingsScreen(
viewModel = viewModel, viewModel = viewModel,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
onNavigateToServerSettings = {
navController.navigate(SettingsRoute.Server.route) {
// Avoid multiple copies of server settings in back stack
launchSingleTop = true
}
}
) )
} }
@@ -89,5 +96,13 @@ fun SettingsNavHost(
onBack = { navController.popBackStack() } 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
@@ -16,9 +17,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.net.HttpURLConnection import java.net.HttpURLConnection
@@ -30,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 {
@@ -39,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
@@ -46,10 +56,30 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// v1.5.0 Fix: Initialize URL with protocol prefix if empty // v1.5.0 Fix: Initialize URL with protocol prefix if empty
private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: "" private val storedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
private val initialUrl = if (storedUrl.isEmpty()) "http://" else storedUrl
private val _serverUrl = MutableStateFlow(initialUrl) // 🌟 v1.6.0: Separate host from prefix for better UX
val serverUrl: StateFlow<String> = _serverUrl.asStateFlow() // isHttps determines the prefix, serverHost is the editable part
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
// Extract host part (everything after http:// or https://)
private fun extractHostFromUrl(url: String): String {
return when {
url.startsWith("https://") -> url.removePrefix("https://")
url.startsWith("http://") -> url.removePrefix("http://")
else -> url
}
}
// 🌟 v1.6.0: Only the host part is editable (without protocol prefix)
private val _serverHost = MutableStateFlow(extractHostFromUrl(storedUrl))
val serverHost: StateFlow<String> = _serverHost.asStateFlow()
// 🌟 v1.6.0: Full URL for display purposes (computed from prefix + host)
val serverUrl: StateFlow<String> = combine(_isHttps, _serverHost) { https, host ->
val prefix = if (https) "https://" else "http://"
if (host.isEmpty()) "" else prefix + host
}.stateIn(viewModelScope, SharingStarted.Eagerly, storedUrl)
private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "") private val _username = MutableStateFlow(prefs.getString(Constants.KEY_USERNAME, "") ?: "")
val username: StateFlow<String> = _username.asStateFlow() val username: StateFlow<String> = _username.asStateFlow()
@@ -57,13 +87,28 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "") private val _password = MutableStateFlow(prefs.getString(Constants.KEY_PASSWORD, "") ?: "")
val password: StateFlow<String> = _password.asStateFlow() val password: StateFlow<String> = _password.asStateFlow()
// v1.5.0 Fix: isHttps based on stored URL (false = HTTP if empty)
private val _isHttps = MutableStateFlow(storedUrl.startsWith("https://"))
val isHttps: StateFlow<Boolean> = _isHttps.asStateFlow()
private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown) private val _serverStatus = MutableStateFlow<ServerStatus>(ServerStatus.Unknown)
val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow() val serverStatus: StateFlow<ServerStatus> = _serverStatus.asStateFlow()
// 🌟 v1.6.0: Offline Mode Toggle
// Default: true for new users (no server), false for existing users (has server config)
private val _offlineMode = MutableStateFlow(
if (prefs.contains(Constants.KEY_OFFLINE_MODE)) {
prefs.getBoolean(Constants.KEY_OFFLINE_MODE, true)
} else {
// Migration: auto-detect based on existing server config
!hasExistingServerConfig()
}
)
val offlineMode: StateFlow<Boolean> = _offlineMode.asStateFlow()
private fun hasExistingServerConfig(): Boolean {
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
return !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Events (for Activity-level actions like dialogs, intents) // Events (for Activity-level actions like dialogs, intents)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -90,6 +135,38 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
) )
val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow() val syncInterval: StateFlow<Long> = _syncInterval.asStateFlow()
// 🌟 v1.6.0: Configurable Sync Triggers
private val _triggerOnSave = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, Constants.DEFAULT_TRIGGER_ON_SAVE)
)
val triggerOnSave: StateFlow<Boolean> = _triggerOnSave.asStateFlow()
private val _triggerOnResume = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, Constants.DEFAULT_TRIGGER_ON_RESUME)
)
val triggerOnResume: StateFlow<Boolean> = _triggerOnResume.asStateFlow()
private val _triggerWifiConnect = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, Constants.DEFAULT_TRIGGER_WIFI_CONNECT)
)
val triggerWifiConnect: StateFlow<Boolean> = _triggerWifiConnect.asStateFlow()
private val _triggerPeriodic = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, Constants.DEFAULT_TRIGGER_PERIODIC)
)
val triggerPeriodic: StateFlow<Boolean> = _triggerPeriodic.asStateFlow()
private val _triggerBoot = MutableStateFlow(
prefs.getBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, Constants.DEFAULT_TRIGGER_BOOT)
)
val triggerBoot: StateFlow<Boolean> = _triggerBoot.asStateFlow()
// 🎉 v1.7.0: WiFi-Only Sync Toggle
private val _wifiOnlySync = MutableStateFlow(
prefs.getBoolean(Constants.KEY_WIFI_ONLY_SYNC, Constants.DEFAULT_WIFI_ONLY_SYNC)
)
val wifiOnlySync: StateFlow<Boolean> = _wifiOnlySync.asStateFlow()
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Markdown Settings State // Markdown Settings State
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -109,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
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -126,52 +212,154 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// Server Settings Actions // Server Settings Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/**
* v1.6.0: Set offline mode on/off
* When enabled, all network features are disabled
*/
fun setOfflineMode(enabled: Boolean) {
_offlineMode.value = enabled
prefs.edit().putBoolean(Constants.KEY_OFFLINE_MODE, enabled).apply()
if (enabled) {
_serverStatus.value = ServerStatus.OfflineMode
} else {
// Re-check server status when disabling offline mode
checkServerStatus()
}
}
fun updateServerUrl(url: String) { fun updateServerUrl(url: String) {
_serverUrl.value = url // 🌟 v1.6.0: Deprecated - use updateServerHost instead
saveServerSettings() // This function is kept for compatibility but now delegates to updateServerHost
val host = extractHostFromUrl(url)
updateServerHost(host)
}
/**
* 🌟 v1.6.0: Update only the host part of the server URL
* The protocol prefix is handled separately by updateProtocol()
* 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
* 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
* but WITHOUT server-change detection (detection happens only on screen exit)
*/
fun updateServerHost(host: String) {
_serverHost.value = host
// ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (host.isEmpty()) "" else prefix + host
prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
} }
fun updateProtocol(useHttps: Boolean) { fun updateProtocol(useHttps: Boolean) {
_isHttps.value = useHttps _isHttps.value = useHttps
val currentUrl = _serverUrl.value // 🌟 v1.6.0: Host stays the same, only prefix changes
// 🔧 v1.7.0 Hotfix: Removed auto-save to prevent false server-change detection
// 🔧 v1.7.0 Regression Fix: Restore immediate SharedPrefs write (for WebDavSyncService)
// v1.5.0 Fix: Automatisch Prefix setzen, auch bei leerem Feld // ✅ Save immediately for WebDavSyncService, but WITHOUT server-change detection
val newUrl = if (useHttps) { val prefix = if (useHttps) "https://" else "http://"
when { val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
currentUrl.isEmpty() || currentUrl == "http://" -> "https://" prefs.edit().putString(Constants.KEY_SERVER_URL, fullUrl).apply()
currentUrl.startsWith("http://") -> currentUrl.replace("http://", "https://")
!currentUrl.startsWith("https://") -> "https://$currentUrl"
else -> currentUrl
}
} else {
when {
currentUrl.isEmpty() || currentUrl == "https://" -> "http://"
currentUrl.startsWith("https://") -> currentUrl.replace("https://", "http://")
!currentUrl.startsWith("http://") -> "http://$currentUrl"
else -> currentUrl
}
}
_serverUrl.value = newUrl
saveServerSettings()
} }
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() { /**
prefs.edit().apply { * 🔧 v1.7.0 Hotfix: Manual save function - only called when leaving settings screen
putString(Constants.KEY_SERVER_URL, _serverUrl.value) * This prevents false "server changed" detection during text input
putString(Constants.KEY_USERNAME, _username.value) * 🔧 v1.7.0 Regression Fix: Settings are now saved IMMEDIATELY in update functions.
putString(Constants.KEY_PASSWORD, _password.value) * This function now ONLY handles server-change detection and sync reset.
apply() */
fun saveServerSettingsManually() {
// 🌟 v1.6.0: Construct full URL from prefix + host
val prefix = if (_isHttps.value) "https://" else "http://"
val fullUrl = if (_serverHost.value.isEmpty()) "" else prefix + _serverHost.value
// 🔄 v1.7.0: Detect server change ONLY against last confirmed URL
val serverChanged = isServerReallyChanged(confirmedServerUrl, fullUrl)
// ✅ Settings are already saved in updateServerHost/Protocol/Username/Password
// This function now ONLY handles server-change detection
// Reset sync status if server actually changed
if (serverChanged) {
viewModelScope.launch {
val count = notesStorage.resetAllSyncStatusToPending()
Logger.d(TAG, "🔄 Server changed from '$confirmedServerUrl' to '$fullUrl': Reset $count notes to PENDING")
emitToast(getString(R.string.toast_server_changed_sync_reset, count))
} }
// Update confirmed state after reset
confirmedServerUrl = fullUrl
} else {
Logger.d(TAG, "💾 Server settings check complete (no server change detected)")
}
}
/**
* <20> v1.7.0 Hotfix: Improved server change detection
*
* Only returns true if the server URL actually changed in a meaningful way.
* Handles edge cases:
* - First setup (empty → filled) = NOT a change
* - Protocol only (http → https) = NOT a change
* - Server removed (filled → empty) = NOT a change
* - Trailing slashes, case differences = NOT a change
* - Different hostname/port/path = IS a change ✓
*/
private fun isServerReallyChanged(confirmedUrl: String, newUrl: String): Boolean {
// Empty → Non-empty = First setup, NOT a change
if (confirmedUrl.isEmpty() && newUrl.isNotEmpty()) {
Logger.d(TAG, "First server setup detected (no reset needed)")
return false
}
// Both empty = No change
if (confirmedUrl.isEmpty() && newUrl.isEmpty()) {
return false
}
// Non-empty → Empty = Server removed (keep notes local, no reset)
if (confirmedUrl.isNotEmpty() && newUrl.isEmpty()) {
Logger.d(TAG, "Server removed (notes stay local, no reset needed)")
return false
}
// Same URL = No change
if (confirmedUrl == newUrl) {
return false
}
// Normalize URLs for comparison (ignore protocol, trailing slash, case)
val normalize = { url: String ->
url.trim()
.removePrefix("http://")
.removePrefix("https://")
.removeSuffix("/")
.lowercase()
}
val confirmedNormalized = normalize(confirmedUrl)
val newNormalized = normalize(newUrl)
// Check if normalized URLs differ
val changed = confirmedNormalized != newNormalized
if (changed) {
Logger.d(TAG, "Server URL changed: '$confirmedNormalized' → '$newNormalized'")
}
return changed
} }
fun testConnection() { fun testConnection() {
@@ -199,13 +387,23 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
fun checkServerStatus() { fun checkServerStatus() {
val serverUrl = _serverUrl.value // 🌟 v1.6.0: Respect offline mode first
// v1.5.0 Fix: URL mit nur Prefix gilt als "nicht konfiguriert" if (_offlineMode.value) {
if (serverUrl.isEmpty() || serverUrl == "http://" || serverUrl == "https://") { _serverStatus.value = ServerStatus.OfflineMode
return
}
// 🌟 v1.6.0: Check if host is configured
val serverHost = _serverHost.value
if (serverHost.isEmpty()) {
_serverStatus.value = ServerStatus.NotConfigured _serverStatus.value = ServerStatus.NotConfigured
return return
} }
// Construct full URL
val prefix = if (_isHttps.value) "https://" else "http://"
val serverUrl = prefix + serverHost
viewModelScope.launch { viewModelScope.launch {
_serverStatus.value = ServerStatus.Checking _serverStatus.value = ServerStatus.Checking
val isReachable = withContext(Dispatchers.IO) { val isReachable = withContext(Dispatchers.IO) {
@@ -231,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
@@ -287,6 +497,54 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
// 🌟 v1.6.0: Configurable Sync Triggers Setters
fun setTriggerOnSave(enabled: Boolean) {
_triggerOnSave.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_SAVE, enabled).apply()
Logger.d(TAG, "Trigger onSave: $enabled")
}
fun setTriggerOnResume(enabled: Boolean) {
_triggerOnResume.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_ON_RESUME, enabled).apply()
Logger.d(TAG, "Trigger onResume: $enabled")
}
fun setTriggerWifiConnect(enabled: Boolean) {
_triggerWifiConnect.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_WIFI_CONNECT, enabled).apply()
viewModelScope.launch {
_events.emit(SettingsEvent.RestartNetworkMonitor)
}
Logger.d(TAG, "Trigger WiFi-Connect: $enabled")
}
fun setTriggerPeriodic(enabled: Boolean) {
_triggerPeriodic.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_PERIODIC, enabled).apply()
viewModelScope.launch {
_events.emit(SettingsEvent.RestartNetworkMonitor)
}
Logger.d(TAG, "Trigger Periodic: $enabled")
}
fun setTriggerBoot(enabled: Boolean) {
_triggerBoot.value = enabled
prefs.edit().putBoolean(Constants.KEY_SYNC_TRIGGER_BOOT, enabled).apply()
Logger.d(TAG, "Trigger Boot: $enabled")
}
/**
* 🎉 v1.7.0: Set WiFi-only sync mode
* When enabled, sync only happens when connected to WiFi
*/
fun setWifiOnlySync(enabled: Boolean) {
_wifiOnlySync.value = enabled
prefs.edit().putBoolean(Constants.KEY_WIFI_ONLY_SYNC, enabled).apply()
Logger.d(TAG, "📡 WiFi-only sync: $enabled")
}
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// Markdown Settings Actions // Markdown Settings Actions
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
@@ -337,6 +595,7 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
_markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true) _markdownExportProgress.value = MarkdownExportProgress(noteCount, noteCount, isComplete = true)
emitToast(getString(R.string.toast_markdown_exported, exportedCount)) emitToast(getString(R.string.toast_markdown_exported, exportedCount))
@Suppress("MagicNumber") // UI progress delay
// Clear progress after short delay // Clear progress after short delay
kotlinx.coroutines.delay(500) kotlinx.coroutines.delay(500)
_markdownExportProgress.value = null _markdownExportProgress.value = null
@@ -371,6 +630,12 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
fun performManualMarkdownSync() { fun performManualMarkdownSync() {
// 🌟 v1.6.0: Block in offline mode
if (_offlineMode.value) {
Logger.d(TAG, "⏭️ Manual Markdown sync blocked: Offline mode enabled")
return
}
viewModelScope.launch { viewModelScope.launch {
try { try {
emitToast(getString(R.string.toast_markdown_syncing)) emitToast(getString(R.string.toast_markdown_syncing))
@@ -387,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 {
@@ -406,11 +671,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
fun restoreFromFile(uri: Uri, mode: RestoreMode) { fun restoreFromFile(uri: Uri, mode: RestoreMode, password: String? = null) {
viewModelScope.launch { 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 {
@@ -425,6 +690,29 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
} }
} }
/**
* 🔐 v1.7.0: Check if backup is encrypted and call appropriate callback
*/
fun checkBackupEncryption(
uri: Uri,
onEncrypted: () -> Unit,
onPlaintext: () -> Unit
) {
viewModelScope.launch {
try {
val isEncrypted = backupManager.isBackupEncrypted(uri)
if (isEncrypted) {
onEncrypted()
} else {
onPlaintext()
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to check encryption status", e)
onPlaintext() // Assume plaintext on error
}
}
}
fun restoreFromServer(mode: RestoreMode) { fun restoreFromServer(mode: RestoreMode) {
viewModelScope.launch { viewModelScope.launch {
_isBackupInProgress.value = true _isBackupInProgress.value = true
@@ -478,10 +766,56 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
// Helper // Helper
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
private fun getString(resId: Int): String = getApplication<android.app.Application>().getString(resId) /**
* Check if server is configured AND not in offline mode
* v1.6.0: Returns false if offline mode is enabled
*/
fun isServerConfigured(): Boolean {
// Offline mode takes priority
if (_offlineMode.value) return false
private fun getString(resId: Int, vararg formatArgs: Any): String = val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
getApplication<android.app.Application>().getString(resId, *formatArgs) return !serverUrl.isNullOrEmpty() &&
serverUrl != "http://" &&
serverUrl != "https://"
}
/**
* 🌍 v1.7.1: Get string resources with correct app locale
*
* AndroidViewModel uses Application context which may not have the correct locale
* applied when using per-app language settings. We need to get a Context that
* respects AppCompatDelegate.getApplicationLocales().
*/
private fun getString(resId: Int): String {
// Get context with correct locale configuration from AppCompatDelegate
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
val context = if (!appLocales.isEmpty) {
// Create configuration with app locale
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
config.setLocale(appLocales.get(0))
getApplication<Application>().createConfigurationContext(config)
} else {
// Use system locale (default)
getApplication<Application>()
}
return context.getString(resId)
}
private fun getString(resId: Int, vararg formatArgs: Any): String {
// Get context with correct locale configuration from AppCompatDelegate
val appLocales = androidx.appcompat.app.AppCompatDelegate.getApplicationLocales()
val context = if (!appLocales.isEmpty) {
// Create configuration with app locale
val config = android.content.res.Configuration(getApplication<Application>().resources.configuration)
config.setLocale(appLocales.get(0))
getApplication<Application>().createConfigurationContext(config)
} else {
// Use system locale (default)
getApplication<Application>()
}
return context.getString(resId, *formatArgs)
}
private suspend fun emitToast(message: String) { private suspend fun emitToast(message: String) {
_showToast.emit(message) _showToast.emit(message)
@@ -489,9 +823,11 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
/** /**
* Server status states * Server status states
* v1.6.0: Added OfflineMode state
*/ */
sealed class ServerStatus { sealed class ServerStatus {
data object Unknown : ServerStatus() data object Unknown : ServerStatus()
data object OfflineMode : ServerStatus() // 🌟 v1.6.0
data object NotConfigured : ServerStatus() data object NotConfigured : ServerStatus()
data object Checking : ServerStatus() data object Checking : ServerStatus()
data object Reachable : ServerStatus() data object Reachable : ServerStatus()
@@ -516,4 +852,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

@@ -95,24 +95,34 @@ fun SettingsDangerButton(
/** /**
* Info card with description text * Info card with description text
* v1.6.0: Added isWarning parameter for offline mode warning
*/ */
@Composable @Composable
fun SettingsInfoCard( fun SettingsInfoCard(
text: String, text: String,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
isWarning: Boolean = false
) { ) {
androidx.compose.material3.Card( androidx.compose.material3.Card(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
colors = androidx.compose.material3.CardDefaults.cardColors( colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHighest containerColor = if (isWarning) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
) )
) { ) {
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = if (isWarning) {
MaterialTheme.colorScheme.onErrorContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.3f
) )

View File

@@ -1,3 +1,4 @@
@file:Suppress("MatchingDeclarationName")
package dev.dettmer.simplenotes.ui.settings.components package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.ui.settings.components package dev.dettmer.simplenotes.ui.settings.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -34,6 +35,7 @@ fun SettingsSwitch(
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(enabled = enabled) { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {

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
@@ -49,17 +51,34 @@ fun BackupSettingsScreen(
) { ) {
val isBackupInProgress by viewModel.isBackupInProgress.collectAsState() val isBackupInProgress by viewModel.isBackupInProgress.collectAsState()
// 🌟 v1.6.0: Check if server restore is available
val isServerConfigured = viewModel.isServerConfigured()
// Restore dialog state // Restore dialog state
var showRestoreDialog by remember { mutableStateOf(false) } var showRestoreDialog by remember { mutableStateOf(false) }
var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) } var restoreSource by remember { mutableStateOf<RestoreSource>(RestoreSource.LocalFile) }
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(
@@ -96,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 = {
@@ -126,6 +155,7 @@ fun BackupSettingsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// 🌟 v1.6.0: Disabled when offline mode active
SettingsOutlinedButton( SettingsOutlinedButton(
text = stringResource(R.string.backup_restore_server), text = stringResource(R.string.backup_restore_server),
onClick = { onClick = {
@@ -133,13 +163,66 @@ fun BackupSettingsScreen(
showRestoreDialog = true showRestoreDialog = true
}, },
isLoading = isBackupInProgress, isLoading = isBackupInProgress,
enabled = isServerConfigured,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
// 🌟 v1.6.0: Show hint when offline
if (!isServerConfigured) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.settings_sync_offline_mode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }
// 🔐 v1.7.0: Encryption password dialog (for backup creation)
if (showEncryptionPasswordDialog) {
BackupPasswordDialog(
title = stringResource(R.string.backup_encryption_title),
onDismiss = {
showEncryptionPasswordDialog = false
pendingBackupUri = null
},
onConfirm = { password ->
showEncryptionPasswordDialog = false
pendingBackupUri?.let { uri ->
viewModel.createBackup(uri, password)
}
pendingBackupUri = null
},
requireConfirmation = true
)
}
// 🔐 v1.7.0: Decryption password dialog (for restore)
if (showDecryptionPasswordDialog) {
BackupPasswordDialog(
title = stringResource(R.string.backup_decryption_required),
onDismiss = {
showDecryptionPasswordDialog = false
pendingRestoreUri = null
},
onConfirm = { password ->
showDecryptionPasswordDialog = false
pendingRestoreUri?.let { uri ->
when (restoreSource) {
RestoreSource.LocalFile -> viewModel.restoreFromFile(uri, selectedRestoreMode, password)
RestoreSource.Server -> { /* Server restore doesn't support encryption */ }
}
}
pendingRestoreUri = null
},
requireConfirmation = false
)
}
// Restore Mode Dialog // Restore Mode Dialog
if (showRestoreDialog) { if (showRestoreDialog) {
RestoreModeDialog( RestoreModeDialog(
@@ -151,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

@@ -42,6 +42,10 @@ fun MarkdownSettingsScreen(
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState() val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val exportProgress by viewModel.markdownExportProgress.collectAsState() val exportProgress by viewModel.markdownExportProgress.collectAsState()
// 🌟 v1.6.0: Check offline mode
val offlineMode by viewModel.offlineMode.collectAsState()
val isServerConfigured = viewModel.isServerConfigured()
// v1.5.0 Fix: Progress Dialog for initial export // v1.5.0 Fix: Progress Dialog for initial export
exportProgress?.let { progress -> exportProgress?.let { progress ->
AlertDialog( AlertDialog(
@@ -96,15 +100,22 @@ fun MarkdownSettingsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Markdown Auto-Sync Toggle // Markdown Auto-Sync Toggle
// 🌟 v1.6.0: Disabled when offline mode active
SettingsSwitch( SettingsSwitch(
title = stringResource(R.string.markdown_auto_sync_title), title = stringResource(R.string.markdown_auto_sync_title),
subtitle = stringResource(R.string.markdown_auto_sync_subtitle), subtitle = if (!isServerConfigured) {
stringResource(R.string.settings_sync_offline_mode)
} else {
stringResource(R.string.markdown_auto_sync_subtitle)
},
checked = markdownAutoSync, checked = markdownAutoSync,
onCheckedChange = { viewModel.setMarkdownAutoSync(it) }, onCheckedChange = { viewModel.setMarkdownAutoSync(it) },
icon = Icons.Default.Description icon = Icons.Default.Description,
enabled = isServerConfigured
) )
// Manual sync button (only visible when auto-sync is off) // Manual sync button (only visible when auto-sync is off)
// 🌟 v1.6.0: Also disabled in offline mode
if (!markdownAutoSync) { if (!markdownAutoSync) {
SettingsDivider() SettingsDivider()
@@ -117,8 +128,20 @@ fun MarkdownSettingsScreen(
SettingsButton( SettingsButton(
text = stringResource(R.string.markdown_manual_sync_button), text = stringResource(R.string.markdown_manual_sync_button),
onClick = { viewModel.performManualMarkdownSync() }, onClick = { viewModel.performManualMarkdownSync() },
enabled = isServerConfigured,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
// 🌟 v1.6.0: Show hint when offline
if (!isServerConfigured) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.settings_sync_offline_mode),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.padding(horizontal = 16.dp)
)
}
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.ui.settings.screens package dev.dettmer.simplenotes.ui.settings.screens
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -29,8 +30,10 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
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
@@ -39,6 +42,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -52,13 +56,18 @@ 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.7.0 Hotfix: Save settings on screen exit (not on every keystroke)
*/ */
@Suppress("LongMethod", "MagicNumber") // Compose UI + Color hex values
@Composable @Composable
fun ServerSettingsScreen( fun ServerSettingsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val serverUrl by viewModel.serverUrl.collectAsState() val offlineMode by viewModel.offlineMode.collectAsState()
val serverHost by viewModel.serverHost.collectAsState() // 🌟 v1.6.0: Only host part
val serverUrl by viewModel.serverUrl.collectAsState() // Full URL for display
val username by viewModel.username.collectAsState() val username by viewModel.username.collectAsState()
val password by viewModel.password.collectAsState() val password by viewModel.password.collectAsState()
val isHttps by viewModel.isHttps.collectAsState() val isHttps by viewModel.isHttps.collectAsState()
@@ -67,10 +76,20 @@ fun ServerSettingsScreen(
var passwordVisible by remember { mutableStateOf(false) } var passwordVisible by remember { mutableStateOf(false) }
// Check server status on load // 🔧 v1.7.0 Hotfix: Save server settings when leaving this screen
LaunchedEffect(Unit) { // This prevents false "server changed" detection during text input
DisposableEffect(Unit) {
onDispose {
viewModel.saveServerSettingsManually()
}
}
// Check server status on load (only if not in offline mode)
LaunchedEffect(offlineMode) {
if (!offlineMode) {
viewModel.checkServerStatus() viewModel.checkServerStatus()
} }
}
SettingsScaffold( SettingsScaffold(
title = stringResource(R.string.server_settings_title), title = stringResource(R.string.server_settings_title),
@@ -83,6 +102,57 @@ fun ServerSettingsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp) .padding(16.dp)
) { ) {
// ═══════════════════════════════════════════════════════════════
// 🌟 v1.6.0: Offline-Modus Toggle (TOP)
// ═══════════════════════════════════════════════════════════════
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setOfflineMode(!offlineMode) },
colors = CardDefaults.cardColors(
containerColor = if (offlineMode) {
MaterialTheme.colorScheme.tertiaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
}
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.server_offline_mode_title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(R.string.server_offline_mode_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = offlineMode,
onCheckedChange = { viewModel.setOfflineMode(it) }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// ═══════════════════════════════════════════════════════════════
// Server Configuration (grayed out when offline mode)
// ═══════════════════════════════════════════════════════════════
val fieldsEnabled = !offlineMode
val fieldsAlpha = if (offlineMode) 0.5f else 1f
Column(modifier = Modifier.alpha(fieldsAlpha)) {
// Verbindungstyp // Verbindungstyp
Text( Text(
text = stringResource(R.string.server_connection_type), text = stringResource(R.string.server_connection_type),
@@ -98,12 +168,14 @@ fun ServerSettingsScreen(
selected = !isHttps, selected = !isHttps,
onClick = { viewModel.updateProtocol(false) }, onClick = { viewModel.updateProtocol(false) },
label = { Text(stringResource(R.string.server_connection_http)) }, label = { Text(stringResource(R.string.server_connection_http)) },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
FilterChip( FilterChip(
selected = isHttps, selected = isHttps,
onClick = { viewModel.updateProtocol(true) }, onClick = { viewModel.updateProtocol(true) },
label = { Text(stringResource(R.string.server_connection_https)) }, label = { Text(stringResource(R.string.server_connection_https)) },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
@@ -119,15 +191,28 @@ fun ServerSettingsScreen(
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp) modifier = Modifier.padding(top = 4.dp, bottom = 16.dp)
) )
// Server-Adresse // 🌟 v1.6.0: Server-Adresse with non-editable prefix
OutlinedTextField( OutlinedTextField(
value = serverUrl, value = serverHost, // Only host part is editable
onValueChange = { viewModel.updateServerUrl(it) }, onValueChange = { viewModel.updateServerHost(it) },
label = { Text(stringResource(R.string.server_address)) }, label = { Text(stringResource(R.string.server_address)) },
supportingText = { Text(stringResource(R.string.server_address_hint)) }, supportingText = { Text(stringResource(R.string.server_address_hint)) },
prefix = {
// Protocol prefix is displayed but not editable
Text(
text = if (isHttps) "https://" else "http://",
style = MaterialTheme.typography.bodyLarge,
color = if (fieldsEnabled) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
)
},
leadingIcon = { Icon(Icons.Default.Language, null) }, leadingIcon = { Icon(Icons.Default.Language, null) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
enabled = fieldsEnabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)
) )
@@ -140,7 +225,8 @@ fun ServerSettingsScreen(
label = { Text(stringResource(R.string.username)) }, label = { Text(stringResource(R.string.username)) },
leadingIcon = { Icon(Icons.Default.Person, null) }, leadingIcon = { Icon(Icons.Default.Person, null) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true singleLine = true,
enabled = fieldsEnabled
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -174,8 +260,10 @@ fun ServerSettingsScreen(
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
enabled = fieldsEnabled,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password) keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
) )
}
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -196,16 +284,18 @@ fun ServerSettingsScreen(
Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge) Text(stringResource(R.string.server_status_label), style = MaterialTheme.typography.labelLarge)
Text( Text(
text = when (serverStatus) { text = when (serverStatus) {
is SettingsViewModel.ServerStatus.OfflineMode -> stringResource(R.string.server_status_offline_mode)
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.server_status_reachable) is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.server_status_reachable)
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable) is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.server_status_unreachable)
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking) is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.server_status_checking)
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_not_configured) is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.server_status_offline_mode)
else -> stringResource(R.string.server_status_unknown) else -> stringResource(R.string.server_status_unknown)
}, },
color = when (serverStatus) { color = when (serverStatus) {
is SettingsViewModel.ServerStatus.OfflineMode -> MaterialTheme.colorScheme.tertiary
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50) is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50)
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336) is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336)
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800) is SettingsViewModel.ServerStatus.NotConfigured -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
} }
) )
@@ -214,13 +304,16 @@ fun ServerSettingsScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
// Action Buttons // Action Buttons (disabled in offline mode)
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.alpha(fieldsAlpha),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
OutlinedButton( OutlinedButton(
onClick = { viewModel.testConnection() }, onClick = { viewModel.testConnection() },
enabled = fieldsEnabled,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(stringResource(R.string.test_connection)) Text(stringResource(R.string.test_connection))
@@ -228,7 +321,7 @@ fun ServerSettingsScreen(
Button( Button(
onClick = { viewModel.syncNow() }, onClick = { viewModel.syncNow() },
enabled = !isSyncing, enabled = fieldsEnabled && !isSyncing,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
if (isSyncing) { if (isSyncing) {

View File

@@ -10,9 +10,11 @@ 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
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -32,6 +34,7 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsScaffold
* Main Settings overview screen with clickable group cards * Main Settings overview screen with clickable group cards
* v1.5.0: Jetpack Compose Settings Redesign * v1.5.0: Jetpack Compose Settings Redesign
*/ */
@Suppress("MagicNumber") // Color hex values
@Composable @Composable
fun SettingsMainScreen( fun SettingsMainScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
@@ -45,6 +48,14 @@ fun SettingsMainScreen(
val markdownAutoSync by viewModel.markdownAutoSync.collectAsState() val markdownAutoSync by viewModel.markdownAutoSync.collectAsState()
val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState() val fileLoggingEnabled by viewModel.fileLoggingEnabled.collectAsState()
// 🌟 v1.6.0: Collect offline mode and trigger states
val offlineMode by viewModel.offlineMode.collectAsState()
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
val triggerBoot by viewModel.triggerBoot.collectAsState()
// Check server status on first load // Check server status on first load
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.checkServerStatus() viewModel.checkServerStatus()
@@ -80,28 +91,56 @@ fun SettingsMainScreen(
) )
} }
// 🎨 v1.7.0: Display Settings
item {
val displayMode by viewModel.displayMode.collectAsState()
val displaySubtitle = when (displayMode) {
"grid" -> stringResource(R.string.display_mode_grid)
else -> stringResource(R.string.display_mode_list)
}
SettingsCard(
icon = Icons.Default.GridView,
title = stringResource(R.string.display_settings_title),
subtitle = displaySubtitle,
onClick = { onNavigate(SettingsRoute.Display) }
)
}
// Server-Einstellungen // Server-Einstellungen
item { item {
// v1.5.0 Fix: Nur Prefix-URLs gelten als "nicht konfiguriert" // 🌟 v1.6.0: Check if server is configured (host is not empty)
val isConfigured = serverUrl.isNotEmpty() && val isConfigured = serverUrl.isNotEmpty()
serverUrl != "http://" &&
serverUrl != "https://"
SettingsCard( SettingsCard(
icon = Icons.Default.Cloud, icon = Icons.Default.Cloud,
title = stringResource(R.string.settings_server), title = stringResource(R.string.settings_server),
subtitle = if (isConfigured) serverUrl else null, subtitle = if (!offlineMode && isConfigured) serverUrl else null,
statusText = when (serverStatus) { statusText = when {
is SettingsViewModel.ServerStatus.Reachable -> stringResource(R.string.settings_server_status_reachable) offlineMode ->
is SettingsViewModel.ServerStatus.Unreachable -> stringResource(R.string.settings_server_status_unreachable) stringResource(R.string.settings_server_status_offline_mode)
is SettingsViewModel.ServerStatus.Checking -> stringResource(R.string.settings_server_status_checking) serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
is SettingsViewModel.ServerStatus.NotConfigured -> stringResource(R.string.settings_server_status_not_configured) stringResource(R.string.settings_server_status_offline_mode)
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
stringResource(R.string.settings_server_status_reachable)
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
stringResource(R.string.settings_server_status_unreachable)
serverStatus is SettingsViewModel.ServerStatus.Checking ->
stringResource(R.string.settings_server_status_checking)
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
stringResource(R.string.settings_server_status_offline_mode)
else -> null else -> null
}, },
statusColor = when (serverStatus) { statusColor = when {
is SettingsViewModel.ServerStatus.Reachable -> Color(0xFF4CAF50) offlineMode -> MaterialTheme.colorScheme.tertiary
is SettingsViewModel.ServerStatus.Unreachable -> Color(0xFFF44336) serverStatus is SettingsViewModel.ServerStatus.OfflineMode ->
is SettingsViewModel.ServerStatus.NotConfigured -> Color(0xFFFF9800) MaterialTheme.colorScheme.tertiary
serverStatus is SettingsViewModel.ServerStatus.Reachable ->
Color(0xFF4CAF50)
serverStatus is SettingsViewModel.ServerStatus.Unreachable ->
Color(0xFFF44336)
serverStatus is SettingsViewModel.ServerStatus.NotConfigured ->
MaterialTheme.colorScheme.tertiary
else -> Color.Gray else -> Color.Gray
}, },
onClick = { onNavigate(SettingsRoute.Server) } onClick = { onNavigate(SettingsRoute.Server) }
@@ -110,33 +149,52 @@ fun SettingsMainScreen(
// Sync-Einstellungen // Sync-Einstellungen
item { item {
val intervalText = when (syncInterval) { // 🌟 v1.6.0: Build dynamic subtitle based on active triggers
15L -> stringResource(R.string.settings_interval_15min) val isServerConfigured = viewModel.isServerConfigured()
60L -> stringResource(R.string.settings_interval_60min) val activeTriggersCount = listOf(
else -> stringResource(R.string.settings_interval_30min) triggerOnSave,
triggerOnResume,
triggerWifiConnect,
triggerPeriodic,
triggerBoot
).count { it }
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
val syncSubtitle = if (isServerConfigured) {
if (activeTriggersCount == 0) {
stringResource(R.string.settings_sync_manual_only)
} else {
stringResource(R.string.settings_sync_triggers_active, activeTriggersCount)
} }
} else null
SettingsCard( SettingsCard(
icon = Icons.Default.Sync, icon = Icons.Default.Sync,
title = stringResource(R.string.settings_sync), title = stringResource(R.string.settings_sync),
subtitle = if (autoSyncEnabled) { subtitle = syncSubtitle,
stringResource(R.string.settings_sync_auto_on, intervalText) statusText = if (!isServerConfigured) stringResource(R.string.settings_sync_offline_mode) else null,
} else { statusColor = if (!isServerConfigured) MaterialTheme.colorScheme.tertiary else Color.Gray,
stringResource(R.string.settings_sync_auto_off)
},
onClick = { onNavigate(SettingsRoute.Sync) } onClick = { onNavigate(SettingsRoute.Sync) }
) )
} }
// Markdown-Integration // Markdown-Integration
item { item {
// 🌟 v1.6.0 Fix: Use statusText for offline mode (consistent with Server card)
val isServerConfiguredForMarkdown = viewModel.isServerConfigured()
SettingsCard( SettingsCard(
icon = Icons.Default.Description, icon = Icons.Default.Description,
title = stringResource(R.string.settings_markdown), title = stringResource(R.string.settings_markdown),
subtitle = if (markdownAutoSync) { subtitle = if (isServerConfiguredForMarkdown) {
if (markdownAutoSync) {
stringResource(R.string.settings_markdown_auto_on) stringResource(R.string.settings_markdown_auto_on)
} else { } else {
stringResource(R.string.settings_markdown_auto_off) stringResource(R.string.settings_markdown_auto_off)
}, }
} else null,
statusText = if (!isServerConfiguredForMarkdown) stringResource(R.string.settings_sync_offline_mode) else null,
statusColor = if (!isServerConfiguredForMarkdown) MaterialTheme.colorScheme.tertiary else Color.Gray,
onClick = { onNavigate(SettingsRoute.Markdown) } onClick = { onNavigate(SettingsRoute.Markdown) }
) )
} }

View File

@@ -8,7 +8,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Sync import androidx.compose.material.icons.filled.PhonelinkRing
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -26,17 +32,30 @@ import dev.dettmer.simplenotes.ui.settings.components.SettingsSectionHeader
import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch import dev.dettmer.simplenotes.ui.settings.components.SettingsSwitch
/** /**
* Sync settings screen (Auto-Sync toggle and interval selection) * Sync settings screen - Configurable Sync Triggers
* v1.5.0: Jetpack Compose Settings Redesign * v1.5.0: Jetpack Compose Settings Redesign
* v1.6.0: Individual toggle for each sync trigger (onSave, onResume, WiFi-Connect, Periodic, Boot)
*/ */
@Composable @Composable
fun SyncSettingsScreen( fun SyncSettingsScreen(
viewModel: SettingsViewModel, viewModel: SettingsViewModel,
onBack: () -> Unit onBack: () -> Unit,
onNavigateToServerSettings: () -> Unit
) { ) {
val autoSyncEnabled by viewModel.autoSyncEnabled.collectAsState() // Collect all trigger states
val triggerOnSave by viewModel.triggerOnSave.collectAsState()
val triggerOnResume by viewModel.triggerOnResume.collectAsState()
val triggerWifiConnect by viewModel.triggerWifiConnect.collectAsState()
val triggerPeriodic by viewModel.triggerPeriodic.collectAsState()
val triggerBoot by viewModel.triggerBoot.collectAsState()
val syncInterval by viewModel.syncInterval.collectAsState() val syncInterval by viewModel.syncInterval.collectAsState()
// 🆕 v1.7.0: WiFi-only sync
val wifiOnlySync by viewModel.wifiOnlySync.collectAsState()
// Check if server is configured
val isServerConfigured = viewModel.isServerConfigured()
SettingsScaffold( SettingsScaffold(
title = stringResource(R.string.sync_settings_title), title = stringResource(R.string.sync_settings_title),
onBack = onBack onBack = onBack
@@ -49,48 +68,121 @@ fun SyncSettingsScreen(
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Auto-Sync Info // 🌟 v1.6.0: Offline Mode Warning if server not configured
if (!isServerConfigured) {
SettingsInfoCard( SettingsInfoCard(
text = stringResource(R.string.sync_auto_sync_info) text = stringResource(R.string.sync_offline_mode_message),
isWarning = true
) )
Spacer(modifier = Modifier.height(8.dp)) Button(
onClick = onNavigateToServerSettings,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(stringResource(R.string.sync_offline_mode_button))
}
// Auto-Sync Toggle 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( SettingsSwitch(
title = stringResource(R.string.sync_auto_sync_enabled), title = stringResource(R.string.sync_wifi_only_title),
checked = autoSyncEnabled, subtitle = stringResource(R.string.sync_wifi_only_subtitle),
onCheckedChange = { viewModel.setAutoSync(it) }, checked = wifiOnlySync,
icon = Icons.Default.Sync 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
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_instant))
// onSave Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_on_save_title),
subtitle = stringResource(R.string.sync_trigger_on_save_subtitle),
checked = triggerOnSave,
onCheckedChange = { viewModel.setTriggerOnSave(it) },
icon = Icons.Default.Save,
enabled = isServerConfigured
)
// onResume Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_on_resume_title),
subtitle = stringResource(R.string.sync_trigger_on_resume_subtitle),
checked = triggerOnResume,
onCheckedChange = { viewModel.setTriggerOnResume(it) },
icon = Icons.Default.PhonelinkRing,
enabled = isServerConfigured
) )
SettingsDivider() SettingsDivider()
// Sync Interval Section // ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_interval_section)) // HINTERGRUND-SYNC Section
// ═══════════════════════════════════════════════════════════════
SettingsInfoCard( SettingsSectionHeader(text = stringResource(R.string.sync_section_background))
text = stringResource(R.string.sync_interval_info)
// WiFi-Connect Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_wifi_connect_title),
subtitle = stringResource(R.string.sync_trigger_wifi_connect_subtitle),
checked = triggerWifiConnect,
onCheckedChange = { viewModel.setTriggerWifiConnect(it) },
icon = Icons.Default.Wifi,
enabled = isServerConfigured
) )
// Periodic Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_periodic_title),
subtitle = stringResource(R.string.sync_trigger_periodic_subtitle),
checked = triggerPeriodic,
onCheckedChange = { viewModel.setTriggerPeriodic(it) },
icon = Icons.Default.Schedule,
enabled = isServerConfigured
)
// Periodic Interval Selection (only visible if periodic trigger is enabled)
if (triggerPeriodic && isServerConfigured) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Interval Radio Group
val intervalOptions = listOf( val intervalOptions = listOf(
RadioOption( RadioOption(
value = 15L, value = 15L,
title = stringResource(R.string.sync_interval_15min_title), title = stringResource(R.string.sync_interval_15min_title),
subtitle = stringResource(R.string.sync_interval_15min_subtitle) subtitle = null
), ),
RadioOption( RadioOption(
value = 30L, value = 30L,
title = stringResource(R.string.sync_interval_30min_title), title = stringResource(R.string.sync_interval_30min_title),
subtitle = stringResource(R.string.sync_interval_30min_subtitle) subtitle = null
), ),
RadioOption( RadioOption(
value = 60L, value = 60L,
title = stringResource(R.string.sync_interval_60min_title), title = stringResource(R.string.sync_interval_60min_title),
subtitle = stringResource(R.string.sync_interval_60min_subtitle) subtitle = null
) )
) )
@@ -100,6 +192,40 @@ fun SyncSettingsScreen(
onValueSelected = { viewModel.setSyncInterval(it) } onValueSelected = { viewModel.setSyncInterval(it) }
) )
Spacer(modifier = Modifier.height(8.dp))
}
SettingsDivider()
// ═══════════════════════════════════════════════════════════════
// ADVANCED Section (Boot Sync)
// ═══════════════════════════════════════════════════════════════
SettingsSectionHeader(text = stringResource(R.string.sync_section_advanced))
// Boot Trigger
SettingsSwitch(
title = stringResource(R.string.sync_trigger_boot_title),
subtitle = stringResource(R.string.sync_trigger_boot_subtitle),
checked = triggerBoot,
onCheckedChange = { viewModel.setTriggerBoot(it) },
icon = Icons.Default.SettingsInputAntenna,
enabled = isServerConfigured
)
SettingsDivider()
// Manual Sync Info
val manualHintText = if (isServerConfigured) {
stringResource(R.string.sync_manual_hint)
} else {
stringResource(R.string.sync_manual_hint_disabled)
}
SettingsInfoCard(
text = manualHintText
)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
} }

View File

@@ -0,0 +1,28 @@
package dev.dettmer.simplenotes.ui.theme
import androidx.compose.ui.unit.dp
/**
* Zentrale UI-Dimensionen für konsistentes Design
*/
object Dimensions {
// Padding & Spacing
val SpacingSmall = 4.dp
val SpacingMedium = 8.dp
val SpacingLarge = 16.dp
val SpacingXLarge = 24.dp
// Icon Sizes
val IconSizeSmall = 16.dp
val IconSizeMedium = 24.dp
val IconSizeLarge = 32.dp
// Minimum Touch Target (Material Design: 48dp)
val MinTouchTarget = 48.dp
// Checklist
val ChecklistItemMinHeight = 48.dp
// Status Bar Heights
val StatusBarHeightDefault = 56.dp
}

View File

@@ -29,6 +29,31 @@ object Constants {
// 🔥 v1.3.1: Debug & Logging // 🔥 v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled" const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
// 🔥 v1.6.0: Offline Mode Toggle
const val KEY_OFFLINE_MODE = "offline_mode_enabled"
// 🔥 v1.7.0: WiFi-Only Sync Toggle
const val KEY_WIFI_ONLY_SYNC = "wifi_only_sync_enabled"
const val DEFAULT_WIFI_ONLY_SYNC = false // Standardmäßig auch mobil syncen
// 🔥 v1.6.0: Configurable Sync Triggers
const val KEY_SYNC_TRIGGER_ON_SAVE = "sync_trigger_on_save"
const val KEY_SYNC_TRIGGER_ON_RESUME = "sync_trigger_on_resume"
const val KEY_SYNC_TRIGGER_WIFI_CONNECT = "sync_trigger_wifi_connect"
const val KEY_SYNC_TRIGGER_PERIODIC = "sync_trigger_periodic"
const val KEY_SYNC_TRIGGER_BOOT = "sync_trigger_boot"
// Sync Trigger Defaults (active after server configuration)
const val DEFAULT_TRIGGER_ON_SAVE = true
const val DEFAULT_TRIGGER_ON_RESUME = true
const val DEFAULT_TRIGGER_WIFI_CONNECT = true
const val DEFAULT_TRIGGER_PERIODIC = false
const val DEFAULT_TRIGGER_BOOT = false
// Throttling for onSave sync (5 seconds)
const val MIN_ON_SAVE_SYNC_INTERVAL_MS = 5_000L
const val PREF_LAST_ON_SAVE_SYNC_TIME = "last_on_save_sync_time"
// WorkManager // WorkManager
const val SYNC_WORK_TAG = "notes_sync" const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L const val SYNC_DELAY_SECONDS = 5L
@@ -36,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 {
@@ -19,6 +19,7 @@ object NotificationHelper {
private const val CHANNEL_ID = "notes_sync_channel" private const val CHANNEL_ID = "notes_sync_channel"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val SYNC_NOTIFICATION_ID = 2 private const val SYNC_NOTIFICATION_ID = 2
const val SYNC_PROGRESS_NOTIFICATION_ID = 1003 // v1.7.2: For expedited work foreground notification
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
/** /**
@@ -54,11 +55,31 @@ object NotificationHelper {
Logger.d(TAG, "🗑️ Cleared old sync notifications") Logger.d(TAG, "🗑️ Cleared old sync notifications")
} }
/**
* 🔧 v1.7.2: Erstellt Notification für Sync-Progress (Expedited Work)
*
* Wird von SyncWorker.getForegroundInfo() aufgerufen auf Android 9-11.
* Muss eine gültige, sichtbare Notification zurückgeben.
*
* @return Notification (nicht anzeigen, nur erstellen)
*/
fun createSyncProgressNotification(context: Context): android.app.Notification {
return NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setContentTitle(context.getString(R.string.sync_in_progress))
.setContentText(context.getString(R.string.sync_in_progress_text))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setProgress(0, 0, true) // Indeterminate progress
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.build()
}
/** /**
* Zeigt Erfolgs-Notification nach Sync * Zeigt Erfolgs-Notification nach Sync
*/ */
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 +175,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 +250,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 +281,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 +318,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

@@ -0,0 +1,13 @@
package dev.dettmer.simplenotes.utils
/**
* Konstanten für Sync-Operationen
*/
object SyncConstants {
// Debounce Delays
const val SEARCH_DEBOUNCE_MS = 300L
const val SYNC_DEBOUNCE_MS = 500L
// Connection Timeouts
const val CONNECTION_TEST_TIMEOUT_MS = 5000L
}

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>
@@ -65,6 +66,7 @@
<string name="delete_note_message">Wie möchtest du diese Notiz löschen?</string> <string name="delete_note_message">Wie möchtest du diese Notiz löschen?</string>
<string name="delete_notes_message">Wie möchtest du diese %d Notizen löschen?</string> <string name="delete_notes_message">Wie möchtest du diese %d Notizen löschen?</string>
<string name="delete_everywhere">Überall löschen (auch Server)</string> <string name="delete_everywhere">Überall löschen (auch Server)</string>
<string name="delete_everywhere_offline_hint">Nicht verfügbar im Offline-Modus</string>
<string name="delete_local_only">Nur lokal löschen</string> <string name="delete_local_only">Nur lokal löschen</string>
<string name="delete">Löschen</string> <string name="delete">Löschen</string>
<string name="cancel">Abbrechen</string> <string name="cancel">Abbrechen</string>
@@ -91,9 +93,21 @@
<string name="snackbar_server_error">Server-Fehler: %s</string> <string name="snackbar_server_error">Server-Fehler: %s</string>
<string name="snackbar_already_synced">Bereits synchronisiert</string> <string name="snackbar_already_synced">Bereits synchronisiert</string>
<string name="snackbar_server_unreachable">Server nicht erreichbar</string> <string name="snackbar_server_unreachable">Server nicht erreichbar</string>
<string name="snackbar_connection_timeout">Verbindungs-Timeout</string>
<string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string> <string name="snackbar_synced_count">✅ Gesynct: %d Notizen</string>
<string name="snackbar_nothing_to_sync"> Nichts zu syncen</string> <string name="snackbar_nothing_to_sync"> Nichts zu syncen</string>
<!-- ============================= -->
<!-- SYNC ERROR MESSAGES -->
<!-- ============================= -->
<string name="sync_error_ssl">SSL-Fehler</string>
<string name="sync_error_auth_failed">Authentifizierung fehlgeschlagen</string>
<string name="sync_error_access_denied">Zugriff verweigert</string>
<string name="sync_error_path_not_found">Server-Pfad nicht gefunden</string>
<string name="sync_error_server">Server-Fehler</string>
<string name="sync_error_http">HTTP-Fehler: %d</string>
<string name="sync_error_unknown">Unbekannter Fehler</string>
<!-- ============================= --> <!-- ============================= -->
<!-- URL VALIDATION ERRORS --> <!-- URL VALIDATION ERRORS -->
<!-- ============================= --> <!-- ============================= -->
@@ -135,9 +149,13 @@
<string name="settings_server_status_unreachable">❌ Nicht erreichbar</string> <string name="settings_server_status_unreachable">❌ Nicht erreichbar</string>
<string name="settings_server_status_checking">🔍 Prüfe…</string> <string name="settings_server_status_checking">🔍 Prüfe…</string>
<string name="settings_server_status_not_configured">⚠️ Nicht konfiguriert</string> <string name="settings_server_status_not_configured">⚠️ Nicht konfiguriert</string>
<string name="settings_server_status_offline_mode">📴 Offline-Modus</string>
<string name="settings_sync">Sync-Einstellungen</string> <string name="settings_sync">Sync-Einstellungen</string>
<string name="settings_sync_auto_on">Auto-Sync: An • %s</string> <string name="settings_sync_auto_on">Auto-Sync: An • %s</string>
<string name="settings_sync_auto_off">Auto-Sync: Aus</string> <string name="settings_sync_auto_off">Auto-Sync: Aus</string>
<string name="settings_sync_offline_mode">📴 Offline-Modus</string>
<string name="settings_sync_manual_only">Nur manueller Sync</string>
<string name="settings_sync_triggers_active">%d Trigger aktiv</string>
<string name="settings_interval_15min">15 Min</string> <string name="settings_interval_15min">15 Min</string>
<string name="settings_interval_30min">30 Min</string> <string name="settings_interval_30min">30 Min</string>
<string name="settings_interval_60min">60 Min</string> <string name="settings_interval_60min">60 Min</string>
@@ -173,7 +191,10 @@
<string name="server_status_unreachable">❌ Nicht erreichbar</string> <string name="server_status_unreachable">❌ Nicht erreichbar</string>
<string name="server_status_checking">🔍 Prüfe…</string> <string name="server_status_checking">🔍 Prüfe…</string>
<string name="server_status_not_configured">⚠️ Nicht konfiguriert</string> <string name="server_status_not_configured">⚠️ Nicht konfiguriert</string>
<string name="server_status_offline_mode">📴 Offline-Modus aktiv</string>
<string name="server_status_unknown">❓ Unbekannt</string> <string name="server_status_unknown">❓ Unbekannt</string>
<string name="server_offline_mode_title">📴 Offline-Modus</string>
<string name="server_offline_mode_subtitle">Alle Netzwerkfunktionen deaktivieren</string>
<string name="test_connection">Verbindung testen</string> <string name="test_connection">Verbindung testen</string>
<string name="sync_now">Jetzt synchronisieren</string> <string name="sync_now">Jetzt synchronisieren</string>
@@ -188,14 +209,49 @@
<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 -->
<string name="sync_section_network">📶 Netzwerk-Einschränkung</string>
<string name="sync_section_instant">📲 Sofort-Sync</string>
<string name="sync_section_background">📡 Hintergrund-Sync</string>
<string name="sync_section_advanced">⚙️ Erweitert</string>
<string name="sync_wifi_only_hint">💡 Der WiFi-Connect Trigger ist davon nicht betroffen \u2013 er synchronisiert immer wenn WiFi verbunden wird.</string>
<string name="sync_trigger_on_save_title">Nach dem Speichern</string>
<string name="sync_trigger_on_save_subtitle">Sync sofort nach jeder Änderung</string>
<string name="sync_trigger_on_resume_title">Beim App-Start</string>
<string name="sync_trigger_on_resume_subtitle">Sync wenn die App geöffnet wird</string>
<string name="sync_trigger_wifi_connect_title">Bei WiFi-Verbindung</string>
<string name="sync_trigger_wifi_connect_subtitle">Sync wenn WiFi verbunden wird</string>
<string name="sync_trigger_periodic_title">Automatisch alle X Minuten</string>
<string name="sync_trigger_periodic_subtitle">Regelmäßiger Hintergrund-Sync</string>
<string name="sync_trigger_boot_title">Nach Gerät-Neustart</string>
<string name="sync_trigger_boot_subtitle">Startet Hintergrund-Sync nach Reboot</string>
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
<string name="sync_wifi_only_title">Sync nur im WLAN</string>
<string name="sync_wifi_only_subtitle">Sync wird nur durchgeführt wenn WLAN verbunden ist. Spart mobiles Datenvolumen und verhindert lange Wartezeit.</string>
<string name="sync_wifi_only_blocked">Sync nur im WLAN möglich</string>
<string name="sync_manual_hint">Manueller Sync (Toolbar/Pull-to-Refresh) ist ebenfalls verfügbar.</string>
<string name="sync_manual_hint_disabled">Sync ist im Offline-Modus nicht verfügbar.</string>
<string name="sync_offline_mode_title">Offline-Modus</string>
<string name="sync_offline_mode_message">Du nutzt die App im Offline-Modus. Richte einen Server ein, um Notizen zu synchronisieren.</string>
<string name="sync_offline_mode_button">Server einrichten</string>
<!-- ============================= --> <!-- ============================= -->
<!-- SETTINGS - MARKDOWN --> <!-- SETTINGS - MARKDOWN -->
<!-- ============================= --> <!-- ============================= -->
@@ -218,6 +274,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>
@@ -273,6 +343,15 @@
<string name="language_info"> Wähle deine bevorzugte Sprache. Die App wird neu gestartet, um die Änderung anzuwenden.</string> <string name="language_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 -->
<!-- ============================= --> <!-- ============================= -->
@@ -322,6 +401,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>
@@ -357,6 +438,8 @@
<!-- ============================= --> <!-- ============================= -->
<string name="notification_channel_name">Notizen Synchronisierung</string> <string name="notification_channel_name">Notizen Synchronisierung</string>
<string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string> <string name="notification_channel_desc">Benachrichtigungen über Sync-Status</string>
<string name="sync_in_progress">Synchronisierung läuft</string>
<string name="sync_in_progress_text">Notizen werden synchronisiert…</string>
<string name="notification_sync_success_title">Sync erfolgreich</string> <string name="notification_sync_success_title">Sync erfolgreich</string>
<string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string> <string name="notification_sync_success_message">%d Notiz(en) synchronisiert</string>
<string name="notification_sync_failed_title">Sync fehlgeschlagen</string> <string name="notification_sync_failed_title">Sync fehlgeschlagen</string>

View File

@@ -66,6 +66,7 @@
<string name="delete_note_message">How do you want to delete this note?</string> <string name="delete_note_message">How do you want to delete this note?</string>
<string name="delete_notes_message">How do you want to delete these %d notes?</string> <string name="delete_notes_message">How do you want to delete these %d notes?</string>
<string name="delete_everywhere">Delete everywhere (also server)</string> <string name="delete_everywhere">Delete everywhere (also server)</string>
<string name="delete_everywhere_offline_hint">Not available in offline mode</string>
<string name="delete_local_only">Delete local only</string> <string name="delete_local_only">Delete local only</string>
<string name="delete">Delete</string> <string name="delete">Delete</string>
<string name="cancel">Cancel</string> <string name="cancel">Cancel</string>
@@ -92,9 +93,21 @@
<string name="snackbar_server_error">Server error: %s</string> <string name="snackbar_server_error">Server error: %s</string>
<string name="snackbar_already_synced">Already synced</string> <string name="snackbar_already_synced">Already synced</string>
<string name="snackbar_server_unreachable">Server not reachable</string> <string name="snackbar_server_unreachable">Server not reachable</string>
<string name="snackbar_connection_timeout">Connection timeout</string>
<string name="snackbar_synced_count">✅ Synced: %d notes</string> <string name="snackbar_synced_count">✅ Synced: %d notes</string>
<string name="snackbar_nothing_to_sync"> Nothing to sync</string> <string name="snackbar_nothing_to_sync"> Nothing to sync</string>
<!-- ============================= -->
<!-- SYNC ERROR MESSAGES -->
<!-- ============================= -->
<string name="sync_error_ssl">SSL error</string>
<string name="sync_error_auth_failed">Authentication failed</string>
<string name="sync_error_access_denied">Access denied</string>
<string name="sync_error_path_not_found">Server path not found</string>
<string name="sync_error_server">Server error</string>
<string name="sync_error_http">HTTP error: %d</string>
<string name="sync_error_unknown">Unknown error</string>
<!-- ============================= --> <!-- ============================= -->
<!-- URL VALIDATION ERRORS --> <!-- URL VALIDATION ERRORS -->
<!-- ============================= --> <!-- ============================= -->
@@ -136,9 +149,13 @@
<string name="settings_server_status_unreachable">❌ Not reachable</string> <string name="settings_server_status_unreachable">❌ Not reachable</string>
<string name="settings_server_status_checking">🔍 Checking…</string> <string name="settings_server_status_checking">🔍 Checking…</string>
<string name="settings_server_status_not_configured">⚠️ Not configured</string> <string name="settings_server_status_not_configured">⚠️ Not configured</string>
<string name="settings_server_status_offline_mode">📴 Offline Mode</string>
<string name="settings_sync">Sync Settings</string> <string name="settings_sync">Sync Settings</string>
<string name="settings_sync_auto_on">Auto-Sync: On • %s</string> <string name="settings_sync_auto_on">Auto-Sync: On • %s</string>
<string name="settings_sync_auto_off">Auto-Sync: Off</string> <string name="settings_sync_auto_off">Auto-Sync: Off</string>
<string name="settings_sync_offline_mode">📴 Offline Mode</string>
<string name="settings_sync_manual_only">Manual sync only</string>
<string name="settings_sync_triggers_active">%d triggers active</string>
<string name="settings_interval_15min">15 min</string> <string name="settings_interval_15min">15 min</string>
<string name="settings_interval_30min">30 min</string> <string name="settings_interval_30min">30 min</string>
<string name="settings_interval_60min">60 min</string> <string name="settings_interval_60min">60 min</string>
@@ -174,7 +191,10 @@
<string name="server_status_unreachable">❌ Not reachable</string> <string name="server_status_unreachable">❌ Not reachable</string>
<string name="server_status_checking">🔍 Checking…</string> <string name="server_status_checking">🔍 Checking…</string>
<string name="server_status_not_configured">⚠️ Not configured</string> <string name="server_status_not_configured">⚠️ Not configured</string>
<string name="server_status_offline_mode">📴 Offline mode active</string>
<string name="server_status_unknown">❓ Unknown</string> <string name="server_status_unknown">❓ Unknown</string>
<string name="server_offline_mode_title">📴 Offline Mode</string>
<string name="server_offline_mode_subtitle">Disable all network features</string>
<string name="test_connection">Test Connection</string> <string name="test_connection">Test Connection</string>
<string name="sync_now">Sync now</string> <string name="sync_now">Sync now</string>
@@ -189,14 +209,49 @@
<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 -->
<string name="sync_section_network">📶 Network Restriction</string>
<string name="sync_section_instant">📲 Instant Sync</string>
<string name="sync_section_background">📡 Background Sync</string>
<string name="sync_section_advanced">⚙️ Advanced</string>
<string name="sync_wifi_only_hint">💡 WiFi-Connect Trigger is not affected by this setting \u2013 it always syncs when WiFi is connected.</string>
<string name="sync_trigger_on_save_title">After Saving</string>
<string name="sync_trigger_on_save_subtitle">Sync immediately after each change</string>
<string name="sync_trigger_on_resume_title">On App Start</string>
<string name="sync_trigger_on_resume_subtitle">Sync when the app is opened</string>
<string name="sync_trigger_wifi_connect_title">On WiFi Connection</string>
<string name="sync_trigger_wifi_connect_subtitle">Sync when WiFi is connected</string>
<string name="sync_trigger_periodic_title">Automatically every X minutes</string>
<string name="sync_trigger_periodic_subtitle">Regular background sync</string>
<string name="sync_trigger_boot_title">After Device Restart</string>
<string name="sync_trigger_boot_subtitle">Starts background sync after reboot</string>
<!-- 🆕 v1.7.0: WiFi-Only Sync -->
<string name="sync_wifi_only_title">WiFi-only sync</string>
<string name="sync_wifi_only_subtitle">Sync only when connected to WiFi. Saves mobile data and prevents long wait times.</string>
<string name="sync_wifi_only_blocked">Sync only works when connected to WiFi</string>
<string name="sync_manual_hint">Manual sync (toolbar/pull-to-refresh) is also available.</string>
<string name="sync_manual_hint_disabled">Sync is not available in offline mode.</string>
<string name="sync_offline_mode_title">Offline Mode</string>
<string name="sync_offline_mode_message">You are using the app in offline mode. Set up a server to synchronize notes.</string>
<string name="sync_offline_mode_button">Set Up Server</string>
<!-- ============================= --> <!-- ============================= -->
<!-- SETTINGS - MARKDOWN --> <!-- SETTINGS - MARKDOWN -->
<!-- ============================= --> <!-- ============================= -->
@@ -219,6 +274,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>
@@ -274,6 +343,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 -->
<!-- ============================= --> <!-- ============================= -->
@@ -323,6 +401,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>
@@ -358,6 +438,8 @@
<!-- ============================= --> <!-- ============================= -->
<string name="notification_channel_name">Notes Synchronization</string> <string name="notification_channel_name">Notes Synchronization</string>
<string name="notification_channel_desc">Notifications about sync status</string> <string name="notification_channel_desc">Notifications about sync status</string>
<string name="sync_in_progress">Syncing</string>
<string name="sync_in_progress_text">Syncing notes…</string>
<string name="notification_sync_success_title">Sync successful</string> <string name="notification_sync_success_title">Sync successful</string>
<string name="notification_sync_success_message">%d note(s) synchronized</string> <string name="notification_sync_success_message">%d note(s) synchronized</string>
<string name="notification_sync_failed_title">Sync failed</string> <string name="notification_sync_failed_title">Sync failed</string>

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.** 🙏

View File

@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
## 🔋 Akku-Optimierung ## 🔋 Akku-Optimierung
### Verbrauchsanalyse ### v1.6.0: Konfigurierbare Sync-Trigger
Seit v1.6.0 kann jeder Sync-Trigger einzeln aktiviert/deaktiviert werden. Das gibt Nutzern feine Kontrolle über den Akkuverbrauch.
#### Sync-Trigger Übersicht
| Trigger | Standard | Akku-Impact | Beschreibung |
|---------|----------|-------------|--------------|
| **Manueller Sync** | Immer an | 0 (nutzer-getriggert) | Toolbar-Button / Pull-to-Refresh |
| **onSave Sync** | ✅ AN | ~0.5 mAh/Speichern | Sync sofort nach Speichern einer Notiz |
| **onResume Sync** | ✅ AN | ~0.3 mAh/Öffnen | Sync beim App-Öffnen (60s Throttle) |
| **WiFi-Connect** | ✅ AN | ~0.5 mAh/Verbindung | Sync bei WiFi-Verbindung |
| **Periodic Sync** | ❌ AUS | 0.2-0.8%/Tag | Hintergrund-Sync alle 15/30/60 Min |
| **Boot Sync** | ❌ AUS | ~0.1 mAh/Boot | Start Hintergrund-Sync nach Neustart |
#### Akku-Verbrauchsberechnung
**Typisches Nutzungsszenario (Standardeinstellungen):**
- onSave: ~5 Speichern/Tag × 0.5 mAh = **~2.5 mAh**
- onResume: ~10 Öffnen/Tag × 0.3 mAh = **~3 mAh**
- WiFi-Connect: ~2 Verbindungen/Tag × 0.5 mAh = **~1 mAh**
- **Gesamt: ~6.5 mAh/Tag (~0.2% bei 3000mAh Akku)**
**Mit aktiviertem Periodic Sync (15/30/60 Min):**
| Intervall | Syncs/Tag | Akku/Tag | Gesamt (mit Standards) |
|-----------|-----------|----------|------------------------|
| **15 Min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
| **30 Min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
| **60 Min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
#### Komponenten-Aufschlüsselung
| Komponente | Frequenz | Verbrauch | Details | | Komponente | Frequenz | Verbrauch | Details |
|------------|----------|-----------|---------| |------------|----------|-----------|---------|
| WorkManager Wakeup | Alle 30 Min | ~0.15 mAh | System wacht auf | | WorkManager Wakeup | Pro Sync | ~0.15 mAh | System wacht auf |
| Network Check | 48x/Tag | ~0.03 mAh | Gateway IP check | | Network Check | Pro Sync | ~0.03 mAh | Gateway IP Check |
| WebDAV Sync | 2-3x/Tag | ~1.5 mAh | Nur bei Änderungen | | WebDAV Sync | Nur bei Änderungen | ~0.25 mAh | HTTP PUT/GET |
| **Total** | - | **~12 mAh/Tag** | **~0.4%** bei 3000mAh | | **Pro-Sync Gesamt** | - | **~0.25 mAh** | Optimiert |
### Optimierungen ### Optimierungen
1. **IP Caching** 1. **Pre-Checks vor Sync**
```kotlin
// Reihenfolge wichtig! Günstigste Checks zuerst
if (!hasUnsyncedChanges()) return // Lokaler Check (günstig)
if (!isServerReachable()) return // Netzwerk Check (teuer)
performSync() // Nur wenn beide bestehen
```
2. **Throttling**
- onResume: 60 Sekunden Mindestabstand
- onSave: 5 Sekunden Mindestabstand
- Periodic: 15/30/60 Minuten Intervalle
3. **IP Caching**
```kotlin ```kotlin
private var cachedServerIP: String? = null private var cachedServerIP: String? = null
// DNS lookup nur 1x beim Start, nicht bei jedem Check // DNS lookup nur 1x beim Start, nicht bei jedem Check
``` ```
2. **Throttling** 4. **Conditional Logging**
```kotlin
private var lastSyncTime = 0L
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 Sync/Min
```
3. **Conditional Logging**
```kotlin ```kotlin
object Logger { object Logger {
fun d(tag: String, msg: String) { fun d(tag: String, msg: String) {
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
} }
``` ```
4. **Network Constraints** 5. **Network Constraints**
- Nur WiFi (nicht mobile Daten) - Nur WiFi (nicht mobile Daten)
- Nur wenn Server erreichbar - Nur wenn Server erreichbar
- Keine permanenten Listeners - Keine permanenten Listeners

View File

@@ -174,30 +174,68 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
## 🔋 Battery Optimization ## 🔋 Battery Optimization
### Usage Analysis ### v1.6.0: Configurable Sync Triggers
Since v1.6.0, each sync trigger can be individually enabled/disabled. This gives users fine-grained control over battery usage.
#### Sync Trigger Overview
| Trigger | Default | Battery Impact | Description |
|---------|---------|----------------|-------------|
| **Manual Sync** | Always on | 0 (user-triggered) | Toolbar button / Pull-to-refresh |
| **onSave Sync** | ✅ ON | ~0.5 mAh/save | Sync immediately after saving a note |
| **onResume Sync** | ✅ ON | ~0.3 mAh/resume | Sync when app is opened (60s throttle) |
| **WiFi-Connect** | ✅ ON | ~0.5 mAh/connect | Sync when WiFi is connected |
| **Periodic Sync** | ❌ OFF | 0.2-0.8%/day | Background sync every 15/30/60 min |
| **Boot Sync** | ❌ OFF | ~0.1 mAh/boot | Start background sync after reboot |
#### Battery Usage Calculation
**Typical usage scenario (defaults):**
- onSave: ~5 saves/day × 0.5 mAh = **~2.5 mAh**
- onResume: ~10 opens/day × 0.3 mAh = **~3 mAh**
- WiFi-Connect: ~2 connects/day × 0.5 mAh = **~1 mAh**
- **Total: ~6.5 mAh/day (~0.2% on 3000mAh battery)**
**With Periodic Sync enabled (15/30/60 min):**
| Interval | Syncs/day | Battery/day | Total (with defaults) |
|----------|-----------|-------------|----------------------|
| **15 min** | ~96 | ~23 mAh | ~30 mAh (~1.0%) |
| **30 min** | ~48 | ~12 mAh | ~19 mAh (~0.6%) |
| **60 min** | ~24 | ~6 mAh | ~13 mAh (~0.4%) |
#### Component Breakdown
| Component | Frequency | Usage | Details | | Component | Frequency | Usage | Details |
|------------|----------|-----------|---------| |-----------|-----------|-------|---------|
| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up | | WorkManager Wakeup | Per sync | ~0.15 mAh | System wakes up |
| Network Check | 48x/day | ~0.03 mAh | Gateway IP check | | Network Check | Per sync | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes | | WebDAV Sync | Only if changes | ~0.25 mAh | HTTP PUT/GET |
| **Total** | - | **~12 mAh/day** | **~0.4%** at 3000mAh | | **Per-Sync Total** | - | **~0.25 mAh** | Optimized |
### Optimizations ### Optimizations
1. **IP Caching** 1. **Pre-Checks before Sync**
```kotlin
// Order matters! Cheapest checks first
if (!hasUnsyncedChanges()) return // Local check (cheap)
if (!isServerReachable()) return // Network check (expensive)
performSync() // Only if both pass
```
2. **Throttling**
- onResume: 60 second minimum interval
- onSave: 5 second minimum interval
- Periodic: 15/30/60 minute intervals
3. **IP Caching**
```kotlin ```kotlin
private var cachedServerIP: String? = null private var cachedServerIP: String? = null
// DNS lookup only once at start, not every check // DNS lookup only once at start, not every check
``` ```
2. **Throttling** 4. **Conditional Logging**
```kotlin
private var lastSyncTime = 0L
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min
```
3. **Conditional Logging**
```kotlin ```kotlin
object Logger { object Logger {
fun d(tag: String, msg: String) { fun d(tag: String, msg: String) {
@@ -206,7 +244,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
} }
``` ```
4. **Network Constraints** 5. **Network Constraints**
- WiFi only (not mobile data) - WiFi only (not mobile data)
- Only when server is reachable - Only when server is reachable
- No permanent listeners - No permanent listeners

View File

@@ -169,16 +169,19 @@
## 🔋 Performance & Optimierung ## 🔋 Performance & Optimierung
### Akku-Effizienz ### Akku-Effizienz (v1.6.0)
-**Optimierte Sync-Intervalle** - 15/30/60 Min -**Konfigurierbare Sync-Trigger** - Jeden Trigger einzeln aktivieren/deaktivieren
-**Smarte Defaults** - Nur ereignisbasierte Trigger standardmäßig aktiv
-**Optimierte Periodische Intervalle** - 15/30/60 Min (Standard: AUS)
-**WiFi-Only** - Kein Mobile Data Sync -**WiFi-Only** - Kein Mobile Data Sync
-**Smart Server-Check** - Sync nur wenn Server erreichbar -**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - System-optimierte Ausführung -**WorkManager** - System-optimierte Ausführung
-**Doze Mode kompatibel** - Sync läuft auch im Standby -**Doze Mode kompatibel** - Sync läuft auch im Standby
-**Gemessener Verbrauch:** -**Gemessener Verbrauch:**
- 15 Min: ~0.8% / Tag (~23 mAh) - Standard (nur ereignisbasiert): ~0.2%/Tag (~6.5 mAh)_Optimal_
- 30 Min: ~0.4% / Tag (~12 mAh)_Empfohlen_ - Mit Periodic 15 Min: ~1.0%/Tag (~30 mAh)
- 60 Min: ~0.2% / Tag (~6 mAh) - Mit Periodic 30 Min: ~0.6%/Tag (~19 mAh)
- Mit Periodic 60 Min: ~0.4%/Tag (~13 mAh)
### App-Performance ### App-Performance
-**Offline-First** - Funktioniert ohne Internet -**Offline-First** - Funktioniert ohne Internet

View File

@@ -169,16 +169,19 @@
## 🔋 Performance & Optimization ## 🔋 Performance & Optimization
### Battery Efficiency ### Battery Efficiency (v1.6.0)
-**Optimized sync intervals** - 15/30/60 min -**Configurable sync triggers** - Enable/disable each trigger individually
-**Smart defaults** - Only event-driven triggers active by default
-**Optimized periodic intervals** - 15/30/60 min (default: OFF)
-**WiFi-only** - No mobile data sync -**WiFi-only** - No mobile data sync
-**Smart server check** - Sync only when server is reachable -**Smart server check** - Sync only when server is reachable
-**WorkManager** - System-optimized execution -**WorkManager** - System-optimized execution
-**Doze mode compatible** - Sync runs even in standby -**Doze mode compatible** - Sync runs even in standby
-**Measured consumption:** -**Measured consumption:**
- 15 min: ~0.8% / day (~23 mAh) - Default (event-driven only): ~0.2%/day (~6.5 mAh)_Optimal_
- 30 min: ~0.4% / day (~12 mAh)_Recommended_ - With periodic 15 min: ~1.0%/day (~30 mAh)
- 60 min: ~0.2% / day (~6 mAh) - With periodic 30 min: ~0.6%/day (~19 mAh)
- With periodic 60 min: ~0.4%/day (~13 mAh)
### App Performance ### App Performance
-**Offline-first** - Works without internet -**Offline-first** - Works without internet

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

@@ -31,9 +31,46 @@
--- ---
## v1.6.0 - Technische Modernisierung ## v1.6.0 - Technische Modernisierung
> **Status:** In Planung 📋 > **Status:** Released 🎉 (Januar 2026)
### ⚙️ Konfigurierbare Sync-Trigger
-**Individuelle Trigger-Kontrolle** - Jeden Sync-Trigger einzeln aktivieren/deaktivieren
-**Ereignisbasierte Defaults** - onSave, onResume, WiFi-Connect standardmäßig aktiv
-**Periodischer Sync optional** - 15/30/60 Min Intervalle (Standard: AUS)
-**Boot Sync optional** - Periodischen Sync nach Geräteneustart starten (Standard: AUS)
-**Offline-Modus UI** - Ausgegraute Toggles wenn kein Server konfiguriert
-**Akku-optimiert** - ~0.2%/Tag mit Defaults, bis zu ~1.0% mit Periodic
---
## v1.6.1 - Clean Code ✅
> **Status:** Released 🎉 (Januar 2026)
### 🧹 Code-Qualität
-**detekt: 0 Issues** - Alle 29 Code-Qualitäts-Issues behoben
-**Zero Build Warnings** - Alle 21 Deprecation Warnings eliminiert
-**ktlint reaktiviert** - Mit Compose-spezifischen Regeln
-**CI/CD Lint-Checks** - In PR Build Workflow integriert
-**Constants Refactoring** - Dimensions.kt, SyncConstants.kt
---
## v1.7.0 - Staggered Grid Layout
> **Status:** Geplant 📝
### 🎨 Adaptives Layout
- **Staggered Grid** - Pinterest-artiges Layout mit `LazyVerticalStaggeredGrid`
- **Intelligente Größen** - Kleine Notizen (kurzer Text, wenige Checklist-Items) kompakt dargestellt
- **Layout-Umschalter** - Zwischen Listen- und Grid-Ansicht in Einstellungen wechseln
- **Adaptive Spalten** - 2-3 Spalten basierend auf Bildschirmgröße
- **120 FPS optimiert** - Lazy Loading für flüssiges Scrollen bei vielen Notizen
### 🔧 Server-Ordner Prüfung ### 🔧 Server-Ordner Prüfung
@@ -43,22 +80,43 @@
### 🔧 Technische Verbesserungen ### 🔧 Technische Verbesserungen
- **Code-Refactoring** - LongMethod und LargeClass Warnings beheben - **Code-Refactoring** - LargeClass Komponenten aufteilen (WebDavSyncService, SettingsActivity)
- **Modernere Background-Sync Architektur** - Noch zuverlässiger
- **Verbesserte Progress-Dialoge** - Material Design 3 konform - **Verbesserte Progress-Dialoge** - Material Design 3 konform
--- ---
## v1.7.0 - Community Features ## v2.0.0 - Legacy Cleanup
> **Status:** Ideen-Sammlung 💡 > **Status:** Geplant 📝
### Mögliche Features ### 🗑️ Legacy Code Entfernung
- **Zusätzliche Sprachen** - Community-Übersetzungen (FR, ES, IT, ...) - **SettingsActivity entfernen** - Ersetzt durch ComposeSettingsActivity
- **MainActivity entfernen** - Ersetzt durch ComposeMainActivity
- **LocalBroadcastManager → SharedFlow** - Moderne Event-Architektur
- **ProgressDialog → Material Dialog** - Volle Material 3 Konformität
- **AbstractSavedStateViewModelFactory → viewModelFactory** - Moderne ViewModel-Erstellung
---
## 📋 Backlog
> Features für zukünftige Überlegungen
### 🔐 Sicherheits-Verbesserungen
- **Passwortgeschützte lokale Backups** - Backup-ZIP mit Passwort verschlüsseln
- **Biometrische Entsperrung** - Fingerabdruck/Gesichtserkennung für App
### 🎨 UI Features
- **Widget** - Schnellzugriff vom Homescreen
- **Kategorien/Tags** - Notizen organisieren - **Kategorien/Tags** - Notizen organisieren
- **Suche** - Volltextsuche in Notizen - **Suche** - Volltextsuche in Notizen
- **Widget** - Schnellzugriff vom Homescreen
### 🌍 Community
- **Zusätzliche Sprachen** - Community-Übersetzungen (FR, ES, IT, ...)
--- ---

View File

@@ -31,9 +31,46 @@
--- ---
## v1.6.0 - Technical Modernization ## v1.6.0 - Technical Modernization
> **Status:** Planned 📋 > **Status:** Released 🎉 (January 2026)
### ⚙️ Configurable Sync Triggers
-**Individual trigger control** - Enable/disable each sync trigger separately
-**Event-driven defaults** - onSave, onResume, WiFi-Connect active by default
-**Periodic sync optional** - 15/30/60 min intervals (default: OFF)
-**Boot sync optional** - Start periodic sync after device restart (default: OFF)
-**Offline mode UI** - Grayed-out toggles when no server configured
-**Battery optimized** - ~0.2%/day with defaults, up to ~1.0% with periodic
---
## v1.6.1 - Clean Code ✅
> **Status:** Released 🎉 (January 2026)
### 🧹 Code Quality
-**detekt: 0 issues** - All 29 code quality issues fixed
-**Zero build warnings** - All 21 deprecation warnings eliminated
-**ktlint reactivated** - With Compose-specific rules
-**CI/CD lint checks** - Integrated into PR build workflow
-**Constants refactoring** - Dimensions.kt, SyncConstants.kt
---
## v1.7.0 - Staggered Grid Layout
> **Status:** Planned 📝
### 🎨 Adaptive Layout
- **Staggered Grid** - Pinterest-style layout using `LazyVerticalStaggeredGrid`
- **Smart sizing** - Small notes (short text, few checklist items) displayed compactly
- **Layout toggle** - Switch between List and Grid view in settings
- **Adaptive columns** - 2-3 columns based on screen size
- **120 FPS optimized** - Lazy loading for smooth scrolling with many notes
### 🔧 Server Folder Check ### 🔧 Server Folder Check
@@ -43,22 +80,43 @@
### 🔧 Technical Improvements ### 🔧 Technical Improvements
- **Code refactoring** - Fix LongMethod and LargeClass warnings - **Code refactoring** - Split LargeClass components (WebDavSyncService, SettingsActivity)
- **Modern background sync architecture** - Even more reliable
- **Improved progress dialogs** - Material Design 3 compliant - **Improved progress dialogs** - Material Design 3 compliant
--- ---
## v1.7.0 - Community Features ## v2.0.0 - Legacy Cleanup
> **Status:** Idea Collection 💡 > **Status:** Planned 📝
### Potential Features ### 🗑️ Legacy Code Removal
- **Additional languages** - Community translations (FR, ES, IT, ...) - **Remove SettingsActivity** - Replaced by ComposeSettingsActivity
- **Remove MainActivity** - Replaced by ComposeMainActivity
- **LocalBroadcastManager → SharedFlow** - Modern event architecture
- **ProgressDialog → Material Dialog** - Full Material 3 compliance
- **AbstractSavedStateViewModelFactory → viewModelFactory** - Modern ViewModel creation
---
## 📋 Backlog
> Features for future consideration
### 🔐 Security Enhancements
- **Password-protected local backups** - Encrypt backup ZIP with password
- **Biometric unlock option** - Fingerprint/Face unlock for app
### 🎨 UI Features
- **Widget** - Quick access from homescreen
- **Categories/Tags** - Organize notes - **Categories/Tags** - Organize notes
- **Search** - Full-text search in notes - **Search** - Full-text search in notes
- **Widget** - Quick access from homescreen
### 🌍 Community
- **Additional languages** - Community translations (FR, ES, IT, ...)
--- ---

View File

@@ -0,0 +1,6 @@
• NEU: Konfigurierbare Sync-Trigger - Jeden einzeln aktivieren/deaktivieren
• NEU: Offline-Modus - Alle Netzwerkfunktionen mit einem Schalter aus
• 5 Trigger: onSave, onResume, WiFi, Periodic (15/30/60 Min), Boot
• Smarte Defaults: Nur ereignisbasiert aktiv (~0.2%/Tag Akku)
• Periodischer Sync optional (Standard: AUS)
• Verschiedene Fixes und UI-Verbesserungen

View File

@@ -0,0 +1,2 @@
• Code Quality Verbesserungen
• Bessere Vorbereitung für zukünftige Updates

View File

@@ -0,0 +1,2 @@
• Behebt Offline-Modus Problem nach Update von v1.5.0
• Nutzer mit konfiguriertem Server werden nicht mehr fälschlicherweise als offline angezeigt

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

@@ -0,0 +1,6 @@
• Behoben: App-Absturz auf Android 9 (Issue #15) - Danke an @roughnecks
- WorkManager Expedited Work Kompatibilität (getForegroundInfo)
- Kernel-VPN-Kompatibilität (Wireguard tun/wg Interfaces)
• Verbessert: Stabilität und Verbindungsverwaltung
• Technisch: Optimierter HTTP-Connection-Lebenszyklus

View File

@@ -1,60 +1,32 @@
Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation. Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisation und modernen Features.
HAUPTFUNKTIONEN: 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.)
• Automatische WiFi-Sync: Synchronisiert automatisch wenn du ein beliebiges WLAN betrittst (wenn Server erreichbar ist)
• Konfigurierbares Interval: 15, 30 oder 60 Minuten
• Optimierte Performance: überspringt unveränderte Dateien (~2-3s Sync-Zeit)
• E-Tag Caching für 20x schnellere "keine Änderungen" Checks
• Gemessener Akkuverbrauch: nur ~0.4% pro Tag (bei 30min)
• Silent-Sync Modus: kein Banner bei Auto-Sync
• Doze Mode optimiert für zuverlässige Background-Syncs
• Manuelle Synchronisation jederzeit möglich
MATERIAL DESIGN 3:
• Moderne Jetpack Compose Benutzeroberfläche
• Dynamic Colors (Material You) auf Android 12+
• Dark Mode Support
• Auswahlmodus mit Batch-Löschen
• Live Sync-Status Anzeige
• Flüssige Slide-Animationen
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -0,0 +1,6 @@
• NEW: Configurable Sync Triggers - Enable/disable each individually
• NEW: Offline Mode - Disable all network features with one switch
• 5 triggers: onSave, onResume, WiFi, Periodic (15/30/60 min), Boot
• Smart defaults: Event-driven only (~0.2%/day battery)
• Periodic sync optional (default: OFF)
• Various fixes and UI improvements

View File

@@ -0,0 +1,2 @@
• Code quality improvements
• Better preparation for future updates

View File

@@ -0,0 +1,2 @@
• Fixes offline mode issue after updating from v1.5.0
• Users with configured servers are no longer incorrectly shown as offline

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

@@ -0,0 +1,5 @@
• Fixed: App crash on Android 9 (Issue #15) - Thanks to @roughnecks
- WorkManager expedited work compatibility (getForegroundInfo)
- Kernel-VPN compatibility (Wireguard tun/wg interfaces)
• Improved: Stability and connection management
• Technical: Optimized HTTP connection lifecycle

View File

@@ -1,60 +1,32 @@
Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization. Simple Notes Sync is a minimalist note-taking app with WebDAV sync and modern features.
KEY FEATURES: 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.)
• Automatic WiFi sync: synchronizes whenever you join any WiFi network (if server is reachable)
• Configurable interval: 15, 30, or 60 minutes
• Optimized performance: skips unchanged files (~2-3s sync time)
• E-Tag caching for 20x faster "no changes" checks
• Measured battery consumption: only ~0.4% per day (at 30min)
• Silent-Sync mode: no banner during auto-sync
• Doze Mode optimized for reliable background syncs
• Manual synchronization available anytime
MATERIAL DESIGN 3:
• Modern Jetpack Compose user interface
• Dynamic Colors (Material You) on Android 12+
• Dark Mode support
• Selection mode with batch delete
• Live sync status indicator
• Smooth slide animations
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -1,30 +0,0 @@
Categories:
- Writing
License: MIT
AuthorName: inventory69
AuthorEmail: admin@dettmer.dev
AuthorWebSite: https://dettmer.dev
SourceCode: https://github.com/inventory69/simple-notes-sync
IssueTracker: https://github.com/inventory69/simple-notes-sync/issues
Changelog: https://github.com/inventory69/simple-notes-sync/releases
AutoName: Simple Notes
RepoType: git
Repo: https://github.com/inventory69/simple-notes-sync.git
Binaries: https://github.com/inventory69/simple-notes-sync/releases/download/v%v/simple-notes-sync-v%v-fdroid.apk
Builds:
- versionName: 1.5.0
versionCode: 13
commit: 65395142fab487e0a286cc5dfe3cf8b76652379d
subdir: android/app
gradle:
- fdroid
AllowedAPKSigningKeys: 42a1c613bbc673045af3dc8191bf9cb6456ee44c7dce40c7cfb566facb69f16a
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 1.5.0
CurrentVersionCode: 13