Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dee85233b6 | ||
|
|
fbcca3807d | ||
|
|
e3e64b83e2 | ||
|
|
2324743f43 | ||
|
|
0e96757fab | ||
|
|
547c0a1011 | ||
|
|
b79c0d25e6 | ||
|
|
cf1142afa2 | ||
|
|
359325bf64 | ||
|
|
c7d0f899e7 | ||
|
|
5121a7b2b8 | ||
|
|
04664c8920 | ||
|
|
2a56dd8128 | ||
|
|
63af7d30dc | ||
|
|
62423f5a5b | ||
|
|
9eabc9a5f0 | ||
|
|
015b90d56e | ||
|
|
6d135e8f0d | ||
|
|
5d82431bb6 | ||
|
|
6bb87816f3 | ||
|
|
4802c3d979 | ||
|
|
85625b4f67 | ||
|
|
609da827c5 | ||
|
|
539f17cdda | ||
|
|
0bd686008d | ||
|
|
65ce3746ca | ||
|
|
6079df3b1e | ||
|
|
5f0dc8a981 | ||
|
|
d79a44491d | ||
|
|
4a04b21975 | ||
|
|
881162737b | ||
|
|
1f78953959 | ||
|
|
3092fcc6d3 | ||
|
|
60d6b1effc |
72
.github/workflows/build-production-apk.yml
vendored
@@ -61,33 +61,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p apk-output
|
mkdir -p apk-output
|
||||||
|
|
||||||
# === Standard Flavor (mit Google Services) ===
|
# Standard Flavor - Universal APK
|
||||||
# Universal APK (funktioniert auf allen Geraeten)
|
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
|
||||||
cp android/app/build/outputs/apk/standard/release/app-standard-universal-release.apk \
|
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk
|
|
||||||
|
|
||||||
# ARM64 APK (moderne Geräte 2018+)
|
# F-Droid Flavor - Universal APK
|
||||||
cp android/app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release.apk \
|
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk
|
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||||
|
|
||||||
# ARMv7 APK (ältere Geräte)
|
echo "✅ APK-Dateien vorbereitet:"
|
||||||
cp android/app/build/outputs/apk/standard/release/app-standard-armeabi-v7a-release.apk \
|
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk
|
|
||||||
|
|
||||||
# === F-Droid Flavor (ohne Google Services) ===
|
|
||||||
# Universal APK
|
|
||||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-universal-release.apk \
|
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk
|
|
||||||
|
|
||||||
# ARM64 APK
|
|
||||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release.apk \
|
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk
|
|
||||||
|
|
||||||
# ARMv7 APK
|
|
||||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release.apk \
|
|
||||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk
|
|
||||||
|
|
||||||
echo "✅ APK-Dateien vorbereitet (Standard + F-Droid):"
|
|
||||||
ls -lh apk-output/
|
ls -lh apk-output/
|
||||||
|
|
||||||
- name: APK-Artefakte hochladen
|
- name: APK-Artefakte hochladen
|
||||||
@@ -104,24 +86,22 @@ jobs:
|
|||||||
|
|
||||||
- name: F-Droid Changelogs lesen
|
- name: F-Droid Changelogs lesen
|
||||||
run: |
|
run: |
|
||||||
# Lese deutsche Changelog (Hauptsprache)
|
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
|
||||||
if [ -f "android/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")
|
||||||
echo 'CHANGELOG_DE<<EOF'
|
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
cat "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||||
echo 'EOF'
|
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||||
} >> $GITHUB_ENV
|
|
||||||
else
|
else
|
||||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Lese englische Changelog (optional)
|
# Lese englische Changelog (optional)
|
||||||
if [ -f "android/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")
|
||||||
echo 'CHANGELOG_EN<<EOF'
|
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
cat "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
|
||||||
echo 'EOF'
|
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||||
} >> $GITHUB_ENV
|
|
||||||
else
|
else
|
||||||
echo "CHANGELOG_EN=" >> $GITHUB_ENV
|
echo "CHANGELOG_EN=" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
@@ -140,25 +120,19 @@ jobs:
|
|||||||
|
|
||||||
| Variante | Datei | Info |
|
| Variante | Datei | Info |
|
||||||
|----------|-------|------|
|
|----------|-------|------|
|
||||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk` | Funktioniert auf allen Android-Geraeten |
|
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
||||||
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk` | Kleinere Dateigröße fuer 64-bit Geräte |
|
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
|
||||||
| Aelter (<2018) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk` | Fuer 32-bit ARM Geräte |
|
|
||||||
| F-Droid Universal | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk` | Fuer F-Droid Store |
|
|
||||||
| F-Droid ARM64 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk` | F-Droid 64-bit |
|
|
||||||
| F-Droid ARMv7 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk` | F-Droid 32-bit |
|
|
||||||
|
|
||||||
💡 **Nicht sicher?** → Nimm die **Universal** APK!
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changelog / Release Notes
|
## 📋 Changelog / Release Notes
|
||||||
|
|
||||||
${{ env.CHANGELOG_EN }}
|
${{ env.CHANGELOG_DE }}
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><EFBFBD>🇪 Deutsche Version (zum Aufklappen)</summary>
|
<summary>🌍 English Version</summary>
|
||||||
|
|
||||||
${{ env.CHANGELOG_DE }}
|
${{ env.CHANGELOG_EN }}
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
18
.github/workflows/pr-build-check.yml
vendored
@@ -1,27 +1,22 @@
|
|||||||
name: PR Build Check
|
name: PR Build Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
paths:
|
paths:
|
||||||
- 'android/**'
|
- 'android/**'
|
||||||
- '.github/workflows/pr-build-check.yml'
|
- '.github/workflows/pr-build-check.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build & Test APK
|
name: Build & Test APK
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Code auschecken
|
- name: Code auschecken
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Java einrichten
|
- name: Java einrichten
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Gradle Cache
|
- name: Gradle Cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
@@ -31,7 +26,6 @@ jobs:
|
|||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Version auslesen
|
- name: Version auslesen
|
||||||
run: |
|
run: |
|
||||||
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/')
|
||||||
@@ -39,18 +33,15 @@ 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)"
|
||||||
|
|
||||||
- name: Debug Build erstellen (ohne Signing)
|
- name: Debug Build erstellen (ohne Signing)
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Unit Tests ausfuehren
|
- name: Unit Tests ausfuehren
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
./gradlew test --no-daemon --stacktrace
|
./gradlew test --no-daemon --stacktrace
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Build-Ergebnis pruefen
|
- name: Build-Ergebnis pruefen
|
||||||
run: |
|
run: |
|
||||||
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
|
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
|
||||||
@@ -60,7 +51,6 @@ jobs:
|
|||||||
echo "❌ Standard Debug APK Build fehlgeschlagen"
|
echo "❌ Standard Debug APK Build fehlgeschlagen"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
|
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
|
||||||
echo "✅ F-Droid Debug APK erfolgreich gebaut"
|
echo "✅ F-Droid Debug APK erfolgreich gebaut"
|
||||||
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
|
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||||
@@ -68,7 +58,6 @@ jobs:
|
|||||||
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
|
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Debug APKs hochladen (Artefakte)
|
- name: Debug APKs hochladen (Artefakte)
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -77,7 +66,6 @@ jobs:
|
|||||||
android/app/build/outputs/apk/standard/debug/*.apk
|
android/app/build/outputs/apk/standard/debug/*.apk
|
||||||
android/app/build/outputs/apk/fdroid/debug/*.apk
|
android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Kommentar zu PR hinzufuegen
|
- name: Kommentar zu PR hinzufuegen
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
if: success()
|
if: success()
|
||||||
@@ -88,7 +76,6 @@ jobs:
|
|||||||
.filter(f => f.endsWith('.apk'));
|
.filter(f => f.endsWith('.apk'));
|
||||||
const fdroidApk = fs.readdirSync('android/app/build/outputs/apk/fdroid/debug/')
|
const fdroidApk = fs.readdirSync('android/app/build/outputs/apk/fdroid/debug/')
|
||||||
.filter(f => f.endsWith('.apk'));
|
.filter(f => f.endsWith('.apk'));
|
||||||
|
|
||||||
github.rest.issues.createComment({
|
github.rest.issues.createComment({
|
||||||
issue_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
@@ -98,14 +85,13 @@ jobs:
|
|||||||
**Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})
|
**Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})
|
||||||
|
|
||||||
### 📦 Debug APKs (Test-Builds)
|
### 📦 Debug APKs (Test-Builds)
|
||||||
|
|
||||||
Die Debug-APKs wurden erfolgreich gebaut und sind als Artefakte verfuegbar:
|
Die Debug-APKs wurden erfolgreich gebaut und sind als Artefakte verfuegbar:
|
||||||
|
|
||||||
**Standard Flavor:**
|
**Standard Flavor:**
|
||||||
${standardApk.map(f => '- `' + f + '`').join('\n')}
|
${standardApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||||
|
|
||||||
**F-Droid Flavor:**
|
**F-Droid Flavor:**
|
||||||
${fdroidApk.map(f => '- `' + f + '`').join('\n')}
|
${fdroidApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||||
|
|
||||||
> ⚠️ **Hinweis:** Dies sind unsigned Debug-Builds zum Testen. Production Releases werden nur bei Merge auf \`main\` erstellt.
|
> ⚠️ **Hinweis:** Dies sind unsigned Debug-Builds zum Testen. Production Releases werden nur bei Merge auf \`main\` erstellt.
|
||||||
|
|
||||||
|
|||||||
444
CHANGELOG.md
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Simple Notes Sync will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-01-10
|
||||||
|
|
||||||
|
### 🎉 New Feature: Checklists
|
||||||
|
|
||||||
|
- **✅ Checklist Notes**
|
||||||
|
- New note type: Checklists with tap-to-toggle items
|
||||||
|
- Add items via dedicated input field with "+" button
|
||||||
|
- Drag & drop reordering (long-press to activate)
|
||||||
|
- Swipe-to-delete items
|
||||||
|
- Visual distinction: Checked items get strikethrough styling
|
||||||
|
- Type selector when creating new notes (Text or Checklist)
|
||||||
|
|
||||||
|
- **📝 Markdown Integration**
|
||||||
|
- Checklists export as GitHub-style task lists (`- [ ]` / `- [x]`)
|
||||||
|
- Compatible with Obsidian, Notion, and other Markdown editors
|
||||||
|
- Full round-trip: Edit in Obsidian → Sync back to app
|
||||||
|
- YAML frontmatter includes `type: checklist` for identification
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **<2A> Markdown Parsing Robustness**
|
||||||
|
- Fixed content extraction after title (was returning empty for some formats)
|
||||||
|
- Now handles single newline after title (was requiring double newline)
|
||||||
|
- Protection: Skips import if parsed content is empty but local has content
|
||||||
|
|
||||||
|
- **📂 Duplicate Filename Handling**
|
||||||
|
- Notes with identical titles now get unique Markdown filenames
|
||||||
|
- Format: `title_shortid.md` (e.g., `test_71540ca9.md`)
|
||||||
|
- Prevents data loss from filename collisions
|
||||||
|
|
||||||
|
- **🔔 Notification UX**
|
||||||
|
- No sync notifications when app is in foreground
|
||||||
|
- User sees changes directly in UI - no redundant notification
|
||||||
|
- Background syncs still show notifications as expected
|
||||||
|
|
||||||
|
### Privacy Improvements
|
||||||
|
|
||||||
|
- **🔒 WiFi Permissions Removed**
|
||||||
|
- Removed `ACCESS_WIFI_STATE` permission
|
||||||
|
- Removed `CHANGE_WIFI_STATE` permission
|
||||||
|
- WiFi binding now works via IP detection instead of SSID matching
|
||||||
|
- Cleaned up all SSID-related code from codebase and documentation
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
|
||||||
|
- **📦 New Data Model**
|
||||||
|
- `NoteType` enum: `TEXT`, `CHECKLIST`
|
||||||
|
- `ChecklistItem` data class with id, text, isChecked, order
|
||||||
|
- `Note.kt` extended with `noteType` and `checklistItems` fields
|
||||||
|
|
||||||
|
- **🔄 Sync Protocol v1.4.0**
|
||||||
|
- JSON format updated to include checklist fields
|
||||||
|
- Full backward compatibility with v1.3.x notes
|
||||||
|
- Robust JSON parsing with manual field extraction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.2] - 2026-01-10
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **🧹 Code-Qualität: "Clean Slate" Release**
|
||||||
|
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
|
||||||
|
- Unused Imports und Members entfernt
|
||||||
|
- Magic Numbers durch benannte Konstanten ersetzt
|
||||||
|
- SwallowedExceptions mit Logger.w() versehen
|
||||||
|
- MaxLineLength-Verstöße reformatiert
|
||||||
|
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
|
||||||
|
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **📝 F-Droid Privacy Notice**
|
||||||
|
- Datenschutz-Hinweis für die Datei-Logging-Funktion
|
||||||
|
- Erklärt dass Logs nur lokal gespeichert werden
|
||||||
|
- Erfüllt F-Droid Opt-in Consent-Anforderungen
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
- **⚡ Neue Konstanten für bessere Wartbarkeit**
|
||||||
|
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
|
||||||
|
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
|
||||||
|
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
|
||||||
|
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
|
||||||
|
- RFC 1918 IP-Range Konstanten (UrlValidator)
|
||||||
|
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
|
||||||
|
|
||||||
|
- **🔒 @Suppress Annotations für legitime Patterns**
|
||||||
|
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
|
||||||
|
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
|
||||||
|
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-01-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
|
||||||
|
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
|
||||||
|
- Funktioniert auch ohne aktiviertes Markdown
|
||||||
|
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
|
||||||
|
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
- **⚡ JSON Sync Performance-Parität**
|
||||||
|
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
|
||||||
|
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
|
||||||
|
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
|
||||||
|
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
|
||||||
|
|
||||||
|
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
|
||||||
|
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
|
||||||
|
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
|
||||||
|
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
|
||||||
|
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
|
||||||
|
|
||||||
|
- **⚡ Session-Caching für WebDAV**
|
||||||
|
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
|
||||||
|
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
|
||||||
|
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
|
||||||
|
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
|
||||||
|
|
||||||
|
- **📝 Content-basierte Markdown-Erkennung**
|
||||||
|
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
|
||||||
|
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
|
||||||
|
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **🔄 Sync-Status-Anzeige (UI)**
|
||||||
|
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
|
||||||
|
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
|
||||||
|
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
|
||||||
|
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **🔧 Sync-Mutex verhindert doppelte Syncs**
|
||||||
|
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
|
||||||
|
- Concurrent Sync-Requests werden korrekt blockiert
|
||||||
|
|
||||||
|
- **🐛 Lint-Fehler behoben**
|
||||||
|
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
|
||||||
|
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **🔍 detekt Code-Analyse**
|
||||||
|
- Statische Code-Analyse mit detekt 1.23.4 integriert
|
||||||
|
- Pragmatische Konfiguration für Sync-intensive Codebasis
|
||||||
|
- 91 Issues identifiziert (als Baseline für v1.4.0)
|
||||||
|
|
||||||
|
- **🏗️ Debug Build mit separatem Package**
|
||||||
|
- Debug-APK kann parallel zur Release-Version installiert werden
|
||||||
|
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
|
||||||
|
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
|
||||||
|
|
||||||
|
- **📊 Debug-Logging UI**
|
||||||
|
- Neuer "Debug Log" Button in Einstellungen → Erweitert
|
||||||
|
- Zeigt letzte Sync-Logs mit Zeitstempeln
|
||||||
|
- Export-Funktion für Fehlerberichte
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
|
||||||
|
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
|
||||||
|
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
|
||||||
|
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
|
||||||
|
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
|
||||||
|
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
|
||||||
|
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
|
||||||
|
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
|
||||||
|
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
|
||||||
|
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
|
||||||
|
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
|
||||||
|
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
|
||||||
|
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
|
||||||
|
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
|
||||||
|
- `ensureNotesDirectoryExists()`: Cached Directory-Check
|
||||||
|
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
|
||||||
|
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
|
||||||
|
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-01-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **🚀 Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
|
||||||
|
- Automatic download of new notes from other devices
|
||||||
|
- Deletion tracking prevents "zombie notes" (deleted notes don't come back)
|
||||||
|
- Smart cleanup: Re-created notes (newer timestamp) are downloaded
|
||||||
|
- Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
|
||||||
|
|
||||||
|
- **🗑️ Server Deletion via Swipe Gesture**
|
||||||
|
- Swipe left on notes to delete from server (requires confirmation)
|
||||||
|
- Prevents duplicate notes on other devices
|
||||||
|
- Works with deletion tracking system
|
||||||
|
- Material Design confirmation dialog
|
||||||
|
|
||||||
|
- **⚡ E-Tag Performance Optimization**
|
||||||
|
- Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
|
||||||
|
- 20x faster when server has no updates
|
||||||
|
- E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
|
||||||
|
- Battery-friendly with minimal server requests
|
||||||
|
|
||||||
|
- **📥 Markdown Auto-Sync Toggle**
|
||||||
|
- NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
|
||||||
|
- When enabled: Notes export to Markdown AND import changes automatically
|
||||||
|
- When disabled: Manual sync button appears for on-demand synchronization
|
||||||
|
- Performance: Auto-Sync OFF = 0ms overhead
|
||||||
|
|
||||||
|
- **🔘 Manual Markdown Sync Button**
|
||||||
|
- Manual sync button for performance-conscious users
|
||||||
|
- Shows import/export counts after completion
|
||||||
|
- Only visible when Auto-Sync is disabled
|
||||||
|
- On-demand synchronization (~150-200ms only when triggered)
|
||||||
|
|
||||||
|
- **⚙️ Server-Restore Modes**
|
||||||
|
- MERGE: Keep local notes + add server notes
|
||||||
|
- REPLACE: Delete all local + download from server
|
||||||
|
- OVERWRITE: Update duplicates, keep non-duplicates
|
||||||
|
- Restore modes now work correctly for WebDAV restore
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- New `DeletionTracker` model with JSON persistence
|
||||||
|
- `NotesStorage`: Added deletion tracking methods
|
||||||
|
- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
|
||||||
|
- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
|
||||||
|
- `WebDavSyncService.restoreFromServer()`: Support for restore modes
|
||||||
|
- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
|
||||||
|
- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
|
||||||
|
- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
|
||||||
|
- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
|
||||||
|
- E-Tag caching in SharedPreferences for performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.2] - 2026-01-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Backward Compatibility for v1.2.0 Users (Critical)**
|
||||||
|
- App now reads BOTH old (Root) AND new (`/notes/`) folder structures
|
||||||
|
- Users upgrading from v1.2.0 no longer lose their existing notes
|
||||||
|
- Server-Restore now finds notes from v1.2.0 stored in Root folder
|
||||||
|
- Automatic deduplication prevents loading the same note twice
|
||||||
|
- Graceful error handling if Root folder is not accessible
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `WebDavSyncService.downloadRemoteNotes()` - Dual-mode download (Root + /notes/)
|
||||||
|
- `WebDavSyncService.restoreFromServer()` - Now uses dual-mode download
|
||||||
|
- Migration happens naturally: new uploads go to `/notes/`, old notes stay readable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.1] - 2026-01-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Markdown Initial Export Bugfix**
|
||||||
|
- Existing notes are now exported as Markdown when Desktop Integration is activated
|
||||||
|
- Previously, only new notes created after activation were exported
|
||||||
|
- Progress dialog shows export status with current/total counter
|
||||||
|
- Error handling for network issues during export
|
||||||
|
- Individual note failures don't abort the entire export
|
||||||
|
|
||||||
|
- **Markdown Directory Structure Fix**
|
||||||
|
- Markdown files now correctly land in `/notes-md/` folder
|
||||||
|
- Smart URL detection supports both Root-URL and `/notes` URL structures
|
||||||
|
- Previously, MD files were incorrectly placed in the root directory
|
||||||
|
- Markdown import now finds files correctly
|
||||||
|
|
||||||
|
- **JSON URL Normalization**
|
||||||
|
- Simplified server configuration: enter only base URL (e.g., `http://server:8080/`)
|
||||||
|
- App automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown
|
||||||
|
- Smart detection: both `http://server:8080/` and `http://server:8080/notes/` work correctly
|
||||||
|
- Backward compatible: existing setups with `/notes` in URL continue to work
|
||||||
|
- No migration required for existing users
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Markdown Directory Creation**
|
||||||
|
- `notes-md/` folder is now created on first sync (regardless of Desktop Integration setting)
|
||||||
|
- Prevents 404 errors when mounting WebDAV folder
|
||||||
|
- Better user experience: folder is visible before enabling the feature
|
||||||
|
|
||||||
|
- **Settings UI Improvements**
|
||||||
|
- Updated example URL from `/webdav` to `/notes` to match app behavior
|
||||||
|
- Example now shows: `http://192.168.0.188:8080/notes`
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `WebDavSyncService.ensureMarkdownDirectoryExists()` - Creates MD folder early
|
||||||
|
- `WebDavSyncService.getMarkdownUrl()` - Smart URL detection for both structures
|
||||||
|
- `WebDavSyncService.exportAllNotesToMarkdown()` - Exports all local notes with progress callback
|
||||||
|
- `SettingsActivity.onMarkdownExportToggled()` - Triggers initial export with ProgressDialog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-01-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Local Backup System**
|
||||||
|
- Export all notes as JSON file to any location (Downloads, SD card, cloud folder)
|
||||||
|
- Import backup with 3 modes: Merge, Replace, or Overwrite duplicates
|
||||||
|
- Automatic safety backup created before every restore
|
||||||
|
- Backup validation (format and version check)
|
||||||
|
|
||||||
|
- **Markdown Desktop Integration**
|
||||||
|
- Optional Markdown export parallel to JSON sync
|
||||||
|
- `.md` files synced to `notes-md/` folder on WebDAV
|
||||||
|
- YAML frontmatter with `id`, `created`, `updated`, `device`
|
||||||
|
- Manual import button to pull Markdown changes from server
|
||||||
|
- Last-Write-Wins conflict resolution via timestamps
|
||||||
|
|
||||||
|
- **Settings UI Extensions**
|
||||||
|
- New "Backup & Restore" section with local + server restore
|
||||||
|
- New "Desktop Integration" section with Markdown toggle
|
||||||
|
- Universal restore dialog with radio button mode selection
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `BackupManager.kt` - Complete backup/restore logic
|
||||||
|
- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter
|
||||||
|
- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror)
|
||||||
|
- ISO8601 timestamp formatting for desktop compatibility
|
||||||
|
- Filename sanitization for safe Markdown file names
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||||
|
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
|
||||||
|
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.2] - 2025-12-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Job was cancelled" Error**
|
||||||
|
- Fixed coroutine cancellation in sync worker
|
||||||
|
- Proper error handling for interrupted syncs
|
||||||
|
|
||||||
|
- **UI Improvements**
|
||||||
|
- Back arrow instead of X in note editor (better UX)
|
||||||
|
- Pull-to-refresh for manual sync trigger
|
||||||
|
- HTTP/HTTPS protocol selection with radio buttons
|
||||||
|
- Inline error display (no toast spam)
|
||||||
|
|
||||||
|
- **Performance & Battery**
|
||||||
|
- Sync only on actual changes (saves battery)
|
||||||
|
- Auto-save notifications removed
|
||||||
|
- 24-hour server offline warning instead of instant error
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
|
||||||
|
- HTTP only allowed for local networks (RFC 1918 IPs)
|
||||||
|
- Swipe-to-delete without UI flicker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.1] - 2025-12-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **WiFi Connect Sync**
|
||||||
|
- No error notifications in foreign WiFi networks
|
||||||
|
- Server reachability check before sync (2s timeout)
|
||||||
|
- Silent abort when server offline
|
||||||
|
- Pre-check waits until network is ready
|
||||||
|
- No errors during network initialization
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Notifications**
|
||||||
|
- Old sync notifications cleared on app start
|
||||||
|
- Error notifications auto-dismiss after 30 seconds
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- Sync icon only shown when sync is configured
|
||||||
|
- Swipe-to-delete without flicker
|
||||||
|
- Scroll to top after saving note
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Server check with 2-second timeout before sync attempts
|
||||||
|
- Network readiness check in WiFi connect trigger
|
||||||
|
- Notification cleanup on MainActivity.onCreate()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-12-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Configurable Sync Intervals**
|
||||||
|
- User choice: 15, 30, or 60 minutes
|
||||||
|
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
|
||||||
|
- Radio button selection in settings
|
||||||
|
- Doze Mode optimization (syncs batched in maintenance windows)
|
||||||
|
|
||||||
|
- **About Section**
|
||||||
|
- App version from BuildConfig
|
||||||
|
- Links to GitHub repository and developer profile
|
||||||
|
- MIT license information
|
||||||
|
- Material 3 card design
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Settings UI redesigned with grouped sections
|
||||||
|
- Periodic sync updated dynamically when interval changes
|
||||||
|
- WorkManager uses selected interval for background sync
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Debug/Logs section from settings (cleaner UI)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `PREF_SYNC_INTERVAL_MINUTES` preference key
|
||||||
|
- NetworkMonitor reads interval from SharedPreferences
|
||||||
|
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-12-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
- WebDAV synchronization
|
||||||
|
- Note creation, editing, deletion
|
||||||
|
- 6 sync triggers:
|
||||||
|
- Periodic sync (configurable interval)
|
||||||
|
- App start sync
|
||||||
|
- WiFi connect sync
|
||||||
|
- Manual sync (menu button)
|
||||||
|
- Pull-to-refresh
|
||||||
|
- Settings "Sync Now" button
|
||||||
|
- Material 3 design
|
||||||
|
- Light/Dark theme support
|
||||||
|
- F-Droid compatible (100% FOSS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
|
||||||
|
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
|
||||||
|
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
|
||||||
|
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
|
||||||
|
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0
|
||||||
@@ -74,9 +74,10 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
|
|||||||
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
||||||
| **Username** | `noteuser` |
|
| **Username** | `noteuser` |
|
||||||
| **Password** | (your password from `.env`) |
|
| **Password** | (your password from `.env`) |
|
||||||
| **Gateway SSID** | Name of your WiFi network |
|
|
||||||
|
|
||||||
4. **Press "Test connection"**
|
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
|
||||||
|
|
||||||
|
4. **Press "Test connection"****
|
||||||
- ✅ Success? → Continue to step 4
|
- ✅ Success? → Continue to step 4
|
||||||
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
@@ -156,9 +157,8 @@ For reliable auto-sync:
|
|||||||
# Should show "Up"
|
# Should show "Up"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Same WiFi?**
|
2. **Same network?**
|
||||||
- Smartphone and server must be on same network
|
- Smartphone and server must be on same network
|
||||||
- Check SSID in app settings
|
|
||||||
|
|
||||||
3. **IP address correct?**
|
3. **IP address correct?**
|
||||||
```bash
|
```bash
|
||||||
@@ -191,9 +191,9 @@ For reliable auto-sync:
|
|||||||
2. **Battery optimization disabled?**
|
2. **Battery optimization disabled?**
|
||||||
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
||||||
|
|
||||||
3. **On correct WiFi?**
|
3. **Connected to WiFi?**
|
||||||
- Sync only works when SSID = Gateway SSID
|
- Auto-sync triggers on any WiFi connection
|
||||||
- Check current SSID in Android settings
|
- Check if you're connected to a WiFi network
|
||||||
|
|
||||||
4. **Test manually:**
|
4. **Test manually:**
|
||||||
- ⚙️ Settings → "Sync now"
|
- ⚙️ Settings → "Sync now"
|
||||||
|
|||||||
@@ -74,7 +74,8 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
|
|||||||
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
|
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
|
||||||
| **Benutzername** | `noteuser` |
|
| **Benutzername** | `noteuser` |
|
||||||
| **Passwort** | (dein Passwort aus `.env`) |
|
| **Passwort** | (dein Passwort aus `.env`) |
|
||||||
| **Gateway SSID** | Name deines WLAN-Netzwerks |
|
|
||||||
|
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
|
||||||
|
|
||||||
4. **"Verbindung testen"** drücken
|
4. **"Verbindung testen"** drücken
|
||||||
- ✅ Erfolg? → Weiter zu Schritt 4
|
- ✅ Erfolg? → Weiter zu Schritt 4
|
||||||
@@ -156,9 +157,8 @@ Für zuverlässigen Auto-Sync:
|
|||||||
# Sollte "Up" zeigen
|
# Sollte "Up" zeigen
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Gleiche WLAN?**
|
2. **Gleiches Netzwerk?**
|
||||||
- Smartphone und Server müssen im selben Netzwerk sein
|
- Smartphone und Server müssen im selben Netzwerk sein
|
||||||
- Prüfe SSID in App-Einstellungen
|
|
||||||
|
|
||||||
3. **IP-Adresse korrekt?**
|
3. **IP-Adresse korrekt?**
|
||||||
```bash
|
```bash
|
||||||
@@ -191,9 +191,9 @@ Für zuverlässigen Auto-Sync:
|
|||||||
2. **Akku-Optimierung deaktiviert?**
|
2. **Akku-Optimierung deaktiviert?**
|
||||||
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
||||||
|
|
||||||
3. **Im richtigen WLAN?**
|
3. **Mit WiFi verbunden?**
|
||||||
- Sync funktioniert nur wenn SSID = Gateway SSID
|
- Auto-Sync triggert bei jeder WiFi-Verbindung
|
||||||
- Prüfe aktuelle SSID in Android-Einstellungen
|
- Prüfe, ob du mit einem WLAN verbunden bist
|
||||||
|
|
||||||
4. **Manuell testen:**
|
4. **Manuell testen:**
|
||||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||||
|
|||||||
67
README.en.md
@@ -6,28 +6,46 @@
|
|||||||
[](https://m3.material.io/)
|
[](https://m3.material.io/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
|
[<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)
|
||||||
|
|
||||||
|
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
|
||||||
|
|
||||||
**🌍 Languages:** [Deutsch](README.md) · **English**
|
**🌍 Languages:** [Deutsch](README.md) · **English**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## 📱 Screenshots
|
||||||
|
|
||||||
- 📝 Offline-First - Notes always available
|
<p align="center">
|
||||||
- 🔄 Auto-Sync - Configurable intervals (15/30/60 min)
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notes list">
|
||||||
- 🏠 Self-Hosted - WebDAV on your server
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Edit note">
|
||||||
- 🔐 Privacy-First - No cloud, no tracking
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Settings">
|
||||||
- 🔋 Battery-friendly - ~0.2-0.8% per day
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Highlights
|
||||||
|
|
||||||
|
- ✅ **NEW: Checklists** - Tap-to-check, drag & drop, swipe-to-delete
|
||||||
|
- 📝 **Offline-first** - Works without internet
|
||||||
|
- 🔄 **Auto-sync** - On WiFi connection (15/30/60 min)
|
||||||
|
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
|
||||||
|
- 💾 **Local backup** - Export/Import as JSON file
|
||||||
|
- 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
|
||||||
|
- 🔋 **Battery-friendly** - ~0.2-0.8% per day
|
||||||
|
- 🎨 **Material Design 3** - Dark mode & dynamic colors
|
||||||
|
|
||||||
|
➡️ **Complete feature list:** [FEATURES.en.md](docs/FEATURES.en.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## 🚀 Quick Start
|
||||||
|
|
||||||
### 1. Server Setup
|
### 1. Server Setup (5 minutes)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||||
|
cd simple-notes-sync/server
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Set password in .env
|
# Set password in .env
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -35,23 +53,30 @@ docker compose up -d
|
|||||||
|
|
||||||
➡️ **Details:** [Server Setup Guide](server/README.en.md)
|
➡️ **Details:** [Server Setup Guide](server/README.en.md)
|
||||||
|
|
||||||
### 2. App Installation
|
### 2. App Installation (2 minutes)
|
||||||
|
|
||||||
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||||
2. Install & open
|
2. Install & open
|
||||||
3. ⚙️ Settings → Configure server
|
3. ⚙️ Settings → Configure server:
|
||||||
4. Enable auto-sync
|
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
|
||||||
|
- **User:** `noteuser`
|
||||||
|
- **Password:** _(from .env)_
|
||||||
|
- **WiFi:** _(your network name)_
|
||||||
|
4. **Test connection** → Enable auto-sync
|
||||||
|
5. Done! 🎉
|
||||||
|
|
||||||
➡️ **Details:** [Complete guide](QUICKSTART.en.md)
|
➡️ **Detailed guide:** [QUICKSTART.en.md](QUICKSTART.en.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
- **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users
|
| Document | Content |
|
||||||
- **[Server Setup](server/README.en.md)** - Configure WebDAV server
|
|----------|---------|
|
||||||
- **[Complete Docs](DOCS.en.md)** - Features, troubleshooting, build instructions
|
| **[QUICKSTART.en.md](QUICKSTART.en.md)** | Step-by-step installation |
|
||||||
|
| **[FEATURES.en.md](docs/FEATURES.en.md)** | Complete feature list |
|
||||||
|
| **[BACKUP.en.md](docs/BACKUP.en.md)** | Backup & restore guide |
|
||||||
|
| **[DESKTOP.en.md](docs/DESKTOP.en.md)** | Desktop integration (Markdown) |
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ Development
|
## 🛠️ Development
|
||||||
@@ -61,13 +86,13 @@ cd android
|
|||||||
./gradlew assembleStandardRelease
|
./gradlew assembleStandardRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
➡️ **Details:** [Build instructions in DOCS.en.md](DOCS.en.md)
|
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,4 +100,6 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
MIT License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
**v1.1.0** · Built with Kotlin + Material Design 3
|
---
|
||||||
|
|
||||||
|
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3
|
||||||
|
|||||||
70
README.md
@@ -6,28 +6,46 @@
|
|||||||
[](https://m3.material.io/)
|
[](https://m3.material.io/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
[<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)
|
||||||
|
|
||||||
|
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
||||||
|
|
||||||
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
|
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
## 📱 Screenshots
|
||||||
|
|
||||||
- 📝 Offline-First - Notizen immer verfügbar
|
<p align="center">
|
||||||
- 🔄 Auto-Sync - Konfigurierbare Intervalle (15/30/60 Min)
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notizliste">
|
||||||
- 🏠 Self-Hosted - WebDAV auf deinem Server
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Notiz bearbeiten">
|
||||||
- 🔐 Privacy-First - Keine Cloud, kein Tracking
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Einstellungen">
|
||||||
- 🔋 Akkuschonend - ~0.2-0.8% pro Tag
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## ✨ Highlights
|
||||||
|
|
||||||
### 1. Server Setup
|
- ✅ **NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete
|
||||||
|
- 📝 **Offline-First** - Funktioniert ohne Internet
|
||||||
|
- 🔄 **Auto-Sync** - Bei WiFi-Verbindung (15/30/60 Min)
|
||||||
|
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
|
||||||
|
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
|
||||||
|
- 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
|
||||||
|
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
|
||||||
|
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
|
||||||
|
|
||||||
|
➡️ **Vollständige Feature-Liste:** [FEATURES.md](docs/FEATURES.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Schnellstart
|
||||||
|
|
||||||
|
### 1. Server Setup (5 Minuten)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||||
|
cd simple-notes-sync/server
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Passwort in .env setzen
|
# Passwort in .env setzen
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -35,22 +53,32 @@ docker compose up -d
|
|||||||
|
|
||||||
➡️ **Details:** [Server Setup Guide](server/README.md)
|
➡️ **Details:** [Server Setup Guide](server/README.md)
|
||||||
|
|
||||||
### 2. App Installation
|
### 2. App Installation (2 Minuten)
|
||||||
|
|
||||||
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||||
2. Installieren & öffnen
|
2. Installieren & öffnen
|
||||||
3. ⚙️ Einstellungen → Server konfigurieren
|
3. ⚙️ Einstellungen → Server konfigurieren:
|
||||||
4. Auto-Sync aktivieren
|
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
|
||||||
|
- **User:** `noteuser`
|
||||||
|
- **Passwort:** _(aus .env)_
|
||||||
|
- **WLAN:** _(dein Netzwerk-Name)_
|
||||||
|
4. **Verbindung testen** → Auto-Sync aktivieren
|
||||||
|
5. Fertig! 🎉
|
||||||
|
|
||||||
➡️ **Details:** [Vollständige Anleitung](QUICKSTART.md)
|
➡️ **Ausführliche Anleitung:** [QUICKSTART.md](QUICKSTART.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Dokumentation
|
## 📚 Dokumentation
|
||||||
|
|
||||||
- **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer
|
| Dokument | Inhalt |
|
||||||
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren
|
|----------|--------|
|
||||||
- **[Vollständige Docs](DOCS.md)** - Features, Troubleshooting, Build-Anleitung
|
| **[QUICKSTART.md](QUICKSTART.md)** | Schritt-für-Schritt Installation |
|
||||||
|
| **[FEATURES.md](docs/FEATURES.md)** | Vollständige Feature-Liste |
|
||||||
|
| **[BACKUP.md](docs/BACKUP.md)** | Backup & Wiederherstellung |
|
||||||
|
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop-Integration (Markdown) |
|
||||||
|
| **[DOCS.md](docs/DOCS.md)** | Technische Details & Troubleshooting |
|
||||||
|
| **[CHANGELOG.md](CHANGELOG.md)** | Versionshistorie |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -61,13 +89,13 @@ cd android
|
|||||||
./gradlew assembleStandardRelease
|
./gradlew assembleStandardRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
➡️ **Details:** [Build-Anleitung in DOCS.md](DOCS.md)
|
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details.
|
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,4 +103,6 @@ Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details
|
|||||||
|
|
||||||
MIT License - siehe [LICENSE](LICENSE)
|
MIT License - siehe [LICENSE](LICENSE)
|
||||||
|
|
||||||
**v1.1.0** · Gebaut mit Kotlin + Material Design 3
|
---
|
||||||
|
|
||||||
|
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3
|
||||||
|
|||||||
61
android/.editorconfig
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ⚡ v1.3.1: EditorConfig for ktlint
|
||||||
|
# https://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
max_line_length = 120
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{kt,kts}]
|
||||||
|
# ktlint rules
|
||||||
|
ktlint_code_style = android_studio
|
||||||
|
ktlint_standard_no-wildcard-imports = disabled
|
||||||
|
ktlint_standard_package-name = enabled
|
||||||
|
ktlint_standard_filename = enabled
|
||||||
|
ktlint_standard_class-naming = enabled
|
||||||
|
ktlint_standard_function-naming = enabled
|
||||||
|
ktlint_standard_property-naming = enabled
|
||||||
|
ktlint_standard_backing-property-naming = enabled
|
||||||
|
ktlint_standard_enum-entry-name-case = enabled
|
||||||
|
ktlint_standard_multiline-if-else = enabled
|
||||||
|
ktlint_standard_no-empty-class-body = enabled
|
||||||
|
ktlint_standard_no-empty-first-line-in-class-body = enabled
|
||||||
|
ktlint_standard_blank-line-before-declaration = enabled
|
||||||
|
ktlint_standard_context-receiver-wrapping = enabled
|
||||||
|
ktlint_standard_function-expression-body = disabled
|
||||||
|
ktlint_standard_function-literal = enabled
|
||||||
|
ktlint_standard_function-type-modifier-spacing = enabled
|
||||||
|
ktlint_standard_kdoc-wrapping = enabled
|
||||||
|
ktlint_standard_modifier-list-spacing = enabled
|
||||||
|
ktlint_standard_no-blank-line-in-list = enabled
|
||||||
|
ktlint_standard_no-consecutive-comments = disabled
|
||||||
|
ktlint_standard_no-single-line-block-comment = enabled
|
||||||
|
ktlint_standard_parameter-list-spacing = enabled
|
||||||
|
ktlint_standard_parameter-list-wrapping = enabled
|
||||||
|
ktlint_standard_property-wrapping = enabled
|
||||||
|
ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled
|
||||||
|
ktlint_standard_statement-wrapping = enabled
|
||||||
|
ktlint_standard_string-template-indent = disabled
|
||||||
|
ktlint_standard_try-catch-finally-spacing = enabled
|
||||||
|
ktlint_standard_type-argument-list-spacing = enabled
|
||||||
|
ktlint_standard_type-parameter-list-spacing = enabled
|
||||||
|
ktlint_standard_value-argument-comment = enabled
|
||||||
|
ktlint_standard_value-parameter-comment = enabled
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{xml,json}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.yml]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
|
||||||
|
// alias(libs.plugins.ktlint)
|
||||||
|
alias(libs.plugins.detekt)
|
||||||
}
|
}
|
||||||
|
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
@@ -17,32 +20,27 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 3 // 🔥 Bugfix: Spurious Sync Error Notifications + Sync Icon Bug
|
versionCode = 11 // 🚀 v1.4.0: Checklists Feature
|
||||||
versionName = "1.1.1" // 🔥 Bugfix: Server-Erreichbarkeits-Check + Notification-Improvements
|
versionName = "1.4.0" // 🚀 v1.4.0: Checklists, Multi-Device Sync Fixes, UX Improvements
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
// 🔥 NEU: Build Date für About Screen
|
|
||||||
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable multiple APKs per ABI for smaller downloads
|
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
||||||
splits {
|
dependenciesInfo {
|
||||||
abi {
|
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
|
||||||
isEnable = true
|
includeInBundle = false // Also disable for AAB (Google Play)
|
||||||
reset()
|
|
||||||
include("armeabi-v7a", "arm64-v8a")
|
|
||||||
isUniversalApk = true // Also generate universal APK
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Product Flavors for F-Droid and standard builds
|
// Product Flavors for F-Droid and standard builds
|
||||||
|
// Note: APK splits are disabled to ensure single APK output
|
||||||
flavorDimensions += "distribution"
|
flavorDimensions += "distribution"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
create("fdroid") {
|
create("fdroid") {
|
||||||
dimension = "distribution"
|
dimension = "distribution"
|
||||||
// F-Droid builds have no proprietary dependencies
|
// F-Droid builds have no proprietary dependencies
|
||||||
// All dependencies in this project are already FOSS-compatible
|
// All dependencies in this project are already FOSS-compatible
|
||||||
|
// No APK splits - F-Droid expects single universal APK
|
||||||
}
|
}
|
||||||
|
|
||||||
create("standard") {
|
create("standard") {
|
||||||
@@ -69,6 +67,16 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
// ⚡ v1.3.1: Debug-Builds können parallel zur Release-App installiert werden
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
isDebuggable = true
|
||||||
|
|
||||||
|
// Optionales separates Icon-Label für Debug-Builds
|
||||||
|
resValue("string", "app_name_debug", "Simple Notes (Debug)")
|
||||||
|
}
|
||||||
|
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
@@ -124,14 +132,35 @@ dependencies {
|
|||||||
// LocalBroadcastManager für UI Refresh
|
// LocalBroadcastManager für UI Refresh
|
||||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||||
|
|
||||||
|
// SwipeRefreshLayout für Pull-to-Refresh
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
// Testing (bleiben so)
|
// Testing (bleiben so)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 NEU: Helper function für Build Date
|
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
|
||||||
fun getBuildDate(): String {
|
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
|
||||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
// ktlint {
|
||||||
return dateFormat.format(Date())
|
// android = true
|
||||||
|
// outputToConsole = true
|
||||||
|
// ignoreFailures = true
|
||||||
|
// enableExperimentalRules = false
|
||||||
|
// filter {
|
||||||
|
// exclude("**/generated/**")
|
||||||
|
// exclude("**/build/**")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ⚡ v1.3.1: detekt-Konfiguration
|
||||||
|
detekt {
|
||||||
|
buildUponDefaultConfig = true
|
||||||
|
allRules = false
|
||||||
|
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
|
||||||
|
baseline = file("$rootDir/config/detekt/baseline.xml")
|
||||||
|
|
||||||
|
// Parallel-Verarbeitung für schnellere Checks
|
||||||
|
parallel = true
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,6 @@
|
|||||||
<!-- Network & Sync Permissions -->
|
<!-- Network & Sync Permissions -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
|
||||||
|
|
||||||
<!-- Notifications -->
|
<!-- Notifications -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
@@ -27,7 +25,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SimpleNotes"
|
android:theme="@style/Theme.SimpleNotes"
|
||||||
android:usesCleartextTraffic="true"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -21,19 +21,32 @@ import com.google.android.material.color.DynamicColors
|
|||||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import com.google.android.material.card.MaterialCardView
|
import com.google.android.material.card.MaterialCardView
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
import dev.dettmer.simplenotes.utils.showToast
|
import dev.dettmer.simplenotes.utils.showToast
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import android.widget.CheckBox
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
|
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.PopupMenu
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -41,10 +54,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var emptyStateCard: MaterialCardView
|
private lateinit var emptyStateCard: MaterialCardView
|
||||||
private lateinit var fabAddNote: FloatingActionButton
|
private lateinit var fabAddNote: FloatingActionButton
|
||||||
private lateinit var toolbar: MaterialToolbar
|
private lateinit var toolbar: MaterialToolbar
|
||||||
|
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||||
|
|
||||||
|
// 🔄 v1.3.1: Sync Status Banner
|
||||||
|
private lateinit var syncStatusBanner: LinearLayout
|
||||||
|
private lateinit var syncStatusText: TextView
|
||||||
|
|
||||||
private lateinit var adapter: NotesAdapter
|
private lateinit var adapter: NotesAdapter
|
||||||
private val storage by lazy { NotesStorage(this) }
|
private val storage by lazy { NotesStorage(this) }
|
||||||
|
|
||||||
|
// Menu reference for sync button state
|
||||||
|
private var optionsMenu: Menu? = null
|
||||||
|
|
||||||
// Track pending deletions to prevent flicker when notes reload
|
// Track pending deletions to prevent flicker when notes reload
|
||||||
private val pendingDeletions = mutableSetOf<String>()
|
private val pendingDeletions = mutableSetOf<String>()
|
||||||
|
|
||||||
@@ -58,6 +79,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private const val REQUEST_SETTINGS = 1002
|
private const val REQUEST_SETTINGS = 1002
|
||||||
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
|
||||||
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
|
||||||
|
private const val SYNC_COMPLETED_DELAY_MS = 1500L
|
||||||
|
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -89,9 +112,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
// File Logging aktivieren wenn eingestellt
|
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
Logger.init(this)
|
||||||
Logger.enableFileLogging(this)
|
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||||
|
Logger.setFileLoggingEnabled(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alte Sync-Notifications beim App-Start löschen
|
// Alte Sync-Notifications beim App-Start löschen
|
||||||
@@ -108,6 +132,65 @@ class MainActivity : AppCompatActivity() {
|
|||||||
setupFab()
|
setupFab()
|
||||||
|
|
||||||
loadNotes()
|
loadNotes()
|
||||||
|
|
||||||
|
// 🔄 v1.3.1: Observe sync state for UI updates
|
||||||
|
setupSyncStateObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
|
||||||
|
*/
|
||||||
|
private fun setupSyncStateObserver() {
|
||||||
|
SyncStateManager.syncStatus.observe(this) { status ->
|
||||||
|
when (status.state) {
|
||||||
|
SyncStateManager.SyncState.SYNCING -> {
|
||||||
|
// Disable sync controls
|
||||||
|
setSyncControlsEnabled(false)
|
||||||
|
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
|
||||||
|
syncStatusText.text = getString(R.string.sync_status_syncing)
|
||||||
|
syncStatusBanner.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
SyncStateManager.SyncState.COMPLETED -> {
|
||||||
|
// Re-enable sync controls
|
||||||
|
setSyncControlsEnabled(true)
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
// Show completed briefly, then hide
|
||||||
|
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
|
||||||
|
syncStatusBanner.visibility = View.GONE
|
||||||
|
SyncStateManager.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncStateManager.SyncState.ERROR -> {
|
||||||
|
// Re-enable sync controls
|
||||||
|
setSyncControlsEnabled(true)
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
// Show error briefly, then hide
|
||||||
|
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
|
||||||
|
syncStatusBanner.visibility = View.GONE
|
||||||
|
SyncStateManager.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SyncStateManager.SyncState.IDLE -> {
|
||||||
|
setSyncControlsEnabled(true)
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
syncStatusBanner.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
|
||||||
|
*/
|
||||||
|
private fun setSyncControlsEnabled(enabled: Boolean) {
|
||||||
|
// Menu Sync-Button
|
||||||
|
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
|
||||||
|
// SwipeRefresh
|
||||||
|
swipeRefreshLayout.isEnabled = enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -143,6 +226,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔄 v1.3.1: Check if sync already running
|
||||||
|
if (!SyncStateManager.tryStartSync("auto-$source")) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
|
||||||
|
|
||||||
// Update last sync timestamp
|
// Update last sync timestamp
|
||||||
@@ -152,6 +241,13 @@ class MainActivity : AppCompatActivity() {
|
|||||||
try {
|
try {
|
||||||
val syncService = WebDavSyncService(this@MainActivity)
|
val syncService = WebDavSyncService(this@MainActivity)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||||
|
SyncStateManager.reset()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
syncService.isServerReachable()
|
syncService.isServerReachable()
|
||||||
@@ -159,6 +255,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
if (!isReachable) {
|
if (!isReachable) {
|
||||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||||
|
SyncStateManager.reset()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +267,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// Feedback abhängig von Source
|
// Feedback abhängig von Source
|
||||||
if (result.isSuccess && result.syncedCount > 0) {
|
if (result.isSuccess && result.syncedCount > 0) {
|
||||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||||
|
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||||
|
|
||||||
// onResume: Nur Success-Toast
|
// onResume: Nur Success-Toast
|
||||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||||
@@ -177,14 +275,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
} else if (result.isSuccess) {
|
} else if (result.isSuccess) {
|
||||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||||
|
SyncStateManager.markCompleted()
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||||
|
SyncStateManager.markError(result.errorMessage)
|
||||||
// Kein Toast - App ist im Hintergrund
|
// Kein Toast - App ist im Hintergrund
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||||
|
SyncStateManager.markError(e.message)
|
||||||
// Kein Toast - App ist im Hintergrund
|
// Kein Toast - App ist im Hintergrund
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -220,6 +321,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||||
fabAddNote = findViewById(R.id.fabAddNote)
|
fabAddNote = findViewById(R.id.fabAddNote)
|
||||||
toolbar = findViewById(R.id.toolbar)
|
toolbar = findViewById(R.id.toolbar)
|
||||||
|
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
|
||||||
|
|
||||||
|
// 🔄 v1.3.1: Sync Status Banner
|
||||||
|
syncStatusBanner = findViewById(R.id.syncStatusBanner)
|
||||||
|
syncStatusText = findViewById(R.id.syncStatusText)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupToolbar() {
|
private fun setupToolbar() {
|
||||||
@@ -233,10 +339,74 @@ class MainActivity : AppCompatActivity() {
|
|||||||
recyclerViewNotes.adapter = adapter
|
recyclerViewNotes.adapter = adapter
|
||||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Setup Pull-to-Refresh
|
||||||
|
setupPullToRefresh()
|
||||||
|
|
||||||
// Setup Swipe-to-Delete
|
// Setup Swipe-to-Delete
|
||||||
setupSwipeToDelete()
|
setupSwipeToDelete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
|
||||||
|
*/
|
||||||
|
private fun setupPullToRefresh() {
|
||||||
|
swipeRefreshLayout.setOnRefreshListener {
|
||||||
|
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
|
||||||
|
|
||||||
|
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||||
|
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
return@setOnRefreshListener
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
|
||||||
|
if (serverUrl.isNullOrEmpty()) {
|
||||||
|
showToast("⚠️ Server noch nicht konfiguriert")
|
||||||
|
SyncStateManager.reset()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val syncService = WebDavSyncService(this@MainActivity)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||||
|
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server is reachable
|
||||||
|
if (!syncService.isServerReachable()) {
|
||||||
|
SyncStateManager.markError("Server nicht erreichbar")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform sync
|
||||||
|
val result = syncService.syncNotes()
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||||
|
loadNotes()
|
||||||
|
} else {
|
||||||
|
SyncStateManager.markError(result.errorMessage)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
|
||||||
|
SyncStateManager.markError(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Material 3 color scheme
|
||||||
|
swipeRefreshLayout.setColorSchemeResources(
|
||||||
|
com.google.android.material.R.color.material_dynamic_primary50
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupSwipeToDelete() {
|
private fun setupSwipeToDelete() {
|
||||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||||
0, // No drag
|
0, // No drag
|
||||||
@@ -249,36 +419,20 @@ class MainActivity : AppCompatActivity() {
|
|||||||
): Boolean = false
|
): Boolean = false
|
||||||
|
|
||||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
val position = viewHolder.adapterPosition
|
val position = viewHolder.bindingAdapterPosition
|
||||||
val note = adapter.currentList[position]
|
val swipedNote = adapter.currentList[position]
|
||||||
val notesCopy = adapter.currentList.toMutableList()
|
|
||||||
|
|
||||||
// Track pending deletion to prevent flicker
|
// Store original list BEFORE removing note
|
||||||
pendingDeletions.add(note.id)
|
val originalList = adapter.currentList.toList()
|
||||||
|
|
||||||
// Remove from list immediately for visual feedback
|
// Remove from list for visual feedback (NOT from storage yet!)
|
||||||
notesCopy.removeAt(position)
|
val listWithoutNote = originalList.toMutableList().apply {
|
||||||
adapter.submitList(notesCopy)
|
removeAt(position)
|
||||||
|
|
||||||
// Show Snackbar with UNDO
|
|
||||||
Snackbar.make(
|
|
||||||
recyclerViewNotes,
|
|
||||||
"Notiz gelöscht",
|
|
||||||
Snackbar.LENGTH_LONG
|
|
||||||
).setAction("RÜCKGÄNGIG") {
|
|
||||||
// UNDO: Remove from pending deletions and restore
|
|
||||||
pendingDeletions.remove(note.id)
|
|
||||||
loadNotes()
|
|
||||||
}.addCallback(object : Snackbar.Callback() {
|
|
||||||
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
|
||||||
if (event != DISMISS_EVENT_ACTION) {
|
|
||||||
// Snackbar dismissed without UNDO → Actually delete the note
|
|
||||||
storage.deleteNote(note.id)
|
|
||||||
pendingDeletions.remove(note.id)
|
|
||||||
loadNotes()
|
|
||||||
}
|
}
|
||||||
}
|
adapter.submitList(listWithoutNote)
|
||||||
}).show()
|
|
||||||
|
// Show dialog with ability to restore
|
||||||
|
showServerDeletionDialog(swipedNote, originalList)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||||
@@ -290,10 +444,163 @@ class MainActivity : AppCompatActivity() {
|
|||||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupFab() {
|
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
|
||||||
fabAddNote.setOnClickListener {
|
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||||
openNoteEditor(null)
|
|
||||||
|
if (alwaysDeleteFromServer) {
|
||||||
|
// Auto-delete from server without asking
|
||||||
|
deleteNoteLocally(note, deleteFromServer = true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
|
||||||
|
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle("Notiz löschen")
|
||||||
|
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?")
|
||||||
|
.setView(dialogView)
|
||||||
|
.setNeutralButton("Abbrechen") { _, _ ->
|
||||||
|
// RESTORE: Re-submit original list (note is NOT deleted from storage)
|
||||||
|
adapter.submitList(originalList)
|
||||||
|
}
|
||||||
|
.setOnCancelListener {
|
||||||
|
// User pressed back - also restore
|
||||||
|
adapter.submitList(originalList)
|
||||||
|
}
|
||||||
|
.setPositiveButton("Nur lokal") { _, _ ->
|
||||||
|
if (checkboxAlways.isChecked) {
|
||||||
|
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
|
||||||
|
}
|
||||||
|
// NOW actually delete from storage
|
||||||
|
deleteNoteLocally(note, deleteFromServer = false)
|
||||||
|
}
|
||||||
|
.setNegativeButton("Vom Server löschen") { _, _ ->
|
||||||
|
if (checkboxAlways.isChecked) {
|
||||||
|
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
|
||||||
|
}
|
||||||
|
deleteNoteLocally(note, deleteFromServer = true)
|
||||||
|
}
|
||||||
|
.setCancelable(true)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
|
||||||
|
// Track pending deletion to prevent flicker
|
||||||
|
pendingDeletions.add(note.id)
|
||||||
|
|
||||||
|
// Delete from storage
|
||||||
|
storage.deleteNote(note.id)
|
||||||
|
|
||||||
|
// Reload to reflect changes
|
||||||
|
loadNotes()
|
||||||
|
|
||||||
|
// Show Snackbar with UNDO option
|
||||||
|
val message = if (deleteFromServer) {
|
||||||
|
"\"${note.title}\" wird lokal und vom Server gelöscht"
|
||||||
|
} else {
|
||||||
|
"\"${note.title}\" lokal gelöscht (Server bleibt)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
|
||||||
|
.setAction("RÜCKGÄNGIG") {
|
||||||
|
// UNDO: Restore note
|
||||||
|
storage.saveNote(note)
|
||||||
|
pendingDeletions.remove(note.id)
|
||||||
|
loadNotes()
|
||||||
|
}
|
||||||
|
.addCallback(object : Snackbar.Callback() {
|
||||||
|
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
|
||||||
|
if (event != DISMISS_EVENT_ACTION) {
|
||||||
|
// Snackbar dismissed without UNDO
|
||||||
|
pendingDeletions.remove(note.id)
|
||||||
|
|
||||||
|
// Delete from server if requested
|
||||||
|
if (deleteFromServer) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val webdavService = WebDavSyncService(this@MainActivity)
|
||||||
|
val success = webdavService.deleteNoteFromServer(note.id)
|
||||||
|
if (success) {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
"Vom Server gelöscht",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
"Server-Löschung fehlgeschlagen",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
"Server-Fehler: ${e.message}",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
|
||||||
|
*/
|
||||||
|
private fun setupFab() {
|
||||||
|
fabAddNote.setOnClickListener { view ->
|
||||||
|
showNoteTypePopup(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
|
||||||
|
*/
|
||||||
|
private fun showNoteTypePopup(anchor: View) {
|
||||||
|
val popupMenu = PopupMenu(this, anchor, Gravity.END)
|
||||||
|
popupMenu.inflate(R.menu.menu_fab_note_types)
|
||||||
|
|
||||||
|
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
|
||||||
|
try {
|
||||||
|
val fields = popupMenu.javaClass.declaredFields
|
||||||
|
for (field in fields) {
|
||||||
|
if ("mPopup" == field.name) {
|
||||||
|
field.isAccessible = true
|
||||||
|
val menuPopupHelper = field.get(popupMenu)
|
||||||
|
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
|
||||||
|
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
|
||||||
|
setForceIcons.invoke(menuPopupHelper, true)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
popupMenu.setOnMenuItemClickListener { menuItem ->
|
||||||
|
val noteType = when (menuItem.itemId) {
|
||||||
|
R.id.action_create_text_note -> NoteType.TEXT
|
||||||
|
R.id.action_create_checklist -> NoteType.CHECKLIST
|
||||||
|
else -> return@setOnMenuItemClickListener false
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(this, NoteEditorActivity::class.java)
|
||||||
|
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
|
||||||
|
startActivity(intent)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
popupMenu.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNotes() {
|
private fun loadNotes() {
|
||||||
@@ -334,13 +641,23 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerManualSync() {
|
private fun triggerManualSync() {
|
||||||
|
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||||
|
if (!SyncStateManager.tryStartSync("manual")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
showToast("Starte Synchronisation...")
|
|
||||||
|
|
||||||
// Create sync service
|
// Create sync service
|
||||||
val syncService = WebDavSyncService(this@MainActivity)
|
val syncService = WebDavSyncService(this@MainActivity)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||||
|
SyncStateManager.markCompleted("Bereits synchronisiert")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
syncService.isServerReachable()
|
syncService.isServerReachable()
|
||||||
@@ -348,7 +665,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")
|
||||||
showToast("Server nicht erreichbar")
|
SyncStateManager.markError("Server nicht erreichbar")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,20 +676,21 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Show result
|
// Show result
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
showToast("Sync erfolgreich: ${result.syncedCount} Notizen")
|
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||||
loadNotes() // Reload notes
|
loadNotes() // Reload notes
|
||||||
} else {
|
} else {
|
||||||
showToast("Sync Fehler: ${result.errorMessage}")
|
SyncStateManager.markError(result.errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
showToast("Sync Fehler: ${e.message}")
|
SyncStateManager.markError(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.menu_main, menu)
|
menuInflater.inflate(R.menu.menu_main, menu)
|
||||||
|
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import dev.dettmer.simplenotes.adapters.ChecklistEditorAdapter
|
||||||
|
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.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.showToast
|
import dev.dettmer.simplenotes.utils.showToast
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor Activity für Notizen und Checklisten
|
||||||
|
*
|
||||||
|
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
|
||||||
|
*/
|
||||||
class NoteEditorActivity : AppCompatActivity() {
|
class NoteEditorActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
// Views
|
||||||
|
private lateinit var toolbar: MaterialToolbar
|
||||||
|
private lateinit var tilTitle: TextInputLayout
|
||||||
private lateinit var editTextTitle: TextInputEditText
|
private lateinit var editTextTitle: TextInputEditText
|
||||||
|
private lateinit var tilContent: TextInputLayout
|
||||||
private lateinit var editTextContent: TextInputEditText
|
private lateinit var editTextContent: TextInputEditText
|
||||||
|
private lateinit var checklistContainer: LinearLayout
|
||||||
|
private lateinit var rvChecklistItems: RecyclerView
|
||||||
|
private lateinit var btnAddItem: MaterialButton
|
||||||
|
|
||||||
private lateinit var storage: NotesStorage
|
private lateinit var storage: NotesStorage
|
||||||
|
|
||||||
|
// State
|
||||||
private var existingNote: Note? = null
|
private var existingNote: Note? = null
|
||||||
|
private var currentNoteType: NoteType = NoteType.TEXT
|
||||||
|
private val checklistItems = mutableListOf<ChecklistItem>()
|
||||||
|
private var checklistAdapter: ChecklistEditorAdapter? = null
|
||||||
|
private var itemTouchHelper: ItemTouchHelper? = null
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "NoteEditorActivity"
|
||||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||||
|
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -36,38 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
storage = NotesStorage(this)
|
storage = NotesStorage(this)
|
||||||
|
|
||||||
// Setup toolbar
|
findViews()
|
||||||
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
|
setupToolbar()
|
||||||
setSupportActionBar(toolbar)
|
loadNoteOrDetermineType()
|
||||||
supportActionBar?.apply {
|
setupUIForNoteType()
|
||||||
setDisplayHomeAsUpEnabled(true)
|
|
||||||
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find views
|
private fun findViews() {
|
||||||
|
toolbar = findViewById(R.id.toolbar)
|
||||||
|
tilTitle = findViewById(R.id.tilTitle)
|
||||||
editTextTitle = findViewById(R.id.editTextTitle)
|
editTextTitle = findViewById(R.id.editTextTitle)
|
||||||
|
tilContent = findViewById(R.id.tilContent)
|
||||||
editTextContent = findViewById(R.id.editTextContent)
|
editTextContent = findViewById(R.id.editTextContent)
|
||||||
|
checklistContainer = findViewById(R.id.checklistContainer)
|
||||||
|
rvChecklistItems = findViewById(R.id.rvChecklistItems)
|
||||||
|
btnAddItem = findViewById(R.id.btnAddItem)
|
||||||
|
}
|
||||||
|
|
||||||
// Load existing note if editing
|
private fun setupToolbar() {
|
||||||
|
setSupportActionBar(toolbar)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadNoteOrDetermineType() {
|
||||||
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
|
||||||
|
|
||||||
if (noteId != null) {
|
if (noteId != null) {
|
||||||
|
// Existierende Notiz laden
|
||||||
existingNote = storage.loadNote(noteId)
|
existingNote = storage.loadNote(noteId)
|
||||||
existingNote?.let {
|
existingNote?.let { note ->
|
||||||
editTextTitle.setText(it.title)
|
editTextTitle.setText(note.title)
|
||||||
editTextContent.setText(it.content)
|
currentNoteType = note.noteType
|
||||||
supportActionBar?.title = "Notiz bearbeiten"
|
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
editTextContent.setText(note.content)
|
||||||
|
supportActionBar?.title = getString(R.string.edit_note)
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
note.checklistItems?.let { items ->
|
||||||
|
checklistItems.clear()
|
||||||
|
checklistItems.addAll(items.sortedBy { it.order })
|
||||||
|
}
|
||||||
|
supportActionBar?.title = getString(R.string.edit_checklist)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
supportActionBar?.title = "Neue Notiz"
|
// Neue Notiz - Typ aus Intent
|
||||||
|
val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
|
||||||
|
currentNoteType = try {
|
||||||
|
NoteType.valueOf(typeString)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
|
||||||
|
NoteType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
when (currentNoteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
supportActionBar?.title = getString(R.string.new_note)
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
supportActionBar?.title = getString(R.string.new_checklist)
|
||||||
|
// Erstes leeres Item hinzufügen
|
||||||
|
checklistItems.add(ChecklistItem.createEmpty(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupUIForNoteType() {
|
||||||
|
when (currentNoteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
tilContent.visibility = View.VISIBLE
|
||||||
|
checklistContainer.visibility = View.GONE
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
tilContent.visibility = View.GONE
|
||||||
|
checklistContainer.visibility = View.VISIBLE
|
||||||
|
setupChecklistRecyclerView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupChecklistRecyclerView() {
|
||||||
|
checklistAdapter = ChecklistEditorAdapter(
|
||||||
|
items = checklistItems,
|
||||||
|
onItemCheckedChanged = { position, isChecked ->
|
||||||
|
if (position in checklistItems.indices) {
|
||||||
|
checklistItems[position].isChecked = isChecked
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onItemTextChanged = { position, newText ->
|
||||||
|
if (position in checklistItems.indices) {
|
||||||
|
checklistItems[position] = checklistItems[position].copy(text = newText)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onItemDeleted = { position ->
|
||||||
|
deleteChecklistItem(position)
|
||||||
|
},
|
||||||
|
onAddNewItem = { position ->
|
||||||
|
addChecklistItemAt(position)
|
||||||
|
},
|
||||||
|
onStartDrag = { viewHolder ->
|
||||||
|
itemTouchHelper?.startDrag(viewHolder)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rvChecklistItems.apply {
|
||||||
|
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
|
||||||
|
adapter = checklistAdapter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag & Drop Setup
|
||||||
|
val callback = object : ItemTouchHelper.SimpleCallback(
|
||||||
|
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
|
||||||
|
0 // Kein Swipe
|
||||||
|
) {
|
||||||
|
override fun onMove(
|
||||||
|
recyclerView: RecyclerView,
|
||||||
|
viewHolder: RecyclerView.ViewHolder,
|
||||||
|
target: RecyclerView.ViewHolder
|
||||||
|
): Boolean {
|
||||||
|
val from = viewHolder.bindingAdapterPosition
|
||||||
|
val to = target.bindingAdapterPosition
|
||||||
|
checklistAdapter?.moveItem(from, to)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||||
|
// Nicht verwendet
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
|
||||||
|
}
|
||||||
|
|
||||||
|
itemTouchHelper = ItemTouchHelper(callback)
|
||||||
|
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
|
||||||
|
|
||||||
|
// Add Item Button
|
||||||
|
btnAddItem.setOnClickListener {
|
||||||
|
addChecklistItemAt(checklistItems.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addChecklistItemAt(position: Int) {
|
||||||
|
val newItem = ChecklistItem.createEmpty(position)
|
||||||
|
checklistAdapter?.insertItem(position, newItem)
|
||||||
|
|
||||||
|
// Zum neuen Item scrollen und fokussieren
|
||||||
|
rvChecklistItems.scrollToPosition(position)
|
||||||
|
checklistAdapter?.focusItem(rvChecklistItems, position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteChecklistItem(position: Int) {
|
||||||
|
checklistAdapter?.removeItem(position)
|
||||||
|
|
||||||
|
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
|
||||||
|
if (checklistItems.isEmpty()) {
|
||||||
|
addChecklistItemAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.menu_editor, menu)
|
menuInflater.inflate(R.menu.menu_editor, menu)
|
||||||
|
// Delete nur für existierende Notizen
|
||||||
// Show delete only for existing notes
|
|
||||||
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun saveNote() {
|
private fun saveNote() {
|
||||||
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
|
when (currentNoteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
if (title.isEmpty() && content.isEmpty()) {
|
if (title.isEmpty() && content.isEmpty()) {
|
||||||
showToast("Notiz ist leer")
|
showToast(getString(R.string.note_is_empty))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val note = if (existingNote != null) {
|
val note = if (existingNote != null) {
|
||||||
// Update existing note
|
|
||||||
existingNote!!.copy(
|
existingNote!!.copy(
|
||||||
title = title,
|
title = title,
|
||||||
content = content,
|
content = content,
|
||||||
|
noteType = NoteType.TEXT,
|
||||||
|
checklistItems = null,
|
||||||
updatedAt = System.currentTimeMillis(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
syncStatus = SyncStatus.PENDING
|
syncStatus = SyncStatus.PENDING
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Create new note
|
|
||||||
Note(
|
Note(
|
||||||
title = title,
|
title = title,
|
||||||
content = content,
|
content = content,
|
||||||
|
noteType = NoteType.TEXT,
|
||||||
|
checklistItems = null,
|
||||||
deviceId = DeviceIdGenerator.getDeviceId(this),
|
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||||
syncStatus = SyncStatus.LOCAL_ONLY
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
storage.saveNote(note)
|
storage.saveNote(note)
|
||||||
showToast("Notiz gespeichert")
|
}
|
||||||
|
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
// Leere Items filtern
|
||||||
|
val validItems = checklistItems.filter { it.text.isNotBlank() }
|
||||||
|
|
||||||
|
if (title.isEmpty() && validItems.isEmpty()) {
|
||||||
|
showToast(getString(R.string.note_is_empty))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order neu setzen
|
||||||
|
val orderedItems = validItems.mapIndexed { index, item ->
|
||||||
|
item.copy(order = index)
|
||||||
|
}
|
||||||
|
|
||||||
|
val note = if (existingNote != null) {
|
||||||
|
existingNote!!.copy(
|
||||||
|
title = title,
|
||||||
|
content = "", // Leer für Checklisten
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = orderedItems,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
syncStatus = SyncStatus.PENDING
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Note(
|
||||||
|
title = title,
|
||||||
|
content = "",
|
||||||
|
noteType = NoteType.CHECKLIST,
|
||||||
|
checklistItems = orderedItems,
|
||||||
|
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||||
|
syncStatus = SyncStatus.LOCAL_ONLY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(getString(R.string.note_saved))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete() {
|
private fun confirmDelete() {
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("Notiz löschen?")
|
.setTitle(getString(R.string.delete_note_title))
|
||||||
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.")
|
.setMessage(getString(R.string.delete_note_message))
|
||||||
.setPositiveButton("Löschen") { _, _ ->
|
.setPositiveButton(getString(R.string.delete)) { _, _ ->
|
||||||
deleteNote()
|
deleteNote()
|
||||||
}
|
}
|
||||||
.setNegativeButton("Abbrechen", null)
|
.setNegativeButton(getString(R.string.cancel), null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteNote() {
|
private fun deleteNote() {
|
||||||
existingNote?.let {
|
existingNote?.let {
|
||||||
storage.deleteNote(it.id)
|
storage.deleteNote(it.id)
|
||||||
showToast("Notiz gelöscht")
|
showToast(getString(R.string.note_deleted))
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package dev.dettmer.simplenotes.adapters
|
||||||
|
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.checkbox.MaterialCheckBox
|
||||||
|
import dev.dettmer.simplenotes.R
|
||||||
|
import dev.dettmer.simplenotes.models.ChecklistItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter für die Bearbeitung von Checklist-Items im Editor
|
||||||
|
*
|
||||||
|
* v1.4.0: Checklisten-Feature
|
||||||
|
*/
|
||||||
|
class ChecklistEditorAdapter(
|
||||||
|
private val items: MutableList<ChecklistItem>,
|
||||||
|
private val onItemCheckedChanged: (Int, Boolean) -> Unit,
|
||||||
|
private val onItemTextChanged: (Int, String) -> Unit,
|
||||||
|
private val onItemDeleted: (Int) -> Unit,
|
||||||
|
private val onAddNewItem: (Int) -> Unit,
|
||||||
|
private val onStartDrag: (RecyclerView.ViewHolder) -> Unit
|
||||||
|
) : RecyclerView.Adapter<ChecklistEditorAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
val dragHandle: ImageView = view.findViewById(R.id.ivDragHandle)
|
||||||
|
val checkbox: MaterialCheckBox = view.findViewById(R.id.cbItem)
|
||||||
|
val editText: EditText = view.findViewById(R.id.etItemText)
|
||||||
|
val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteItem)
|
||||||
|
|
||||||
|
private var textWatcher: TextWatcher? = null
|
||||||
|
|
||||||
|
@Suppress("NestedBlockDepth", "UNUSED_PARAMETER")
|
||||||
|
fun bind(item: ChecklistItem, position: Int) {
|
||||||
|
// Vorherigen TextWatcher entfernen um Loops zu vermeiden
|
||||||
|
textWatcher?.let { editText.removeTextChangedListener(it) }
|
||||||
|
|
||||||
|
// Checkbox
|
||||||
|
checkbox.isChecked = item.isChecked
|
||||||
|
checkbox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
onItemCheckedChanged(bindingAdapterPosition, isChecked)
|
||||||
|
updateStrikethrough(isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text
|
||||||
|
editText.setText(item.text)
|
||||||
|
updateStrikethrough(item.isChecked)
|
||||||
|
|
||||||
|
// TextWatcher für Änderungen
|
||||||
|
textWatcher = object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
val pos = bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) {
|
||||||
|
onItemTextChanged(pos, s?.toString() ?: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editText.addTextChangedListener(textWatcher)
|
||||||
|
|
||||||
|
// Enter-Taste = neues Item
|
||||||
|
editText.setOnEditorActionListener { _, actionId, event ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_NEXT ||
|
||||||
|
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
|
||||||
|
val pos = bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) {
|
||||||
|
onAddNewItem(pos + 1)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Button
|
||||||
|
deleteButton.setOnClickListener {
|
||||||
|
val pos = bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) {
|
||||||
|
onItemDeleted(pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag Handle Touch Listener
|
||||||
|
dragHandle.setOnTouchListener { _, event ->
|
||||||
|
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
|
||||||
|
onStartDrag(this)
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStrikethrough(isChecked: Boolean) {
|
||||||
|
if (isChecked) {
|
||||||
|
editText.paintFlags = editText.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
|
||||||
|
editText.alpha = CHECKED_ITEM_ALPHA
|
||||||
|
} else {
|
||||||
|
editText.paintFlags = editText.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
|
||||||
|
editText.alpha = UNCHECKED_ITEM_ALPHA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_checklist_editor, parent, false)
|
||||||
|
return ViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
holder.bind(items[position], position)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewegt ein Item von einer Position zu einer anderen (für Drag & Drop)
|
||||||
|
*/
|
||||||
|
fun moveItem(fromPosition: Int, toPosition: Int) {
|
||||||
|
val item = items.removeAt(fromPosition)
|
||||||
|
items.add(toPosition, item)
|
||||||
|
notifyItemMoved(fromPosition, toPosition)
|
||||||
|
|
||||||
|
// Order-Werte aktualisieren
|
||||||
|
items.forEachIndexed { index, checklistItem ->
|
||||||
|
checklistItem.order = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entfernt ein Item an der angegebenen Position
|
||||||
|
*/
|
||||||
|
fun removeItem(position: Int) {
|
||||||
|
if (position in items.indices) {
|
||||||
|
items.removeAt(position)
|
||||||
|
notifyItemRemoved(position)
|
||||||
|
// Order-Werte aktualisieren
|
||||||
|
items.forEachIndexed { index, checklistItem ->
|
||||||
|
checklistItem.order = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fügt ein neues Item an der angegebenen Position ein
|
||||||
|
*/
|
||||||
|
fun insertItem(position: Int, item: ChecklistItem) {
|
||||||
|
items.add(position, item)
|
||||||
|
notifyItemInserted(position)
|
||||||
|
// Order-Werte aktualisieren
|
||||||
|
items.forEachIndexed { index, checklistItem ->
|
||||||
|
checklistItem.order = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fokussiert das EditText des Items an der angegebenen Position
|
||||||
|
*/
|
||||||
|
fun focusItem(recyclerView: RecyclerView, position: Int) {
|
||||||
|
recyclerView.post {
|
||||||
|
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) as? ViewHolder
|
||||||
|
viewHolder?.editText?.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Alpha-Wert für abgehakte Items (durchgestrichen) */
|
||||||
|
private const val CHECKED_ITEM_ALPHA = 0.6f
|
||||||
|
/** Alpha-Wert für nicht abgehakte Items */
|
||||||
|
private const val UNCHECKED_ITEM_ALPHA = 1.0f
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import dev.dettmer.simplenotes.R
|
import dev.dettmer.simplenotes.R
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.models.NoteType
|
||||||
import dev.dettmer.simplenotes.models.SyncStatus
|
import dev.dettmer.simplenotes.models.SyncStatus
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||||
import dev.dettmer.simplenotes.utils.truncate
|
import dev.dettmer.simplenotes.utils.truncate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter für die Notizen-Liste
|
||||||
|
*
|
||||||
|
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
|
||||||
|
*/
|
||||||
class NotesAdapter(
|
class NotesAdapter(
|
||||||
private val onNoteClick: (Note) -> Unit
|
private val onNoteClick: (Note) -> Unit
|
||||||
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
||||||
@@ -31,16 +37,46 @@ class NotesAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon)
|
||||||
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
|
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
|
||||||
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
|
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
|
||||||
|
private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview)
|
||||||
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
|
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
|
||||||
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
||||||
|
|
||||||
fun bind(note: Note) {
|
fun bind(note: Note) {
|
||||||
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
|
// Titel
|
||||||
textViewContent.text = note.content.truncate(100)
|
textViewTitle.text = note.title.ifEmpty {
|
||||||
|
itemView.context.getString(R.string.untitled)
|
||||||
|
}
|
||||||
textViewTimestamp.text = note.updatedAt.toReadableTime()
|
textViewTimestamp.text = note.updatedAt.toReadableTime()
|
||||||
|
|
||||||
|
// v1.4.0: Typ-spezifische Anzeige
|
||||||
|
when (note.noteType) {
|
||||||
|
NoteType.TEXT -> {
|
||||||
|
ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24)
|
||||||
|
textViewContent.text = note.content.truncate(100)
|
||||||
|
textViewContent.visibility = View.VISIBLE
|
||||||
|
textViewChecklistPreview.visibility = View.GONE
|
||||||
|
}
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24)
|
||||||
|
textViewContent.visibility = View.GONE
|
||||||
|
textViewChecklistPreview.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Fortschritt berechnen
|
||||||
|
val items = note.checklistItems ?: emptyList()
|
||||||
|
val checkedCount = items.count { it.isChecked }
|
||||||
|
val totalCount = items.size
|
||||||
|
|
||||||
|
textViewChecklistPreview.text = if (totalCount > 0) {
|
||||||
|
itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount)
|
||||||
|
} else {
|
||||||
|
itemView.context.getString(R.string.empty_checklist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Icon nur zeigen wenn Sync konfiguriert ist
|
// Sync Icon nur zeigen wenn Sync konfiguriert ist
|
||||||
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
|||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package dev.dettmer.simplenotes.backup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BackupManager: Lokale Backup & Restore Funktionalität
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Backup aller Notizen in JSON-Datei
|
||||||
|
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
|
||||||
|
* - Auto-Backup vor Restore (Sicherheitsnetz)
|
||||||
|
* - Backup-Validierung
|
||||||
|
*/
|
||||||
|
class BackupManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BackupManager"
|
||||||
|
private const val BACKUP_VERSION = 1
|
||||||
|
private const val AUTO_BACKUP_DIR = "auto_backups"
|
||||||
|
private const val AUTO_BACKUP_RETENTION_DAYS = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
private val storage = NotesStorage(context)
|
||||||
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt Backup aller Notizen
|
||||||
|
*
|
||||||
|
* @param uri Output-URI (via Storage Access Framework)
|
||||||
|
* @return BackupResult mit Erfolg/Fehler Info
|
||||||
|
*/
|
||||||
|
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
Logger.d(TAG, "📦 Creating backup to: $uri")
|
||||||
|
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
|
||||||
|
|
||||||
|
val backupData = BackupData(
|
||||||
|
backupVersion = BACKUP_VERSION,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
notesCount = allNotes.size,
|
||||||
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
|
notes = allNotes
|
||||||
|
)
|
||||||
|
|
||||||
|
val jsonString = gson.toJson(backupData)
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
outputStream.write(jsonString.toByteArray())
|
||||||
|
Logger.d(TAG, "✅ Backup created successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupResult(
|
||||||
|
success = true,
|
||||||
|
notesCount = allNotes.size,
|
||||||
|
message = "Backup erstellt: ${allNotes.size} Notizen"
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to create backup", e)
|
||||||
|
BackupResult(
|
||||||
|
success = false,
|
||||||
|
error = "Backup fehlgeschlagen: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt automatisches Backup (vor Restore)
|
||||||
|
* Gespeichert in app-internem Storage
|
||||||
|
*
|
||||||
|
* @return Uri des Auto-Backups oder null bei Fehler
|
||||||
|
*/
|
||||||
|
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
|
||||||
|
if (!exists()) mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||||
|
.format(Date())
|
||||||
|
val filename = "auto_backup_before_restore_$timestamp.json"
|
||||||
|
val file = File(autoBackupDir, filename)
|
||||||
|
|
||||||
|
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
|
||||||
|
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
val backupData = BackupData(
|
||||||
|
backupVersion = BACKUP_VERSION,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
notesCount = allNotes.size,
|
||||||
|
appVersion = BuildConfig.VERSION_NAME,
|
||||||
|
notes = allNotes
|
||||||
|
)
|
||||||
|
|
||||||
|
file.writeText(gson.toJson(backupData))
|
||||||
|
|
||||||
|
// Cleanup alte Auto-Backups
|
||||||
|
cleanupOldAutoBackups(autoBackupDir)
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
|
||||||
|
Uri.fromFile(file)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to create auto-backup", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stellt Notizen aus Backup wieder her
|
||||||
|
*
|
||||||
|
* @param uri Backup-Datei URI
|
||||||
|
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
|
||||||
|
* @return RestoreResult mit Details
|
||||||
|
*/
|
||||||
|
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
|
||||||
|
|
||||||
|
// 1. Backup-Datei lesen
|
||||||
|
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
|
||||||
|
inputStream.bufferedReader().use { it.readText() }
|
||||||
|
} ?: return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Datei konnte nicht gelesen werden"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2. Backup validieren & parsen
|
||||||
|
val validationResult = validateBackup(jsonString)
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
return@withContext RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||||
|
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
|
||||||
|
|
||||||
|
// 3. Auto-Backup erstellen (Sicherheitsnetz)
|
||||||
|
val autoBackupUri = createAutoBackup()
|
||||||
|
if (autoBackupUri == null) {
|
||||||
|
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Restore durchführen (je nach Modus)
|
||||||
|
val result = when (mode) {
|
||||||
|
RestoreMode.MERGE -> restoreMerge(backupData.notes)
|
||||||
|
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
|
||||||
|
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
|
||||||
|
result
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to restore backup", e)
|
||||||
|
RestoreResult(
|
||||||
|
success = false,
|
||||||
|
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert Backup-Datei
|
||||||
|
*/
|
||||||
|
private fun validateBackup(jsonString: String): ValidationResult {
|
||||||
|
return try {
|
||||||
|
val backupData = gson.fromJson(jsonString, BackupData::class.java)
|
||||||
|
|
||||||
|
// Version kompatibel?
|
||||||
|
if (backupData.backupVersion > BACKUP_VERSION) {
|
||||||
|
return ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup-Version nicht unterstützt " +
|
||||||
|
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notizen-Array vorhanden?
|
||||||
|
if (backupData.notes.isEmpty()) {
|
||||||
|
return ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup enthält keine Notizen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Notizen haben ID, title, content?
|
||||||
|
val invalidNotes = backupData.notes.filter { note ->
|
||||||
|
note.id.isBlank() || note.title.isBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidNotes.isNotEmpty()) {
|
||||||
|
return ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationResult(isValid = true)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ValidationResult(
|
||||||
|
isValid = false,
|
||||||
|
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Modus: MERGE
|
||||||
|
* Fügt neue Notizen hinzu, behält bestehende
|
||||||
|
*/
|
||||||
|
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
|
||||||
|
val existingNotes = storage.loadAllNotes()
|
||||||
|
val existingIds = existingNotes.map { it.id }.toSet()
|
||||||
|
|
||||||
|
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||||
|
val skippedNotes = backupNotes.size - newNotes.size
|
||||||
|
|
||||||
|
newNotes.forEach { note ->
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoreResult(
|
||||||
|
success = true,
|
||||||
|
importedNotes = newNotes.size,
|
||||||
|
skippedNotes = skippedNotes,
|
||||||
|
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Modus: REPLACE
|
||||||
|
* Löscht alle bestehenden Notizen, importiert Backup
|
||||||
|
*/
|
||||||
|
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
|
||||||
|
// Alle bestehenden Notizen löschen
|
||||||
|
storage.deleteAllNotes()
|
||||||
|
|
||||||
|
// Backup-Notizen importieren
|
||||||
|
backupNotes.forEach { note ->
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoreResult(
|
||||||
|
success = true,
|
||||||
|
importedNotes = backupNotes.size,
|
||||||
|
skippedNotes = 0,
|
||||||
|
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Modus: OVERWRITE_DUPLICATES
|
||||||
|
* Backup überschreibt bei ID-Konflikten
|
||||||
|
*/
|
||||||
|
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
|
||||||
|
val existingNotes = storage.loadAllNotes()
|
||||||
|
val existingIds = existingNotes.map { it.id }.toSet()
|
||||||
|
|
||||||
|
val newNotes = backupNotes.filter { it.id !in existingIds }
|
||||||
|
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
|
||||||
|
|
||||||
|
// Alle Backup-Notizen speichern (überschreibt automatisch)
|
||||||
|
backupNotes.forEach { note ->
|
||||||
|
storage.saveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return RestoreResult(
|
||||||
|
success = true,
|
||||||
|
importedNotes = newNotes.size,
|
||||||
|
skippedNotes = 0,
|
||||||
|
overwrittenNotes = overwrittenNotes.size,
|
||||||
|
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht Auto-Backups älter als RETENTION_DAYS
|
||||||
|
*/
|
||||||
|
private fun cleanupOldAutoBackups(autoBackupDir: File) {
|
||||||
|
try {
|
||||||
|
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
|
||||||
|
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
|
||||||
|
|
||||||
|
autoBackupDir.listFiles()?.forEach { file ->
|
||||||
|
if (file.lastModified() < cutoffTime) {
|
||||||
|
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to cleanup old backups", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup-Daten Struktur (JSON)
|
||||||
|
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
|
||||||
|
*/
|
||||||
|
data class BackupData(
|
||||||
|
@com.google.gson.annotations.SerializedName("backup_version")
|
||||||
|
val backupVersion: Int,
|
||||||
|
@com.google.gson.annotations.SerializedName("created_at")
|
||||||
|
val createdAt: Long,
|
||||||
|
@com.google.gson.annotations.SerializedName("notes_count")
|
||||||
|
val notesCount: Int,
|
||||||
|
@com.google.gson.annotations.SerializedName("app_version")
|
||||||
|
val appVersion: String,
|
||||||
|
val notes: List<Note>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiederherstellungs-Modi
|
||||||
|
*/
|
||||||
|
enum class RestoreMode {
|
||||||
|
MERGE, // Bestehende + Neue (Standard)
|
||||||
|
REPLACE, // Alles löschen + Importieren
|
||||||
|
OVERWRITE_DUPLICATES // Backup überschreibt bei ID-Konflikten
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup-Ergebnis
|
||||||
|
*/
|
||||||
|
data class BackupResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val notesCount: Int = 0,
|
||||||
|
val message: String? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Ergebnis
|
||||||
|
*/
|
||||||
|
data class RestoreResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val importedNotes: Int = 0,
|
||||||
|
val skippedNotes: Int = 0,
|
||||||
|
val overwrittenNotes: Int = 0,
|
||||||
|
val message: String? = null,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validierungs-Ergebnis
|
||||||
|
*/
|
||||||
|
data class ValidationResult(
|
||||||
|
val isValid: Boolean,
|
||||||
|
val errorMessage: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert ein einzelnes Item in einer Checkliste
|
||||||
|
*
|
||||||
|
* v1.4.0: Checklisten-Feature
|
||||||
|
*
|
||||||
|
* @property id Eindeutige ID für Sync-Konflikterkennung
|
||||||
|
* @property text Der Text des Items
|
||||||
|
* @property isChecked Ob das Item abgehakt ist
|
||||||
|
* @property order Sortierreihenfolge (0-basiert)
|
||||||
|
*/
|
||||||
|
data class ChecklistItem(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val text: String = "",
|
||||||
|
var isChecked: Boolean = false,
|
||||||
|
var order: Int = 0
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Erstellt ein neues leeres ChecklistItem
|
||||||
|
*/
|
||||||
|
fun createEmpty(order: Int): ChecklistItem {
|
||||||
|
return ChecklistItem(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
text = "",
|
||||||
|
isChecked = false,
|
||||||
|
order = order
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
data class DeletionRecord(
|
||||||
|
val id: String,
|
||||||
|
val deletedAt: Long,
|
||||||
|
val deviceId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class DeletionTracker(
|
||||||
|
val version: Int = 1,
|
||||||
|
val deletedNotes: MutableList<DeletionRecord> = mutableListOf()
|
||||||
|
) {
|
||||||
|
fun addDeletion(noteId: String, deviceId: String) {
|
||||||
|
if (!deletedNotes.any { it.id == noteId }) {
|
||||||
|
deletedNotes.add(DeletionRecord(noteId, System.currentTimeMillis(), deviceId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isDeleted(noteId: String): Boolean {
|
||||||
|
return deletedNotes.any { it.id == noteId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeletionTimestamp(noteId: String): Long? {
|
||||||
|
return deletedNotes.find { it.id == noteId }?.deletedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDeletion(noteId: String) {
|
||||||
|
deletedNotes.removeIf { it.id == noteId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toJson(): String {
|
||||||
|
val jsonObject = JSONObject()
|
||||||
|
jsonObject.put("version", version)
|
||||||
|
|
||||||
|
val notesArray = JSONArray()
|
||||||
|
for (record in deletedNotes) {
|
||||||
|
val recordObj = JSONObject()
|
||||||
|
recordObj.put("id", record.id)
|
||||||
|
recordObj.put("deletedAt", record.deletedAt)
|
||||||
|
recordObj.put("deviceId", record.deviceId)
|
||||||
|
notesArray.put(recordObj)
|
||||||
|
}
|
||||||
|
jsonObject.put("deletedNotes", notesArray)
|
||||||
|
|
||||||
|
return jsonObject.toString(2) // Pretty print with 2-space indent
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DeletionTracker"
|
||||||
|
|
||||||
|
fun fromJson(json: String): DeletionTracker? {
|
||||||
|
return try {
|
||||||
|
val jsonObject = JSONObject(json)
|
||||||
|
val version = jsonObject.optInt("version", 1)
|
||||||
|
val deletedNotes = mutableListOf<DeletionRecord>()
|
||||||
|
|
||||||
|
val notesArray = jsonObject.optJSONArray("deletedNotes")
|
||||||
|
if (notesArray != null) {
|
||||||
|
for (i in 0 until notesArray.length()) {
|
||||||
|
val recordObj = notesArray.getJSONObject(i)
|
||||||
|
val record = DeletionRecord(
|
||||||
|
id = recordObj.getString("id"),
|
||||||
|
deletedAt = recordObj.getLong("deletedAt"),
|
||||||
|
deviceId = recordObj.getString("deviceId")
|
||||||
|
)
|
||||||
|
deletedNotes.add(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DeletionTracker(version, deletedNotes)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
package dev.dettmer.simplenotes.models
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class Note(
|
data class Note(
|
||||||
@@ -9,30 +14,234 @@ data class Note(
|
|||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY
|
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
|
||||||
|
// v1.4.0: Checklisten-Felder
|
||||||
|
val noteType: NoteType = NoteType.TEXT,
|
||||||
|
val checklistItems: List<ChecklistItem>? = null
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Serialisiert Note zu JSON (v1.4.0: Nutzt Gson für komplexe Strukturen)
|
||||||
|
*/
|
||||||
fun toJson(): String {
|
fun toJson(): String {
|
||||||
return """
|
val gson = com.google.gson.GsonBuilder()
|
||||||
{
|
.setPrettyPrinting()
|
||||||
"id": "$id",
|
.create()
|
||||||
"title": "${title.escapeJson()}",
|
return gson.toJson(this)
|
||||||
"content": "${content.escapeJson()}",
|
|
||||||
"createdAt": $createdAt,
|
|
||||||
"updatedAt": $updatedAt,
|
|
||||||
"deviceId": "$deviceId",
|
|
||||||
"syncStatus": "${syncStatus.name}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
||||||
|
* Format kompatibel mit Obsidian, Joplin, Typora
|
||||||
|
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||||
|
*/
|
||||||
|
fun toMarkdown(): String {
|
||||||
|
val header = """
|
||||||
|
---
|
||||||
|
id: $id
|
||||||
|
created: ${formatISO8601(createdAt)}
|
||||||
|
updated: ${formatISO8601(updatedAt)}
|
||||||
|
device: $deviceId
|
||||||
|
type: ${noteType.name.lowercase()}
|
||||||
|
---
|
||||||
|
|
||||||
|
# $title
|
||||||
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
return when (noteType) {
|
||||||
|
NoteType.TEXT -> header + content
|
||||||
|
NoteType.CHECKLIST -> {
|
||||||
|
val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
|
||||||
|
val checkbox = if (item.isChecked) "[x]" else "[ ]"
|
||||||
|
"- $checkbox ${item.text}"
|
||||||
|
} ?: ""
|
||||||
|
header + checklistMarkdown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "Note"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
|
||||||
|
*/
|
||||||
fun fromJson(json: String): Note? {
|
fun fromJson(json: String): Note? {
|
||||||
return try {
|
return try {
|
||||||
val gson = com.google.gson.Gson()
|
val gson = com.google.gson.Gson()
|
||||||
gson.fromJson(json, Note::class.java)
|
val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject
|
||||||
|
|
||||||
|
// Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT
|
||||||
|
val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) {
|
||||||
|
try {
|
||||||
|
NoteType.valueOf(jsonObject.get("noteType").asString)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}")
|
||||||
|
NoteType.TEXT
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NoteType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parsen der Basis-Note
|
||||||
|
val rawNote = gson.fromJson(json, NoteRaw::class.java)
|
||||||
|
|
||||||
|
// Checklist-Items parsen (kann null sein)
|
||||||
|
val checklistItemsType = object : com.google.gson.reflect.TypeToken<List<ChecklistItem>>() {}.type
|
||||||
|
val checklistItems = if (jsonObject.has("checklistItems") &&
|
||||||
|
!jsonObject.get("checklistItems").isJsonNull
|
||||||
|
) {
|
||||||
|
gson.fromJson<List<ChecklistItem>>(
|
||||||
|
jsonObject.get("checklistItems"),
|
||||||
|
checklistItemsType
|
||||||
|
)
|
||||||
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note mit korrekten Werten erstellen
|
||||||
|
Note(
|
||||||
|
id = rawNote.id,
|
||||||
|
title = rawNote.title,
|
||||||
|
content = rawNote.content,
|
||||||
|
createdAt = rawNote.createdAt,
|
||||||
|
updatedAt = rawNote.updatedAt,
|
||||||
|
deviceId = rawNote.deviceId,
|
||||||
|
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
|
||||||
|
noteType = noteType,
|
||||||
|
checklistItems = checklistItems
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hilfsklasse für Gson-Parsing mit nullable Feldern
|
||||||
|
*/
|
||||||
|
private data class NoteRaw(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val title: String = "",
|
||||||
|
val content: String = "",
|
||||||
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
|
val deviceId: String = "",
|
||||||
|
val syncStatus: SyncStatus? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||||
|
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
|
||||||
|
*
|
||||||
|
* @param md Markdown-String mit YAML Frontmatter
|
||||||
|
* @return Note-Objekt oder null bei Parse-Fehler
|
||||||
|
*/
|
||||||
|
fun fromMarkdown(md: String): Note? {
|
||||||
|
return try {
|
||||||
|
// Parse YAML Frontmatter + Markdown Content
|
||||||
|
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
val match = frontmatterRegex.find(md) ?: return null
|
||||||
|
|
||||||
|
val yamlBlock = match.groupValues[1]
|
||||||
|
val contentBlock = match.groupValues[2]
|
||||||
|
|
||||||
|
// Parse YAML (einfach per String-Split für MVP)
|
||||||
|
val metadata = yamlBlock.lines()
|
||||||
|
.mapNotNull { line ->
|
||||||
|
val parts = line.split(":", limit = 2)
|
||||||
|
if (parts.size == 2) {
|
||||||
|
parts[0].trim() to parts[1].trim()
|
||||||
|
} else null
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
// Extract title from first # heading
|
||||||
|
val title = contentBlock.lines()
|
||||||
|
.firstOrNull { it.startsWith("# ") }
|
||||||
|
?.removePrefix("# ")?.trim() ?: "Untitled"
|
||||||
|
|
||||||
|
// v1.4.0: Prüfe ob type: checklist im Frontmatter
|
||||||
|
val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
|
||||||
|
val noteType = when (noteTypeStr) {
|
||||||
|
"checklist" -> NoteType.CHECKLIST
|
||||||
|
else -> NoteType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1.4.0: Parse Content basierend auf Typ
|
||||||
|
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
|
||||||
|
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
|
||||||
|
val contentAfterTitle = if (titleLineIndex >= 0) {
|
||||||
|
// Alles nach der Titel-Zeile, überspringe führende Leerzeilen
|
||||||
|
contentBlock.lines()
|
||||||
|
.drop(titleLineIndex + 1)
|
||||||
|
.dropWhile { it.isBlank() }
|
||||||
|
.joinToString("\n")
|
||||||
|
.trim()
|
||||||
|
} else {
|
||||||
|
// Fallback: Gesamter Content (kein Titel gefunden)
|
||||||
|
contentBlock.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
val content: String
|
||||||
|
val checklistItems: List<ChecklistItem>?
|
||||||
|
|
||||||
|
if (noteType == NoteType.CHECKLIST) {
|
||||||
|
// Parse Checklist Items
|
||||||
|
val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE)
|
||||||
|
checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult ->
|
||||||
|
ChecklistItem(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
text = matchResult.groupValues[2].trim(),
|
||||||
|
isChecked = matchResult.groupValues[1].lowercase() == "x",
|
||||||
|
order = index
|
||||||
|
)
|
||||||
|
}.toList().ifEmpty { null }
|
||||||
|
content = "" // Checklisten haben keinen "content"
|
||||||
|
} else {
|
||||||
|
content = contentAfterTitle
|
||||||
|
checklistItems = null
|
||||||
|
}
|
||||||
|
|
||||||
|
Note(
|
||||||
|
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||||
|
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
||||||
|
deviceId = metadata["device"] ?: "desktop",
|
||||||
|
syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
|
||||||
|
noteType = noteType,
|
||||||
|
checklistItems = checklistItems
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
|
||||||
|
* Format: 2024-12-21T18:00:00Z (UTC)
|
||||||
|
*/
|
||||||
|
private fun formatISO8601(timestamp: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
return sdf.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
|
||||||
|
* Fallback: Aktueller Timestamp bei Fehler
|
||||||
|
*/
|
||||||
|
private fun parseISO8601(dateString: String): Long {
|
||||||
|
return try {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
|
||||||
|
System.currentTimeMillis() // Fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Definiert die verschiedenen Notiz-Typen
|
||||||
|
*
|
||||||
|
* v1.4.0: Checklisten-Feature
|
||||||
|
*/
|
||||||
|
enum class NoteType {
|
||||||
|
TEXT, // Normale Text-Notiz (Standard)
|
||||||
|
CHECKLIST // Checkliste mit abhakbaren Items
|
||||||
|
}
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
package dev.dettmer.simplenotes.storage
|
package dev.dettmer.simplenotes.storage
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||||
import dev.dettmer.simplenotes.models.Note
|
import dev.dettmer.simplenotes.models.Note
|
||||||
|
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class NotesStorage(private val context: Context) {
|
class NotesStorage(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "NotesStorage"
|
||||||
|
}
|
||||||
|
|
||||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||||
if (!exists()) mkdirs()
|
if (!exists()) mkdirs()
|
||||||
}
|
}
|
||||||
@@ -34,19 +41,89 @@ class NotesStorage(private val context: Context) {
|
|||||||
|
|
||||||
fun deleteNote(id: String): Boolean {
|
fun deleteNote(id: String): Boolean {
|
||||||
val file = File(notesDir, "$id.json")
|
val file = File(notesDir, "$id.json")
|
||||||
return file.delete()
|
val deleted = file.delete()
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
Logger.d(TAG, "🗑️ Deleted note: $id")
|
||||||
|
|
||||||
|
// Track deletion to prevent zombie notes
|
||||||
|
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||||
|
trackDeletion(id, deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteAllNotes(): Boolean {
|
fun deleteAllNotes(): Boolean {
|
||||||
return try {
|
return try {
|
||||||
notesDir.listFiles()
|
val notes = loadAllNotes()
|
||||||
?.filter { it.extension == "json" }
|
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||||
?.forEach { it.delete() }
|
|
||||||
|
for (note in notes) {
|
||||||
|
deleteNote(note.id) // Uses trackDeletion() automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to delete all notes", e)
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Deletion Tracking ===
|
||||||
|
|
||||||
|
private fun getDeletionTrackerFile(): File {
|
||||||
|
return File(context.filesDir, "deleted_notes.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadDeletionTracker(): DeletionTracker {
|
||||||
|
val file = getDeletionTrackerFile()
|
||||||
|
if (!file.exists()) {
|
||||||
|
return DeletionTracker()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val json = file.readText()
|
||||||
|
DeletionTracker.fromJson(json) ?: DeletionTracker()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to load deletion tracker", e)
|
||||||
|
DeletionTracker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveDeletionTracker(tracker: DeletionTracker) {
|
||||||
|
try {
|
||||||
|
val file = getDeletionTrackerFile()
|
||||||
|
file.writeText(tracker.toJson())
|
||||||
|
|
||||||
|
if (tracker.deletedNotes.size > 1000) {
|
||||||
|
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to save deletion tracker", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trackDeletion(noteId: String, deviceId: String) {
|
||||||
|
val tracker = loadDeletionTracker()
|
||||||
|
tracker.addDeletion(noteId, deviceId)
|
||||||
|
saveDeletionTracker(tracker)
|
||||||
|
Logger.d(TAG, "📝 Tracked deletion: $noteId")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isNoteDeleted(noteId: String): Boolean {
|
||||||
|
val tracker = loadDeletionTracker()
|
||||||
|
return tracker.isDeleted(noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDeletionTracker() {
|
||||||
|
saveDeletionTracker(DeletionTracker())
|
||||||
|
Logger.d(TAG, "🗑️ Deletion tracker cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getNotesDir(): File = notesDir
|
fun getNotesDir(): File = notesDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,11 @@ class NetworkMonitor(private val context: Context) {
|
|||||||
if (isWifi) {
|
if (isWifi) {
|
||||||
lastConnectedNetworkId = activeNetwork.toString()
|
lastConnectedNetworkId = activeNetwork.toString()
|
||||||
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
|
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
|
||||||
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change")
|
Logger.d(
|
||||||
|
TAG,
|
||||||
|
" 📡 WiFi already connected at startup - " +
|
||||||
|
"onAvailable() will only trigger on network change"
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
lastConnectedNetworkId = null
|
lastConnectedNetworkId = null
|
||||||
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
|
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
|
||||||
@@ -268,7 +272,7 @@ class NetworkMonitor(private val context: Context) {
|
|||||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||||
Logger.d(TAG, "✅ WiFi monitoring stopped")
|
Logger.d(TAG, "✅ WiFi monitoring stopped")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Already unregistered
|
Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package dev.dettmer.simplenotes.sync
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.MutableLiveData
|
||||||
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
|
||||||
|
*
|
||||||
|
* Verhindert doppelte Syncs und informiert die UI über den aktuellen Status.
|
||||||
|
* Thread-safe Singleton mit LiveData für UI-Reaktivität.
|
||||||
|
*/
|
||||||
|
object SyncStateManager {
|
||||||
|
|
||||||
|
private const val TAG = "SyncStateManager"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mögliche Sync-Zustände
|
||||||
|
*/
|
||||||
|
enum class SyncState {
|
||||||
|
IDLE, // Kein Sync aktiv
|
||||||
|
SYNCING, // Sync läuft gerade
|
||||||
|
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
|
||||||
|
ERROR // Sync fehlgeschlagen (kurz anzeigen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detaillierte Sync-Informationen für UI
|
||||||
|
*/
|
||||||
|
data class SyncStatus(
|
||||||
|
val state: SyncState = SyncState.IDLE,
|
||||||
|
val message: String? = null,
|
||||||
|
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
|
||||||
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Private mutable LiveData
|
||||||
|
private val _syncStatus = MutableLiveData(SyncStatus())
|
||||||
|
|
||||||
|
// Public immutable LiveData für Observer
|
||||||
|
val syncStatus: LiveData<SyncStatus> = _syncStatus
|
||||||
|
|
||||||
|
// Lock für Thread-Sicherheit
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob gerade ein Sync läuft
|
||||||
|
*/
|
||||||
|
val isSyncing: Boolean
|
||||||
|
get() = _syncStatus.value?.state == SyncState.SYNCING
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Versucht einen Sync zu starten.
|
||||||
|
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
|
||||||
|
*/
|
||||||
|
fun tryStartSync(source: String): Boolean {
|
||||||
|
synchronized(lock) {
|
||||||
|
if (isSyncing) {
|
||||||
|
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "🔄 Starting sync from: $source")
|
||||||
|
_syncStatus.postValue(
|
||||||
|
SyncStatus(
|
||||||
|
state = SyncState.SYNCING,
|
||||||
|
message = "Synchronisiere...",
|
||||||
|
source = source
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert Sync als erfolgreich abgeschlossen
|
||||||
|
*/
|
||||||
|
fun markCompleted(message: String? = null) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val currentSource = _syncStatus.value?.source
|
||||||
|
Logger.d(TAG, "✅ Sync completed from: $currentSource")
|
||||||
|
_syncStatus.postValue(
|
||||||
|
SyncStatus(
|
||||||
|
state = SyncState.COMPLETED,
|
||||||
|
message = message,
|
||||||
|
source = currentSource
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markiert Sync als fehlgeschlagen
|
||||||
|
*/
|
||||||
|
fun markError(errorMessage: String?) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val currentSource = _syncStatus.value?.source
|
||||||
|
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
|
||||||
|
_syncStatus.postValue(
|
||||||
|
SyncStatus(
|
||||||
|
state = SyncState.ERROR,
|
||||||
|
message = errorMessage,
|
||||||
|
source = currentSource
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt Status zurück auf IDLE
|
||||||
|
*/
|
||||||
|
fun reset() {
|
||||||
|
synchronized(lock) {
|
||||||
|
_syncStatus.postValue(SyncStatus())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualisiert die Nachricht während des Syncs (z.B. Progress)
|
||||||
|
*/
|
||||||
|
fun updateMessage(message: String) {
|
||||||
|
synchronized(lock) {
|
||||||
|
val current = _syncStatus.value ?: return
|
||||||
|
if (current.state == SyncState.SYNCING) {
|
||||||
|
_syncStatus.postValue(current.copy(message = message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package dev.dettmer.simplenotes.sync
|
package dev.dettmer.simplenotes.sync
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
@@ -8,6 +9,7 @@ import androidx.work.WorkerParameters
|
|||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
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.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -21,6 +23,21 @@ class SyncWorker(
|
|||||||
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
|
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob die App im Vordergrund ist.
|
||||||
|
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
|
||||||
|
*/
|
||||||
|
private fun isAppInForeground(): Boolean {
|
||||||
|
val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||||
|
val appProcesses = activityManager.runningAppProcesses ?: return false
|
||||||
|
val packageName = applicationContext.packageName
|
||||||
|
|
||||||
|
return appProcesses.any { process ->
|
||||||
|
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
|
||||||
|
process.processName == packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
@@ -52,7 +69,25 @@ class SyncWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "📍 Step 2: Checking server reachability (Pre-Check)")
|
Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
|
||||||
|
// Spart Batterie + Netzwerk-Traffic + Server-Last
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ No local changes - skipping sync (performance optimization)")
|
||||||
|
Logger.d(TAG, " Saves battery, network traffic, and server load")
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (no changes to sync)")
|
||||||
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
|
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
|
||||||
@@ -63,6 +98,9 @@ class SyncWorker(
|
|||||||
Logger.d(TAG, " Reason: Server offline/wrong network/network not ready/not configured")
|
Logger.d(TAG, " Reason: Server offline/wrong network/network not ready/not configured")
|
||||||
Logger.d(TAG, " This is normal in foreign WiFi or during network initialization")
|
Logger.d(TAG, " This is normal in foreign WiFi or during network initialization")
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if we should show warning (server unreachable for >24h)
|
||||||
|
checkAndShowSyncWarning(syncService)
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
@@ -95,7 +133,11 @@ class SyncWorker(
|
|||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "📍 Step 4: Processing result")
|
Logger.d(TAG, "📍 Step 4: Processing result")
|
||||||
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
|
Logger.d(
|
||||||
|
TAG,
|
||||||
|
"📦 Sync result: success=${result.isSuccess}, " +
|
||||||
|
"count=${result.syncedCount}, error=${result.errorMessage}"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
@@ -105,7 +147,12 @@ class SyncWorker(
|
|||||||
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||||
|
|
||||||
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
|
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
|
||||||
|
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
|
||||||
if (result.syncedCount > 0) {
|
if (result.syncedCount > 0) {
|
||||||
|
val appInForeground = isAppInForeground()
|
||||||
|
if (appInForeground) {
|
||||||
|
Logger.d(TAG, "ℹ️ App in foreground - skipping notification (UI shows changes)")
|
||||||
|
} else {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, " Showing success notification...")
|
Logger.d(TAG, " Showing success notification...")
|
||||||
}
|
}
|
||||||
@@ -113,6 +160,7 @@ class SyncWorker(
|
|||||||
applicationContext,
|
applicationContext,
|
||||||
result.syncedCount
|
result.syncedCount
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Logger.d(TAG, "ℹ️ No changes to sync - no notification")
|
Logger.d(TAG, "ℹ️ No changes to sync - no notification")
|
||||||
}
|
}
|
||||||
@@ -147,6 +195,32 @@ class SyncWorker(
|
|||||||
}
|
}
|
||||||
Result.failure()
|
Result.failure()
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// ⭐ Job wurde gecancelt - KEIN FEHLER!
|
||||||
|
// Gründe: App-Update, Doze Mode, Battery Optimization, Network Constraint, etc.
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
Logger.d(TAG, "⏹️ Job was cancelled (normal - update/doze/constraints)")
|
||||||
|
Logger.d(TAG, " Reason could be: App update, Doze mode, Battery opt, Network disconnect")
|
||||||
|
Logger.d(TAG, " This is expected Android behavior - not an error!")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// UI-Refresh trotzdem triggern (falls MainActivity geöffnet)
|
||||||
|
broadcastSyncCompleted(false, 0)
|
||||||
|
} catch (broadcastError: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to broadcast after cancellation", broadcastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cancelled, no error)")
|
||||||
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ WICHTIG: Result.success() zurückgeben!
|
||||||
|
// Cancellation ist KEIN Fehler, WorkManager soll nicht retries machen
|
||||||
|
Result.success()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
@@ -189,4 +263,69 @@ class SyncWorker(
|
|||||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||||
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
|
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob Server längere Zeit unreachable und zeigt ggf. Warnung (v1.1.2)
|
||||||
|
* - Nur wenn Auto-Sync aktiviert
|
||||||
|
* - Nur wenn schon mal erfolgreich gesynct
|
||||||
|
* - Nur wenn >24h seit letztem erfolgreichen Sync
|
||||||
|
* - Throttling: Max. 1 Warnung pro 24h
|
||||||
|
*/
|
||||||
|
private fun checkAndShowSyncWarning(syncService: WebDavSyncService) {
|
||||||
|
try {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(
|
||||||
|
dev.dettmer.simplenotes.utils.Constants.PREFS_NAME,
|
||||||
|
android.content.Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check 1: Auto-Sync aktiviert?
|
||||||
|
val autoSyncEnabled = prefs.getBoolean(
|
||||||
|
dev.dettmer.simplenotes.utils.Constants.KEY_AUTO_SYNC,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
if (!autoSyncEnabled) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-Sync disabled - no warning needed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: Schon mal erfolgreich gesynct?
|
||||||
|
val lastSuccessfulSync = syncService.getLastSuccessfulSyncTimestamp()
|
||||||
|
if (lastSuccessfulSync == 0L) {
|
||||||
|
Logger.d(TAG, "⏭️ Never synced successfully - no warning needed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: >24h seit letztem erfolgreichen Sync?
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val timeSinceLastSync = now - lastSuccessfulSync
|
||||||
|
if (timeSinceLastSync < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
|
||||||
|
Logger.d(TAG, "⏭️ Last successful sync <24h ago - no warning needed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 4: Throttling - schon Warnung in letzten 24h gezeigt?
|
||||||
|
val lastWarningShown = prefs.getLong(
|
||||||
|
dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN,
|
||||||
|
0L
|
||||||
|
)
|
||||||
|
if (now - lastWarningShown < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
|
||||||
|
Logger.d(TAG, "⏭️ Warning already shown in last 24h - throttling")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeige Warnung
|
||||||
|
val hoursSinceLastSync = timeSinceLastSync / (1000 * 60 * 60)
|
||||||
|
NotificationHelper.showSyncWarning(applicationContext, hoursSinceLastSync)
|
||||||
|
|
||||||
|
// Speichere Zeitpunkt der Warnung
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, now)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
Logger.d(TAG, "⚠️ Sync warning shown: Server unreachable for ${hoursSinceLastSync}h")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to check/show sync warning", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.wifi.WifiManager
|
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import dev.dettmer.simplenotes.utils.Constants
|
import dev.dettmer.simplenotes.utils.Constants
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WiFi-Sync BroadcastReceiver
|
||||||
|
*
|
||||||
|
* Triggert Sync wenn WiFi verbunden wird (jedes WiFi, keine SSID-Prüfung mehr)
|
||||||
|
* Die eigentliche Server-Erreichbarkeitsprüfung erfolgt im SyncWorker.
|
||||||
|
*/
|
||||||
class WifiSyncReceiver : BroadcastReceiver() {
|
class WifiSyncReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
@@ -22,33 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if connected to home WiFi
|
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
|
||||||
if (isConnectedToHomeWifi(context)) {
|
if (isConnectedToWifi(context)) {
|
||||||
scheduleSyncWork(context)
|
scheduleSyncWork(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isConnectedToHomeWifi(context: Context): Boolean {
|
/**
|
||||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
* Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
|
||||||
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
|
* Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
|
||||||
|
*/
|
||||||
|
private fun isConnectedToWifi(context: Context): Boolean {
|
||||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||||
as ConnectivityManager
|
as ConnectivityManager
|
||||||
|
|
||||||
val network = connectivityManager.activeNetwork ?: return false
|
val network = connectivityManager.activeNetwork ?: return false
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||||
|
|
||||||
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current SSID
|
|
||||||
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
|
|
||||||
as WifiManager
|
|
||||||
val wifiInfo = wifiManager.connectionInfo
|
|
||||||
val currentSSID = wifiInfo.ssid.replace("\"", "")
|
|
||||||
|
|
||||||
return currentSSID == homeSSID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleSyncWork(context: Context) {
|
private fun scheduleSyncWork(context: Context) {
|
||||||
|
|||||||
@@ -6,14 +6,29 @@ object Constants {
|
|||||||
const val KEY_SERVER_URL = "server_url"
|
const val KEY_SERVER_URL = "server_url"
|
||||||
const val KEY_USERNAME = "username"
|
const val KEY_USERNAME = "username"
|
||||||
const val KEY_PASSWORD = "password"
|
const val KEY_PASSWORD = "password"
|
||||||
const val KEY_HOME_SSID = "home_ssid"
|
|
||||||
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
||||||
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Last Successful Sync Monitoring
|
||||||
|
const val KEY_LAST_SUCCESSFUL_SYNC = "last_successful_sync_time"
|
||||||
|
const val KEY_LAST_SYNC_WARNING_SHOWN = "last_sync_warning_shown_time"
|
||||||
|
const val SYNC_WARNING_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24h
|
||||||
|
|
||||||
// 🔥 NEU: Sync Interval Configuration
|
// 🔥 NEU: Sync Interval Configuration
|
||||||
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
||||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
|
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
|
||||||
|
|
||||||
|
// 🔥 v1.2.0: Markdown Export/Import
|
||||||
|
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
|
||||||
|
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
|
||||||
|
|
||||||
|
// 🔥 v1.3.0: Performance & Multi-Device Sync
|
||||||
|
const val KEY_ALWAYS_CHECK_SERVER = "always_check_server"
|
||||||
|
const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server"
|
||||||
|
|
||||||
|
// 🔥 v1.3.1: Debug & Logging
|
||||||
|
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
private const val DAYS_THRESHOLD = 7L
|
||||||
|
private const val TRUNCATE_SUFFIX_LENGTH = 3
|
||||||
|
|
||||||
// Toast Extensions
|
// Toast Extensions
|
||||||
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
|
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
|
||||||
Toast.makeText(this, message, duration).show()
|
Toast.makeText(this, message, duration).show()
|
||||||
@@ -27,7 +30,7 @@ fun Long.toReadableTime(): String {
|
|||||||
val hours = TimeUnit.MILLISECONDS.toHours(diff)
|
val hours = TimeUnit.MILLISECONDS.toHours(diff)
|
||||||
"Vor $hours Std"
|
"Vor $hours Std"
|
||||||
}
|
}
|
||||||
diff < TimeUnit.DAYS.toMillis(7) -> {
|
diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
|
||||||
val days = TimeUnit.MILLISECONDS.toDays(diff)
|
val days = TimeUnit.MILLISECONDS.toDays(diff)
|
||||||
"Vor $days Tagen"
|
"Vor $days Tagen"
|
||||||
}
|
}
|
||||||
@@ -41,7 +44,7 @@ fun Long.toReadableTime(): String {
|
|||||||
// Truncate long strings
|
// Truncate long strings
|
||||||
fun String.truncate(maxLength: Int): String {
|
fun String.truncate(maxLength: Int): String {
|
||||||
return if (length > maxLength) {
|
return if (length > maxLength) {
|
||||||
substring(0, maxLength - 3) + "..."
|
substring(0, maxLength - TRUNCATE_SUFFIX_LENGTH) + "..."
|
||||||
} else {
|
} else {
|
||||||
this
|
this
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.util.Log
|
|||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileWriter
|
import java.io.FileWriter
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -15,10 +14,34 @@ import java.util.*
|
|||||||
*/
|
*/
|
||||||
object Logger {
|
object Logger {
|
||||||
|
|
||||||
|
private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge
|
||||||
|
|
||||||
private var fileLoggingEnabled = false
|
private var fileLoggingEnabled = false
|
||||||
private var logFile: File? = null
|
private var logFile: File? = null
|
||||||
|
private var appContext: Context? = null
|
||||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
|
||||||
private val maxLogEntries = 500 // Nur letzte 500 Einträge
|
|
||||||
|
/**
|
||||||
|
* Setzt den File-Logging Status (für UI Toggle)
|
||||||
|
*/
|
||||||
|
fun setFileLoggingEnabled(enabled: Boolean) {
|
||||||
|
fileLoggingEnabled = enabled
|
||||||
|
if (!enabled) {
|
||||||
|
logFile = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt zurück, ob File-Logging aktiviert ist
|
||||||
|
*/
|
||||||
|
fun isFileLoggingEnabled(): Boolean = fileLoggingEnabled
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialisiert den Logger mit App-Context
|
||||||
|
*/
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aktiviert File-Logging für Debugging
|
* Aktiviert File-Logging für Debugging
|
||||||
@@ -50,11 +73,47 @@ object Logger {
|
|||||||
*/
|
*/
|
||||||
fun getLogFile(): File? = logFile
|
fun getLogFile(): File? = logFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt Log-Datei mit Context zurück (für SettingsActivity)
|
||||||
|
*/
|
||||||
|
fun getLogFile(context: Context): File? {
|
||||||
|
if (logFile == null && fileLoggingEnabled) {
|
||||||
|
logFile = File(context.filesDir, "simplenotes_debug.log")
|
||||||
|
}
|
||||||
|
return logFile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht die Log-Datei
|
||||||
|
*/
|
||||||
|
fun clearLogFile(context: Context): Boolean {
|
||||||
|
return try {
|
||||||
|
val file = File(context.filesDir, "simplenotes_debug.log")
|
||||||
|
if (file.exists()) {
|
||||||
|
file.delete()
|
||||||
|
logFile = null
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Logger", "Failed to clear log file", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schreibt Log-Eintrag in Datei
|
* Schreibt Log-Eintrag in Datei
|
||||||
*/
|
*/
|
||||||
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
|
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
|
||||||
if (!fileLoggingEnabled || logFile == null) return
|
if (!fileLoggingEnabled) return
|
||||||
|
|
||||||
|
// Lazy-init logFile mit appContext
|
||||||
|
if (logFile == null && appContext != null) {
|
||||||
|
logFile = File(appContext!!.filesDir, "simplenotes_debug.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logFile == null) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val timestamp = dateFormat.format(Date())
|
val timestamp = dateFormat.format(Date())
|
||||||
@@ -80,13 +139,13 @@ object Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begrenzt Log-Datei auf maxLogEntries
|
* Begrenzt Log-Datei auf MAX_LOG_ENTRIES
|
||||||
*/
|
*/
|
||||||
private fun trimLogFile() {
|
private fun trimLogFile() {
|
||||||
try {
|
try {
|
||||||
val lines = logFile?.readLines() ?: return
|
val lines = logFile?.readLines() ?: return
|
||||||
if (lines.size > maxLogEntries) {
|
if (lines.size > MAX_LOG_ENTRIES) {
|
||||||
val trimmed = lines.takeLast(maxLogEntries)
|
val trimmed = lines.takeLast(MAX_LOG_ENTRIES)
|
||||||
logFile?.writeText(trimmed.joinToString("\n") + "\n")
|
logFile?.writeText(trimmed.joinToString("\n") + "\n")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ object NotificationHelper {
|
|||||||
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
|
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
|
||||||
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
|
||||||
|
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt Notification Channel (Android 8.0+)
|
* Erstellt Notification Channel (Android 8.0+)
|
||||||
@@ -286,6 +287,42 @@ object NotificationHelper {
|
|||||||
Handler(Looper.getMainLooper()).postDelayed({
|
Handler(Looper.getMainLooper()).postDelayed({
|
||||||
manager.cancel(SYNC_NOTIFICATION_ID)
|
manager.cancel(SYNC_NOTIFICATION_ID)
|
||||||
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
|
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
|
||||||
}, 30_000)
|
}, AUTO_CANCEL_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt Warnung wenn Server längere Zeit nicht erreichbar (v1.1.2)
|
||||||
|
* Throttling: Max. 1 Warnung pro 24h
|
||||||
|
*/
|
||||||
|
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
|
||||||
|
// PendingIntent für App-Öffnung
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
0,
|
||||||
|
intent,
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
.setContentTitle("⚠️ Sync-Warnung")
|
||||||
|
.setContentText("Server seit ${hoursSinceLastSync}h nicht erreichbar")
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle()
|
||||||
|
.bigText("Der WebDAV-Server ist seit ${hoursSinceLastSync} Stunden nicht erreichbar. " +
|
||||||
|
"Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen."))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.setContentIntent(pendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||||
|
as NotificationManager
|
||||||
|
manager.notify(SYNC_NOTIFICATION_ID, notification)
|
||||||
|
|
||||||
|
Logger.d(TAG, "⚠️ Showed sync warning: Server unreachable for ${hoursSinceLastSync}h")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package dev.dettmer.simplenotes.utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception für Sync-spezifische Fehler
|
||||||
|
*
|
||||||
|
* Verwendet anstelle von generischen Exceptions für bessere
|
||||||
|
* Fehlerbehandlung und klarere Fehlermeldungen.
|
||||||
|
*/
|
||||||
|
class SyncException(
|
||||||
|
message: String,
|
||||||
|
cause: Throwable? = null
|
||||||
|
) : Exception(message, cause)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception für Validierungsfehler
|
||||||
|
*
|
||||||
|
* Verwendet für ungültige Eingaben oder Konfigurationsfehler.
|
||||||
|
*/
|
||||||
|
class ValidationException(
|
||||||
|
message: String
|
||||||
|
) : IllegalArgumentException(message)
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package dev.dettmer.simplenotes.utils
|
||||||
|
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL Validator für Network Security (v1.1.2)
|
||||||
|
* Erlaubt HTTP nur für lokale Netzwerke (RFC 1918 Private IPs)
|
||||||
|
*/
|
||||||
|
object UrlValidator {
|
||||||
|
|
||||||
|
// RFC 1918 Private IP Ranges
|
||||||
|
private const val PRIVATE_CLASS_A_FIRST_OCTET = 10
|
||||||
|
private const val PRIVATE_CLASS_B_FIRST_OCTET = 172
|
||||||
|
private const val PRIVATE_CLASS_B_SECOND_OCTET_MIN = 16
|
||||||
|
private const val PRIVATE_CLASS_B_SECOND_OCTET_MAX = 31
|
||||||
|
private const val PRIVATE_CLASS_C_FIRST_OCTET = 192
|
||||||
|
private const val PRIVATE_CLASS_C_SECOND_OCTET = 168
|
||||||
|
private const val LOCALHOST_FIRST_OCTET = 127
|
||||||
|
private const val OCTET_MAX_VALUE = 255
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob eine URL eine lokale/private Adresse ist
|
||||||
|
* Erlaubt:
|
||||||
|
* - 192.168.x.x (Class C private)
|
||||||
|
* - 10.x.x.x (Class A private)
|
||||||
|
* - 172.16.x.x - 172.31.x.x (Class B private)
|
||||||
|
* - 127.x.x.x (Localhost)
|
||||||
|
* - .local domains (mDNS/Bonjour)
|
||||||
|
*/
|
||||||
|
@Suppress("ReturnCount") // Early returns for validation checks are clearer
|
||||||
|
fun isLocalUrl(url: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val parsedUrl = URL(url)
|
||||||
|
val host = parsedUrl.host.lowercase()
|
||||||
|
|
||||||
|
// Check for .local domains (e.g., nas.local)
|
||||||
|
if (host.endsWith(".local")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for localhost
|
||||||
|
if (host == "localhost" || host == "127.0.0.1") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse IP address if it's numeric
|
||||||
|
val ipPattern = """^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""".toRegex()
|
||||||
|
val match = ipPattern.find(host)
|
||||||
|
|
||||||
|
if (match != null) {
|
||||||
|
val octets = match.groupValues.drop(1).map { it.toInt() }
|
||||||
|
|
||||||
|
// Validate octets are in range 0-255
|
||||||
|
if (octets.any { it > OCTET_MAX_VALUE }) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract octets individually (destructuring with 4 elements triggers detekt warning)
|
||||||
|
val o1 = octets[0]
|
||||||
|
val o2 = octets[1]
|
||||||
|
|
||||||
|
// Check RFC 1918 private IP ranges
|
||||||
|
return when {
|
||||||
|
// 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
|
||||||
|
o1 == PRIVATE_CLASS_A_FIRST_OCTET -> true
|
||||||
|
|
||||||
|
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
||||||
|
o1 == PRIVATE_CLASS_B_FIRST_OCTET &&
|
||||||
|
o2 in PRIVATE_CLASS_B_SECOND_OCTET_MIN..PRIVATE_CLASS_B_SECOND_OCTET_MAX -> true
|
||||||
|
|
||||||
|
// 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
|
||||||
|
o1 == PRIVATE_CLASS_C_FIRST_OCTET &&
|
||||||
|
o2 == PRIVATE_CLASS_C_SECOND_OCTET -> true
|
||||||
|
|
||||||
|
// 127.0.0.0/8 (Localhost)
|
||||||
|
o1 == LOCALHOST_FIRST_OCTET -> true
|
||||||
|
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a recognized local address
|
||||||
|
false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.w("UrlValidator", "Failed to parse URL: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert ob HTTP URL erlaubt ist
|
||||||
|
* @return Pair<Boolean, String?> - (isValid, errorMessage)
|
||||||
|
*/
|
||||||
|
fun validateHttpUrl(url: String): Pair<Boolean, String?> {
|
||||||
|
return try {
|
||||||
|
val parsedUrl = URL(url)
|
||||||
|
|
||||||
|
// HTTPS ist immer erlaubt
|
||||||
|
if (parsedUrl.protocol.equals("https", ignoreCase = true)) {
|
||||||
|
return Pair(true, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP nur für lokale URLs erlaubt
|
||||||
|
if (parsedUrl.protocol.equals("http", ignoreCase = true)) {
|
||||||
|
if (isLocalUrl(url)) {
|
||||||
|
return Pair(true, null)
|
||||||
|
} else {
|
||||||
|
return Pair(
|
||||||
|
false,
|
||||||
|
"HTTP ist nur für lokale Server erlaubt (z.B. 192.168.x.x, 10.x.x.x, nas.local). " +
|
||||||
|
"Für öffentliche Server verwende bitte HTTPS."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anderes Protokoll
|
||||||
|
Pair(false, "Ungültiges Protokoll: ${parsedUrl.protocol}. Bitte verwende HTTP oder HTTPS.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Pair(false, "Ungültige URL: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
android/app/src/main/res/drawable/ic_checklist_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
|
||||||
|
</vector>
|
||||||
10
android/app/src/main/res/drawable/ic_delete_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurfaceVariant">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||||
|
</vector>
|
||||||
10
android/app/src/main/res/drawable/ic_drag_handle_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurfaceVariant">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4v2z"/>
|
||||||
|
</vector>
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#3DDC84"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="85.84757"
|
|
||||||
android:endY="92.4963"
|
|
||||||
android:startX="42.9492"
|
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="#00000000" />
|
|
||||||
</vector>
|
|
||||||
10
android/app/src/main/res/drawable/ic_note_24.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z"/>
|
||||||
|
</vector>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<LinearLayout
|
<LinearLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
@@ -14,12 +15,13 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="?attr/actionBarSize"
|
android:layout_height="?attr/actionBarSize"
|
||||||
android:elevation="0dp"
|
android:elevation="0dp"
|
||||||
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
|
app:navigationIcon="?attr/homeAsUpIndicator"
|
||||||
app:title="@string/edit_note"
|
app:title="@string/edit_note"
|
||||||
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||||
|
|
||||||
<!-- Material 3 Outlined TextInputLayout with 16dp corners -->
|
<!-- Title Input (für beide Typen) -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilTitle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_margin="16dp"
|
android:layout_margin="16dp"
|
||||||
@@ -44,8 +46,9 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<!-- Material 3 Outlined TextInputLayout for Content -->
|
<!-- Content Input (nur für TEXT sichtbar) -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilContent"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
@@ -74,4 +77,39 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- v1.4.0: Checklist Container (nur für CHECKLIST sichtbar) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/checklistContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
tools:visibility="visible">
|
||||||
|
|
||||||
|
<!-- Checklist Items RecyclerView -->
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvChecklistItems"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginHorizontal="8dp"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
|
||||||
|
tools:listitem="@layout/item_checklist_editor" />
|
||||||
|
|
||||||
|
<!-- Add Item Button -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnAddItem"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton.Icon"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:text="@string/add_item"
|
||||||
|
app:icon="@android:drawable/ic_input_add" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -22,16 +22,57 @@
|
|||||||
app:title="@string/app_name"
|
app:title="@string/app_name"
|
||||||
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||||
|
|
||||||
|
<!-- 🔄 v1.3.1: Sync Status Banner -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/syncStatusBanner"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="8dp"
|
||||||
|
android:background="?attr/colorPrimaryContainer"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/syncProgressIndicator"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:indeterminate="true"
|
||||||
|
app:indicatorSize="24dp"
|
||||||
|
app:trackThickness="3dp"
|
||||||
|
app:indicatorColor="?attr/colorOnPrimaryContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/syncStatusText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:text="@string/sync_status_syncing"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:textColor="?attr/colorOnPrimaryContainer" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefreshLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
<!-- RecyclerView mit größerem Padding für Material 3 -->
|
<!-- RecyclerView mit größerem Padding für Material 3 -->
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerViewNotes"
|
android:id="@+id/recyclerViewNotes"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:padding="16dp"
|
android:padding="16dp" />
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
<!-- Material 3 Empty State Card -->
|
<!-- Material 3 Empty State Card -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
|||||||
@@ -30,17 +30,6 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<!-- Auto-Save Status Indicator -->
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/chipAutoSaveStatus"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center_horizontal"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:textSize="12sp"
|
|
||||||
app:chipIconEnabled="false" />
|
|
||||||
|
|
||||||
<!-- Material 3 Card: Server Configuration -->
|
<!-- Material 3 Card: Server Configuration -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -63,15 +52,65 @@
|
|||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
android:layout_marginBottom="16dp" />
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
<!-- Server URL with Icon -->
|
<!-- Protocol Selection -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="@string/server_url"
|
android:text="Verbindungstyp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
android:id="@+id/protocolRadioGroup"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.radiobutton.MaterialRadioButton
|
||||||
|
android:id="@+id/radioHttp"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="🏠 Intern (HTTP)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:checked="false" />
|
||||||
|
|
||||||
|
<com.google.android.material.radiobutton.MaterialRadioButton
|
||||||
|
android:id="@+id/radioHttps"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="🌐 Extern (HTTPS)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:checked="true" />
|
||||||
|
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<!-- Helper Text for Protocol Selection -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/protocolHintText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<!-- Server URL with Icon -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/textInputLayoutServerUrl"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Server-Adresse"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
app:startIconDrawable="@android:drawable/ic_menu_compass"
|
app:startIconDrawable="@android:drawable/ic_menu_compass"
|
||||||
app:endIconMode="clear_text"
|
app:endIconMode="clear_text"
|
||||||
|
app:helperText="z.B. http://192.168.0.188:8080/notes"
|
||||||
|
app:helperTextEnabled="true"
|
||||||
app:boxCornerRadiusTopStart="12dp"
|
app:boxCornerRadiusTopStart="12dp"
|
||||||
app:boxCornerRadiusTopEnd="12dp"
|
app:boxCornerRadiusTopEnd="12dp"
|
||||||
app:boxCornerRadiusBottomStart="12dp"
|
app:boxCornerRadiusBottomStart="12dp"
|
||||||
@@ -185,7 +224,7 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Material 3 Card: Auto-Sync Settings -->
|
<!-- Material 3 Card: Synchronisation Settings (Auto-Sync + Interval) -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -230,6 +269,7 @@
|
|||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
@@ -247,87 +287,22 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
<!-- Divider -->
|
||||||
|
<View
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Material 3 Card: Backup & Restore -->
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="1dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginVertical="16dp"
|
||||||
style="@style/Widget.Material3.CardView.Elevated"
|
android:background="?attr/colorOutlineVariant" />
|
||||||
app:cardCornerRadius="16dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<!-- Sync Interval Section -->
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="20dp">
|
|
||||||
|
|
||||||
<!-- Section Header -->
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/backup_restore_title"
|
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
|
||||||
android:layout_marginBottom="12dp" />
|
|
||||||
|
|
||||||
<!-- Warning Info Card -->
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="0dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="16dp"
|
|
||||||
android:text="@string/backup_restore_warning"
|
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
|
||||||
android:textColor="?attr/colorOnErrorContainer"
|
|
||||||
android:lineSpacingMultiplier="1.3" />
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Restore Button -->
|
|
||||||
<Button
|
|
||||||
android:id="@+id/buttonRestoreFromServer"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/restore_from_server"
|
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Material 3 Card: Sync Interval Configuration -->
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
style="@style/Widget.Material3.CardView.Elevated"
|
|
||||||
app:cardCornerRadius="16dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="20dp">
|
|
||||||
|
|
||||||
<!-- Section Header -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Sync-Intervall"
|
android:text="Sync-Intervall"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
<!-- Info Card -->
|
<!-- Interval Info Card -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -412,6 +387,201 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Material 3 Card: Markdown Desktop-Integration (v1.2.0) -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
style="@style/Widget.Material3.CardView.Elevated"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<!-- Section Header -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Markdown Desktop-Integration"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Info Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:text="ℹ️ Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnPrimaryContainer"
|
||||||
|
android:lineSpacingMultiplier="1.3" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Markdown Auto-Sync Toggle (fusioniert Export + Auto-Import) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="🔄 Markdown Auto-Sync"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
|
android:id="@+id/switchMarkdownAutoSync"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:checked="false" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Auto-Sync Info Text -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<!-- Manual Sync Info (nur sichtbar wenn Auto-Sync OFF) -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textViewManualSyncInfo"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:text="Oder synchronisiere Markdown-Dateien manuell:"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Manual Sync Button (nur sichtbar wenn Auto-Sync OFF) -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonManualMarkdownSync"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Markdown synchronisieren"
|
||||||
|
android:visibility="gone"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Material 3 Card: Backup & Restore -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
style="@style/Widget.Material3.CardView.Elevated"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<!-- Section Header -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/backup_restore_title"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Info Card (anstatt Warning) -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:text="ℹ️ Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt."
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnPrimaryContainer"
|
||||||
|
android:lineSpacingMultiplier="1.3" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Lokales Backup Sektion -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Lokales Backup"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Backup erstellen Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonCreateBackup"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📥 Backup erstellen"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
<!-- Aus Datei wiederherstellen Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonRestoreFromFile"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📤 Aus Datei wiederherstellen"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="?attr/colorOutline"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<!-- Server-Backup Sektion -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Server-Backup"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Vom Server wiederherstellen Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonRestoreFromServer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="🔄 Vom Server wiederherstellen"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Material 3 Card: About Section -->
|
<!-- Material 3 Card: About Section -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -579,6 +749,107 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Material 3 Card: Debug Section -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
style="@style/Widget.Material3.CardView.Elevated"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<!-- Section Header -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Debug & Diagnose"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<!-- File Logging Toggle -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorSurfaceVariant"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📝 Datei-Logging"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
|
android:textColor="?attr/colorPrimary"
|
||||||
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Sync-Logs in Datei speichern"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchFileLogging"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- F-Droid Privacy Notice -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/file_logging_privacy_notice"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<!-- Export Logs Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonExportLogs"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📤 Logs exportieren & teilen"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<!-- Clear Logs Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonClearLogs"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="🗑️ Logs löschen"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
android:layout_gravity="center_horizontal"
|
android:layout_gravity="center_horizontal"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:src="@android:drawable/ic_menu_delete"
|
android:src="@android:drawable/ic_menu_delete"
|
||||||
android:tint="?attr/colorError"
|
app:tint="?attr/colorError"
|
||||||
android:contentDescription="@string/delete" />
|
android:contentDescription="@string/delete" />
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- Title -->
|
||||||
|
|||||||
15
android/app/src/main/res/layout/dialog_server_deletion.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<CheckBox
|
||||||
|
android:id="@+id/checkboxAlwaysDeleteFromServer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Diese Entscheidung merken"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
57
android/app/src/main/res/layout/item_checklist_editor.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- v1.4.0: Checklist Item Layout für Editor -->
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingVertical="4dp"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:minHeight="48dp">
|
||||||
|
|
||||||
|
<!-- Drag Handle -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivDragHandle"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:src="@drawable/ic_drag_handle_24"
|
||||||
|
android:contentDescription="@string/reorder_item"
|
||||||
|
android:importantForAccessibility="yes" />
|
||||||
|
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<com.google.android.material.checkbox.MaterialCheckBox
|
||||||
|
android:id="@+id/cbItem"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:minHeight="0dp" />
|
||||||
|
|
||||||
|
<!-- Text Input (ohne Box, nur transparent) -->
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/etItemText"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:background="@null"
|
||||||
|
android:hint="@string/item_placeholder"
|
||||||
|
android:inputType="text"
|
||||||
|
android:imeOptions="actionNext"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||||
|
tools:text="Milch kaufen" />
|
||||||
|
|
||||||
|
<!-- Delete Button -->
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnDeleteItem"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_delete_24"
|
||||||
|
android:contentDescription="@string/delete_item"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
|
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
|
||||||
|
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="8dp"
|
android:layout_marginHorizontal="8dp"
|
||||||
@@ -17,17 +19,37 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="20dp">
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<!-- v1.4.0: Header Row mit Icon und Titel -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<!-- v1.4.0: Note Type Icon -->
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivNoteTypeIcon"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:src="@drawable/ic_note_24"
|
||||||
|
app:tint="?attr/colorPrimary"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<!-- Material 3 Typography: TitleMedium -->
|
<!-- Material 3 Typography: TitleMedium -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/textViewTitle"
|
android:id="@+id/textViewTitle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
android:text="@string/note_title_placeholder"
|
android:text="@string/note_title_placeholder"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
android:maxLines="2"
|
android:maxLines="2"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
|
|
||||||
<!-- Material 3 Typography: BodyMedium -->
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Content Preview (für TEXT Notizen) -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/textViewContent"
|
android:id="@+id/textViewContent"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -39,6 +61,18 @@
|
|||||||
android:maxLines="3"
|
android:maxLines="3"
|
||||||
android:ellipsize="end" />
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<!-- v1.4.0: Checklist Preview (für CHECKLIST Notizen) -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textViewChecklistPreview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
tools:visibility="visible"
|
||||||
|
tools:text="2/5 erledigt" />
|
||||||
|
|
||||||
<!-- Metadata Row mit Timestamp und Sync-Status -->
|
<!-- Metadata Row mit Timestamp und Sync-Status -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -64,7 +98,7 @@
|
|||||||
android:layout_height="18dp"
|
android:layout_height="18dp"
|
||||||
android:layout_gravity="center_vertical"
|
android:layout_gravity="center_vertical"
|
||||||
android:src="@android:drawable/ic_popup_sync"
|
android:src="@android:drawable/ic_popup_sync"
|
||||||
android:tint="?attr/colorPrimary"
|
app:tint="?attr/colorPrimary"
|
||||||
android:contentDescription="@string/sync_status" />
|
android:contentDescription="@string/sync_status" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
11
android/app/src/main/res/menu/menu_fab_note_types.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_create_text_note"
|
||||||
|
android:icon="@drawable/ic_note_24"
|
||||||
|
android:title="@string/create_text_note" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_create_checklist"
|
||||||
|
android:icon="@drawable/ic_checklist_24"
|
||||||
|
android:title="@string/create_checklist" />
|
||||||
|
</menu>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 982 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -4,6 +4,9 @@
|
|||||||
<color name="black">#FF000000</color>
|
<color name="black">#FF000000</color>
|
||||||
<color name="white">#FFFFFFFF</color>
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
|
||||||
|
<!-- Adaptive Icon Background -->
|
||||||
|
<color name="ic_launcher_background">#f9e9c8</color>
|
||||||
|
|
||||||
<!-- Material 3 Light Theme Colors -->
|
<!-- Material 3 Light Theme Colors -->
|
||||||
<color name="md_theme_light_primary">#6750A4</color>
|
<color name="md_theme_light_primary">#6750A4</color>
|
||||||
<color name="md_theme_light_onPrimary">#FFFFFF</color>
|
<color name="md_theme_light_onPrimary">#FFFFFF</color>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<string name="note_title_placeholder">Note Title</string>
|
<string name="note_title_placeholder">Note Title</string>
|
||||||
<string name="note_content_placeholder">Note content preview…</string>
|
<string name="note_content_placeholder">Note content preview…</string>
|
||||||
<string name="note_timestamp_placeholder">Vor 2 Std</string>
|
<string name="note_timestamp_placeholder">Vor 2 Std</string>
|
||||||
|
<string name="untitled">Ohne Titel</string>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
<string name="delete_note_title">Notiz löschen?</string>
|
<string name="delete_note_title">Notiz löschen?</string>
|
||||||
@@ -42,10 +43,9 @@
|
|||||||
|
|
||||||
<!-- Auto-Sync Settings -->
|
<!-- Auto-Sync Settings -->
|
||||||
<string name="sync_settings">Sync-Einstellungen</string>
|
<string name="sync_settings">Sync-Einstellungen</string>
|
||||||
<string name="home_ssid">Heim-WLAN SSID</string>
|
|
||||||
<string name="auto_sync">Auto-Sync aktiviert</string>
|
<string name="auto_sync">Auto-Sync aktiviert</string>
|
||||||
<string name="sync_status">Sync-Status</string>
|
<string name="sync_status">Sync-Status</string>
|
||||||
<string name="auto_sync_info">ℹ️ Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\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>
|
||||||
|
|
||||||
<!-- Backup & Restore -->
|
<!-- Backup & Restore -->
|
||||||
<string name="backup_restore_title">Backup & Wiederherstellung</string>
|
<string name="backup_restore_title">Backup & Wiederherstellung</string>
|
||||||
@@ -57,4 +57,36 @@
|
|||||||
<string name="restore_progress">Stelle Notizen wieder her…</string>
|
<string name="restore_progress">Stelle Notizen wieder her…</string>
|
||||||
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
|
<string name="restore_success">✓ %d Notizen wiederhergestellt</string>
|
||||||
<string name="restore_error">Fehler: %s</string>
|
<string name="restore_error">Fehler: %s</string>
|
||||||
|
|
||||||
|
<!-- Sync Status Banner (v1.3.1) -->
|
||||||
|
<string name="sync_status_syncing">Synchronisiere…</string>
|
||||||
|
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
|
||||||
|
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
|
||||||
|
<string name="sync_already_running">Synchronisierung läuft bereits</string>
|
||||||
|
|
||||||
|
<!-- Debug/Logging Section (v1.3.2) -->
|
||||||
|
<string name="file_logging_privacy_notice">ℹ️ Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
|
||||||
|
|
||||||
|
<!-- ========================== -->
|
||||||
|
<!-- CHECKLIST FEATURE (v1.4.0) -->
|
||||||
|
<!-- ========================== -->
|
||||||
|
|
||||||
|
<!-- FAB Menu -->
|
||||||
|
<string name="create_text_note">Notiz</string>
|
||||||
|
<string name="create_checklist">Liste</string>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<string name="new_checklist">Neue Liste</string>
|
||||||
|
<string name="edit_checklist">Liste bearbeiten</string>
|
||||||
|
<string name="add_item">Element hinzufügen</string>
|
||||||
|
<string name="item_placeholder">Neues Element…</string>
|
||||||
|
<string name="reorder_item">Element verschieben</string>
|
||||||
|
<string name="delete_item">Element löschen</string>
|
||||||
|
<string name="note_is_empty">Notiz ist leer</string>
|
||||||
|
<string name="note_saved">Notiz gespeichert</string>
|
||||||
|
<string name="note_deleted">Notiz gelöscht</string>
|
||||||
|
|
||||||
|
<!-- List Preview -->
|
||||||
|
<string name="checklist_progress">%1$d/%2$d erledigt</string>
|
||||||
|
<string name="empty_checklist">Keine Einträge</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
16
android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- Allow HTTP for all connections during development/testing -->
|
||||||
|
<!-- Production validation happens in UrlValidator.kt to restrict HTTP to:
|
||||||
|
- Private IP ranges: 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 127.x.x.x
|
||||||
|
- .local domains (mDNS/Bonjour)
|
||||||
|
|
||||||
|
This permissive config is necessary because Android's Network Security Config
|
||||||
|
doesn't support IP-based rules, only domain patterns.
|
||||||
|
We handle security through application-level validation instead. -->
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -2,4 +2,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.ktlint) apply false
|
||||||
|
alias(libs.plugins.detekt) apply false
|
||||||
}
|
}
|
||||||
136
android/config/detekt/detekt.yml
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# ⚡ v1.3.1: detekt Configuration
|
||||||
|
# Pragmatic rules for simple-notes-sync
|
||||||
|
|
||||||
|
build:
|
||||||
|
maxIssues: 100 # Allow existing issues for v1.3.1 release, fix in v1.4.0
|
||||||
|
excludeCorrectable: false
|
||||||
|
|
||||||
|
config:
|
||||||
|
validation: true
|
||||||
|
warningsAsErrors: false
|
||||||
|
|
||||||
|
comments:
|
||||||
|
CommentOverPrivateProperty:
|
||||||
|
active: false
|
||||||
|
UndocumentedPublicClass:
|
||||||
|
active: false
|
||||||
|
UndocumentedPublicFunction:
|
||||||
|
active: false
|
||||||
|
|
||||||
|
complexity:
|
||||||
|
ComplexCondition:
|
||||||
|
active: true
|
||||||
|
threshold: 5
|
||||||
|
CyclomaticComplexMethod:
|
||||||
|
active: true
|
||||||
|
threshold: 15
|
||||||
|
ignoreSingleWhenExpression: true
|
||||||
|
LargeClass:
|
||||||
|
active: true
|
||||||
|
threshold: 600 # Increased for WebDavSyncService
|
||||||
|
LongMethod:
|
||||||
|
active: true
|
||||||
|
threshold: 80 # Increased for sync methods
|
||||||
|
LongParameterList:
|
||||||
|
active: true
|
||||||
|
functionThreshold: 6
|
||||||
|
constructorThreshold: 7
|
||||||
|
NestedBlockDepth:
|
||||||
|
active: true
|
||||||
|
threshold: 5
|
||||||
|
TooManyFunctions:
|
||||||
|
active: true
|
||||||
|
thresholdInFiles: 25
|
||||||
|
thresholdInClasses: 25
|
||||||
|
thresholdInInterfaces: 20
|
||||||
|
thresholdInObjects: 20
|
||||||
|
thresholdInEnums: 10
|
||||||
|
|
||||||
|
empty-blocks:
|
||||||
|
EmptyCatchBlock:
|
||||||
|
active: true
|
||||||
|
allowedExceptionNameRegex: "_|(ignore|expected).*"
|
||||||
|
EmptyFunctionBlock:
|
||||||
|
active: true
|
||||||
|
ignoreOverridden: true
|
||||||
|
|
||||||
|
exceptions:
|
||||||
|
SwallowedException:
|
||||||
|
active: true
|
||||||
|
ignoredExceptionTypes:
|
||||||
|
- "InterruptedException"
|
||||||
|
- "MalformedURLException"
|
||||||
|
- "NumberFormatException"
|
||||||
|
- "ParseException"
|
||||||
|
TooGenericExceptionCaught:
|
||||||
|
active: true
|
||||||
|
exceptionNames:
|
||||||
|
- "Error"
|
||||||
|
- "Throwable"
|
||||||
|
allowedExceptionNameRegex: "_|(ignore|expected).*"
|
||||||
|
|
||||||
|
naming:
|
||||||
|
FunctionNaming:
|
||||||
|
active: true
|
||||||
|
functionPattern: "[a-zA-Z][a-zA-Z0-9]*"
|
||||||
|
VariableNaming:
|
||||||
|
active: true
|
||||||
|
variablePattern: "[a-z][A-Za-z0-9]*"
|
||||||
|
PackageNaming:
|
||||||
|
active: true
|
||||||
|
packagePattern: "[a-z]+(\\.[a-z][A-Za-z0-9]*)*"
|
||||||
|
|
||||||
|
performance:
|
||||||
|
SpreadOperator:
|
||||||
|
active: false # Spread operator is fine in most cases
|
||||||
|
|
||||||
|
potential-bugs:
|
||||||
|
CastToNullableType:
|
||||||
|
active: true
|
||||||
|
EqualsWithHashCodeExist:
|
||||||
|
active: true
|
||||||
|
UnconditionalJumpStatementInLoop:
|
||||||
|
active: true
|
||||||
|
|
||||||
|
style:
|
||||||
|
ForbiddenComment:
|
||||||
|
active: true
|
||||||
|
comments:
|
||||||
|
- "FIXME:"
|
||||||
|
- "STOPSHIP:"
|
||||||
|
allowedPatterns: ""
|
||||||
|
MagicNumber:
|
||||||
|
active: true
|
||||||
|
ignoreNumbers:
|
||||||
|
- "-1"
|
||||||
|
- "0"
|
||||||
|
- "1"
|
||||||
|
- "2"
|
||||||
|
- "100"
|
||||||
|
- "1000"
|
||||||
|
ignoreHashCodeFunction: true
|
||||||
|
ignorePropertyDeclaration: true
|
||||||
|
ignoreLocalVariableDeclaration: true
|
||||||
|
ignoreAnnotation: true
|
||||||
|
ignoreEnums: true
|
||||||
|
ignoreRanges: true
|
||||||
|
ignoreExtensionFunctions: true
|
||||||
|
MaxLineLength:
|
||||||
|
active: true
|
||||||
|
maxLineLength: 120
|
||||||
|
excludePackageStatements: true
|
||||||
|
excludeImportStatements: true
|
||||||
|
ReturnCount:
|
||||||
|
active: true
|
||||||
|
max: 4
|
||||||
|
excludedFunctions: []
|
||||||
|
excludeLabeled: true
|
||||||
|
excludeReturnFromLambda: true
|
||||||
|
excludeGuardClauses: true
|
||||||
|
UnusedImports:
|
||||||
|
active: true
|
||||||
|
UnusedPrivateMember:
|
||||||
|
active: true
|
||||||
|
allowedNames: "_.*"
|
||||||
|
WildcardImport:
|
||||||
|
active: false # Allow wildcard imports
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
🐛 Bugfixes v1.1.1
|
|
||||||
|
|
||||||
✅ Keine Fehler-Notifications mehr in fremden WiFi-Netzwerken!
|
|
||||||
- Server-Erreichbarkeits-Check vor jedem Sync (2s Timeout)
|
|
||||||
- Stiller Abbruch wenn Server nicht erreichbar
|
|
||||||
- 80% schnellerer Abbruch: 2s statt 10+ Sekunden
|
|
||||||
|
|
||||||
✅ Keine Fehler beim WiFi-Connect / Nach-Hause-Kommen!
|
|
||||||
- Pre-Check wartet bis Netzwerk bereit ist (DHCP, Routing, Gateway)
|
|
||||||
- Kein Fehler mehr bei Netzwerk-Initialisierung
|
|
||||||
|
|
||||||
🔧 Notification-Verbesserungen:
|
|
||||||
- Alte Notifications werden beim App-Start gelöscht
|
|
||||||
- Fehler-Notifications verschwinden automatisch nach 30 Sekunden
|
|
||||||
- Bessere Batterie-Effizienz (keine langen Timeouts mehr)
|
|
||||||
|
|
||||||
📱 UI-Fixes:
|
|
||||||
- Sync-Icon wird nicht mehr angezeigt wenn Sync nicht konfiguriert ist
|
|
||||||
- Swipe-to-Delete: Kein Flackern mehr beim schnellen Löschen mehrerer Notizen
|
|
||||||
- Nach dem Speichern einer Notiz landet man automatisch ganz oben in der Liste
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Einfache Notizen-App mit WebDAV-Synchronisation
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
🐛 Bugfixes v1.1.1
|
|
||||||
|
|
||||||
✅ No more error notifications in foreign WiFi networks!
|
|
||||||
- Server reachability check before each sync (2s timeout)
|
|
||||||
- Silent abort when server is unreachable
|
|
||||||
- 80% faster abort: 2s instead of 10+ seconds
|
|
||||||
|
|
||||||
✅ No more errors when connecting to WiFi / arriving home!
|
|
||||||
- Pre-check waits until network is ready (DHCP, routing, gateway)
|
|
||||||
- No more errors during network initialization
|
|
||||||
|
|
||||||
🔧 Notification improvements:
|
|
||||||
- Old notifications are cleared on app start
|
|
||||||
- Error notifications disappear automatically after 30 seconds
|
|
||||||
- Better battery efficiency (no more long timeouts)
|
|
||||||
|
|
||||||
📱 UI fixes:
|
|
||||||
- Sync icon no longer shown when sync is not configured
|
|
||||||
- Swipe-to-delete: No more flickering when quickly deleting multiple notes
|
|
||||||
- After saving a note, you automatically land at the top of the list
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Simple note-taking app with WebDAV synchronization
|
|
||||||
@@ -9,6 +9,8 @@ appcompat = "1.6.1"
|
|||||||
material = "1.10.0"
|
material = "1.10.0"
|
||||||
activity = "1.8.0"
|
activity = "1.8.0"
|
||||||
constraintlayout = "2.1.4"
|
constraintlayout = "2.1.4"
|
||||||
|
ktlint = "12.1.0"
|
||||||
|
detekt = "1.23.4"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -23,4 +25,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
|
|||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
|
||||||
|
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
|
||||||
|
|
||||||
|
|||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
@@ -1,6 +1,6 @@
|
|||||||
#Sat Dec 20 00:06:31 CET 2025
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
|
|||||||
6
android/gradlew
vendored
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright © 2015 the original authors.
|
# Copyright © 2015-2021 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -114,7 +114,7 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-classpath "$CLASSPATH" \
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
4
android/gradlew.bat
vendored
@@ -70,11 +70,11 @@ goto fail
|
|||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
|||||||
324
docs/BACKUP.en.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# Backup & Restore 💾
|
||||||
|
|
||||||
|
**🌍 Languages:** [Deutsch](BACKUP.md) · **English**
|
||||||
|
|
||||||
|
> Secure your notes locally - independent from the server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
The backup system works **completely offline** and independent from the WebDAV server. Perfect for:
|
||||||
|
- 📥 Regular backups
|
||||||
|
- 📤 Migration to new server
|
||||||
|
- 🔄 Recovery after data loss
|
||||||
|
- 💾 Archiving old notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Create Backup
|
||||||
|
|
||||||
|
### Step-by-Step
|
||||||
|
|
||||||
|
1. **Open settings** (⚙️ icon top right)
|
||||||
|
2. **Find "Backup & Restore"** section
|
||||||
|
3. **Tap "📥 Create backup"**
|
||||||
|
4. **Choose location:**
|
||||||
|
- 📁 Downloads
|
||||||
|
- 💳 SD card
|
||||||
|
- ☁️ Cloud folder (Nextcloud, Google Drive, etc.)
|
||||||
|
- 📧 Email as attachment
|
||||||
|
5. **Done!** Backup file is saved
|
||||||
|
|
||||||
|
### File Format
|
||||||
|
|
||||||
|
**Filename:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
|
||||||
|
|
||||||
|
**Example:** `simplenotes_backup_2026-01-05_143022.json`
|
||||||
|
|
||||||
|
**Content:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.2.1",
|
||||||
|
"exported_at": "2026-01-05T14:30:22Z",
|
||||||
|
"notes_count": 42,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "abc-123-def",
|
||||||
|
"title": "Shopping List",
|
||||||
|
"content": "Milk\nBread\nCheese",
|
||||||
|
"createdAt": 1704467422000,
|
||||||
|
"updatedAt": 1704467422000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format details:**
|
||||||
|
- ✅ Human-readable (formatted JSON)
|
||||||
|
- ✅ All data included (title, content, IDs, timestamps)
|
||||||
|
- ✅ Version info for compatibility
|
||||||
|
- ✅ Note count for validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 Restore Backup
|
||||||
|
|
||||||
|
### 3 Restore Modes
|
||||||
|
|
||||||
|
#### 1. Merge ⭐ _Recommended_
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- ✅ New notes from backup are added
|
||||||
|
- ✅ Existing notes remain unchanged
|
||||||
|
- ✅ No data loss
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Import backup from another device
|
||||||
|
- Recover old notes
|
||||||
|
- Restore accidentally deleted notes
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
App: [Note A, Note B, Note C]
|
||||||
|
Backup: [Note A, Note D, Note E]
|
||||||
|
Result: [Note A, Note B, Note C, Note D, Note E]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Replace
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- ❌ ALL existing notes are deleted
|
||||||
|
- ✅ Backup notes are imported
|
||||||
|
- ⚠️ Irreversible (except through auto-backup)
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Server migration (complete restart)
|
||||||
|
- Return to old backup state
|
||||||
|
- App reinstallation
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
App: [Note A, Note B, Note C]
|
||||||
|
Backup: [Note X, Note Y]
|
||||||
|
Result: [Note X, Note Y]
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Warning:** Automatic safety backup is created!
|
||||||
|
|
||||||
|
#### 3. Overwrite Duplicates
|
||||||
|
|
||||||
|
**What happens:**
|
||||||
|
- ✅ New notes from backup are added
|
||||||
|
- 🔄 On ID conflicts, backup wins
|
||||||
|
- ✅ Other notes remain unchanged
|
||||||
|
|
||||||
|
**When to use:**
|
||||||
|
- Backup is newer than app data
|
||||||
|
- Import desktop changes
|
||||||
|
- Conflict resolution
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
App: [Note A (v1), Note B, Note C]
|
||||||
|
Backup: [Note A (v2), Note D]
|
||||||
|
Result: [Note A (v2), Note B, Note C, Note D]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Process
|
||||||
|
|
||||||
|
1. **Settings** → **"📤 Restore from file"**
|
||||||
|
2. **Select backup file** (`.json`)
|
||||||
|
3. **Choose mode:**
|
||||||
|
- 🔵 Merge _(Default)_
|
||||||
|
- 🟡 Overwrite duplicates
|
||||||
|
- 🔴 Replace _(Caution!)_
|
||||||
|
4. **Confirm** - Automatic safety backup is created
|
||||||
|
5. **Wait** - Import runs
|
||||||
|
6. **Done!** - Success message with number of imported notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Automatic Safety Backup
|
||||||
|
|
||||||
|
**Before every restore:**
|
||||||
|
- ✅ Automatic backup is created
|
||||||
|
- 📁 Saved in: `Android/data/dev.dettmer.simplenotes/files/`
|
||||||
|
- 🏷️ Filename: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
|
||||||
|
- ⏱️ Timestamp: Right before restore
|
||||||
|
|
||||||
|
**Why?**
|
||||||
|
- Protection against accidental "Replace"
|
||||||
|
- Ability to undo
|
||||||
|
- Double security
|
||||||
|
|
||||||
|
**Access via file manager:**
|
||||||
|
```
|
||||||
|
/Android/data/dev.dettmer.simplenotes/files/auto_backup_before_restore_*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Best Practices
|
||||||
|
|
||||||
|
### Backup Strategy
|
||||||
|
|
||||||
|
#### Regular Backups
|
||||||
|
```
|
||||||
|
Daily: ❌ Too often (server sync is enough)
|
||||||
|
Weekly: ✅ Recommended for important notes
|
||||||
|
Monthly: ✅ Archiving
|
||||||
|
Before updates: ✅ Safety
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-2-1 Rule
|
||||||
|
1. **3 copies** - Original + 2 backups
|
||||||
|
2. **2 media** - e.g., SD card + cloud
|
||||||
|
3. **1 offsite** - e.g., cloud storage
|
||||||
|
|
||||||
|
### Backup Locations
|
||||||
|
|
||||||
|
**Local (fast):**
|
||||||
|
- 📱 Internal storage / Downloads
|
||||||
|
- 💳 SD card
|
||||||
|
- 🖥️ PC (via USB)
|
||||||
|
|
||||||
|
**Cloud (secure):**
|
||||||
|
- ☁️ Nextcloud (self-hosted)
|
||||||
|
- 📧 Email to yourself
|
||||||
|
- 🗄️ Syncthing (sync between devices)
|
||||||
|
|
||||||
|
**⚠️ Avoid:**
|
||||||
|
- ❌ Google Drive / Dropbox (privacy)
|
||||||
|
- ❌ Only one copy
|
||||||
|
- ❌ Only on server (if server fails)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Advanced Usage
|
||||||
|
|
||||||
|
### Edit Backup File
|
||||||
|
|
||||||
|
The `.json` file can be edited with any text editor:
|
||||||
|
|
||||||
|
1. **Open with:** VS Code, Notepad++, nano
|
||||||
|
2. **Add/remove notes**
|
||||||
|
3. **Change title/content**
|
||||||
|
4. **Adjust IDs** (for migration)
|
||||||
|
5. **Save** and import to app
|
||||||
|
|
||||||
|
**⚠️ Important:**
|
||||||
|
- Keep valid JSON format
|
||||||
|
- IDs must be unique (UUIDs)
|
||||||
|
- Timestamps in milliseconds (Unix Epoch)
|
||||||
|
|
||||||
|
### Bulk Import
|
||||||
|
|
||||||
|
Merge multiple backups:
|
||||||
|
|
||||||
|
1. Import backup 1 (Mode: Merge)
|
||||||
|
2. Import backup 2 (Mode: Merge)
|
||||||
|
3. Import backup 3 (Mode: Merge)
|
||||||
|
4. Result: All notes combined
|
||||||
|
|
||||||
|
### Server Migration
|
||||||
|
|
||||||
|
Step-by-step:
|
||||||
|
|
||||||
|
1. **Create backup** on old server
|
||||||
|
2. **Set up new server** (see [QUICKSTART.en.md](QUICKSTART.en.md))
|
||||||
|
3. **Change server URL** in app settings
|
||||||
|
4. **Restore backup** (Mode: Replace)
|
||||||
|
5. **Test sync** - All notes on new server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Troubleshooting
|
||||||
|
|
||||||
|
### "Invalid backup file"
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Corrupt JSON file
|
||||||
|
- Wrong file extension (must be `.json`)
|
||||||
|
- Incompatible app version
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check JSON file with validator (e.g., jsonlint.com)
|
||||||
|
2. Verify file extension
|
||||||
|
3. Create backup with current app version
|
||||||
|
|
||||||
|
### "No permission to save"
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Storage permission missing
|
||||||
|
- Write-protected folder
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Android: Settings → Apps → Simple Notes → Permissions
|
||||||
|
2. Activate "Storage"
|
||||||
|
3. Choose different location
|
||||||
|
|
||||||
|
### "Import failed"
|
||||||
|
|
||||||
|
**Causes:**
|
||||||
|
- Not enough storage space
|
||||||
|
- Corrupt backup file
|
||||||
|
- App crash during import
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Free up storage space
|
||||||
|
2. Create new backup file
|
||||||
|
3. Restart app and try again
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security & Privacy
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
- ✅ **Locally stored** - No cloud upload without your action
|
||||||
|
- ✅ **No encryption** - Plain text format for readability
|
||||||
|
- ⚠️ **Sensitive data?** - Encrypt backup file yourself (e.g., 7-Zip with password)
|
||||||
|
|
||||||
|
### Recommendations
|
||||||
|
- 🔐 Store backup files in encrypted container
|
||||||
|
- 🗑️ Regularly delete old backups
|
||||||
|
- 📧 Don't send via unencrypted email
|
||||||
|
- ☁️ Use self-hosted cloud (Nextcloud)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Technical Details
|
||||||
|
|
||||||
|
### Format Specification
|
||||||
|
|
||||||
|
**JSON structure:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "string", // App version at export
|
||||||
|
"exported_at": "ISO8601", // Export timestamp
|
||||||
|
"notes_count": number, // Number of notes
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "UUID", // Unique ID
|
||||||
|
"title": "string", // Note title
|
||||||
|
"content": "string", // Note content
|
||||||
|
"createdAt": number, // Unix timestamp (ms)
|
||||||
|
"updatedAt": number // Unix timestamp (ms)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
- ✅ v1.2.0+ - Fully compatible
|
||||||
|
- ⚠️ v1.1.x - Basic functions (without auto-backup)
|
||||||
|
- ❌ v1.0.x - Not supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**📚 See also:**
|
||||||
|
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App installation and setup
|
||||||
|
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
|
||||||
|
- [DESKTOP.en.md](DESKTOP.en.md) - Desktop integration with Markdown
|
||||||
|
|
||||||
|
**Last update:** v1.2.1 (2026-01-05)
|
||||||
324
docs/BACKUP.md
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
# Backup & Wiederherstellung 💾
|
||||||
|
|
||||||
|
**🌍 Languages:** **Deutsch** · [English](BACKUP.en.md)
|
||||||
|
|
||||||
|
> Sichere deine Notizen lokal - unabhängig vom Server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Übersicht
|
||||||
|
|
||||||
|
Das Backup-System funktioniert **komplett offline** und unabhängig vom WebDAV-Server. Perfekt für:
|
||||||
|
- 📥 Regelmäßige Sicherungen
|
||||||
|
- 📤 Migration zu neuem Server
|
||||||
|
- 🔄 Wiederherstellung nach Datenverlust
|
||||||
|
- 💾 Archivierung alter Notizen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📥 Backup erstellen
|
||||||
|
|
||||||
|
### Schritt-für-Schritt
|
||||||
|
|
||||||
|
1. **Einstellungen öffnen** (⚙️ Icon oben rechts)
|
||||||
|
2. **"Backup & Wiederherstellung"** Section finden
|
||||||
|
3. **"📥 Backup erstellen"** antippen
|
||||||
|
4. **Speicherort wählen:**
|
||||||
|
- 📁 Downloads
|
||||||
|
- 💳 SD-Karte
|
||||||
|
- ☁️ Cloud-Ordner (Nextcloud, Google Drive, etc.)
|
||||||
|
- 📧 E-Mail als Anhang
|
||||||
|
5. **Fertig!** Backup-Datei ist gespeichert
|
||||||
|
|
||||||
|
### Dateiformat
|
||||||
|
|
||||||
|
**Dateiname:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
|
||||||
|
|
||||||
|
**Beispiel:** `simplenotes_backup_2026-01-05_143022.json`
|
||||||
|
|
||||||
|
**Inhalt:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1.2.1",
|
||||||
|
"exported_at": "2026-01-05T14:30:22Z",
|
||||||
|
"notes_count": 42,
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "abc-123-def",
|
||||||
|
"title": "Einkaufsliste",
|
||||||
|
"content": "Milch\nBrot\nKäse",
|
||||||
|
"createdAt": 1704467422000,
|
||||||
|
"updatedAt": 1704467422000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format-Details:**
|
||||||
|
- ✅ Menschenlesbar (formatiertes JSON)
|
||||||
|
- ✅ Alle Daten inklusive (Titel, Inhalt, IDs, Timestamps)
|
||||||
|
- ✅ Versions-Info für Kompatibilität
|
||||||
|
- ✅ Anzahl der Notizen für Validierung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📤 Backup wiederherstellen
|
||||||
|
|
||||||
|
### 3 Wiederherstellungs-Modi
|
||||||
|
|
||||||
|
#### 1. Zusammenführen (Merge) ⭐ _Empfohlen_
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- ✅ Neue Notizen aus Backup werden hinzugefügt
|
||||||
|
- ✅ Bestehende Notizen bleiben unverändert
|
||||||
|
- ✅ Keine Datenverluste
|
||||||
|
|
||||||
|
**Wann nutzen:**
|
||||||
|
- Backup von anderem Gerät einspielen
|
||||||
|
- Alte Notizen zurückholen
|
||||||
|
- Versehentlich gelöschte Notizen wiederherstellen
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```
|
||||||
|
App: [Notiz A, Notiz B, Notiz C]
|
||||||
|
Backup: [Notiz A, Notiz D, Notiz E]
|
||||||
|
Ergebnis: [Notiz A, Notiz B, Notiz C, Notiz D, Notiz E]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Ersetzen (Replace)
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- ❌ ALLE bestehenden Notizen werden gelöscht
|
||||||
|
- ✅ Backup-Notizen werden importiert
|
||||||
|
- ⚠️ Unwiderruflich (außer durch Auto-Backup)
|
||||||
|
|
||||||
|
**Wann nutzen:**
|
||||||
|
- Server-Wechsel (kompletter Neustart)
|
||||||
|
- Zurück zu altem Backup-Stand
|
||||||
|
- App-Neuinstallation
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```
|
||||||
|
App: [Notiz A, Notiz B, Notiz C]
|
||||||
|
Backup: [Notiz X, Notiz Y]
|
||||||
|
Ergebnis: [Notiz X, Notiz Y]
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Warnung:** Automatisches Sicherheits-Backup wird erstellt!
|
||||||
|
|
||||||
|
#### 3. Duplikate überschreiben (Overwrite)
|
||||||
|
|
||||||
|
**Was passiert:**
|
||||||
|
- ✅ Neue Notizen aus Backup werden hinzugefügt
|
||||||
|
- 🔄 Bei ID-Konflikten gewinnt das Backup
|
||||||
|
- ✅ Andere Notizen bleiben unverändert
|
||||||
|
|
||||||
|
**Wann nutzen:**
|
||||||
|
- Backup ist neuer als App-Daten
|
||||||
|
- Desktop-Änderungen einspielen
|
||||||
|
- Konflikt-Auflösung
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```
|
||||||
|
App: [Notiz A (v1), Notiz B, Notiz C]
|
||||||
|
Backup: [Notiz A (v2), Notiz D]
|
||||||
|
Ergebnis: [Notiz A (v2), Notiz B, Notiz C, Notiz D]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wiederherstellungs-Prozess
|
||||||
|
|
||||||
|
1. **Einstellungen** → **"📤 Aus Datei wiederherstellen"**
|
||||||
|
2. **Backup-Datei auswählen** (`.json`)
|
||||||
|
3. **Modus wählen:**
|
||||||
|
- 🔵 Zusammenführen _(Standard)_
|
||||||
|
- 🟡 Duplikate überschreiben
|
||||||
|
- 🔴 Ersetzen _(Vorsicht!)_
|
||||||
|
4. **Bestätigen** - Automatisches Sicherheits-Backup wird erstellt
|
||||||
|
5. **Warten** - Import läuft
|
||||||
|
6. **Fertig!** - Erfolgsmeldung mit Anzahl importierter Notizen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Automatisches Sicherheits-Backup
|
||||||
|
|
||||||
|
**Vor jeder Wiederherstellung:**
|
||||||
|
- ✅ Automatisches Backup wird erstellt
|
||||||
|
- 📁 Gespeichert in: `Android/data/dev.dettmer.simplenotes/files/`
|
||||||
|
- 🏷️ Dateiname: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
|
||||||
|
- ⏱️ Zeitstempel: Direkt vor Wiederherstellung
|
||||||
|
|
||||||
|
**Warum?**
|
||||||
|
- Schutz vor versehentlichem "Ersetzen"
|
||||||
|
- Möglichkeit zum Rückgängigmachen
|
||||||
|
- Doppelte Sicherheit
|
||||||
|
|
||||||
|
**Zugriff via Dateimanager:**
|
||||||
|
```
|
||||||
|
/Android/data/dev.dettmer.simplenotes/files/auto_backup_before_restore_*.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Best Practices
|
||||||
|
|
||||||
|
### Backup-Strategie
|
||||||
|
|
||||||
|
#### Regelmäßige Backups
|
||||||
|
```
|
||||||
|
Täglich: ❌ Zu oft (Server-Sync reicht)
|
||||||
|
Wöchentlich: ✅ Empfohlen für wichtige Notizen
|
||||||
|
Monatlich: ✅ Archivierung
|
||||||
|
Vor Updates: ✅ Sicherheit
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3-2-1 Regel
|
||||||
|
1. **3 Kopien** - Original + 2 Backups
|
||||||
|
2. **2 Medien** - z.B. SD-Karte + Cloud
|
||||||
|
3. **1 Offsite** - z.B. Cloud-Speicher
|
||||||
|
|
||||||
|
### Backup-Speicherorte
|
||||||
|
|
||||||
|
**Lokal (schnell):**
|
||||||
|
- 📱 Internal Storage / Downloads
|
||||||
|
- 💳 SD-Karte
|
||||||
|
- 🖥️ PC (via USB)
|
||||||
|
|
||||||
|
**Cloud (sicher):**
|
||||||
|
- ☁️ Nextcloud (Self-Hosted)
|
||||||
|
- 📧 E-Mail an sich selbst
|
||||||
|
- 🗄️ Syncthing (Sync zwischen Geräten)
|
||||||
|
|
||||||
|
**⚠️ Vermeiden:**
|
||||||
|
- ❌ Google Drive / Dropbox (Privacy)
|
||||||
|
- ❌ Nur eine Kopie
|
||||||
|
- ❌ Nur auf Server (wenn Server ausfällt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Erweiterte Nutzung
|
||||||
|
|
||||||
|
### Backup-Datei bearbeiten
|
||||||
|
|
||||||
|
Die `.json` Datei kann mit jedem Texteditor bearbeitet werden:
|
||||||
|
|
||||||
|
1. **Öffnen mit:** VS Code, Notepad++, nano
|
||||||
|
2. **Notizen hinzufügen/entfernen**
|
||||||
|
3. **Titel/Inhalt ändern**
|
||||||
|
4. **IDs anpassen** (für Migration)
|
||||||
|
5. **Speichern** und in App importieren
|
||||||
|
|
||||||
|
**⚠️ Wichtig:**
|
||||||
|
- Valides JSON-Format behalten
|
||||||
|
- IDs müssen eindeutig sein (UUIDs)
|
||||||
|
- Timestamps in Millisekunden (Unix Epoch)
|
||||||
|
|
||||||
|
### Bulk-Import
|
||||||
|
|
||||||
|
Mehrere Backups zusammenführen:
|
||||||
|
|
||||||
|
1. Backup 1 importieren (Modus: Zusammenführen)
|
||||||
|
2. Backup 2 importieren (Modus: Zusammenführen)
|
||||||
|
3. Backup 3 importieren (Modus: Zusammenführen)
|
||||||
|
4. Ergebnis: Alle Notizen vereint
|
||||||
|
|
||||||
|
### Server-Migration
|
||||||
|
|
||||||
|
Schritt-für-Schritt:
|
||||||
|
|
||||||
|
1. **Backup erstellen** auf altem Server
|
||||||
|
2. **Neuen Server einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
|
||||||
|
3. **Server-URL ändern** in App-Einstellungen
|
||||||
|
4. **Backup wiederherstellen** (Modus: Ersetzen)
|
||||||
|
5. **Sync testen** - Alle Notizen auf neuem Server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Fehlerbehebung
|
||||||
|
|
||||||
|
### "Backup-Datei ungültig"
|
||||||
|
|
||||||
|
**Ursachen:**
|
||||||
|
- Korrupte JSON-Datei
|
||||||
|
- Falsche Datei-Endung (muss `.json` sein)
|
||||||
|
- Inkompatible App-Version
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. JSON-Datei mit Validator prüfen (z.B. jsonlint.com)
|
||||||
|
2. Dateiendung überprüfen
|
||||||
|
3. Backup mit aktueller App-Version erstellen
|
||||||
|
|
||||||
|
### "Keine Berechtigung zum Speichern"
|
||||||
|
|
||||||
|
**Ursachen:**
|
||||||
|
- Speicher-Berechtigung fehlt
|
||||||
|
- Schreibgeschützter Ordner
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Android: Einstellungen → Apps → Simple Notes → Berechtigungen
|
||||||
|
2. "Speicher" aktivieren
|
||||||
|
3. Anderen Speicherort wählen
|
||||||
|
|
||||||
|
### "Import fehlgeschlagen"
|
||||||
|
|
||||||
|
**Ursachen:**
|
||||||
|
- Zu wenig Speicherplatz
|
||||||
|
- Korrupte Backup-Datei
|
||||||
|
- App-Crash während Import
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Speicherplatz freigeben
|
||||||
|
2. Backup-Datei neu erstellen
|
||||||
|
3. App neu starten und erneut importieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Sicherheit & Privacy
|
||||||
|
|
||||||
|
### Daten-Schutz
|
||||||
|
- ✅ **Lokal gespeichert** - Kein Cloud-Upload ohne deine Aktion
|
||||||
|
- ✅ **Keine Verschlüsselung** - Klartextformat für Lesbarkeit
|
||||||
|
- ⚠️ **Sensible Daten?** - Backup-Datei selbst verschlüsseln (z.B. 7-Zip mit Passwort)
|
||||||
|
|
||||||
|
### Empfehlungen
|
||||||
|
- 🔐 Backup-Dateien in verschlüsseltem Container speichern
|
||||||
|
- 🗑️ Alte Backups regelmäßig löschen
|
||||||
|
- 📧 Nicht per unverschlüsselter E-Mail versenden
|
||||||
|
- ☁️ Self-Hosted Cloud nutzen (Nextcloud)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Technische Details
|
||||||
|
|
||||||
|
### Format-Spezifikation
|
||||||
|
|
||||||
|
**JSON-Struktur:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "string", // App-Version beim Export
|
||||||
|
"exported_at": "ISO8601", // Zeitstempel des Exports
|
||||||
|
"notes_count": number, // Anzahl der Notizen
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"id": "UUID", // Eindeutige ID
|
||||||
|
"title": "string", // Notiz-Titel
|
||||||
|
"content": "string", // Notiz-Inhalt
|
||||||
|
"createdAt": number, // Unix Timestamp (ms)
|
||||||
|
"updatedAt": number // Unix Timestamp (ms)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kompatibilität
|
||||||
|
- ✅ v1.2.0+ - Vollständig kompatibel
|
||||||
|
- ⚠️ v1.1.x - Grundfunktionen (ohne Auto-Backup)
|
||||||
|
- ❌ v1.0.x - Nicht unterstützt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**📚 Siehe auch:**
|
||||||
|
- [QUICKSTART.md](../QUICKSTART.md) - App-Installation und Einrichtung
|
||||||
|
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
|
||||||
|
- [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown
|
||||||
|
|
||||||
|
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)
|
||||||
505
docs/DESKTOP.en.md
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
# Desktop Integration 🖥️
|
||||||
|
|
||||||
|
**🌍 Languages:** [Deutsch](DESKTOP.md) · **English**
|
||||||
|
|
||||||
|
> Edit your notes with any Markdown editor on desktop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Overview
|
||||||
|
|
||||||
|
Desktop integration allows you to edit notes on PC/Mac:
|
||||||
|
- 📝 Any Markdown editor works
|
||||||
|
- 🔄 Automatic synchronization via WebDAV
|
||||||
|
- 💾 Dual-format: JSON (master) + Markdown (mirror)
|
||||||
|
- ⚡ Last-Write-Wins conflict resolution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Why Markdown?
|
||||||
|
|
||||||
|
### Dual-Format Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Android App │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌─────────────┐ │
|
||||||
|
│ │ JSON │ ──→ │ Markdown │ │
|
||||||
|
│ │ (Master) │ │ (Mirror) │ │
|
||||||
|
│ └──────────┘ └─────────────┘ │
|
||||||
|
└────────┬────────────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
↓ ↓
|
||||||
|
WebDAV Server
|
||||||
|
│ │
|
||||||
|
┌────┴────┐ ┌────┴──────┐
|
||||||
|
│ /notes/ │ │ /notes-md/│
|
||||||
|
│ *.json │ │ *.md │
|
||||||
|
└─────────┘ └───────────┘
|
||||||
|
↑ ↑
|
||||||
|
│ │
|
||||||
|
┌────┴────────────────┴───────────┐
|
||||||
|
│ Desktop Editor │
|
||||||
|
│ (VS Code, Typora, etc.) │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advantages
|
||||||
|
|
||||||
|
**JSON (Master):**
|
||||||
|
- ✅ Reliable and fast
|
||||||
|
- ✅ Structured data (IDs, timestamps)
|
||||||
|
- ✅ Primary sync mechanism
|
||||||
|
- ✅ Always active
|
||||||
|
|
||||||
|
**Markdown (Mirror):**
|
||||||
|
- ✅ Human-readable
|
||||||
|
- ✅ Desktop editor compatible
|
||||||
|
- ✅ Syntax highlighting
|
||||||
|
- ✅ Optionally activatable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Quick Start
|
||||||
|
|
||||||
|
### 1. First Synchronization
|
||||||
|
|
||||||
|
**Important:** Perform a sync FIRST before activating desktop integration!
|
||||||
|
|
||||||
|
1. **Set up app** (see [QUICKSTART.en.md](QUICKSTART.en.md))
|
||||||
|
2. **Test server connection**
|
||||||
|
3. **Create first note**
|
||||||
|
4. **Synchronize** (pull-to-refresh or auto-sync)
|
||||||
|
5. ✅ Server automatically creates `/notes/` and `/notes-md/` folders
|
||||||
|
|
||||||
|
### 2. Activate Desktop Integration
|
||||||
|
|
||||||
|
1. **Settings** → **Desktop Integration**
|
||||||
|
2. **Toggle ON**
|
||||||
|
3. **Initial export starts** - Shows progress (X/Y)
|
||||||
|
4. ✅ All existing notes are exported as `.md`
|
||||||
|
|
||||||
|
### 3. Mount WebDAV as Network Drive
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Open Explorer
|
||||||
|
2. Right-click on "This PC"
|
||||||
|
3. "Map network drive"
|
||||||
|
4. Enter URL: http://YOUR-SERVER:8080/notes-md/
|
||||||
|
5. Username: noteuser
|
||||||
|
6. Password: (your WebDAV password)
|
||||||
|
7. Drive letter: Z:\ (or any)
|
||||||
|
8. Done!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access:** `Z:\` in Explorer
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Open Finder
|
||||||
|
2. Menu "Go" → "Connect to Server" (⌘K)
|
||||||
|
3. Server address: http://YOUR-SERVER:8080/notes-md/
|
||||||
|
4. Connect
|
||||||
|
5. Username: noteuser
|
||||||
|
6. Password: (your WebDAV password)
|
||||||
|
7. Done!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access:** Finder → Network → notes-md
|
||||||
|
|
||||||
|
#### Linux (GNOME)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Open Files / Nautilus
|
||||||
|
2. "Other Locations"
|
||||||
|
3. "Connect to Server"
|
||||||
|
4. Server address: dav://YOUR-SERVER:8080/notes-md/
|
||||||
|
5. Username: noteuser
|
||||||
|
6. Password: (your WebDAV password)
|
||||||
|
7. Done!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access:** `/run/user/1000/gvfs/dav:host=...`
|
||||||
|
|
||||||
|
#### Linux (davfs2 - permanent)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installation
|
||||||
|
sudo apt install davfs2
|
||||||
|
|
||||||
|
# Create mount point
|
||||||
|
sudo mkdir -p /mnt/notes-md
|
||||||
|
|
||||||
|
# Mount once
|
||||||
|
sudo mount -t davfs http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md
|
||||||
|
|
||||||
|
# Permanent in /etc/fstab
|
||||||
|
echo "http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access:** `/mnt/notes-md/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Markdown Editors
|
||||||
|
|
||||||
|
### Recommended Editors
|
||||||
|
|
||||||
|
#### 1. VS Code ⭐ _Recommended_
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- ✅ Free & open source
|
||||||
|
- ✅ Markdown preview (Ctrl+Shift+V)
|
||||||
|
- ✅ Syntax highlighting
|
||||||
|
- ✅ Git integration
|
||||||
|
- ✅ Extensions (spell check, etc.)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. Install VS Code
|
||||||
|
2. Mount WebDAV drive
|
||||||
|
3. Open folder: Z:\notes-md\ (Windows) or /mnt/notes-md (Linux)
|
||||||
|
4. Done! Edit Markdown files
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extensions (optional):**
|
||||||
|
- `Markdown All in One` - Shortcuts & preview
|
||||||
|
- `Markdown Preview Enhanced` - Better preview
|
||||||
|
- `Code Spell Checker` - Spell checking
|
||||||
|
|
||||||
|
#### 2. Typora
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- ✅ WYSIWYG Markdown editor
|
||||||
|
- ✅ Minimalist design
|
||||||
|
- ✅ Live preview
|
||||||
|
- ⚠️ Paid (~15€)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. Install Typora
|
||||||
|
2. Mount WebDAV
|
||||||
|
3. Open folder in Typora
|
||||||
|
4. Edit notes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Notepad++
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- ✅ Lightweight
|
||||||
|
- ✅ Fast
|
||||||
|
- ✅ Syntax highlighting
|
||||||
|
- ⚠️ No Markdown preview
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. Install Notepad++
|
||||||
|
2. Mount WebDAV
|
||||||
|
3. Open files directly
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Obsidian
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- ✅ Second brain philosophy
|
||||||
|
- ✅ Graph view for links
|
||||||
|
- ✅ Many plugins
|
||||||
|
- ⚠️ Sync conflicts possible (2 masters)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. Install Obsidian
|
||||||
|
2. Open WebDAV as vault
|
||||||
|
3. Caution: Obsidian creates own metadata!
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Not recommended:** Can alter frontmatter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Markdown File Format
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
Each note is exported as `.md` file with YAML frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: abc-123-def-456
|
||||||
|
created: 2026-01-05T14:30:22Z
|
||||||
|
updated: 2026-01-05T14:30:22Z
|
||||||
|
tags: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Note Title
|
||||||
|
|
||||||
|
Note content here...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontmatter Fields
|
||||||
|
|
||||||
|
| Field | Type | Description | Required |
|
||||||
|
|-------|------|-------------|----------|
|
||||||
|
| `id` | UUID | Unique note ID | ✅ Yes |
|
||||||
|
| `created` | ISO8601 | Creation date | ✅ Yes |
|
||||||
|
| `updated` | ISO8601 | Modification date | ✅ Yes |
|
||||||
|
| `tags` | Array | Tags (future) | ❌ No |
|
||||||
|
|
||||||
|
### Filenames
|
||||||
|
|
||||||
|
**Sanitization rules:**
|
||||||
|
```
|
||||||
|
Title: "My Shopping List 🛒"
|
||||||
|
→ Filename: "My_Shopping_List.md"
|
||||||
|
|
||||||
|
Removed:
|
||||||
|
- Emojis: 🛒 → removed
|
||||||
|
- Special chars: / \ : * ? " < > | → removed
|
||||||
|
- Multiple spaces → single space
|
||||||
|
- Spaces → underscore _
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```
|
||||||
|
"Meeting Notes 2026" → "Meeting_Notes_2026.md"
|
||||||
|
"To-Do: Project" → "To-Do_Project.md"
|
||||||
|
"Vacation ☀️" → "Vacation.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Synchronization
|
||||||
|
|
||||||
|
### Workflow: Android → Desktop
|
||||||
|
|
||||||
|
1. **Create/edit note in app**
|
||||||
|
2. **Run sync** (auto or manual)
|
||||||
|
3. **JSON is uploaded** (`/notes/abc-123.json`)
|
||||||
|
4. **Markdown is exported** (`/notes-md/Note_Title.md`) _(only if Desktop Integration ON)_
|
||||||
|
5. **Desktop editor shows changes** (after refresh)
|
||||||
|
|
||||||
|
### Workflow: Desktop → Android
|
||||||
|
|
||||||
|
1. **Edit Markdown file** (in mounted folder)
|
||||||
|
2. **Save** - File is immediately on server
|
||||||
|
3. **In app: Run Markdown import**
|
||||||
|
- Settings → "Import Markdown Changes"
|
||||||
|
- Or: Auto-import on every sync (future)
|
||||||
|
4. **App adopts changes** (if desktop version is newer)
|
||||||
|
|
||||||
|
### Conflict Resolution: Last-Write-Wins
|
||||||
|
|
||||||
|
**Rule:** Newest version (by `updated` timestamp) wins
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
App version: updated: 2026-01-05 14:00
|
||||||
|
Desktop version: updated: 2026-01-05 14:30
|
||||||
|
→ Desktop wins (newer timestamp)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatic:**
|
||||||
|
- ✅ On Markdown import
|
||||||
|
- ✅ On JSON sync
|
||||||
|
- ⚠️ No merge conflicts - only complete overwrite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Settings
|
||||||
|
|
||||||
|
### Desktop Integration Toggle
|
||||||
|
|
||||||
|
**Settings → Desktop Integration**
|
||||||
|
|
||||||
|
**ON (activated):**
|
||||||
|
- ✅ New notes → automatically exported as `.md`
|
||||||
|
- ✅ Updated notes → `.md` update
|
||||||
|
- ✅ Deleted notes → `.md` remains (future: also delete)
|
||||||
|
|
||||||
|
**OFF (deactivated):**
|
||||||
|
- ❌ No Markdown export
|
||||||
|
- ✅ JSON sync continues normally
|
||||||
|
- ✅ Existing `.md` files remain
|
||||||
|
|
||||||
|
### Initial Export
|
||||||
|
|
||||||
|
**What happens on activation:**
|
||||||
|
1. All existing notes are scanned
|
||||||
|
2. Progress dialog shows progress (e.g., "23/42")
|
||||||
|
3. Each note is exported as `.md`
|
||||||
|
4. On errors: Individual note is skipped
|
||||||
|
5. Success message with number of exported notes
|
||||||
|
|
||||||
|
**Time:** ~1-2 seconds per 50 notes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Advanced Usage
|
||||||
|
|
||||||
|
### Manual Markdown Creation
|
||||||
|
|
||||||
|
You can create `.md` files manually:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: 00000000-0000-0000-0000-000000000001
|
||||||
|
created: 2026-01-05T12:00:00Z
|
||||||
|
updated: 2026-01-05T12:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# New Desktop Note
|
||||||
|
|
||||||
|
Content here...
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Important:**
|
||||||
|
- `id` must be valid UUID (e.g., with uuidgen.io)
|
||||||
|
- Timestamps in ISO8601 format
|
||||||
|
- Frontmatter enclosed with `---`
|
||||||
|
|
||||||
|
### Bulk Operations
|
||||||
|
|
||||||
|
**Edit multiple notes at once:**
|
||||||
|
|
||||||
|
1. Mount WebDAV
|
||||||
|
2. Open all `.md` files in VS Code
|
||||||
|
3. Find & Replace across all files (Ctrl+Shift+H)
|
||||||
|
4. Save
|
||||||
|
5. In app: "Import Markdown Changes"
|
||||||
|
|
||||||
|
### Scripting
|
||||||
|
|
||||||
|
**Example: Sort all notes by date**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
cd /mnt/notes-md/
|
||||||
|
|
||||||
|
# Sort all .md files by update date
|
||||||
|
for file in *.md; do
|
||||||
|
updated=$(grep "^updated:" "$file" | cut -d' ' -f2)
|
||||||
|
echo "$updated $file"
|
||||||
|
done | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Troubleshooting
|
||||||
|
|
||||||
|
### "404 Not Found" when mounting WebDAV
|
||||||
|
|
||||||
|
**Cause:** `/notes-md/` folder doesn't exist
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. **Perform first sync** - Folder is created automatically
|
||||||
|
2. OR: Create manually via terminal:
|
||||||
|
```bash
|
||||||
|
curl -X MKCOL -u noteuser:password http://server:8080/notes-md/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markdown files don't appear
|
||||||
|
|
||||||
|
**Cause:** Desktop integration not activated
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Settings → "Desktop Integration" ON
|
||||||
|
2. Wait for initial export
|
||||||
|
3. Refresh WebDAV folder
|
||||||
|
|
||||||
|
### Changes from desktop don't appear in app
|
||||||
|
|
||||||
|
**Cause:** Markdown import not executed
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Settings → "Import Markdown Changes"
|
||||||
|
2. OR: Wait for auto-sync (future feature)
|
||||||
|
|
||||||
|
### "Frontmatter missing" error
|
||||||
|
|
||||||
|
**Cause:** `.md` file without valid YAML frontmatter
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Open file in editor
|
||||||
|
2. Add frontmatter at the beginning:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
id: NEW-UUID-HERE
|
||||||
|
created: 2026-01-05T12:00:00Z
|
||||||
|
updated: 2026-01-05T12:00:00Z
|
||||||
|
---
|
||||||
|
```
|
||||||
|
3. Save and import again
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Security & Best Practices
|
||||||
|
|
||||||
|
### Do's ✅
|
||||||
|
|
||||||
|
- ✅ **Backup before bulk edits** - Create local backup
|
||||||
|
- ✅ **One editor at a time** - Don't edit in app AND desktop in parallel
|
||||||
|
- ✅ **Wait for sync** - Run sync before desktop editing
|
||||||
|
- ✅ **Respect frontmatter** - Don't change manually (unless you know what you're doing)
|
||||||
|
|
||||||
|
### Don'ts ❌
|
||||||
|
|
||||||
|
- ❌ **Parallel editing** - App and desktop simultaneously → conflicts
|
||||||
|
- ❌ **Delete frontmatter** - Note can't be imported anymore
|
||||||
|
- ❌ **Change IDs** - Note is recognized as new
|
||||||
|
- ❌ **Manipulate timestamps** - Conflict resolution doesn't work
|
||||||
|
|
||||||
|
### Recommended Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Sync in app (pull-to-refresh)
|
||||||
|
2. Open desktop
|
||||||
|
3. Make changes
|
||||||
|
4. Save
|
||||||
|
5. In app: "Import Markdown Changes"
|
||||||
|
6. Verify
|
||||||
|
7. Run another sync
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Comparison: JSON vs Markdown
|
||||||
|
|
||||||
|
| Aspect | JSON | Markdown |
|
||||||
|
|--------|------|----------|
|
||||||
|
| **Format** | Structured | Flowing text |
|
||||||
|
| **Readability (human)** | ⚠️ Medium | ✅ Good |
|
||||||
|
| **Readability (machine)** | ✅ Perfect | ⚠️ Parsing needed |
|
||||||
|
| **Metadata** | Native | Frontmatter |
|
||||||
|
| **Editors** | Code editors | All text editors |
|
||||||
|
| **Sync speed** | ✅ Fast | ⚠️ Slower |
|
||||||
|
| **Reliability** | ✅ 100% | ⚠️ Frontmatter errors possible |
|
||||||
|
| **Mobile-first** | ✅ Yes | ❌ No |
|
||||||
|
| **Desktop-first** | ❌ No | ✅ Yes |
|
||||||
|
|
||||||
|
**Conclusion:** Using both formats = Best experience on both platforms!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔮 Future Features
|
||||||
|
|
||||||
|
Planned for v1.3.0+:
|
||||||
|
|
||||||
|
- ⏳ **Auto-Markdown-import** - Automatically on every sync
|
||||||
|
- ⏳ **Bidirectional sync** - Without manual import
|
||||||
|
- ⏳ **Markdown preview** - In the app
|
||||||
|
- ⏳ **Conflict UI** - On simultaneous changes
|
||||||
|
- ⏳ **Tags in frontmatter** - Synchronized with app
|
||||||
|
- ⏳ **Attachments** - Images/files in Markdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**📚 See also:**
|
||||||
|
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App setup
|
||||||
|
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
|
||||||
|
- [BACKUP.en.md](BACKUP.en.md) - Backup & restore
|
||||||
|
|
||||||
|
**Last update:** v1.2.1 (2026-01-05)
|
||||||
505
docs/DESKTOP.md
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
# Desktop-Integration 🖥️
|
||||||
|
|
||||||
|
**🌍 Languages:** **Deutsch** · [English](DESKTOP.en.md)
|
||||||
|
|
||||||
|
> Bearbeite deine Notizen mit jedem Markdown-Editor auf dem Desktop
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Übersicht
|
||||||
|
|
||||||
|
Die Desktop-Integration ermöglicht dir, Notizen auf dem PC/Mac zu bearbeiten:
|
||||||
|
- 📝 Jeder Markdown-Editor funktioniert
|
||||||
|
- 🔄 Automatische Synchronisation über WebDAV
|
||||||
|
- 💾 Dual-Format: JSON (Master) + Markdown (Mirror)
|
||||||
|
- ⚡ Last-Write-Wins Konfliktauflösung
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Warum Markdown?
|
||||||
|
|
||||||
|
### Dual-Format Architektur
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Android App │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌─────────────┐ │
|
||||||
|
│ │ JSON │ ──→ │ Markdown │ │
|
||||||
|
│ │ (Master) │ │ (Mirror) │ │
|
||||||
|
│ └──────────┘ └─────────────┘ │
|
||||||
|
└────────┬────────────────┬───────────┘
|
||||||
|
│ │
|
||||||
|
↓ ↓
|
||||||
|
WebDAV Server
|
||||||
|
│ │
|
||||||
|
┌────┴────┐ ┌────┴──────┐
|
||||||
|
│ /notes/ │ │ /notes-md/│
|
||||||
|
│ *.json │ │ *.md │
|
||||||
|
└─────────┘ └───────────┘
|
||||||
|
↑ ↑
|
||||||
|
│ │
|
||||||
|
┌────┴────────────────┴───────────┐
|
||||||
|
│ Desktop Editor │
|
||||||
|
│ (VS Code, Typora, etc.) │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
|
||||||
|
**JSON (Master):**
|
||||||
|
- ✅ Zuverlässig und schnell
|
||||||
|
- ✅ Strukturierte Daten (IDs, Timestamps)
|
||||||
|
- ✅ Primärer Sync-Mechanismus
|
||||||
|
- ✅ Immer aktiv
|
||||||
|
|
||||||
|
**Markdown (Mirror):**
|
||||||
|
- ✅ Menschenlesbar
|
||||||
|
- ✅ Desktop-Editor kompatibel
|
||||||
|
- ✅ Syntax-Highlighting
|
||||||
|
- ✅ Optional aktivierbar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Schnellstart
|
||||||
|
|
||||||
|
### 1. Erste Synchronisation
|
||||||
|
|
||||||
|
**Wichtig:** Führe ZUERST einen Sync durch, bevor du Desktop-Integration aktivierst!
|
||||||
|
|
||||||
|
1. **App einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
|
||||||
|
2. **Server-Verbindung testen**
|
||||||
|
3. **Erste Notiz erstellen**
|
||||||
|
4. **Synchronisieren** (Pull-to-Refresh oder Auto-Sync)
|
||||||
|
5. ✅ Server erstellt automatisch `/notes/` und `/notes-md/` Ordner
|
||||||
|
|
||||||
|
### 2. Desktop-Integration aktivieren
|
||||||
|
|
||||||
|
1. **Einstellungen** → **Desktop-Integration**
|
||||||
|
2. **Toggle aktivieren**
|
||||||
|
3. **Initial Export startet** - Zeigt Progress (X/Y)
|
||||||
|
4. ✅ Alle bestehenden Notizen werden als `.md` exportiert
|
||||||
|
|
||||||
|
### 3. WebDAV als Netzlaufwerk mounten
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Explorer öffnen
|
||||||
|
2. Rechtsklick auf "Dieser PC"
|
||||||
|
3. "Netzlaufwerk verbinden"
|
||||||
|
4. URL eingeben: http://DEIN-SERVER:8080/notes-md/
|
||||||
|
5. Benutzername: noteuser
|
||||||
|
6. Passwort: (dein WebDAV-Passwort)
|
||||||
|
7. Laufwerksbuchstabe: Z:\ (oder beliebig)
|
||||||
|
8. Fertig!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zugriff:** `Z:\` im Explorer
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Finder öffnen
|
||||||
|
2. Menü "Gehe zu" → "Mit Server verbinden" (⌘K)
|
||||||
|
3. Server-Adresse: http://DEIN-SERVER:8080/notes-md/
|
||||||
|
4. Verbinden
|
||||||
|
5. Benutzername: noteuser
|
||||||
|
6. Passwort: (dein WebDAV-Passwort)
|
||||||
|
7. Fertig!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zugriff:** Finder → Netzwerk → notes-md
|
||||||
|
|
||||||
|
#### Linux (GNOME)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Files / Nautilus öffnen
|
||||||
|
2. "Andere Orte"
|
||||||
|
3. "Mit Server verbinden"
|
||||||
|
4. Server-Adresse: dav://DEIN-SERVER:8080/notes-md/
|
||||||
|
5. Benutzername: noteuser
|
||||||
|
6. Passwort: (dein WebDAV-Passwort)
|
||||||
|
7. Fertig!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zugriff:** `/run/user/1000/gvfs/dav:host=...`
|
||||||
|
|
||||||
|
#### Linux (davfs2 - permanent)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installation
|
||||||
|
sudo apt install davfs2
|
||||||
|
|
||||||
|
# Mount-Point erstellen
|
||||||
|
sudo mkdir -p /mnt/notes-md
|
||||||
|
|
||||||
|
# Einmalig mounten
|
||||||
|
sudo mount -t davfs http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md
|
||||||
|
|
||||||
|
# Permanent in /etc/fstab
|
||||||
|
echo "http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zugriff:** `/mnt/notes-md/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Markdown-Editoren
|
||||||
|
|
||||||
|
### Empfohlene Editoren
|
||||||
|
|
||||||
|
#### 1. VS Code ⭐ _Empfohlen_
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Kostenlos & Open Source
|
||||||
|
- ✅ Markdown-Preview (Ctrl+Shift+V)
|
||||||
|
- ✅ Syntax-Highlighting
|
||||||
|
- ✅ Git-Integration
|
||||||
|
- ✅ Erweiterungen (Spell Check, etc.)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. VS Code installieren
|
||||||
|
2. WebDAV-Laufwerk mounten
|
||||||
|
3. Ordner öffnen: Z:\notes-md\ (Windows) oder /mnt/notes-md (Linux)
|
||||||
|
4. Fertig! Markdown-Dateien bearbeiten
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extensions (optional):**
|
||||||
|
- `Markdown All in One` - Shortcuts & Preview
|
||||||
|
- `Markdown Preview Enhanced` - Bessere Preview
|
||||||
|
- `Code Spell Checker` - Rechtschreibprüfung
|
||||||
|
|
||||||
|
#### 2. Typora
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ WYSIWYG Markdown-Editor
|
||||||
|
- ✅ Minimalistisches Design
|
||||||
|
- ✅ Live-Preview
|
||||||
|
- ⚠️ Kostenpflichtig (~15€)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. Typora installieren
|
||||||
|
2. WebDAV mounten
|
||||||
|
3. Ordner in Typora öffnen
|
||||||
|
4. Notizen bearbeiten
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Notepad++
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Leichtgewichtig
|
||||||
|
- ✅ Schnell
|
||||||
|
- ✅ Syntax-Highlighting
|
||||||
|
- ⚠️ Keine Markdown-Preview
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. Notepad++ installieren
|
||||||
|
2. WebDAV mounten
|
||||||
|
3. Dateien direkt öffnen
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Obsidian
|
||||||
|
|
||||||
|
**Vorteile:**
|
||||||
|
- ✅ Zweite Gehirn-Philosophie
|
||||||
|
- ✅ Graph-View für Verlinkungen
|
||||||
|
- ✅ Viele Plugins
|
||||||
|
- ⚠️ Sync-Konflikte möglich (2 Master)
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
```
|
||||||
|
1. Obsidian installieren
|
||||||
|
2. WebDAV als Vault öffnen
|
||||||
|
3. Vorsicht: Obsidian erstellt eigene Metadaten!
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Nicht empfohlen:** Kann Frontmatter verändern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Markdown-Dateiformat
|
||||||
|
|
||||||
|
### Struktur
|
||||||
|
|
||||||
|
Jede Notiz wird als `.md` Datei mit YAML-Frontmatter exportiert:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: abc-123-def-456
|
||||||
|
created: 2026-01-05T14:30:22Z
|
||||||
|
updated: 2026-01-05T14:30:22Z
|
||||||
|
tags: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# Notiz-Titel
|
||||||
|
|
||||||
|
Notiz-Inhalt hier...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontmatter-Felder
|
||||||
|
|
||||||
|
| Feld | Typ | Beschreibung | Pflicht |
|
||||||
|
|------|-----|--------------|---------|
|
||||||
|
| `id` | UUID | Eindeutige Notiz-ID | ✅ Ja |
|
||||||
|
| `created` | ISO8601 | Erstellungsdatum | ✅ Ja |
|
||||||
|
| `updated` | ISO8601 | Änderungsdatum | ✅ Ja |
|
||||||
|
| `tags` | Array | Tags (zukünftig) | ❌ Nein |
|
||||||
|
|
||||||
|
### Dateinamen
|
||||||
|
|
||||||
|
**Sanitization-Regeln:**
|
||||||
|
```
|
||||||
|
Titel: "Meine Einkaufsliste 🛒"
|
||||||
|
→ Dateiname: "Meine_Einkaufsliste.md"
|
||||||
|
|
||||||
|
Entfernt werden:
|
||||||
|
- Emojis: 🛒 → entfernt
|
||||||
|
- Sonderzeichen: / \ : * ? " < > | → entfernt
|
||||||
|
- Mehrfache Leerzeichen → einzelnes Leerzeichen
|
||||||
|
- Leerzeichen → Unterstrich _
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiele:**
|
||||||
|
```
|
||||||
|
"Meeting Notes 2026" → "Meeting_Notes_2026.md"
|
||||||
|
"To-Do: Projekt" → "To-Do_Projekt.md"
|
||||||
|
"Urlaub ☀️" → "Urlaub.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Synchronisation
|
||||||
|
|
||||||
|
### Workflow: Android → Desktop
|
||||||
|
|
||||||
|
1. **Notiz in App erstellen/bearbeiten**
|
||||||
|
2. **Sync ausführen** (Auto oder manuell)
|
||||||
|
3. **JSON wird hochgeladen** (`/notes/abc-123.json`)
|
||||||
|
4. **Markdown wird exportiert** (`/notes-md/Notiz_Titel.md`) _(nur wenn Desktop-Integration AN)_
|
||||||
|
5. **Desktop-Editor zeigt Änderungen** (nach Refresh)
|
||||||
|
|
||||||
|
### Workflow: Desktop → Android
|
||||||
|
|
||||||
|
1. **Markdown-Datei bearbeiten** (im gemounteten Ordner)
|
||||||
|
2. **Speichern** - Datei liegt sofort auf Server
|
||||||
|
3. **In App: Markdown-Import ausführen**
|
||||||
|
- Einstellungen → "Import Markdown Changes"
|
||||||
|
- Oder: Auto-Import bei jedem Sync (zukünftig)
|
||||||
|
4. **App übernimmt Änderungen** (wenn Desktop-Version neuer)
|
||||||
|
|
||||||
|
### Konfliktauflösung: Last-Write-Wins
|
||||||
|
|
||||||
|
**Regel:** Neueste Version (nach `updated` Timestamp) gewinnt
|
||||||
|
|
||||||
|
**Beispiel:**
|
||||||
|
```
|
||||||
|
App-Version: updated: 2026-01-05 14:00
|
||||||
|
Desktop-Version: updated: 2026-01-05 14:30
|
||||||
|
→ Desktop gewinnt (neuerer Timestamp)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatisch:**
|
||||||
|
- ✅ Beim Markdown-Import
|
||||||
|
- ✅ Beim JSON-Sync
|
||||||
|
- ⚠️ Keine Merge-Konflikte - nur komplettes Überschreiben
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ Einstellungen
|
||||||
|
|
||||||
|
### Desktop-Integration Toggle
|
||||||
|
|
||||||
|
**Einstellungen → Desktop-Integration**
|
||||||
|
|
||||||
|
**AN (aktiviert):**
|
||||||
|
- ✅ Neue Notizen → automatisch als `.md` exportiert
|
||||||
|
- ✅ Aktualisierte Notizen → `.md` Update
|
||||||
|
- ✅ Gelöschte Notizen → `.md` bleibt (zukünftig: auch löschen)
|
||||||
|
|
||||||
|
**AUS (deaktiviert):**
|
||||||
|
- ❌ Kein Markdown-Export
|
||||||
|
- ✅ JSON-Sync läuft normal weiter
|
||||||
|
- ✅ Bestehende `.md` Dateien bleiben erhalten
|
||||||
|
|
||||||
|
### Initial Export
|
||||||
|
|
||||||
|
**Was passiert beim Aktivieren:**
|
||||||
|
1. Alle bestehenden Notizen werden gescannt
|
||||||
|
2. Progress-Dialog zeigt Fortschritt (z.B. "23/42")
|
||||||
|
3. Jede Notiz wird als `.md` exportiert
|
||||||
|
4. Bei Fehlern: Einzelne Notiz wird übersprungen
|
||||||
|
5. Erfolgsmeldung mit Anzahl exportierter Notizen
|
||||||
|
|
||||||
|
**Zeit:** ~1-2 Sekunden pro 50 Notizen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Erweiterte Nutzung
|
||||||
|
|
||||||
|
### Manuelle Markdown-Erstellung
|
||||||
|
|
||||||
|
Du kannst `.md` Dateien manuell erstellen:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
id: 00000000-0000-0000-0000-000000000001
|
||||||
|
created: 2026-01-05T12:00:00Z
|
||||||
|
updated: 2026-01-05T12:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Neue Desktop-Notiz
|
||||||
|
|
||||||
|
Inhalt hier...
|
||||||
|
```
|
||||||
|
|
||||||
|
**⚠️ Wichtig:**
|
||||||
|
- `id` muss gültige UUID sein (z.B. mit uuidgen.io)
|
||||||
|
- Timestamps in ISO8601-Format
|
||||||
|
- Frontmatter mit `---` umschließen
|
||||||
|
|
||||||
|
### Bulk-Operations
|
||||||
|
|
||||||
|
**Mehrere Notizen auf einmal bearbeiten:**
|
||||||
|
|
||||||
|
1. WebDAV mounten
|
||||||
|
2. Alle `.md` Dateien in VS Code öffnen
|
||||||
|
3. Suchen & Ersetzen über alle Dateien (Ctrl+Shift+H)
|
||||||
|
4. Speichern
|
||||||
|
5. In App: "Import Markdown Changes"
|
||||||
|
|
||||||
|
### Scripting
|
||||||
|
|
||||||
|
**Beispiel: Alle Notizen nach Datum sortieren**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
cd /mnt/notes-md/
|
||||||
|
|
||||||
|
# Alle .md Dateien nach Update-Datum sortieren
|
||||||
|
for file in *.md; do
|
||||||
|
updated=$(grep "^updated:" "$file" | cut -d' ' -f2)
|
||||||
|
echo "$updated $file"
|
||||||
|
done | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❌ Fehlerbehebung
|
||||||
|
|
||||||
|
### "404 Not Found" beim WebDAV-Mount
|
||||||
|
|
||||||
|
**Ursache:** `/notes-md/` Ordner existiert nicht
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. **Erste Sync durchführen** - Ordner wird automatisch erstellt
|
||||||
|
2. ODER: Manuell erstellen via Terminal:
|
||||||
|
```bash
|
||||||
|
curl -X MKCOL -u noteuser:password http://server:8080/notes-md/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Markdown-Dateien erscheinen nicht
|
||||||
|
|
||||||
|
**Ursache:** Desktop-Integration nicht aktiviert
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Einstellungen → "Desktop-Integration" AN
|
||||||
|
2. Warten auf Initial Export
|
||||||
|
3. WebDAV-Ordner refreshen
|
||||||
|
|
||||||
|
### Änderungen vom Desktop erscheinen nicht in App
|
||||||
|
|
||||||
|
**Ursache:** Markdown-Import nicht ausgeführt
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Einstellungen → "Import Markdown Changes"
|
||||||
|
2. ODER: Auto-Sync abwarten (zukünftiges Feature)
|
||||||
|
|
||||||
|
### "Frontmatter fehlt" Fehler
|
||||||
|
|
||||||
|
**Ursache:** `.md` Datei ohne gültiges YAML-Frontmatter
|
||||||
|
|
||||||
|
**Lösung:**
|
||||||
|
1. Datei in Editor öffnen
|
||||||
|
2. Frontmatter am Anfang hinzufügen:
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
id: NEUE-UUID-HIER
|
||||||
|
created: 2026-01-05T12:00:00Z
|
||||||
|
updated: 2026-01-05T12:00:00Z
|
||||||
|
---
|
||||||
|
```
|
||||||
|
3. Speichern und erneut importieren
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Sicherheit & Best Practices
|
||||||
|
|
||||||
|
### Do's ✅
|
||||||
|
|
||||||
|
- ✅ **Backup vor Bulk-Edits** - Lokales Backup erstellen
|
||||||
|
- ✅ **Ein Editor zur Zeit** - Nicht parallel in App UND Desktop bearbeiten
|
||||||
|
- ✅ **Sync abwarten** - Vor Desktop-Bearbeitung Sync durchführen
|
||||||
|
- ✅ **Frontmatter respektieren** - Nicht manuell ändern (außer du weißt was du tust)
|
||||||
|
|
||||||
|
### Don'ts ❌
|
||||||
|
|
||||||
|
- ❌ **Parallel bearbeiten** - App und Desktop gleichzeitig → Konflikte
|
||||||
|
- ❌ **Frontmatter löschen** - Notiz kann nicht mehr importiert werden
|
||||||
|
- ❌ **IDs ändern** - Notiz wird als neue erkannt
|
||||||
|
- ❌ **Timestamps manipulieren** - Konfliktauflösung funktioniert nicht
|
||||||
|
|
||||||
|
### Empfohlener Workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Sync in App (Pull-to-Refresh)
|
||||||
|
2. Desktop öffnen
|
||||||
|
3. Änderungen machen
|
||||||
|
4. Speichern
|
||||||
|
5. In App: "Import Markdown Changes"
|
||||||
|
6. Überprüfen
|
||||||
|
7. Weiteren Sync durchführen
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Vergleich: JSON vs Markdown
|
||||||
|
|
||||||
|
| Aspekt | JSON | Markdown |
|
||||||
|
|--------|------|----------|
|
||||||
|
| **Format** | Strukturiert | Fließtext |
|
||||||
|
| **Lesbarkeit (Mensch)** | ⚠️ Mittel | ✅ Gut |
|
||||||
|
| **Lesbarkeit (Maschine)** | ✅ Perfekt | ⚠️ Parsing nötig |
|
||||||
|
| **Metadata** | Native | Frontmatter |
|
||||||
|
| **Editoren** | Code-Editoren | Alle Text-Editoren |
|
||||||
|
| **Sync-Geschwindigkeit** | ✅ Schnell | ⚠️ Langsamer |
|
||||||
|
| **Zuverlässigkeit** | ✅ 100% | ⚠️ Frontmatter-Fehler möglich |
|
||||||
|
| **Mobile-First** | ✅ Ja | ❌ Nein |
|
||||||
|
| **Desktop-First** | ❌ Nein | ✅ Ja |
|
||||||
|
|
||||||
|
**Fazit:** Beide Formate nutzen = Beste Erfahrung auf beiden Plattformen!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔮 Zukünftige Features
|
||||||
|
|
||||||
|
Geplant für v1.3.0+:
|
||||||
|
|
||||||
|
- ⏳ **Auto-Markdown-Import** - Bei jedem Sync automatisch
|
||||||
|
- ⏳ **Bidirektionaler Sync** - Ohne manuellen Import
|
||||||
|
- ⏳ **Markdown-Vorschau** - In der App
|
||||||
|
- ⏳ **Konflikts-UI** - Bei gleichzeitigen Änderungen
|
||||||
|
- ⏳ **Tags in Frontmatter** - Synchronisiert mit App
|
||||||
|
- ⏳ **Attachments** - Bilder/Dateien in Markdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**📚 Siehe auch:**
|
||||||
|
- [QUICKSTART.md](../QUICKSTART.md) - App-Einrichtung
|
||||||
|
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
|
||||||
|
- [BACKUP.md](BACKUP.md) - Backup & Wiederherstellung
|
||||||
|
|
||||||
|
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)
|
||||||
@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
|
|||||||
|
|
||||||
### Network Detection
|
### Network Detection
|
||||||
|
|
||||||
Instead of SSID-based detection (Android 13+ privacy issues), we use **Gateway IP Comparison**:
|
We use **Gateway IP Comparison** to check if the server is reachable:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
fun isInHomeNetwork(): Boolean {
|
fun isInHomeNetwork(): Boolean {
|
||||||
@@ -127,7 +127,7 @@ The app uses **4 different sync triggers** with different use cases:
|
|||||||
| **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes |
|
| **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes |
|
||||||
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes |
|
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes |
|
||||||
| **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes |
|
| **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes |
|
||||||
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi enabled/SSID changed | ✅ Yes |
|
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi connected | ✅ Yes |
|
||||||
|
|
||||||
### Server Reachability Check (Pre-Check)
|
### Server Reachability Check (Pre-Check)
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
|||||||
| Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None |
|
| Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None |
|
||||||
| Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min |
|
| Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min |
|
||||||
| Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min |
|
| Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min |
|
||||||
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | SSID-based |
|
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | WiFi-based |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ The app requires **minimal permissions**:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**No Location Permissions!**
|
**No Location Permissions!**
|
||||||
Earlier versions required `ACCESS_FINE_LOCATION` for SSID detection. Now we use Gateway IP Comparison.
|
We use Gateway IP Comparison instead of SSID detection. No location permission required.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
|
|||||||
|
|
||||||
### Network Detection
|
### Network Detection
|
||||||
|
|
||||||
Statt SSID-basierter Erkennung (Android 13+ Privacy-Probleme) verwenden wir **Gateway IP Comparison**:
|
Wir verwenden **Gateway IP Comparison** um zu prüfen, ob der Server erreichbar ist:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
fun isInHomeNetwork(): Boolean {
|
fun isInHomeNetwork(): Boolean {
|
||||||
@@ -127,7 +127,7 @@ Die App verwendet **4 verschiedene Sync-Trigger** mit unterschiedlichen Anwendun
|
|||||||
| **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja |
|
| **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja |
|
||||||
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja |
|
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja |
|
||||||
| **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja |
|
| **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja |
|
||||||
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi an/SSID-Wechsel | ✅ Ja |
|
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt` → `SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi verbunden | ✅ Ja |
|
||||||
|
|
||||||
### Server-Erreichbarkeits-Check (Pre-Check)
|
### Server-Erreichbarkeits-Check (Pre-Check)
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
|
|||||||
| Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins |
|
| Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins |
|
||||||
| Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min |
|
| Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min |
|
||||||
| Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min |
|
| Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min |
|
||||||
| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | SSID-basiert |
|
| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | WiFi-basiert |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ Die App benötigt **minimale Permissions**:
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Keine Location Permissions!**
|
**Keine Location Permissions!**
|
||||||
Frühere Versionen benötigten `ACCESS_FINE_LOCATION` für SSID-Erkennung. Jetzt verwenden wir Gateway IP Comparison.
|
Wir verwenden Gateway IP Comparison statt SSID-Erkennung. Keine Standortberechtigung nötig.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
284
docs/FEATURES.en.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Complete Feature List 📋
|
||||||
|
|
||||||
|
**🌍 Languages:** [Deutsch](FEATURES.md) · **English**
|
||||||
|
|
||||||
|
> All features of Simple Notes Sync in detail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Note Management
|
||||||
|
|
||||||
|
### Note Types
|
||||||
|
- ✅ **Text notes** - Classic free-form notes
|
||||||
|
- ✅ **Checklists** _(NEW in v1.4.0)_ - Task lists with tap-to-check
|
||||||
|
- ➕ Add items via input field
|
||||||
|
- ☑️ Tap to check/uncheck
|
||||||
|
- 📌 Long-press for drag & drop sorting
|
||||||
|
- 🗑️ Swipe-to-delete individual items
|
||||||
|
- ~~Strikethrough~~ for completed entries
|
||||||
|
|
||||||
|
### Basic Features
|
||||||
|
- ✅ **Auto-save** - No manual saving needed
|
||||||
|
- ✅ **Title + content** - Clear structure for each note
|
||||||
|
- ✅ **Timestamps** - Creation and modification date automatically
|
||||||
|
- ✅ **Swipe-to-delete** - Intuitive gesture for deletion
|
||||||
|
- ✅ **Confirmation dialog** - Protection against accidental deletion
|
||||||
|
- ✅ **Material Design 3** - Modern, clean UI
|
||||||
|
- ✅ **Dark mode** - Automatically based on system settings
|
||||||
|
- ✅ **Dynamic colors** - Adapts to your Android theme
|
||||||
|
|
||||||
|
### Editor
|
||||||
|
- ✅ **Minimalist editor** - No bells and whistles
|
||||||
|
- ✅ **Auto-focus** - Start writing immediately
|
||||||
|
- ✅ **Fullscreen mode** - Maximum writing space
|
||||||
|
- ✅ **Save button** - Manual confirmation possible
|
||||||
|
- ✅ **Back navigation** - Saves automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Backup & Restore
|
||||||
|
|
||||||
|
### Local Backup System
|
||||||
|
- ✅ **JSON export** - All notes in one file
|
||||||
|
- ✅ **Free location choice** - Downloads, SD card, cloud folder
|
||||||
|
- ✅ **Filenames with timestamp** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
|
||||||
|
- ✅ **Complete export** - Title, content, timestamps, IDs
|
||||||
|
- ✅ **Human-readable format** - JSON with formatting
|
||||||
|
- ✅ **Independent from server** - Works completely offline
|
||||||
|
|
||||||
|
### Restore Modes
|
||||||
|
- ✅ **Merge** - Add new notes, keep existing ones _(Default)_
|
||||||
|
- ✅ **Replace** - Delete all and import backup
|
||||||
|
- ✅ **Overwrite duplicates** - Backup wins on ID conflicts
|
||||||
|
- ✅ **Automatic safety backup** - Before every restore
|
||||||
|
- ✅ **Backup validation** - Checks format and version
|
||||||
|
- ✅ **Error handling** - Clear error messages on issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Desktop Integration
|
||||||
|
|
||||||
|
### Markdown Export
|
||||||
|
- ✅ **Automatic export** - Each note → `.md` file
|
||||||
|
- ✅ **Checklists as task lists** _(NEW)_ - `- [ ]` / `- [x]` format (GitHub-compatible)
|
||||||
|
- ✅ **Dual-format** - JSON (master) + Markdown (mirror)
|
||||||
|
- ✅ **Filename sanitization** - Safe filenames from titles
|
||||||
|
- ✅ **Duplicate handling** _(NEW)_ - ID suffix for same titles
|
||||||
|
- ✅ **Frontmatter metadata** - YAML with ID, timestamps, type
|
||||||
|
- ✅ **WebDAV sync** - Parallel to JSON sync
|
||||||
|
- ✅ **Optional** - Toggle in settings
|
||||||
|
- ✅ **Initial export** - All existing notes when activated
|
||||||
|
- ✅ **Progress indicator** - Shows X/Y during export
|
||||||
|
|
||||||
|
### Markdown Import
|
||||||
|
- ✅ **Desktop → App** - Import changes from desktop
|
||||||
|
- ✅ **Last-Write-Wins** - Conflict resolution via timestamp
|
||||||
|
- ✅ **Frontmatter parsing** - Reads metadata from `.md` files
|
||||||
|
- ✅ **Detect new notes** - Automatically adopt to app
|
||||||
|
- ✅ **Detect updates** - Only if desktop version is newer
|
||||||
|
- ✅ **Error tolerance** - Individual errors don't abort import
|
||||||
|
|
||||||
|
### WebDAV Access
|
||||||
|
- ✅ **Network drive mount** - Windows, macOS, Linux
|
||||||
|
- ✅ **Any Markdown editor** - VS Code, Typora, Notepad++, iA Writer
|
||||||
|
- ✅ **Live editing** - Direct access to `.md` files
|
||||||
|
- ✅ **Folder structure** - `/notes/` for JSON, `/notes-md/` for Markdown
|
||||||
|
- ✅ **Automatic folder creation** - On first sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Synchronization
|
||||||
|
|
||||||
|
### Auto-Sync
|
||||||
|
- ✅ **Interval selection** - 15, 30 or 60 minutes
|
||||||
|
- ✅ **WiFi trigger** - Sync on WiFi connection _(no SSID restriction)_
|
||||||
|
- ✅ **Battery-friendly** - ~0.2-0.8% per day
|
||||||
|
- ✅ **Smart server check** - Sync only when server is reachable
|
||||||
|
- ✅ **WorkManager** - Reliable background execution
|
||||||
|
- ✅ **Battery optimization compatible** - Works even with Doze mode
|
||||||
|
|
||||||
|
### Sync Triggers (6 total)
|
||||||
|
1. ✅ **Periodic sync** - Automatically after interval
|
||||||
|
2. ✅ **App-start sync** - When opening the app
|
||||||
|
3. ✅ **WiFi-connect sync** - On any WiFi connection
|
||||||
|
4. ✅ **Manual sync** - Button in settings
|
||||||
|
5. ✅ **Pull-to-refresh** - Swipe gesture in notes list
|
||||||
|
6. ✅ **Settings-save sync** - After server configuration
|
||||||
|
|
||||||
|
### Sync Mechanism
|
||||||
|
- ✅ **Upload** - Local changes to server
|
||||||
|
- ✅ **Download** - Server changes to app
|
||||||
|
- ✅ **Conflict detection** - On simultaneous changes
|
||||||
|
- ✅ **Conflict-free merging** - Last-Write-Wins via timestamp
|
||||||
|
- ✅ **Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
|
||||||
|
- ✅ **Error handling** - Retry on network issues
|
||||||
|
- ✅ **Offline-first** - App works without server
|
||||||
|
|
||||||
|
### Server Connection
|
||||||
|
- ✅ **WebDAV protocol** - Standard protocol
|
||||||
|
- ✅ **HTTP/HTTPS** - HTTP only local, HTTPS for external
|
||||||
|
- ✅ **Username/password** - Basic authentication
|
||||||
|
- ✅ **Connection test** - Test in settings
|
||||||
|
- ✅ **Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_
|
||||||
|
- ✅ **Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Privacy & Security
|
||||||
|
|
||||||
|
### Self-Hosted
|
||||||
|
- ✅ **Own server** - Full control over data
|
||||||
|
- ✅ **No cloud** - No third parties
|
||||||
|
- ✅ **No tracking** - No analytics, no telemetry
|
||||||
|
- ✅ **No account** - Only server credentials
|
||||||
|
- ✅ **100% open source** - MIT License
|
||||||
|
|
||||||
|
### Data Security
|
||||||
|
- ✅ **Local storage** - App-private storage (Android)
|
||||||
|
- ✅ **WebDAV encryption** - HTTPS for external servers
|
||||||
|
- ✅ **Password storage** - Android SharedPreferences (encrypted)
|
||||||
|
- ✅ **No third-party libs** - Only Android SDK + Sardine (WebDAV)
|
||||||
|
|
||||||
|
### Developer Features
|
||||||
|
- ✅ **File logging** - Optional, only when enabled _(NEW in v1.3.2)_
|
||||||
|
- ✅ **Privacy notice** - Explicit warning on activation
|
||||||
|
- ✅ **Local logs** - Logs stay on device
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔋 Performance & Optimization
|
||||||
|
|
||||||
|
### Battery Efficiency
|
||||||
|
- ✅ **Optimized sync intervals** - 15/30/60 min
|
||||||
|
- ✅ **WiFi-only** - No mobile data sync
|
||||||
|
- ✅ **Smart server check** - Sync only when server is reachable
|
||||||
|
- ✅ **WorkManager** - System-optimized execution
|
||||||
|
- ✅ **Doze mode compatible** - Sync runs even in standby
|
||||||
|
- ✅ **Measured consumption:**
|
||||||
|
- 15 min: ~0.8% / day (~23 mAh)
|
||||||
|
- 30 min: ~0.4% / day (~12 mAh) ⭐ _Recommended_
|
||||||
|
- 60 min: ~0.2% / day (~6 mAh)
|
||||||
|
|
||||||
|
### App Performance
|
||||||
|
- ✅ **Offline-first** - Works without internet
|
||||||
|
- ✅ **Instant-load** - Notes load in <100ms
|
||||||
|
- ✅ **Smooth scrolling** - RecyclerView with ViewHolder
|
||||||
|
- ✅ **Material Design 3** - Native Android UI
|
||||||
|
- ✅ **Kotlin Coroutines** - Asynchronous operations
|
||||||
|
- ✅ **Minimal APK size** - ~2 MB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Technical Details
|
||||||
|
|
||||||
|
### Platform
|
||||||
|
- ✅ **Android 8.0+** (API 26+)
|
||||||
|
- ✅ **Target SDK 36** (Android 15)
|
||||||
|
- ✅ **Kotlin** - Modern programming language
|
||||||
|
- ✅ **Material Design 3** - Latest design guidelines
|
||||||
|
- ✅ **ViewBinding** - Type-safe view references
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
- ✅ **MVVM-Light** - Simple architecture
|
||||||
|
- ✅ **Single Activity** - Modern navigation
|
||||||
|
- ✅ **Kotlin Coroutines** - Async/Await pattern
|
||||||
|
- ✅ **Dispatchers.IO** - Background operations
|
||||||
|
- ✅ **SharedPreferences** - Settings storage
|
||||||
|
- ✅ **File-based storage** - JSON files locally
|
||||||
|
- ✅ **Custom exceptions** - Dedicated SyncException for better error handling _(NEW in v1.3.2)_
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- ✅ **AndroidX** - Jetpack libraries
|
||||||
|
- ✅ **Material Components** - Material Design 3
|
||||||
|
- ✅ **Sardine** - WebDAV client (com.thegrizzlylabs)
|
||||||
|
- ✅ **Gson** - JSON serialization
|
||||||
|
- ✅ **WorkManager** - Background tasks
|
||||||
|
- ✅ **OkHttp** - HTTP client (via Sardine)
|
||||||
|
|
||||||
|
### Build Variants
|
||||||
|
- ✅ **Standard** - Universal APK (100% FOSS, no Google dependencies)
|
||||||
|
- ✅ **F-Droid** - Identical to Standard (100% FOSS)
|
||||||
|
- ✅ **Debug/Release** - Development and production
|
||||||
|
- ✅ **No Google Services** - Completely FOSS, no proprietary libraries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Server Compatibility
|
||||||
|
|
||||||
|
### Tested WebDAV Servers
|
||||||
|
- ✅ **Docker WebDAV** (recommended for self-hosting)
|
||||||
|
- ✅ **Nextcloud** - Fully compatible
|
||||||
|
- ✅ **ownCloud** - Works perfectly
|
||||||
|
- ✅ **Apache mod_dav** - Standard WebDAV
|
||||||
|
- ✅ **nginx + WebDAV** - With correct configuration
|
||||||
|
|
||||||
|
### Server Features
|
||||||
|
- ✅ **Basic Auth** - Username/password
|
||||||
|
- ✅ **Directory listing** - For download
|
||||||
|
- ✅ **PUT/GET** - Upload/download
|
||||||
|
- ✅ **MKCOL** - Create folders
|
||||||
|
- ✅ **DELETE** - Delete notes (future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔮 Future Features
|
||||||
|
|
||||||
|
Planned for upcoming versions:
|
||||||
|
|
||||||
|
### v1.4.0 - Checklists
|
||||||
|
- ⏳ **Checklist notes** - New note type with checkboxes
|
||||||
|
- ⏳ **Completed items** - Strike-through/check off
|
||||||
|
- ⏳ **Drag & drop** - Reorder items
|
||||||
|
|
||||||
|
### v1.5.0 - Internationalization
|
||||||
|
- ⏳ **Multi-language** - German + English UI
|
||||||
|
- ⏳ **Language selection** - Selectable in settings
|
||||||
|
- ⏳ **Full translation** - All strings in both languages
|
||||||
|
|
||||||
|
### v1.6.0 - Modern APIs
|
||||||
|
- ⏳ **Replace LocalBroadcastManager** - Use SharedFlow instead
|
||||||
|
- ⏳ **PackageInfo Flags** - Use PackageInfoFlags.of()
|
||||||
|
- ⏳ **Complexity refactoring** - Split long functions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Comparison with Other Apps
|
||||||
|
|
||||||
|
| Feature | Simple Notes Sync | Google Keep | Nextcloud Notes |
|
||||||
|
|---------|------------------|-------------|-----------------|
|
||||||
|
| Offline-first | ✅ | ⚠️ Limited | ⚠️ Limited |
|
||||||
|
| Self-hosted | ✅ | ❌ | ✅ |
|
||||||
|
| Auto-sync | ✅ | ✅ | ✅ |
|
||||||
|
| Markdown export | ✅ | ❌ | ✅ |
|
||||||
|
| Desktop access | ✅ (WebDAV) | ✅ (Web) | ✅ (Web + WebDAV) |
|
||||||
|
| Local backup | ✅ | ❌ | ⚠️ Server backup |
|
||||||
|
| No Google account | ✅ | ❌ | ✅ |
|
||||||
|
| Open source | ✅ MIT | ❌ | ✅ AGPL |
|
||||||
|
| APK size | ~2 MB | ~50 MB | ~8 MB |
|
||||||
|
| Battery usage | ~0.4%/day | ~1-2%/day | ~0.5%/day |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
**Q: Do I need a server?**
|
||||||
|
A: No! The app works completely offline. The server is optional for sync.
|
||||||
|
|
||||||
|
**Q: Which server is best?**
|
||||||
|
A: For beginners: Docker WebDAV (simple, easy). For pros: Nextcloud (many features).
|
||||||
|
|
||||||
|
**Q: Does Markdown export work without Desktop Integration?**
|
||||||
|
A: No, you need to activate the feature in settings.
|
||||||
|
|
||||||
|
**Q: Will my data be lost if I switch servers?**
|
||||||
|
A: No! Create a local backup, switch servers, restore.
|
||||||
|
|
||||||
|
**Q: Why JSON + Markdown?**
|
||||||
|
A: JSON is reliable and fast (master). Markdown is human-readable (mirror for desktop).
|
||||||
|
|
||||||
|
**Q: Can I use the app without Google Play?**
|
||||||
|
A: Yes! Download the APK directly from GitHub or use F-Droid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last update:** v1.3.2 (2026-01-10)
|
||||||
284
docs/FEATURES.md
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
# Vollständige Feature-Liste 📋
|
||||||
|
|
||||||
|
**🌍 Languages:** **Deutsch** · [English](FEATURES.en.md)
|
||||||
|
|
||||||
|
> Alle Features von Simple Notes Sync im Detail
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notiz-Verwaltung
|
||||||
|
|
||||||
|
### Notiz-Typen
|
||||||
|
- ✅ **Textnotizen** - Klassische Freitext-Notizen
|
||||||
|
- ✅ **Checklisten** _(NEU in v1.4.0)_ - Aufgabenlisten mit Tap-to-Check
|
||||||
|
- ➕ Items hinzufügen über Eingabefeld
|
||||||
|
- ☑️ Tap zum Abhaken/Wieder-Öffnen
|
||||||
|
- 📌 Long-Press für Drag & Drop Sortierung
|
||||||
|
- 🗑️ Swipe-to-Delete für einzelne Items
|
||||||
|
- ~~Durchstreichen~~ bei erledigten Einträgen
|
||||||
|
|
||||||
|
### Basis-Funktionen
|
||||||
|
- ✅ **Automatisches Speichern** - Kein manuelles Speichern nötig
|
||||||
|
- ✅ **Titel + Inhalt** - Klare Struktur für jede Notiz
|
||||||
|
- ✅ **Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
|
||||||
|
- ✅ **Swipe-to-Delete** - Intuitive Geste zum Löschen
|
||||||
|
- ✅ **Bestätigungs-Dialog** - Schutz vor versehentlichem Löschen
|
||||||
|
- ✅ **Material Design 3** - Moderne, saubere UI
|
||||||
|
- ✅ **Dark Mode** - Automatisch je nach System-Einstellung
|
||||||
|
- ✅ **Dynamic Colors** - Passt sich deinem Android-Theme an
|
||||||
|
|
||||||
|
### Editor
|
||||||
|
- ✅ **Minimalistischer Editor** - Kein Schnickschnack
|
||||||
|
- ✅ **Auto-Fokus** - Direkt losschreiben
|
||||||
|
- ✅ **Vollbild-Modus** - Maximale Schreibfläche
|
||||||
|
- ✅ **Speichern-Button** - Manuelle Bestätigung möglich
|
||||||
|
- ✅ **Zurück-Navigation** - Speichert automatisch
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Backup & Wiederherstellung
|
||||||
|
|
||||||
|
### Lokales Backup System
|
||||||
|
- ✅ **JSON-Export** - Alle Notizen in einer Datei
|
||||||
|
- ✅ **Freie Speicherort-Wahl** - Downloads, SD-Karte, Cloud-Ordner
|
||||||
|
- ✅ **Dateinamen mit Zeitstempel** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
|
||||||
|
- ✅ **Vollständiger Export** - Titel, Inhalt, Timestamps, IDs
|
||||||
|
- ✅ **Menschenlesbares Format** - JSON mit Formatierung
|
||||||
|
- ✅ **Unabhängig vom Server** - Funktioniert komplett offline
|
||||||
|
|
||||||
|
### Wiederherstellungs-Modi
|
||||||
|
- ✅ **Zusammenführen (Merge)** - Neue Notizen hinzufügen, bestehende behalten _(Standard)_
|
||||||
|
- ✅ **Ersetzen (Replace)** - Alle löschen und Backup importieren
|
||||||
|
- ✅ **Duplikate überschreiben (Overwrite)** - Backup gewinnt bei ID-Konflikten
|
||||||
|
- ✅ **Automatisches Sicherheits-Backup** - Vor jeder Wiederherstellung
|
||||||
|
- ✅ **Backup-Validierung** - Prüft Format und Version
|
||||||
|
- ✅ **Fehlerbehandlung** - Klare Fehlermeldungen bei Problemen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Desktop-Integration
|
||||||
|
|
||||||
|
### Markdown-Export
|
||||||
|
- ✅ **Automatischer Export** - Jede Notiz → `.md` Datei
|
||||||
|
- ✅ **Checklisten als Task-Listen** _(NEU)_ - `- [ ]` / `- [x]` Format (GitHub-kompatibel)
|
||||||
|
- ✅ **Dual-Format** - JSON (Master) + Markdown (Mirror)
|
||||||
|
- ✅ **Dateinamen-Sanitization** - Sichere Dateinamen aus Titeln
|
||||||
|
- ✅ **Duplikat-Handling** _(NEU)_ - ID-Suffix bei gleichen Titeln
|
||||||
|
- ✅ **Frontmatter-Metadata** - YAML mit ID, Timestamps, Type
|
||||||
|
- ✅ **WebDAV-Sync** - Parallel zum JSON-Sync
|
||||||
|
- ✅ **Optional** - In Einstellungen ein/ausschaltbar
|
||||||
|
- ✅ **Initial Export** - Alle bestehenden Notizen beim Aktivieren
|
||||||
|
- ✅ **Progress-Anzeige** - Zeigt X/Y beim Export
|
||||||
|
|
||||||
|
### Markdown-Import
|
||||||
|
- ✅ **Desktop → App** - Änderungen vom Desktop importieren
|
||||||
|
- ✅ **Last-Write-Wins** - Konfliktauflösung via Timestamp
|
||||||
|
- ✅ **Frontmatter-Parsing** - Liest Metadata aus `.md` Dateien
|
||||||
|
- ✅ **Neue Notizen erkennen** - Automatisch in App übernehmen
|
||||||
|
- ✅ **Updates erkennen** - Nur wenn Desktop-Version neuer ist
|
||||||
|
- ✅ **Fehlertoleranz** - Einzelne Fehler brechen Import nicht ab
|
||||||
|
|
||||||
|
### WebDAV-Zugriff
|
||||||
|
- ✅ **Network Drive Mount** - Windows, macOS, Linux
|
||||||
|
- ✅ **Jeder Markdown-Editor** - VS Code, Typora, Notepad++, iA Writer
|
||||||
|
- ✅ **Live-Bearbeitung** - Direkter Zugriff auf `.md` Dateien
|
||||||
|
- ✅ **Ordner-Struktur** - `/notes/` für JSON, `/notes-md/` für Markdown
|
||||||
|
- ✅ **Automatische Ordner-Erstellung** - Beim ersten Sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Synchronisation
|
||||||
|
|
||||||
|
### Auto-Sync
|
||||||
|
- ✅ **Intervall-Auswahl** - 15, 30 oder 60 Minuten
|
||||||
|
- ✅ **WiFi-Trigger** - Sync bei WiFi-Verbindung _(keine SSID-Einschränkung)_
|
||||||
|
- ✅ **Akkuschonend** - ~0.2-0.8% pro Tag
|
||||||
|
- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar
|
||||||
|
- ✅ **WorkManager** - Zuverlässige Background-Ausführung
|
||||||
|
- ✅ **Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode
|
||||||
|
|
||||||
|
### Sync-Trigger (6 Stück)
|
||||||
|
1. ✅ **Periodic Sync** - Automatisch nach Intervall
|
||||||
|
2. ✅ **App-Start Sync** - Beim Öffnen der App
|
||||||
|
3. ✅ **WiFi-Connect Sync** - Bei jeder WiFi-Verbindung
|
||||||
|
4. ✅ **Manual Sync** - Button in Einstellungen
|
||||||
|
5. ✅ **Pull-to-Refresh** - Wisch-Geste in Notizliste
|
||||||
|
6. ✅ **Settings-Save Sync** - Nach Server-Konfiguration
|
||||||
|
|
||||||
|
### Sync-Mechanismus
|
||||||
|
- ✅ **Upload** - Lokale Änderungen zum Server
|
||||||
|
- ✅ **Download** - Server-Änderungen in App
|
||||||
|
- ✅ **Konflikt-Erkennung** - Bei gleichzeitigen Änderungen
|
||||||
|
- ✅ **Konfliktfreies Merging** - Last-Write-Wins via Timestamp
|
||||||
|
- ✅ **Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
|
||||||
|
- ✅ **Fehlerbehandlung** - Retry bei Netzwerkproblemen
|
||||||
|
- ✅ **Offline-First** - App funktioniert ohne Server
|
||||||
|
|
||||||
|
### Server-Verbindung
|
||||||
|
- ✅ **WebDAV-Protokoll** - Standard-Protokoll
|
||||||
|
- ✅ **HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
|
||||||
|
- ✅ **Username/Password** - Basic Authentication
|
||||||
|
- ✅ **Connection Test** - In Einstellungen testen
|
||||||
|
- ✅ **Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_
|
||||||
|
- ✅ **Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 Privacy & Sicherheit
|
||||||
|
|
||||||
|
### Self-Hosted
|
||||||
|
- ✅ **Eigener Server** - Volle Kontrolle über Daten
|
||||||
|
- ✅ **Keine Cloud** - Keine Drittanbieter
|
||||||
|
- ✅ **Kein Tracking** - Keine Analytik, keine Telemetrie
|
||||||
|
- ✅ **Kein Account** - Nur Server-Zugangsdaten
|
||||||
|
- ✅ **100% Open Source** - MIT Lizenz
|
||||||
|
|
||||||
|
### Daten-Sicherheit
|
||||||
|
- ✅ **Lokale Speicherung** - App-Private Storage (Android)
|
||||||
|
- ✅ **WebDAV-Verschlüsselung** - HTTPS für externe Server
|
||||||
|
- ✅ **Passwort-Speicherung** - Android SharedPreferences (verschlüsselt)
|
||||||
|
- ✅ **Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV)
|
||||||
|
|
||||||
|
### Entwickler-Features
|
||||||
|
- ✅ **Datei-Logging** - Optional, nur bei Aktivierung _(NEU in v1.3.2)_
|
||||||
|
- ✅ **Datenschutz-Hinweis** - Explizite Warnung bei Aktivierung
|
||||||
|
- ✅ **Lokale Logs** - Logs bleiben auf dem Gerät
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔋 Performance & Optimierung
|
||||||
|
|
||||||
|
### Akku-Effizienz
|
||||||
|
- ✅ **Optimierte Sync-Intervalle** - 15/30/60 Min
|
||||||
|
- ✅ **WiFi-Only** - Kein Mobile Data Sync
|
||||||
|
- ✅ **Smart Server-Check** - Sync nur wenn Server erreichbar
|
||||||
|
- ✅ **WorkManager** - System-optimierte Ausführung
|
||||||
|
- ✅ **Doze Mode kompatibel** - Sync läuft auch im Standby
|
||||||
|
- ✅ **Gemessener Verbrauch:**
|
||||||
|
- 15 Min: ~0.8% / Tag (~23 mAh)
|
||||||
|
- 30 Min: ~0.4% / Tag (~12 mAh) ⭐ _Empfohlen_
|
||||||
|
- 60 Min: ~0.2% / Tag (~6 mAh)
|
||||||
|
|
||||||
|
### App-Performance
|
||||||
|
- ✅ **Offline-First** - Funktioniert ohne Internet
|
||||||
|
- ✅ **Instant-Load** - Notizen laden in <100ms
|
||||||
|
- ✅ **Smooth Scrolling** - RecyclerView mit ViewHolder
|
||||||
|
- ✅ **Material Design 3** - Native Android UI
|
||||||
|
- ✅ **Kotlin Coroutines** - Asynchrone Operationen
|
||||||
|
- ✅ **Minimale APK-Größe** - ~2 MB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Technische Details
|
||||||
|
|
||||||
|
### Plattform
|
||||||
|
- ✅ **Android 8.0+** (API 26+)
|
||||||
|
- ✅ **Target SDK 36** (Android 15)
|
||||||
|
- ✅ **Kotlin** - Moderne Programmiersprache
|
||||||
|
- ✅ **Material Design 3** - Neueste Design-Richtlinien
|
||||||
|
- ✅ **ViewBinding** - Typ-sichere View-Referenzen
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
- ✅ **MVVM-Light** - Einfache Architektur
|
||||||
|
- ✅ **Single Activity** - Moderne Navigation
|
||||||
|
- ✅ **Kotlin Coroutines** - Async/Await Pattern
|
||||||
|
- ✅ **Dispatchers.IO** - Background-Operationen
|
||||||
|
- ✅ **SharedPreferences** - Settings-Speicherung
|
||||||
|
- ✅ **File-Based Storage** - JSON-Dateien lokal
|
||||||
|
- ✅ **Custom Exceptions** - Dedizierte SyncException für bessere Fehlerbehandlung _(NEU in v1.3.2)_
|
||||||
|
|
||||||
|
### Abhängigkeiten
|
||||||
|
- ✅ **AndroidX** - Jetpack Libraries
|
||||||
|
- ✅ **Material Components** - Material Design 3
|
||||||
|
- ✅ **Sardine** - WebDAV Client (com.thegrizzlylabs)
|
||||||
|
- ✅ **Gson** - JSON Serialization
|
||||||
|
- ✅ **WorkManager** - Background Tasks
|
||||||
|
- ✅ **OkHttp** - HTTP Client (via Sardine)
|
||||||
|
|
||||||
|
### Build-Varianten
|
||||||
|
- ✅ **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
|
||||||
|
- ✅ **F-Droid** - Identisch mit Standard (100% FOSS)
|
||||||
|
- ✅ **Debug/Release** - Entwicklung und Production
|
||||||
|
- ✅ **Keine Google Services** - Komplett FOSS, keine proprietären Bibliotheken
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Server-Kompatibilität
|
||||||
|
|
||||||
|
### Getestete WebDAV-Server
|
||||||
|
- ✅ **Docker WebDAV** (empfohlen für Self-Hosting)
|
||||||
|
- ✅ **Nextcloud** - Vollständig kompatibel
|
||||||
|
- ✅ **ownCloud** - Funktioniert einwandfrei
|
||||||
|
- ✅ **Apache mod_dav** - Standard WebDAV
|
||||||
|
- ✅ **nginx + WebDAV** - Mit korrekter Konfiguration
|
||||||
|
|
||||||
|
### Server-Features
|
||||||
|
- ✅ **Basic Auth** - Username/Password
|
||||||
|
- ✅ **Directory Listing** - Für Download
|
||||||
|
- ✅ **PUT/GET** - Upload/Download
|
||||||
|
- ✅ **MKCOL** - Ordner erstellen
|
||||||
|
- ✅ **DELETE** - Notizen löschen (zukünftig)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔮 Zukünftige Features
|
||||||
|
|
||||||
|
Geplant für kommende Versionen:
|
||||||
|
|
||||||
|
### v1.4.0 - Checklisten
|
||||||
|
- ⏳ **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
|
||||||
|
- ⏳ **Erledigte Items** - Durchstreichen/Abhaken
|
||||||
|
- ⏳ **Drag & Drop** - Items neu anordnen
|
||||||
|
|
||||||
|
### v1.5.0 - Internationalisierung
|
||||||
|
- ⏳ **Mehrsprachigkeit** - Deutsch + Englisch UI
|
||||||
|
- ⏳ **Sprachauswahl** - In Einstellungen wählbar
|
||||||
|
- ⏳ **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
|
||||||
|
|
||||||
|
### v1.6.0 - Modern APIs
|
||||||
|
- ⏳ **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
|
||||||
|
- ⏳ **PackageInfo Flags** - PackageInfoFlags.of() verwenden
|
||||||
|
- ⏳ **Komplexitäts-Refactoring** - Lange Funktionen aufteilen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Vergleich mit anderen Apps
|
||||||
|
|
||||||
|
| Feature | Simple Notes Sync | Google Keep | Nextcloud Notes |
|
||||||
|
|---------|------------------|-------------|-----------------|
|
||||||
|
| Offline-First | ✅ | ⚠️ Eingeschränkt | ⚠️ Eingeschränkt |
|
||||||
|
| Self-Hosted | ✅ | ❌ | ✅ |
|
||||||
|
| Auto-Sync | ✅ | ✅ | ✅ |
|
||||||
|
| Markdown-Export | ✅ | ❌ | ✅ |
|
||||||
|
| Desktop-Zugriff | ✅ (WebDAV) | ✅ (Web) | ✅ (Web + WebDAV) |
|
||||||
|
| Lokales Backup | ✅ | ❌ | ⚠️ Server-Backup |
|
||||||
|
| Kein Google-Account | ✅ | ❌ | ✅ |
|
||||||
|
| Open Source | ✅ MIT | ❌ | ✅ AGPL |
|
||||||
|
| APK-Größe | ~2 MB | ~50 MB | ~8 MB |
|
||||||
|
| Akku-Verbrauch | ~0.4%/Tag | ~1-2%/Tag | ~0.5%/Tag |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
**Q: Brauche ich einen Server?**
|
||||||
|
A: Nein! Die App funktioniert auch komplett offline. Der Server ist optional für Sync.
|
||||||
|
|
||||||
|
**Q: Welcher Server ist am besten?**
|
||||||
|
A: Für Einstieg: Docker WebDAV (einfach, leicht). Für Profis: Nextcloud (viele Features).
|
||||||
|
|
||||||
|
**Q: Funktioniert Markdown-Export ohne Desktop-Integration?**
|
||||||
|
A: Nein, du musst das Feature in den Einstellungen aktivieren.
|
||||||
|
|
||||||
|
**Q: Gehen meine Daten verloren wenn ich den Server wechsle?**
|
||||||
|
A: Nein! Erstelle ein lokales Backup, wechsle Server, stelle wieder her.
|
||||||
|
|
||||||
|
**Q: Warum JSON + Markdown?**
|
||||||
|
A: JSON ist zuverlässig und schnell (Master). Markdown ist menschenlesbar (Mirror für Desktop).
|
||||||
|
|
||||||
|
**Q: Kann ich die App ohne Google Play nutzen?**
|
||||||
|
A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Letzte Aktualisierung:** v1.3.2 (2026-01-10)
|
||||||