Compare commits
46 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 | ||
|
|
9b6bf04954 | ||
|
|
7644f5bf76 | ||
|
|
300dc67a7c | ||
|
|
c42a9c84d7 | ||
|
|
7942b73af3 | ||
|
|
b4d868434f | ||
|
|
ad5fd0a313 | ||
|
|
80a46e0e49 | ||
|
|
1338da9dde | ||
|
|
0c2d069443 | ||
|
|
70efc13ea4 | ||
|
|
55401977e3 |
152
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
name: 🐛 Bug Report / Fehlerbericht
|
||||
description: Melde einen Fehler in der App / Report a bug in the app
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Danke für deinen Bug Report! / Thanks for reporting a bug!
|
||||
|
||||
Bitte fülle alle relevanten Felder aus. / Please fill out all relevant fields.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 🐛 Beschreibung / Description
|
||||
description: Beschreibe den Fehler kurz und präzise / Describe the bug briefly and precisely
|
||||
placeholder: "z.B. Auto-Sync funktioniert nicht mehr nach App-Update / e.g. Auto-sync stopped working after app update"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: android-version
|
||||
attributes:
|
||||
label: 📱 Android Version
|
||||
description: Welche Android Version verwendest du? / Which Android version are you using?
|
||||
options:
|
||||
- Android 14
|
||||
- Android 13
|
||||
- Android 12
|
||||
- Android 11
|
||||
- Android 10
|
||||
- Android 9
|
||||
- Android 8.1
|
||||
- Android 8.0
|
||||
- Andere / Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: app-version
|
||||
attributes:
|
||||
label: 📲 App Version
|
||||
description: Welche Version der App verwendest du? (Einstellungen → Über) / Which app version? (Settings → About)
|
||||
placeholder: "z.B. / e.g. v1.1.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: 📱 Gerät / Device
|
||||
description: Welches Gerät verwendest du? / Which device are you using?
|
||||
placeholder: "z.B. Samsung Galaxy S21, Google Pixel 7, etc."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 🔄 Schritte zum Reproduzieren / Steps to Reproduce
|
||||
description: Wie kann der Fehler reproduziert werden? / How can the bug be reproduced?
|
||||
placeholder: |
|
||||
1. Öffne die App / Open the app
|
||||
2. Gehe zu Einstellungen / Go to settings
|
||||
3. Klicke auf ... / Click on ...
|
||||
4. Fehler tritt auf / Bug occurs
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: ✅ Erwartetes Verhalten / Expected Behavior
|
||||
description: Was sollte passieren? / What should happen?
|
||||
placeholder: "z.B. Notizen sollten alle 30 Min synchronisiert werden / e.g. Notes should sync every 30 min"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: ❌ Tatsächliches Verhalten / Actual Behavior
|
||||
description: Was passiert stattdessen? / What happens instead?
|
||||
placeholder: "z.B. Sync funktioniert nicht, keine Notification / e.g. Sync doesn't work, no notification"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: sync-enabled
|
||||
attributes:
|
||||
label: <EFBFBD> Auto-Sync aktiviert? / Auto-Sync enabled?
|
||||
options:
|
||||
- "Ja / Yes"
|
||||
- "Nein / No"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: battery-optimization
|
||||
attributes:
|
||||
label: 🔋 Akku-Optimierung deaktiviert? / Battery optimization disabled?
|
||||
description: Einstellungen → Apps → Simple Notes → Akku → Nicht optimieren / Settings → Apps → Simple Notes → Battery → Don't optimize
|
||||
options:
|
||||
- "Ja, deaktiviert / Yes, disabled"
|
||||
- "Nein, noch optimiert / No, still optimized"
|
||||
- "Weiß nicht / Don't know"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: server-config
|
||||
attributes:
|
||||
label: 🌐 Server-Konfiguration / Server Configuration
|
||||
description: Falls relevant / If relevant (KEINE Passwörter! / NO passwords!)
|
||||
placeholder: |
|
||||
- Server läuft lokal / Server runs locally
|
||||
- Docker auf Raspberry Pi / Docker on Raspberry Pi
|
||||
- Gleiche WiFi / Same WiFi
|
||||
- Server-IP: 192.168.x.x (erste 3 Zahlen reichen / first 3 numbers sufficient)
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 📋 Logs / Screenshots
|
||||
description: |
|
||||
Falls vorhanden: Screenshots oder LogCat Output / If available: Screenshots or LogCat output
|
||||
|
||||
LogCat Filter: `adb logcat -s SyncWorker NetworkMonitor WebDavSyncService`
|
||||
placeholder: "Füge hier Logs oder Screenshots ein / Paste logs or screenshots here"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 🔧 Zusätzliche Informationen / Additional Context
|
||||
description: Gibt es noch etwas, das wir wissen sollten? / Is there anything else we should know?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: ✅ Checklist
|
||||
options:
|
||||
- label: Ich habe die [Troubleshooting-Sektion](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting) gelesen / I have read the troubleshooting section
|
||||
required: false
|
||||
- label: Ich habe "Verbindung testen" in den Einstellungen probiert / I have tried "Test connection" in settings
|
||||
required: false
|
||||
11
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📖 Dokumentation / Documentation
|
||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/README.md
|
||||
about: Schau zuerst in die Dokumentation / Check documentation first
|
||||
- name: 🚀 Quick Start Guide
|
||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md
|
||||
about: Schritt-für-Schritt Anleitung / Step-by-step guide
|
||||
- name: 🐛 Troubleshooting
|
||||
url: https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting
|
||||
about: Häufige Probleme und Lösungen / Common issues and solutions
|
||||
84
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
name: 💡 Feature Request / Feature-Wunsch
|
||||
description: Schlage eine neue Funktion vor / Suggest a new feature
|
||||
title: "[FEATURE] "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Danke für deinen Feature-Vorschlag! / Thanks for your feature suggestion!
|
||||
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
attributes:
|
||||
label: 💡 Feature-Beschreibung / Feature Description
|
||||
description: Was möchtest du hinzugefügt haben? / What would you like to be added?
|
||||
placeholder: "z.B. Notizen sollten Markdown-Formatierung unterstützen / e.g. Notes should support markdown formatting"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 🎯 Problem / Motivation
|
||||
description: Welches Problem würde dieses Feature lösen? / What problem would this feature solve?
|
||||
placeholder: "z.B. Ich möchte Code-Snippets und Listen in meinen Notizen formatieren / e.g. I want to format code snippets and lists in my notes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 📝 Vorgeschlagene Lösung / Proposed Solution
|
||||
description: Wie könnte das Feature funktionieren? / How could the feature work?
|
||||
placeholder: "z.B. Markdown-Editor mit Live-Preview / e.g. Markdown editor with live preview"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 🔄 Alternativen / Alternatives
|
||||
description: Hast du andere Lösungsansätze in Betracht gezogen? / Have you considered other solutions?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 📱 Plattform / Platform
|
||||
description: Für welche Komponente ist das Feature? / For which component is the feature?
|
||||
options:
|
||||
- Android App
|
||||
- WebDAV Server
|
||||
- Dokumentation / Documentation
|
||||
- Andere / Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 🌟 Priorität (aus deiner Sicht) / Priority (from your perspective)
|
||||
options:
|
||||
- Nice to have
|
||||
- Wichtig / Important
|
||||
- Sehr wichtig / Very important
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: willing-to-contribute
|
||||
attributes:
|
||||
label: 🤝 Beitragen / Contribute
|
||||
options:
|
||||
- label: Ich würde gerne bei der Implementierung helfen / I would like to help with implementation
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 🔧 Zusätzliche Informationen / Additional Context
|
||||
description: Screenshots, Mockups, Links, ähnliche Apps, etc.
|
||||
validations:
|
||||
required: false
|
||||
76
.github/ISSUE_TEMPLATE/question.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: ❓ Question / Frage
|
||||
description: Stelle eine Frage zur Nutzung / Ask a question about usage
|
||||
title: "[QUESTION] "
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Hast du eine Frage? Wir helfen gerne! / Have a question? We're happy to help!
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: ❓ Frage / Question
|
||||
description: Was möchtest du wissen? / What would you like to know?
|
||||
placeholder: "z.B. Wie kann ich die Sync-URL für einen externen Server konfigurieren? / e.g. How can I configure the sync URL for an external server?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: documentation-checked
|
||||
attributes:
|
||||
label: 📚 Dokumentation gelesen / Documentation checked
|
||||
description: Hast du bereits in der Dokumentation nachgeschaut? / Have you already checked the documentation?
|
||||
options:
|
||||
- label: Ich habe die [README](https://github.com/inventory69/simple-notes-sync/blob/main/README.md) gelesen / I have read the README
|
||||
required: false
|
||||
- label: Ich habe den [Quick Start Guide](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md) gelesen / I have read the Quick Start Guide
|
||||
required: false
|
||||
- label: Ich habe das [Troubleshooting](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md#troubleshooting) durchgearbeitet / I have checked the troubleshooting section
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: tried
|
||||
attributes:
|
||||
label: 🔍 Was hast du bereits versucht? / What have you already tried?
|
||||
description: Hilf uns, dir besser zu helfen / Help us help you better
|
||||
placeholder: "z.B. Ich habe versucht die Server-URL anzupassen, aber... / e.g. I tried adjusting the server URL, but..."
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: topic
|
||||
attributes:
|
||||
label: <EFBFBD> Thema / Topic
|
||||
description: Um was geht es? / What is this about?
|
||||
options:
|
||||
- Server Setup / Server-Einrichtung
|
||||
- App-Konfiguration / App configuration
|
||||
- Sync-Probleme / Sync issues
|
||||
- Netzwerk / Network
|
||||
- Android-Einstellungen / Android settings
|
||||
- Andere / Other
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: <EFBFBD> Kontext / Context
|
||||
description: Zusätzliche Informationen die hilfreich sein könnten / Additional information that might be helpful
|
||||
placeholder: |
|
||||
- Android Version: Android 13
|
||||
- App Version: v1.1.0
|
||||
- Server: Raspberry Pi mit Docker / Raspberry Pi with Docker
|
||||
- Netzwerk: Lokales WiFi / Local WiFi
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 🔧 Screenshots / Config
|
||||
description: Falls hilfreich (KEINE Passwörter!) / If helpful (NO passwords!)
|
||||
validations:
|
||||
required: false
|
||||
201
.github/workflows/build-production-apk.yml
vendored
@@ -2,11 +2,11 @@ name: Build Android Production APK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ] # Trigger on push to main branch
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
branches: [ main ] # Nur bei Push/Merge auf main triggern
|
||||
workflow_dispatch: # Ermöglicht manuellen Trigger
|
||||
|
||||
permissions:
|
||||
contents: write # Required for creating releases
|
||||
contents: write # Fuer Release-Erstellung erforderlich
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -14,185 +14,138 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
- name: Code auschecken
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
- name: Java einrichten
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
|
||||
- name: Generate Production version number
|
||||
- name: Semantic Versionsnummer aus build.gradle.kts extrahieren
|
||||
run: |
|
||||
# Generate semantic version: YYYY.MM.DD
|
||||
VERSION_NAME="$(date +'%Y.%m.%d')"
|
||||
# Version aus build.gradle.kts fuer F-Droid Kompatibilität
|
||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
|
||||
# Use GitHub run number as build number for production
|
||||
BUILD_NUMBER="${{ github.run_number }}"
|
||||
# Semantische Versionierung (nicht datums-basiert)
|
||||
BUILD_NUMBER="$VERSION_CODE"
|
||||
|
||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
|
||||
echo "VERSION_TAG=v$VERSION_NAME-prod.$BUILD_NUMBER" >> $GITHUB_ENV
|
||||
echo "VERSION_TAG=v$VERSION_NAME" >> $GITHUB_ENV
|
||||
|
||||
echo "🚀 Generated PRODUCTION version: $VERSION_NAME+$BUILD_NUMBER"
|
||||
echo "🚀 Baue Version: $VERSION_NAME (Code: $BUILD_NUMBER)"
|
||||
|
||||
- name: Update build.gradle.kts with Production version
|
||||
- name: Version aus build.gradle.kts verifizieren
|
||||
run: |
|
||||
# Update versionCode and versionName in build.gradle.kts
|
||||
sed -i "s/versionCode = [0-9]*/versionCode = ${{ env.BUILD_NUMBER }}/" android/app/build.gradle.kts
|
||||
sed -i "s/versionName = \".*\"/versionName = \"${{ env.VERSION_NAME }}\"/" android/app/build.gradle.kts
|
||||
|
||||
echo "✅ Updated build.gradle.kts:"
|
||||
echo "✅ Verwende Version aus build.gradle.kts:"
|
||||
grep -E "versionCode|versionName" android/app/build.gradle.kts
|
||||
|
||||
- name: Setup Android signing
|
||||
- name: Android Signing konfigurieren
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
|
||||
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
|
||||
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
|
||||
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
|
||||
echo "storeFile=simple-notes-release.jks" >> android/key.properties
|
||||
echo "✅ Signing configuration created"
|
||||
echo "✅ Signing-Konfiguration erstellt"
|
||||
|
||||
- name: Build Production APK (Release)
|
||||
- name: Produktions-APK bauen (Standard + F-Droid Flavors)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleRelease --no-daemon --stacktrace
|
||||
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon --stacktrace
|
||||
|
||||
- name: Copy APK variants to root with version names
|
||||
- name: APK-Varianten mit Versionsnamen kopieren
|
||||
run: |
|
||||
mkdir -p apk-output
|
||||
|
||||
# Universal APK
|
||||
cp android/app/build/outputs/apk/release/app-universal-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk
|
||||
# Standard Flavor - Universal APK
|
||||
cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
|
||||
|
||||
# ARM64 APK
|
||||
cp android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk
|
||||
# F-Droid Flavor - Universal APK
|
||||
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
|
||||
|
||||
# ARMv7 APK
|
||||
cp android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk \
|
||||
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk
|
||||
|
||||
echo "✅ APK files prepared:"
|
||||
echo "✅ APK-Dateien vorbereitet:"
|
||||
ls -lh apk-output/
|
||||
|
||||
- name: Upload APK artifacts
|
||||
- name: APK-Artefakte hochladen
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
|
||||
path: apk-output/*.apk
|
||||
retention-days: 90 # Keep production builds longer
|
||||
retention-days: 90 # Produktions-Builds länger aufbewahren
|
||||
|
||||
- name: Get commit info
|
||||
- name: Commit-Informationen auslesen
|
||||
run: |
|
||||
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
|
||||
|
||||
# Get full commit message preserving newlines and emojis (UTF-8)
|
||||
{
|
||||
echo 'COMMIT_MSG<<EOF'
|
||||
git -c core.quotepath=false log -1 --pretty=%B
|
||||
echo 'EOF'
|
||||
} >> $GITHUB_ENV
|
||||
- name: F-Droid Changelogs lesen
|
||||
run: |
|
||||
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
|
||||
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
# Lese englische Changelog (optional)
|
||||
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||
echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
|
||||
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||
else
|
||||
echo "CHANGELOG_EN=" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Create Production Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ env.VERSION_TAG }}
|
||||
name: "📝 Simple Notes Sync v${{ env.VERSION_NAME }} (Produktions-Release)"
|
||||
name: "📝 Simple Notes Sync v${{ env.VERSION_NAME }}"
|
||||
files: apk-output/*.apk
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: false
|
||||
body: |
|
||||
# 📝 Produktions-Release: Simple Notes Sync v${{ env.VERSION_NAME }}
|
||||
## 📦 Downloads
|
||||
|
||||
## Build-Informationen
|
||||
| Variante | Datei | Info |
|
||||
|----------|-------|------|
|
||||
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
|
||||
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
|
||||
|
||||
- **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }}
|
||||
- **Build-Datum:** ${{ env.COMMIT_DATE }}
|
||||
---
|
||||
|
||||
## 📋 Changelog / Release Notes
|
||||
|
||||
${{ env.CHANGELOG_DE }}
|
||||
|
||||
<details>
|
||||
<summary>🌍 English Version</summary>
|
||||
|
||||
${{ env.CHANGELOG_EN }}
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📊 Build-Info
|
||||
|
||||
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
|
||||
- **Datum:** ${{ env.COMMIT_DATE }}
|
||||
- **Commit:** ${{ env.SHORT_SHA }}
|
||||
- **Umgebung:** 🟢 **PRODUKTION**
|
||||
|
||||
---
|
||||
|
||||
## 📋 Änderungen
|
||||
|
||||
${{ env.COMMIT_MSG }}
|
||||
|
||||
---
|
||||
|
||||
## 📦 Download & Installation
|
||||
|
||||
### Welche APK soll ich herunterladen?
|
||||
|
||||
| Dein Gerät | Lade diese APK herunter | Größe | Kompatibilität |
|
||||
|------------|------------------------|-------|----------------|
|
||||
| 🤷 Nicht sicher? | `simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk` | ~5 MB | Funktioniert auf allen Geräten |
|
||||
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk` | ~3 MB | Schneller, kleiner |
|
||||
| Ältere Geräte | `simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk` | ~3 MB | Ältere ARM-Chips |
|
||||
|
||||
### Installationsschritte
|
||||
1. Lade die passende APK aus den Assets unten herunter
|
||||
2. Aktiviere "Installation aus unbekannten Quellen" in den Android-Einstellungen
|
||||
3. Öffne die heruntergeladene APK-Datei
|
||||
4. Folge den Installationsanweisungen
|
||||
5. Konfiguriere die WebDAV-Einstellungen in der App
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Funktionen
|
||||
|
||||
- ✅ Automatische WebDAV-Synchronisation alle 30 Minuten (~0,4% Akku/Tag)
|
||||
- ✅ Intelligente Gateway-Erkennung (automatische Heimnetzwerk-Erkennung)
|
||||
- ✅ Material Design 3 Oberfläche
|
||||
- ✅ Datenschutzorientiert (kein Tracking, keine Analysen)
|
||||
- ✅ Offline-First Architektur
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Update von vorheriger Version
|
||||
|
||||
Installiere diese APK einfach über die bestehende Installation - alle Daten und Einstellungen bleiben erhalten.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Obtanium - Auto-Update App
|
||||
|
||||
Erhalte automatische Updates mit [Obtanium](https://github.com/ImranR98/Obtanium/releases/latest).
|
||||
|
||||
**Einrichtung:**
|
||||
1. Installiere Obtanium über den Link oben
|
||||
2. Füge die App mit dieser URL hinzu: `https://github.com/dettmersLiq/simple-notes-sync`
|
||||
3. Aktiviere Auto-Updates
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Support
|
||||
|
||||
Bei Problemen oder Fragen öffne bitte ein Issue auf GitHub.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Datenschutz & Sicherheit
|
||||
|
||||
- Alle Daten werden über deinen eigenen WebDAV-Server synchronisiert
|
||||
- Keine Drittanbieter-Analysen oder Tracking
|
||||
- Keine Internet-Berechtigungen außer für WebDAV-Sync
|
||||
- Alle Synchronisationsvorgänge verschlüsselt (HTTPS)
|
||||
- Open Source - prüfe den Code selbst
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Erstellt mit
|
||||
|
||||
- **Sprache:** Kotlin
|
||||
- **UI:** Material Design 3
|
||||
- **Sync:** WorkManager + WebDAV
|
||||
- **Target SDK:** Android 16 (API 36)
|
||||
- **Min SDK:** Android 8.0 (API 26)
|
||||
**[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)**
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
99
.github/workflows/pr-build-check.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: PR Build Check
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'android/**'
|
||||
- '.github/workflows/pr-build-check.yml'
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Test APK
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Code auschecken
|
||||
uses: actions/checkout@v4
|
||||
- name: Java einrichten
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- name: Gradle Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
- name: Version auslesen
|
||||
run: |
|
||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||
VERSION_CODE=$(grep "versionCode = " android/app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
||||
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
|
||||
- name: Debug Build erstellen (ohne Signing)
|
||||
run: |
|
||||
cd android
|
||||
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
||||
- name: Unit Tests ausfuehren
|
||||
run: |
|
||||
cd android
|
||||
./gradlew test --no-daemon --stacktrace
|
||||
continue-on-error: true
|
||||
- name: Build-Ergebnis pruefen
|
||||
run: |
|
||||
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
|
||||
echo "✅ Standard Debug APK erfolgreich gebaut"
|
||||
ls -lh android/app/build/outputs/apk/standard/debug/*.apk
|
||||
else
|
||||
echo "❌ Standard Debug APK Build fehlgeschlagen"
|
||||
exit 1
|
||||
fi
|
||||
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
|
||||
echo "✅ F-Droid Debug APK erfolgreich gebaut"
|
||||
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||
else
|
||||
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
|
||||
exit 1
|
||||
fi
|
||||
- name: Debug APKs hochladen (Artefakte)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: debug-apks-pr-${{ github.event.pull_request.number }}
|
||||
path: |
|
||||
android/app/build/outputs/apk/standard/debug/*.apk
|
||||
android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||
retention-days: 30
|
||||
- name: Kommentar zu PR hinzufuegen
|
||||
uses: actions/github-script@v7
|
||||
if: success()
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const standardApk = fs.readdirSync('android/app/build/outputs/apk/standard/debug/')
|
||||
.filter(f => f.endsWith('.apk'));
|
||||
const fdroidApk = fs.readdirSync('android/app/build/outputs/apk/fdroid/debug/')
|
||||
.filter(f => f.endsWith('.apk'));
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `## ✅ Build erfolgreich!
|
||||
|
||||
**Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})
|
||||
|
||||
### 📦 Debug APKs (Test-Builds)
|
||||
Die Debug-APKs wurden erfolgreich gebaut und sind als Artefakte verfuegbar:
|
||||
|
||||
**Standard Flavor:**
|
||||
${standardApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||
|
||||
**F-Droid Flavor:**
|
||||
${fdroidApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||
|
||||
> ⚠️ **Hinweis:** Dies sind unsigned Debug-Builds zum Testen. Production Releases werden nur bei Merge auf \`main\` erstellt.
|
||||
|
||||
[📥 Download Artefakte](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`
|
||||
})
|
||||
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
|
||||
263
CONTRIBUTING.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Contributing to Simple Notes Sync 🤝
|
||||
|
||||
> Beiträge sind willkommen! / Contributions are welcome!
|
||||
|
||||
**🌍 Languages:** [Deutsch](#deutsch) · [English](#english)
|
||||
|
||||
---
|
||||
|
||||
## Deutsch
|
||||
|
||||
Danke, dass du zu Simple Notes Sync beitragen möchtest!
|
||||
|
||||
### 🚀 Schnellstart
|
||||
|
||||
1. **Fork & Clone**
|
||||
```bash
|
||||
git clone https://github.com/DEIN-USERNAME/simple-notes-sync.git
|
||||
cd simple-notes-sync
|
||||
```
|
||||
|
||||
2. **Branch erstellen**
|
||||
```bash
|
||||
git checkout -b feature/mein-feature
|
||||
# oder
|
||||
git checkout -b fix/mein-bugfix
|
||||
```
|
||||
|
||||
3. **Änderungen machen**
|
||||
- Code schreiben
|
||||
- Testen
|
||||
- Committen mit aussagekräftiger Message
|
||||
|
||||
4. **Pull Request erstellen**
|
||||
- Push deinen Branch: `git push origin feature/mein-feature`
|
||||
- Gehe zu GitHub und erstelle einen Pull Request
|
||||
- Beschreibe deine Änderungen
|
||||
|
||||
### 🧪 Automatische Tests
|
||||
|
||||
Wenn du einen Pull Request erstellst, läuft automatisch ein **Build Check**:
|
||||
|
||||
- ✅ Debug APKs werden gebaut (Standard + F-Droid)
|
||||
- ✅ Unit Tests werden ausgeführt
|
||||
- ✅ APKs werden als Artefakte hochgeladen (zum Testen)
|
||||
- ✅ Build-Status wird als Kommentar im PR gepostet
|
||||
|
||||
**Wichtig:** Der Build muss erfolgreich sein (grüner Haken ✅) bevor der PR gemerged werden kann.
|
||||
|
||||
### 📱 Android App Development
|
||||
|
||||
**Build lokal testen:**
|
||||
```bash
|
||||
cd android
|
||||
|
||||
# Debug Build
|
||||
./gradlew assembleStandardDebug
|
||||
|
||||
# Tests ausführen
|
||||
./gradlew test
|
||||
|
||||
# Lint Check
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
**Anforderungen:**
|
||||
- Android SDK 36 (Target)
|
||||
- Android SDK 24 (Minimum)
|
||||
- JDK 17
|
||||
- Kotlin 1.9+
|
||||
|
||||
### 📝 Code Style
|
||||
|
||||
- **Kotlin:** Folge den [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html)
|
||||
- **Formatierung:** Android Studio Default Formatter
|
||||
- **Kommentare:** Deutsch oder Englisch (bevorzugt Englisch für Code)
|
||||
|
||||
### 🐛 Bug Reports
|
||||
|
||||
Nutze die [Bug Report Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) mit:
|
||||
- Android Version
|
||||
- App Version
|
||||
- Schritte zum Reproduzieren
|
||||
- Erwartetes vs. tatsächliches Verhalten
|
||||
|
||||
### 💡 Feature Requests
|
||||
|
||||
Nutze die [Feature Request Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) und beschreibe:
|
||||
- Was soll hinzugefügt werden
|
||||
- Warum ist es nützlich
|
||||
- Wie könnte es funktionieren
|
||||
|
||||
### 📚 Dokumentation
|
||||
|
||||
Dokumentations-Verbesserungen sind auch Contributions!
|
||||
|
||||
**Dateien:**
|
||||
- `README.md` / `README.en.md` - Übersicht
|
||||
- `QUICKSTART.md` / `QUICKSTART.en.md` - Schritt-für-Schritt Anleitung
|
||||
- `DOCS.md` / `DOCS.en.md` - Technische Details
|
||||
- `server/README.md` / `server/README.en.md` - Server Setup
|
||||
|
||||
**Bitte:** Halte beide Sprachen (DE/EN) synchron!
|
||||
|
||||
### ✅ Pull Request Checklist
|
||||
|
||||
- [ ] Code kompiliert lokal (`./gradlew assembleStandardDebug`)
|
||||
- [ ] Tests laufen durch (`./gradlew test`)
|
||||
- [ ] Keine neuen Lint-Warnungen
|
||||
- [ ] Commit-Messages sind aussagekräftig
|
||||
- [ ] Dokumentation aktualisiert (falls nötig)
|
||||
- [ ] Beide Sprachen aktualisiert (bei Doku-Änderungen)
|
||||
|
||||
### 🎯 Was wird akzeptiert?
|
||||
|
||||
**✅ Gerne:**
|
||||
- Bug Fixes
|
||||
- Performance-Verbesserungen
|
||||
- Neue Features (nach Diskussion in einem Issue)
|
||||
- Dokumentations-Verbesserungen
|
||||
- Tests
|
||||
- UI/UX Verbesserungen
|
||||
|
||||
**❌ Schwierig:**
|
||||
- Breaking Changes (bitte erst als Issue diskutieren)
|
||||
- Komplett neue Architektur
|
||||
- Dependencies mit fragwürdigen Lizenzen
|
||||
|
||||
### 📄 Lizenz
|
||||
|
||||
Indem du contributest, stimmst du zu dass dein Code unter der [MIT License](LICENSE) veröffentlicht wird.
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
Thanks for wanting to contribute to Simple Notes Sync!
|
||||
|
||||
### 🚀 Quick Start
|
||||
|
||||
1. **Fork & Clone**
|
||||
```bash
|
||||
git clone https://github.com/YOUR-USERNAME/simple-notes-sync.git
|
||||
cd simple-notes-sync
|
||||
```
|
||||
|
||||
2. **Create Branch**
|
||||
```bash
|
||||
git checkout -b feature/my-feature
|
||||
# or
|
||||
git checkout -b fix/my-bugfix
|
||||
```
|
||||
|
||||
3. **Make Changes**
|
||||
- Write code
|
||||
- Test
|
||||
- Commit with meaningful message
|
||||
|
||||
4. **Create Pull Request**
|
||||
- Push your branch: `git push origin feature/my-feature`
|
||||
- Go to GitHub and create a Pull Request
|
||||
- Describe your changes
|
||||
|
||||
### 🧪 Automated Tests
|
||||
|
||||
When you create a Pull Request, an automatic **Build Check** runs:
|
||||
|
||||
- ✅ Debug APKs are built (Standard + F-Droid)
|
||||
- ✅ Unit tests are executed
|
||||
- ✅ APKs are uploaded as artifacts (for testing)
|
||||
- ✅ Build status is posted as comment in PR
|
||||
|
||||
**Important:** The build must succeed (green checkmark ✅) before the PR can be merged.
|
||||
|
||||
### 📱 Android App Development
|
||||
|
||||
**Test build locally:**
|
||||
```bash
|
||||
cd android
|
||||
|
||||
# Debug Build
|
||||
./gradlew assembleStandardDebug
|
||||
|
||||
# Run tests
|
||||
./gradlew test
|
||||
|
||||
# Lint Check
|
||||
./gradlew lint
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Android SDK 36 (Target)
|
||||
- Android SDK 24 (Minimum)
|
||||
- JDK 17
|
||||
- Kotlin 1.9+
|
||||
|
||||
### 📝 Code Style
|
||||
|
||||
- **Kotlin:** Follow [Kotlin Coding Conventions](https://kotlinlang.org/docs/coding-conventions.html)
|
||||
- **Formatting:** Android Studio Default Formatter
|
||||
- **Comments:** German or English (preferably English for code)
|
||||
|
||||
### 🐛 Bug Reports
|
||||
|
||||
Use the [Bug Report Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) with:
|
||||
- Android version
|
||||
- App version
|
||||
- Steps to reproduce
|
||||
- Expected vs. actual behavior
|
||||
|
||||
### 💡 Feature Requests
|
||||
|
||||
Use the [Feature Request Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose) and describe:
|
||||
- What should be added
|
||||
- Why is it useful
|
||||
- How could it work
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
Documentation improvements are also contributions!
|
||||
|
||||
**Files:**
|
||||
- `README.md` / `README.en.md` - Overview
|
||||
- `QUICKSTART.md` / `QUICKSTART.en.md` - Step-by-step guide
|
||||
- `DOCS.md` / `DOCS.en.md` - Technical details
|
||||
- `server/README.md` / `server/README.en.md` - Server setup
|
||||
|
||||
**Please:** Keep both languages (DE/EN) in sync!
|
||||
|
||||
### ✅ Pull Request Checklist
|
||||
|
||||
- [ ] Code compiles locally (`./gradlew assembleStandardDebug`)
|
||||
- [ ] Tests pass (`./gradlew test`)
|
||||
- [ ] No new lint warnings
|
||||
- [ ] Commit messages are meaningful
|
||||
- [ ] Documentation updated (if needed)
|
||||
- [ ] Both languages updated (for doc changes)
|
||||
|
||||
### 🎯 What Gets Accepted?
|
||||
|
||||
**✅ Welcome:**
|
||||
- Bug fixes
|
||||
- Performance improvements
|
||||
- New features (after discussion in an issue)
|
||||
- Documentation improvements
|
||||
- Tests
|
||||
- UI/UX improvements
|
||||
|
||||
**❌ Difficult:**
|
||||
- Breaking changes (please discuss in issue first)
|
||||
- Completely new architecture
|
||||
- Dependencies with questionable licenses
|
||||
|
||||
### 📄 License
|
||||
|
||||
By contributing, you agree that your code will be published under the [MIT License](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Fragen? / Questions?
|
||||
|
||||
Öffne ein [Issue](https://github.com/inventory69/simple-notes-sync/issues) oder nutze die [Question Template](https://github.com/inventory69/simple-notes-sync/issues/new/choose).
|
||||
|
||||
**Frohe Weihnachten & Happy Coding! 🎄**
|
||||
@@ -1,188 +0,0 @@
|
||||
# GitHub Actions Setup Guide
|
||||
|
||||
This guide explains how to set up the GitHub Actions workflow for automated APK builds with proper signing.
|
||||
|
||||
## Overview
|
||||
|
||||
The workflow in `.github/workflows/build-production-apk.yml` automatically:
|
||||
- Builds signed APKs on every push to `main`
|
||||
- Generates version numbers using `YYYY.MM.DD` + build number
|
||||
- Creates 3 APK variants (universal, arm64-v8a, armeabi-v7a)
|
||||
- Creates GitHub releases with all APKs attached
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- GitHub CLI (`gh`) installed
|
||||
- Java 17+ installed (for keytool)
|
||||
- Git repository initialized with GitHub remote
|
||||
|
||||
## Step 1: Generate Signing Keystore
|
||||
|
||||
⚠️ **IMPORTANT**: Store the keystore securely! Without it, you cannot publish updates to your app.
|
||||
|
||||
```bash
|
||||
# Navigate to project root
|
||||
cd /path/to/simple-notes-sync
|
||||
|
||||
# Generate keystore (replace values as needed)
|
||||
keytool -genkey -v \
|
||||
-keystore android/app/simple-notes-release.jks \
|
||||
-keyalg RSA \
|
||||
-keysize 2048 \
|
||||
-validity 10000 \
|
||||
-alias simple-notes
|
||||
|
||||
# You will be prompted for:
|
||||
# - Keystore password (remember this!)
|
||||
# - Key password (remember this!)
|
||||
# - Your name, organization, etc.
|
||||
```
|
||||
|
||||
**Store these securely:**
|
||||
- Keystore password
|
||||
- Key password
|
||||
- Alias: `simple-notes`
|
||||
- Keystore file: `android/app/simple-notes-release.jks`
|
||||
|
||||
⚠️ **BACKUP**: Make a backup of the keystore file in a secure location (NOT in the repository).
|
||||
|
||||
## Step 2: Base64 Encode Keystore
|
||||
|
||||
```bash
|
||||
# Create base64 encoded version
|
||||
base64 android/app/simple-notes-release.jks > simple-notes-release.jks.b64
|
||||
|
||||
# Or on macOS:
|
||||
base64 -i android/app/simple-notes-release.jks -o simple-notes-release.jks.b64
|
||||
```
|
||||
|
||||
## Step 3: Set GitHub Secrets
|
||||
|
||||
Using GitHub CLI (recommended):
|
||||
|
||||
```bash
|
||||
# Set KEYSTORE_BASE64 secret
|
||||
gh secret set KEYSTORE_BASE64 < simple-notes-release.jks.b64
|
||||
|
||||
# Set KEYSTORE_PASSWORD (will prompt for input)
|
||||
gh secret set KEYSTORE_PASSWORD
|
||||
|
||||
# Set KEY_PASSWORD (will prompt for input)
|
||||
gh secret set KEY_PASSWORD
|
||||
|
||||
# Set KEY_ALIAS (value: simple-notes)
|
||||
printf "simple-notes" | gh secret set KEY_ALIAS
|
||||
```
|
||||
|
||||
Or manually via GitHub web interface:
|
||||
1. Go to repository Settings → Secrets and variables → Actions
|
||||
2. Click "New repository secret"
|
||||
3. Add these secrets:
|
||||
- `KEYSTORE_BASE64`: Paste content of `simple-notes-release.jks.b64`
|
||||
- `KEYSTORE_PASSWORD`: Your keystore password
|
||||
- `KEY_PASSWORD`: Your key password
|
||||
- `KEY_ALIAS`: `simple-notes`
|
||||
|
||||
## Step 4: Verify Setup
|
||||
|
||||
```bash
|
||||
# Check secrets are set
|
||||
gh secret list
|
||||
|
||||
# Expected output:
|
||||
# KEYSTORE_BASE64 Updated YYYY-MM-DD
|
||||
# KEYSTORE_PASSWORD Updated YYYY-MM-DD
|
||||
# KEY_PASSWORD Updated YYYY-MM-DD
|
||||
# KEY_ALIAS Updated YYYY-MM-DD
|
||||
```
|
||||
|
||||
## Step 5: Cleanup
|
||||
|
||||
```bash
|
||||
# Remove sensitive files (they're in .gitignore, but double-check)
|
||||
rm simple-notes-release.jks.b64
|
||||
rm -f android/key.properties # Generated by workflow
|
||||
|
||||
# Verify keystore is NOT tracked by git
|
||||
git status | grep -i jks
|
||||
# Should return nothing
|
||||
```
|
||||
|
||||
## Step 6: Trigger First Build
|
||||
|
||||
```bash
|
||||
# Commit and push to main
|
||||
git add .
|
||||
git commit -m "🚀 feat: Add GitHub Actions deployment workflow"
|
||||
git push origin main
|
||||
|
||||
# Or manually trigger workflow
|
||||
gh workflow run build-production-apk.yml
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. Go to GitHub repository → Actions tab
|
||||
2. Check workflow run status
|
||||
3. Once complete, go to Releases tab
|
||||
4. Verify release was created with 3 APK variants
|
||||
5. Download and test one of the APKs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build fails with "Keystore not found"
|
||||
- Check `KEYSTORE_BASE64` secret is set correctly
|
||||
- Verify base64 encoding was done without line breaks
|
||||
|
||||
### Build fails with "Incorrect password"
|
||||
- Verify `KEYSTORE_PASSWORD` and `KEY_PASSWORD` are correct
|
||||
- Re-set secrets if needed
|
||||
|
||||
### APK files not found
|
||||
- Check build logs for errors in assembleRelease step
|
||||
- Verify APK output paths match workflow expectations
|
||||
|
||||
### Updates don't work
|
||||
- Ensure you're using the same keystore for all builds
|
||||
- Verify `applicationId` in build.gradle.kts matches
|
||||
|
||||
## Security Notes
|
||||
|
||||
- ✅ Keystore is base64-encoded in GitHub secrets (secure)
|
||||
- ✅ Passwords are stored in GitHub secrets (encrypted)
|
||||
- ✅ `key.properties` and `.jks` files are in `.gitignore`
|
||||
- ⚠️ Never commit keystore files to repository
|
||||
- ⚠️ Keep backup of keystore in secure location
|
||||
- ⚠️ Don't share keystore passwords
|
||||
|
||||
## Versioning
|
||||
|
||||
Versions follow this pattern:
|
||||
- **Version Name**: `YYYY.MM.DD` (e.g., `2025.01.15`)
|
||||
- **Version Code**: GitHub run number (e.g., `42`)
|
||||
- **Release Tag**: `vYYYY.MM.DD-prod.BUILD` (e.g., `v2025.01.15-prod.42`)
|
||||
|
||||
This ensures:
|
||||
- Semantic versioning based on release date
|
||||
- Incremental version codes for Play Store compatibility
|
||||
- Clear distinction between builds
|
||||
|
||||
## APK Variants
|
||||
|
||||
The workflow generates 3 APK variants:
|
||||
|
||||
1. **Universal APK** (~5 MB)
|
||||
- Works on all devices
|
||||
- Larger file size
|
||||
- Recommended for most users
|
||||
|
||||
2. **arm64-v8a APK** (~3 MB)
|
||||
- For modern devices (2018+)
|
||||
- Smaller, faster
|
||||
- 64-bit ARM processors
|
||||
|
||||
3. **armeabi-v7a APK** (~3 MB)
|
||||
- For older devices
|
||||
- 32-bit ARM processors
|
||||
|
||||
Users can choose based on their device - Obtanium auto-updates work with all variants.
|
||||
2338
IMPROVEMENT_PLAN.md
269
QUICKSTART.en.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# Quick Start Guide - Simple Notes Sync 📝
|
||||
|
||||
> Step-by-step installation and setup guide
|
||||
|
||||
**🌍 Languages:** [Deutsch](QUICKSTART.md) · **English**
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ✅ Android 8.0+ smartphone/tablet
|
||||
- ✅ WiFi connection
|
||||
- ✅ Own server with Docker (optional - for self-hosting)
|
||||
|
||||
---
|
||||
|
||||
## Option 1: With own server (Self-Hosted) 🏠
|
||||
|
||||
### Step 1: Setup WebDAV Server
|
||||
|
||||
On your server (e.g. Raspberry Pi, NAS, VPS):
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
|
||||
# Configure environment variables
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Adjust in `.env`:**
|
||||
```env
|
||||
WEBDAV_PASSWORD=your-secure-password-here
|
||||
```
|
||||
|
||||
**Start server:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Find IP address:**
|
||||
```bash
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
➡️ **Note down:** `http://YOUR-SERVER-IP:8080/`
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Install App
|
||||
|
||||
1. **Download APK:** [Latest version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Choose: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||
|
||||
2. **Allow installation:**
|
||||
- Android: Settings → Security → Enable "Unknown sources" for your browser
|
||||
|
||||
3. **Open and install APK**
|
||||
|
||||
---
|
||||
|
||||
### Step 3: Configure App
|
||||
|
||||
1. **Open app**
|
||||
|
||||
2. **Open settings** (⚙️ icon top right)
|
||||
|
||||
3. **Configure server settings:**
|
||||
|
||||
| Field | Value |
|
||||
|------|------|
|
||||
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
|
||||
| **Username** | `noteuser` |
|
||||
| **Password** | (your password from `.env`) |
|
||||
|
||||
> **💡 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
|
||||
- ❌ Error? → See [Troubleshooting](#troubleshooting)
|
||||
|
||||
5. **Enable auto-sync** (toggle switch)
|
||||
|
||||
6. **Choose sync interval:**
|
||||
- **15 min** - Maximum currency (~0.8% battery/day)
|
||||
- **30 min** - Recommended (~0.4% battery/day) ⭐
|
||||
- **60 min** - Maximum battery life (~0.2% battery/day)
|
||||
|
||||
---
|
||||
|
||||
### Step 4: Create First Note
|
||||
|
||||
1. Back to main view (← arrow)
|
||||
|
||||
2. **"Add note"** (+ icon)
|
||||
|
||||
3. Enter title and text
|
||||
|
||||
4. **Save** (💾 icon)
|
||||
|
||||
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
|
||||
|
||||
🎉 **Done!** Your notes will be automatically synchronized!
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Local notes only (no server) 📱
|
||||
|
||||
You can also use Simple Notes **without a server**:
|
||||
|
||||
1. **Install app** (see step 2 above)
|
||||
|
||||
2. **Use without server configuration:**
|
||||
- Notes are only stored locally
|
||||
- No auto-sync
|
||||
- Perfect for offline-only use
|
||||
|
||||
---
|
||||
|
||||
## 🔋 Disable Battery Optimization
|
||||
|
||||
For reliable auto-sync:
|
||||
|
||||
1. **Settings** → **Apps** → **Simple Notes Sync**
|
||||
|
||||
2. **Battery** → **Battery usage**
|
||||
|
||||
3. Select: **"Don't optimize"** or **"Unrestricted"**
|
||||
|
||||
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Sync Intervals in Detail
|
||||
|
||||
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|
||||
|-----------|-----------|----------|-----------|----------------|
|
||||
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
|
||||
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
|
||||
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Connection test fails
|
||||
|
||||
**Problem:** "Connection failed" during test
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Server running?**
|
||||
```bash
|
||||
docker compose ps
|
||||
# Should show "Up"
|
||||
```
|
||||
|
||||
2. **Same network?**
|
||||
- Smartphone and server must be on same network
|
||||
|
||||
3. **IP address correct?**
|
||||
```bash
|
||||
ip addr show | grep "inet "
|
||||
# Check if IP in URL matches
|
||||
```
|
||||
|
||||
4. **Firewall?**
|
||||
```bash
|
||||
# Open port 8080 (if firewall active)
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
5. **Check server logs:**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Auto-sync not working
|
||||
|
||||
**Problem:** Notes are not automatically synchronized
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **Auto-sync enabled?**
|
||||
- ⚙️ Settings → Toggle "Auto-sync" must be **ON**
|
||||
|
||||
2. **Battery optimization disabled?**
|
||||
- See [Disable Battery Optimization](#-disable-battery-optimization)
|
||||
|
||||
3. **Connected to WiFi?**
|
||||
- Auto-sync triggers on any WiFi connection
|
||||
- Check if you're connected to a WiFi network
|
||||
|
||||
4. **Test manually:**
|
||||
- ⚙️ Settings → "Sync now"
|
||||
- Works? → Auto-sync should work too
|
||||
|
||||
---
|
||||
|
||||
### Notes not showing up
|
||||
|
||||
**Problem:** After installation, no notes visible even though they exist on server
|
||||
|
||||
**Solution:**
|
||||
|
||||
1. **Manually sync once:**
|
||||
- ⚙️ Settings → "Sync now"
|
||||
|
||||
2. **Check server data:**
|
||||
```bash
|
||||
docker compose exec webdav ls -la /data/
|
||||
# Should show .json files
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Sync errors
|
||||
|
||||
**Problem:** Error message during sync
|
||||
|
||||
**Solutions:**
|
||||
|
||||
1. **"401 Unauthorized"** → Wrong password
|
||||
- Check password in app settings
|
||||
- Compare with `.env` on server
|
||||
|
||||
2. **"404 Not Found"** → Wrong URL
|
||||
- Should end with `/` (e.g. `http://192.168.1.100:8080/`)
|
||||
|
||||
3. **"Network error"** → No connection
|
||||
- See [Connection test fails](#connection-test-fails)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Updates
|
||||
|
||||
### Automatic with Obtainium (recommended)
|
||||
|
||||
1. **[Install Obtainium](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||
|
||||
2. **Add app:**
|
||||
- URL: `https://github.com/inventory69/simple-notes-sync`
|
||||
- Enable auto-update
|
||||
|
||||
3. **Done!** Obtainium notifies you of new versions
|
||||
|
||||
### Manual
|
||||
|
||||
1. Download new APK from [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
|
||||
2. Install (overwrites old version)
|
||||
|
||||
3. All data remains intact!
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Further Help
|
||||
|
||||
- **GitHub Issues:** [Report problem](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Complete docs:** [DOCS.en.md](DOCS.en.md)
|
||||
- **Server setup details:** [server/README.en.md](server/README.en.md)
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1.0 · **Created:** December 2025
|
||||
465
QUICKSTART.md
@@ -1,208 +1,269 @@
|
||||
# 🚀 Quick Start Guide
|
||||
# Quick Start Guide - Simple Notes Sync 📝
|
||||
|
||||
## ✅ Server ist bereits gestartet!
|
||||
> Schritt-für-Schritt Anleitung zur Installation und Einrichtung
|
||||
|
||||
Der WebDAV-Server läuft bereits auf:
|
||||
- **Lokal:** `http://localhost:8080/`
|
||||
- **Im Netzwerk:** `http://192.168.0.188:8080/`
|
||||
|
||||
### Credentials
|
||||
- **Username:** `noteuser`
|
||||
- **Password:** `SimpleNotes2025!`
|
||||
|
||||
## 📱 Nächste Schritte: Android App erstellen
|
||||
|
||||
### Option 1: Mit Android Studio (Empfohlen)
|
||||
|
||||
1. **Android Studio öffnen**
|
||||
```
|
||||
File → New → New Project
|
||||
```
|
||||
|
||||
2. **Template wählen:**
|
||||
- Empty Views Activity
|
||||
|
||||
3. **Projekt konfigurieren:**
|
||||
```
|
||||
Name: Simple Notes
|
||||
Package: com.example.simplenotes
|
||||
Save location: /home/liq/gitProjects/simple-notes-sync/android/
|
||||
Language: Kotlin
|
||||
Minimum SDK: API 24 (Android 7.0)
|
||||
Build configuration: Kotlin DSL
|
||||
```
|
||||
|
||||
4. **Dependencies hinzufügen:**
|
||||
|
||||
Öffne `app/build.gradle.kts` und füge hinzu:
|
||||
```kotlin
|
||||
dependencies {
|
||||
// ... existing dependencies
|
||||
|
||||
// WebDAV
|
||||
implementation("com.github.thegrizzlylabs:sardine-android:0.8")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// JSON
|
||||
implementation("com.google.code.gson:gson:2.10.1")
|
||||
|
||||
// WorkManager
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
}
|
||||
```
|
||||
|
||||
Und in `settings.gradle.kts`:
|
||||
```kotlin
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://jitpack.io") } // Für Sardine
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **Code implementieren:**
|
||||
|
||||
Alle Code-Beispiele findest du in:
|
||||
- [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
|
||||
|
||||
Kopiere der Reihe nach:
|
||||
- `models/Note.kt`
|
||||
- `models/SyncStatus.kt`
|
||||
- `storage/NotesStorage.kt`
|
||||
- `utils/DeviceIdGenerator.kt`
|
||||
- `utils/NotificationHelper.kt`
|
||||
- `utils/Extensions.kt`
|
||||
- `utils/Constants.kt`
|
||||
- UI Layouts aus dem Guide
|
||||
- Activities (MainActivity, NoteEditorActivity, SettingsActivity)
|
||||
- `sync/WebDavSyncService.kt`
|
||||
- `sync/WifiSyncReceiver.kt`
|
||||
- `sync/SyncWorker.kt`
|
||||
- `adapters/NotesAdapter.kt`
|
||||
|
||||
6. **AndroidManifest.xml anpassen:**
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
...
|
||||
android:usesCleartextTraffic="true">
|
||||
```
|
||||
|
||||
7. **Build & Run:**
|
||||
```
|
||||
Build → Make Project
|
||||
Run → Run 'app'
|
||||
```
|
||||
|
||||
8. **In der App konfigurieren:**
|
||||
- Einstellungen öffnen
|
||||
- Server URL: `http://192.168.0.188:8080/`
|
||||
- Username: `noteuser`
|
||||
- Password: `SimpleNotes2025!`
|
||||
- Heim-WLAN SSID: `DeinWLANName`
|
||||
- "Verbindung testen" → sollte erfolgreich sein ✓
|
||||
|
||||
### Option 2: Schritt-für-Schritt Implementation
|
||||
|
||||
Folge dem [IMPLEMENTATION_PLAN.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/IMPLEMENTATION_PLAN.md) mit 6 Sprints:
|
||||
|
||||
1. **Sprint 1:** Server & Foundation (bereits done ✓)
|
||||
2. **Sprint 2:** Basic UI (4-6h)
|
||||
3. **Sprint 3:** Settings & WebDAV (6h)
|
||||
4. **Sprint 4:** Auto-Sync (6h)
|
||||
5. **Sprint 5:** Conflicts & Errors (6h)
|
||||
6. **Sprint 6:** Polish & Testing (6h)
|
||||
|
||||
## 🧪 Server testen
|
||||
|
||||
Der Server läuft bereits. Teste ihn:
|
||||
|
||||
```bash
|
||||
# Einfacher Test
|
||||
curl -u noteuser:SimpleNotes2025! http://localhost:8080/
|
||||
|
||||
# Test-Notiz hochladen
|
||||
echo '{"id":"test-123","title":"Test","content":"Hello World","createdAt":1703001234567,"updatedAt":1703001234567,"deviceId":"test","syncStatus":"SYNCED"}' > test.json
|
||||
|
||||
curl -u noteuser:SimpleNotes2025! \
|
||||
-T test.json \
|
||||
http://localhost:8080/test.json
|
||||
|
||||
# Test-Notiz abrufen
|
||||
curl -u noteuser:SimpleNotes2025! http://localhost:8080/test.json
|
||||
|
||||
# Löschen
|
||||
curl -u noteuser:SimpleNotes2025! \
|
||||
-X DELETE \
|
||||
http://localhost:8080/test.json
|
||||
```
|
||||
|
||||
## 📊 Server Management
|
||||
|
||||
```bash
|
||||
cd /home/liq/gitProjects/simple-notes-sync/server
|
||||
|
||||
# Status
|
||||
docker-compose ps
|
||||
|
||||
# Logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stoppen
|
||||
docker-compose down
|
||||
|
||||
# Neu starten
|
||||
docker-compose up -d
|
||||
|
||||
# Daten ansehen
|
||||
ls -la notes-data/
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Server nicht erreichbar von Android
|
||||
|
||||
1. **Firewall prüfen:**
|
||||
```bash
|
||||
sudo ufw status
|
||||
sudo ufw allow 8080
|
||||
```
|
||||
|
||||
2. **Ping-Test:**
|
||||
```bash
|
||||
ping 192.168.0.188
|
||||
```
|
||||
|
||||
3. **Port-Test:**
|
||||
```bash
|
||||
telnet 192.168.0.188 8080
|
||||
```
|
||||
|
||||
### Permission Denied in Android
|
||||
|
||||
- Android 13+: POST_NOTIFICATIONS Permission akzeptieren
|
||||
- Internet Permission in Manifest vorhanden?
|
||||
- `usesCleartextTraffic="true"` gesetzt?
|
||||
|
||||
## 📚 Weitere Hilfe
|
||||
|
||||
- **Vollständige Doku:** [project-docs/simple-notes-sync](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync)
|
||||
- **Android Code:** [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
|
||||
- **Server Setup:** [SERVER_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SERVER_SETUP.md)
|
||||
- **Notifications:** [NOTIFICATIONS.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/NOTIFICATIONS.md)
|
||||
**🌍 Sprachen:** **Deutsch** · [English](QUICKSTART.en.md)
|
||||
|
||||
---
|
||||
|
||||
**Server Status:** ✅ Running on `http://192.168.0.188:8080/`
|
||||
**Next:** Android App in Android Studio erstellen
|
||||
**Estimated Time:** 18-24 Stunden für vollständige App
|
||||
## Voraussetzungen
|
||||
|
||||
Viel Erfolg! 🚀
|
||||
- ✅ Android 8.0+ Smartphone/Tablet
|
||||
- ✅ WLAN-Verbindung
|
||||
- ✅ Eigener Server mit Docker (optional - für Self-Hosting)
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Mit eigenem Server (Self-Hosted) 🏠
|
||||
|
||||
### Schritt 1: WebDAV Server einrichten
|
||||
|
||||
Auf deinem Server (z.B. Raspberry Pi, NAS, VPS):
|
||||
|
||||
```bash
|
||||
# Repository klonen
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
|
||||
# Umgebungsvariablen konfigurieren
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**In `.env` anpassen:**
|
||||
```env
|
||||
WEBDAV_PASSWORD=dein-sicheres-passwort-hier
|
||||
```
|
||||
|
||||
**Server starten:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**IP-Adresse finden:**
|
||||
```bash
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
➡️ **Notiere dir:** `http://DEINE-SERVER-IP:8080/`
|
||||
|
||||
---
|
||||
|
||||
### Schritt 2: App installieren
|
||||
|
||||
1. **APK herunterladen:** [Neueste Version](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
- Wähle: `simple-notes-sync-vX.X.X-standard-universal.apk`
|
||||
|
||||
2. **Installation erlauben:**
|
||||
- Android: Einstellungen → Sicherheit → "Unbekannte Quellen" für deinen Browser aktivieren
|
||||
|
||||
3. **APK öffnen und installieren**
|
||||
|
||||
---
|
||||
|
||||
### Schritt 3: App konfigurieren
|
||||
|
||||
1. **App öffnen**
|
||||
|
||||
2. **Einstellungen öffnen** (⚙️ Icon oben rechts)
|
||||
|
||||
3. **Server-Einstellungen konfigurieren:**
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
|
||||
| **Benutzername** | `noteuser` |
|
||||
| **Passwort** | (dein Passwort aus `.env`) |
|
||||
|
||||
> **💡 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
|
||||
- ✅ Erfolg? → Weiter zu Schritt 4
|
||||
- ❌ Fehler? → Siehe [Troubleshooting](#troubleshooting)
|
||||
|
||||
5. **Auto-Sync aktivieren** (Toggle Switch)
|
||||
|
||||
6. **Sync-Intervall wählen:**
|
||||
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
|
||||
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
|
||||
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
|
||||
|
||||
---
|
||||
|
||||
### Schritt 4: Erste Notiz erstellen
|
||||
|
||||
1. Zurück zur Hauptansicht (← Pfeil)
|
||||
|
||||
2. **"Notiz hinzufügen"** (+ Icon)
|
||||
|
||||
3. Titel und Text eingeben
|
||||
|
||||
4. **Speichern** (💾 Icon)
|
||||
|
||||
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
|
||||
|
||||
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Nur lokale Notizen (kein Server) 📱
|
||||
|
||||
Du kannst Simple Notes auch **ohne Server** nutzen:
|
||||
|
||||
1. **App installieren** (siehe Schritt 2 oben)
|
||||
|
||||
2. **Ohne Server-Konfiguration verwenden:**
|
||||
- Notizen werden nur lokal gespeichert
|
||||
- Kein Auto-Sync
|
||||
- Perfekt für reine Offline-Nutzung
|
||||
|
||||
---
|
||||
|
||||
## 🔋 Akku-Optimierung deaktivieren
|
||||
|
||||
Für zuverlässigen Auto-Sync:
|
||||
|
||||
1. **Einstellungen** → **Apps** → **Simple Notes Sync**
|
||||
|
||||
2. **Akku** → **Akkuverbrauch**
|
||||
|
||||
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
|
||||
|
||||
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Sync-Intervalle im Detail
|
||||
|
||||
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
|
||||
|-----------|-----------|----------|-----------|----------------|
|
||||
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
|
||||
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
|
||||
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Verbindungstest schlägt fehl
|
||||
|
||||
**Problem:** "Verbindung fehlgeschlagen" beim Test
|
||||
|
||||
**Lösungen:**
|
||||
|
||||
1. **Server läuft?**
|
||||
```bash
|
||||
docker compose ps
|
||||
# Sollte "Up" zeigen
|
||||
```
|
||||
|
||||
2. **Gleiches Netzwerk?**
|
||||
- Smartphone und Server müssen im selben Netzwerk sein
|
||||
|
||||
3. **IP-Adresse korrekt?**
|
||||
```bash
|
||||
ip addr show | grep "inet "
|
||||
# Prüfe ob IP in URL stimmt
|
||||
```
|
||||
|
||||
4. **Firewall?**
|
||||
```bash
|
||||
# Port 8080 öffnen (falls Firewall aktiv)
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
5. **Server-Logs prüfen:**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Auto-Sync funktioniert nicht
|
||||
|
||||
**Problem:** Notizen werden nicht automatisch synchronisiert
|
||||
|
||||
**Lösungen:**
|
||||
|
||||
1. **Auto-Sync aktiviert?**
|
||||
- ⚙️ Einstellungen → Toggle "Auto-Sync" muss **AN** sein
|
||||
|
||||
2. **Akku-Optimierung deaktiviert?**
|
||||
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
|
||||
|
||||
3. **Mit WiFi verbunden?**
|
||||
- Auto-Sync triggert bei jeder WiFi-Verbindung
|
||||
- Prüfe, ob du mit einem WLAN verbunden bist
|
||||
|
||||
4. **Manuell testen:**
|
||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||
- Funktioniert das? → Auto-Sync sollte auch funktionieren
|
||||
|
||||
---
|
||||
|
||||
### Notizen werden nicht angezeigt
|
||||
|
||||
**Problem:** Nach Installation sind keine Notizen da, obwohl welche auf dem Server liegen
|
||||
|
||||
**Lösung:**
|
||||
|
||||
1. **Einmalig manuell synchronisieren:**
|
||||
- ⚙️ Einstellungen → "Jetzt synchronisieren"
|
||||
|
||||
2. **Server-Daten prüfen:**
|
||||
```bash
|
||||
docker compose exec webdav ls -la /data/
|
||||
# Sollte .json Dateien zeigen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Fehler beim Sync
|
||||
|
||||
**Problem:** Fehlermeldung beim Synchronisieren
|
||||
|
||||
**Lösungen:**
|
||||
|
||||
1. **"401 Unauthorized"** → Passwort falsch
|
||||
- Prüfe Passwort in App-Einstellungen
|
||||
- Vergleiche mit `.env` auf Server
|
||||
|
||||
2. **"404 Not Found"** → URL falsch
|
||||
- Sollte enden mit `/` (z.B. `http://192.168.1.100:8080/`)
|
||||
|
||||
3. **"Network error"** → Keine Verbindung
|
||||
- Siehe [Verbindungstest schlägt fehl](#verbindungstest-schlägt-fehl)
|
||||
|
||||
---
|
||||
|
||||
## 📱 Updates
|
||||
|
||||
### Automatisch mit Obtainium (empfohlen)
|
||||
|
||||
1. **[Obtainium installieren](https://github.com/ImranR98/Obtanium/releases/latest)**
|
||||
|
||||
2. **App hinzufügen:**
|
||||
- URL: `https://github.com/inventory69/simple-notes-sync`
|
||||
- Auto-Update aktivieren
|
||||
|
||||
3. **Fertig!** Obtainium benachrichtigt dich bei neuen Versionen
|
||||
|
||||
### Manuell
|
||||
|
||||
1. Neue APK von [Releases](https://github.com/inventory69/simple-notes-sync/releases/latest) herunterladen
|
||||
|
||||
2. Installieren (überschreibt alte Version)
|
||||
|
||||
3. Alle Daten bleiben erhalten!
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Weitere Hilfe
|
||||
|
||||
- **GitHub Issues:** [Problem melden](https://github.com/inventory69/simple-notes-sync/issues)
|
||||
- **Vollständige Docs:** [DOCS.md](DOCS.md)
|
||||
- **Server Setup Details:** [server/README.md](server/README.md)
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1.0 · **Erstellt:** Dezember 2025
|
||||
|
||||
105
README.en.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Simple Notes Sync 📝
|
||||
|
||||
> Minimalist offline notes with auto-sync to your own server
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
|
||||
|
||||
**📱 [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**
|
||||
|
||||
---
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notes list">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Edit note">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Settings">
|
||||
</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
|
||||
|
||||
### 1. Server Setup (5 minutes)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
cp .env.example .env
|
||||
# Set password in .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
➡️ **Details:** [Server Setup Guide](server/README.en.md)
|
||||
|
||||
### 2. App Installation (2 minutes)
|
||||
|
||||
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
2. Install & open
|
||||
3. ⚙️ Settings → Configure server:
|
||||
- **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! 🎉
|
||||
|
||||
➡️ **Detailed guide:** [QUICKSTART.en.md](QUICKSTART.en.md)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
| Document | Content |
|
||||
|----------|---------|
|
||||
| **[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
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3
|
||||
145
README.md
@@ -1,130 +1,101 @@
|
||||
# Simple Notes Sync 📝
|
||||
|
||||
> **Minimalistische Android Notiz-App mit automatischer WLAN-Synchronisierung**
|
||||
> Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
|
||||
|
||||
[](https://www.android.com/)
|
||||
[](https://kotlinlang.org/)
|
||||
[](https://m3.material.io/)
|
||||
[](LICENSE)
|
||||
|
||||
Schlanke Offline-Notizen ohne Schnickschnack - deine Daten bleiben bei dir. Automatische Synchronisierung zu deinem eigenen WebDAV-Server, kein Google, kein Microsoft, keine Cloud.
|
||||
[<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)
|
||||
|
||||
## ✨ Features
|
||||
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
|
||||
|
||||
- 📝 **Offline-First** - Notizen lokal gespeichert, immer verfügbar
|
||||
- 🔄 **Auto-Sync** - Konfigurierbare Intervalle (15/30/60 Min.) mit ~0.2-0.8% Akku/Tag
|
||||
- 🏠 **Self-Hosted** - Deine Daten auf deinem Server (WebDAV)
|
||||
- 🎨 **Material Design 3** - Modern & Dynamic Theming
|
||||
- 🔋 **Akkuschonend** - Optimiert für Hintergrund-Synchronisierung
|
||||
- 🔐 **Privacy-First** - Kein Tracking, keine Analytics, keine Cloud
|
||||
- 🚫 **Keine Berechtigungen** - Nur Internet für WebDAV Sync
|
||||
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
|
||||
|
||||
## 📥 Quick Download
|
||||
---
|
||||
|
||||
**Android APK:** [📱 Neueste Version herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
## 📱 Screenshots
|
||||
|
||||
💡 **Tipp:** Nutze [Obtainium](https://github.com/ImranR98/Obtainium) für automatische Updates!
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notizliste">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Notiz bearbeiten">
|
||||
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Einstellungen">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- ✅ **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️⃣ WebDAV Server starten
|
||||
### 1. Server Setup (5 Minuten)
|
||||
|
||||
```fish
|
||||
cd server
|
||||
```bash
|
||||
git clone https://github.com/inventory69/simple-notes-sync.git
|
||||
cd simple-notes-sync/server
|
||||
cp .env.example .env
|
||||
# Passwort in .env anpassen
|
||||
# Passwort in .env setzen
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 2️⃣ App installieren & konfigurieren
|
||||
➡️ **Details:** [Server Setup Guide](server/README.md)
|
||||
|
||||
1. APK herunterladen und installieren
|
||||
2. App öffnen → **Einstellungen** (⚙️)
|
||||
3. Server konfigurieren:
|
||||
- URL: `http://192.168.0.XXX:8080/notes`
|
||||
- Benutzername: `noteuser`
|
||||
- Passwort: (aus `.env`)
|
||||
4. **Auto-Sync aktivieren**
|
||||
5. **Sync-Intervall wählen** (15/30/60 Min.)
|
||||
### 2. App Installation (2 Minuten)
|
||||
|
||||
**Fertig!** Notizen werden automatisch synchronisiert 🎉
|
||||
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
|
||||
2. Installieren & öffnen
|
||||
3. ⚙️ Einstellungen → Server konfigurieren:
|
||||
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
|
||||
- **User:** `noteuser`
|
||||
- **Passwort:** _(aus .env)_
|
||||
- **WLAN:** _(dein Netzwerk-Name)_
|
||||
4. **Verbindung testen** → Auto-Sync aktivieren
|
||||
5. Fertig! 🎉
|
||||
|
||||
➡️ **Ausführliche Anleitung:** [QUICKSTART.md](QUICKSTART.md)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Sync-Intervalle
|
||||
## 📚 Dokumentation
|
||||
|
||||
| Intervall | Akku/Tag | Anwendungsfall |
|
||||
|-----------|----------|----------------|
|
||||
| **15 Min** | ~0.8% (~23 mAh) | ⚡ Maximale Aktualität |
|
||||
| **30 Min** | ~0.4% (~12 mAh) | ✓ Empfohlen - Ausgewogen |
|
||||
| **60 Min** | ~0.2% (~6 mAh) | 🔋 Maximale Akkulaufzeit |
|
||||
|
||||
💡 **Hinweis:** Android Doze Mode kann Sync im Standby auf ~60 Min. verzögern (betrifft alle Apps).
|
||||
| Dokument | Inhalt |
|
||||
|----------|--------|
|
||||
| **[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 |
|
||||
|
||||
---
|
||||
|
||||
## <EFBFBD> Neue Features in v1.1.0
|
||||
## 🛠️ Entwicklung
|
||||
|
||||
### Konfigurierbare Sync-Intervalle
|
||||
- ⏱️ Wählbare Intervalle: 15/30/60 Minuten
|
||||
- 📊 Transparente Akkuverbrauchs-Anzeige
|
||||
- <20> Sofortige Anwendung ohne App-Neustart
|
||||
|
||||
### Über-Sektion
|
||||
- <20> App-Version & Build-Datum
|
||||
- 🌐 Links zu GitHub Repo & Entwickler
|
||||
- ⚖️ Lizenz-Information
|
||||
|
||||
### Verbesserungen
|
||||
- 🎯 Benutzerfreundliche Doze-Mode Erklärung
|
||||
- 🔕 Keine störenden Sync-Fehler Toasts im Hintergrund
|
||||
- 📝 Erweiterte Debug-Logs für Troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Selbst bauen
|
||||
|
||||
```fish
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleStandardRelease
|
||||
# APK: android/app/build/outputs/apk/standard/release/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Auto-Sync funktioniert nicht
|
||||
|
||||
1. **Akku-Optimierung deaktivieren**
|
||||
- Einstellungen → Apps → Simple Notes → Akku → Nicht optimieren
|
||||
2. **WLAN-Verbindung prüfen**
|
||||
- Funktioniert nur im selben Netzwerk wie Server
|
||||
3. **Server-Status checken**
|
||||
- Settings → "Verbindung testen"
|
||||
|
||||
### Server nicht erreichbar
|
||||
|
||||
```fish
|
||||
# Status prüfen
|
||||
docker compose ps
|
||||
|
||||
# Logs ansehen
|
||||
docker compose logs -f
|
||||
|
||||
# IP-Adresse finden
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
Mehr Details: [📖 Dokumentation](DOCS.md)
|
||||
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
|
||||
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -134,4 +105,4 @@ MIT License - siehe [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**Version:** 1.1.0 · **Status:** ✅ Produktiv · **Gebaut mit:** Kotlin + Material Design 3
|
||||
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3
|
||||
|
||||
335
README.old.md
@@ -1,335 +0,0 @@
|
||||
# Simple Notes Sync 📝
|
||||
|
||||
> Minimalistische Android-App für Offline-Notizen mit automatischer WLAN-Synchronisierung
|
||||
|
||||
Eine schlanke Notiz-App ohne Schnickschnack - perfekt für schnelle Gedanken, die automatisch zu Hause synchronisiert werden.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 📝 **Offline-first** - Notizen werden lokal gespeichert und sind immer verfügbar
|
||||
- 🔄 **Auto-Sync** - Automatische Synchronisierung wenn du im Heimnetzwerk bist
|
||||
- 🏠 **WebDAV Server** - Deine Daten bleiben bei dir (Docker-Container)
|
||||
- 🔋 **Akkuschonend** - Nur ~0.4% Akkuverbrauch pro Tag
|
||||
- 🚫 **Keine Cloud** - Keine Google, keine Microsoft, keine Drittanbieter
|
||||
- 🔐 **Privacy** - Keine Tracking, keine Analytics, keine Standort-Berechtigungen
|
||||
|
||||
---
|
||||
|
||||
## 📥 Installation
|
||||
|
||||
### Android App
|
||||
|
||||
**Option 1: APK herunterladen**
|
||||
|
||||
1. Neueste [Release](../../releases/latest) öffnen
|
||||
2. `app-debug.apk` herunterladen
|
||||
3. APK auf dem Handy installieren
|
||||
|
||||
**Option 2: Selbst bauen**
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
# APK: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### WebDAV Server
|
||||
|
||||
Der Server läuft als Docker-Container und speichert deine Notizen.
|
||||
|
||||
```bash
|
||||
cd server
|
||||
cp .env.example .env
|
||||
nano .env # Passwort anpassen!
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Server testen:**
|
||||
```bash
|
||||
curl -u noteuser:dein_passwort http://192.168.0.XXX:8080/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Schnellstart
|
||||
|
||||
1. **Server starten** (siehe oben)
|
||||
2. **App installieren** und öffnen
|
||||
3. **Einstellungen öffnen** (⚙️ Symbol oben rechts)
|
||||
4. **Server konfigurieren:**
|
||||
- Server-URL: `http://192.168.0.XXX:8080/notes`
|
||||
- Benutzername: `noteuser`
|
||||
- Passwort: (aus `.env` Datei)
|
||||
- Auto-Sync: **AN**
|
||||
5. **Fertig!** Notizen werden jetzt automatisch synchronisiert
|
||||
|
||||
---
|
||||
|
||||
## 💡 Wie funktioniert Auto-Sync?
|
||||
|
||||
Die App prüft **alle 30 Minuten**, ob:
|
||||
- ✅ WLAN verbunden ist
|
||||
- ✅ Server im gleichen Netzwerk erreichbar ist
|
||||
- ✅ Neue Notizen vorhanden sind
|
||||
|
||||
Wenn alle Bedingungen erfüllt → **Automatische Synchronisierung**
|
||||
|
||||
**Wichtig:** Funktioniert nur im selben Netzwerk wie der Server (kein Internet-Zugriff nötig!)
|
||||
|
||||
---
|
||||
|
||||
## 🔋 Akkuverbrauch
|
||||
|
||||
| Komponente | Verbrauch/Tag |
|
||||
|------------|---------------|
|
||||
| WorkManager (alle 30 Min) | ~0.3% |
|
||||
| Netzwerk-Checks | ~0.1% |
|
||||
| **Total** | **~0.4%** |
|
||||
|
||||
Bei einem 3000 mAh Akku entspricht das ~12 mAh pro Tag.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Screenshots
|
||||
|
||||
_TODO: Screenshots hinzufügen_
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Technische Details
|
||||
|
||||
Mehr Infos zur Architektur und Implementierung findest du in der [technischen Dokumentation](DOCS.md).
|
||||
|
||||
**Stack:**
|
||||
- **Android:** Kotlin, Material Design 3, WorkManager
|
||||
- **Server:** Docker, WebDAV (bytemark/webdav)
|
||||
- **Sync:** Sardine Android (WebDAV Client)
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
[Lizenz hier einfügen]
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Beitragen
|
||||
|
||||
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
MIT License - siehe [LICENSE](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**Projekt Start:** 19. Dezember 2025
|
||||
**Status:** ✅ Funktional & Produktiv
|
||||
|
||||
## 📖 Dokumentation
|
||||
|
||||
### In diesem Repository:
|
||||
|
||||
- **[QUICKSTART.md](QUICKSTART.md)** - Schnellstart-Anleitung
|
||||
- **[server/README.md](server/README.md)** - Server-Verwaltung
|
||||
|
||||
### Vollständige Dokumentation (project-docs):
|
||||
|
||||
- [README.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/README.md) - Projekt-Übersicht & Architektur
|
||||
- [IMPLEMENTATION_PLAN.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/IMPLEMENTATION_PLAN.md) - Detaillierter Sprint-Plan
|
||||
- [SERVER_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SERVER_SETUP.md) - Server-Setup Details
|
||||
- [ANDROID_GUIDE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md) - 📱 Kompletter Android-Code
|
||||
- [NOTIFICATIONS.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/NOTIFICATIONS.md) - Notification-System Details
|
||||
- [WINDOWS_SETUP.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/WINDOWS_SETUP.md) - 🪟 Windows + Android Studio Setup
|
||||
- [CODE_REFERENCE.md](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/CODE_REFERENCE.md) - Schnelle Code-Referenz
|
||||
|
||||
## ⚙️ Server Konfiguration
|
||||
|
||||
**Standard-Credentials:**
|
||||
- Username: `noteuser`
|
||||
- Password: Siehe `.env` im `server/` Verzeichnis
|
||||
|
||||
**Server-URL:**
|
||||
- Lokal: `http://localhost:8080/`
|
||||
- Im Netzwerk: `http://YOUR_IP:8080/`
|
||||
|
||||
IP-Adresse finden:
|
||||
```bash
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
## 📱 Android App Setup
|
||||
|
||||
### Vorraussetzungen
|
||||
|
||||
- Android Studio Hedgehog (2023.1.1) oder neuer
|
||||
- JDK 17
|
||||
- Min SDK 24 (Android 7.0)
|
||||
- Target SDK 34 (Android 14)
|
||||
|
||||
### In App konfigurieren
|
||||
|
||||
1. App starten
|
||||
2. Einstellungen öffnen
|
||||
3. Server-URL eintragen (z.B. `http://192.168.1.100:8080/`)
|
||||
4. Username & Passwort eingeben
|
||||
5. Heim-WLAN SSID eingeben
|
||||
6. "Verbindung testen"
|
||||
|
||||
## 🔧 Entwicklung
|
||||
|
||||
### Server-Management
|
||||
|
||||
```bash
|
||||
# Status prüfen
|
||||
docker-compose ps
|
||||
|
||||
# Logs anschauen
|
||||
docker-compose logs -f
|
||||
|
||||
# Neustarten
|
||||
docker-compose restart
|
||||
|
||||
# Stoppen
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Android-Build
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleDebug
|
||||
|
||||
# APK Location:
|
||||
# app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Server-Test
|
||||
|
||||
```bash
|
||||
# Testdatei hochladen
|
||||
echo '{"id":"test","title":"Test","content":"Hello"}' > test.json
|
||||
curl -u noteuser:password -T test.json http://localhost:8080/test.json
|
||||
|
||||
# Datei abrufen
|
||||
curl -u noteuser:password http://localhost:8080/test.json
|
||||
|
||||
# Datei löschen
|
||||
curl -u noteuser:password -X DELETE http://localhost:8080/test.json
|
||||
```
|
||||
|
||||
### Android-App
|
||||
|
||||
1. Notiz erstellen → speichern → in Liste sichtbar ✓
|
||||
2. WLAN verbinden → Auto-Sync ✓
|
||||
3. Server offline → Fehlermeldung ✓
|
||||
4. Konflikt-Szenario → Auflösung ✓
|
||||
|
||||
## 📦 Deployment
|
||||
|
||||
### Server (Production)
|
||||
|
||||
**Option 1: Lokaler Server (Raspberry Pi, etc.)**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
**Option 2: VPS (DigitalOcean, Hetzner, etc.)**
|
||||
```bash
|
||||
# Mit HTTPS (empfohlen)
|
||||
# Zusätzlich: Reverse Proxy (nginx/Caddy) + Let's Encrypt
|
||||
```
|
||||
|
||||
### Android App
|
||||
|
||||
```bash
|
||||
# Release Build
|
||||
./gradlew assembleRelease
|
||||
|
||||
# APK signieren
|
||||
# Play Store Upload oder Direct Install
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
**Entwicklung:**
|
||||
- ✅ HTTP Basic Auth
|
||||
- ✅ Nur im lokalen Netzwerk
|
||||
|
||||
**Produktion:**
|
||||
- ⚠️ HTTPS mit SSL/TLS (empfohlen)
|
||||
- ⚠️ Starkes Passwort
|
||||
- ⚠️ Firewall-Regeln
|
||||
- ⚠️ VPN für externen Zugriff
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Server startet nicht
|
||||
|
||||
```bash
|
||||
# Port bereits belegt?
|
||||
sudo netstat -tlnp | grep 8080
|
||||
|
||||
# Logs checken
|
||||
docker-compose logs webdav
|
||||
```
|
||||
|
||||
### Android kann nicht verbinden
|
||||
|
||||
- Ist Android im gleichen WLAN?
|
||||
- Ist die Server-IP korrekt?
|
||||
- Firewall blockiert Port 8080?
|
||||
- Credentials korrekt?
|
||||
|
||||
```bash
|
||||
# Ping zum Server
|
||||
ping YOUR_SERVER_IP
|
||||
|
||||
# Port erreichbar?
|
||||
telnet YOUR_SERVER_IP 8080
|
||||
```
|
||||
|
||||
## 📝 TODO / Roadmap
|
||||
|
||||
### Version 1.0 (MVP)
|
||||
- [x] Docker WebDAV Server
|
||||
- [ ] Android Basic CRUD
|
||||
- [ ] Auto-Sync bei WLAN
|
||||
- [ ] Error Handling
|
||||
- [ ] Notifications
|
||||
|
||||
### Version 1.1
|
||||
- [ ] Suche
|
||||
- [ ] Dark Mode
|
||||
- [ ] Markdown-Support
|
||||
|
||||
### Version 2.0
|
||||
- [ ] Desktop-Client (Flutter Desktop)
|
||||
- [ ] Tags/Kategorien
|
||||
- [ ] Verschlüsselung
|
||||
- [ ] Shared Notes
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - siehe [LICENSE](LICENSE)
|
||||
|
||||
## 👤 Author
|
||||
|
||||
Created for personal use - 2025
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [bytemark/webdav](https://hub.docker.com/r/bytemark/webdav) - Docker WebDAV Server
|
||||
- [Sardine Android](https://github.com/thegrizzlylabs/sardine-android) - WebDAV Client
|
||||
- [Android WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager) - Background Tasks
|
||||
|
||||
---
|
||||
|
||||
**Project Start:** 19. Dezember 2025
|
||||
**Status:** 🚧 In Development
|
||||
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
|
||||
5
android/.gitignore
vendored
@@ -13,3 +13,8 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
|
||||
# Signing configuration (contains sensitive keys)
|
||||
key.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
157
android/LOCAL_BUILDS.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Lokale Gradle Builds mit Release-Signierung
|
||||
|
||||
Dieses Dokument erklärt, wie du lokal signierte APKs erstellst, die mit den GitHub Release-APKs kompatibel sind.
|
||||
|
||||
## Problem
|
||||
|
||||
- **GitHub Actions** erstellt signierte Release-APKs mit dem Production-Keystore
|
||||
- **Lokale Debug-Builds** verwenden einen temporären Debug-Key
|
||||
- ❌ **Resultat:** Nutzer können lokale Debug-APKs NICHT über Release-APKs installieren (Signature Mismatch!)
|
||||
|
||||
## Lösung: Lokale Release-Builds mit Production-Key
|
||||
|
||||
### 1️⃣ Keystore-Konfiguration einrichten
|
||||
|
||||
Du hast bereits den Keystore: `/android/app/simple-notes-release.jks`
|
||||
|
||||
Erstelle eine `key.properties` Datei im `/android/` Ordner:
|
||||
|
||||
```bash
|
||||
cd /home/liq/gitProjects/simple-notes-sync/android
|
||||
cp key.properties.example key.properties
|
||||
```
|
||||
|
||||
Bearbeite `key.properties` mit den echten Werten:
|
||||
|
||||
```properties
|
||||
storeFile=simple-notes-release.jks
|
||||
storePassword=<dein-keystore-password>
|
||||
keyAlias=<dein-key-alias>
|
||||
keyPassword=<dein-key-password>
|
||||
```
|
||||
|
||||
**Wichtig:** Die Werte müssen **exakt** mit den GitHub Secrets übereinstimmen:
|
||||
- `KEYSTORE_PASSWORD` → `storePassword`
|
||||
- `KEY_ALIAS` → `keyAlias`
|
||||
- `KEY_PASSWORD` → `keyPassword`
|
||||
|
||||
### 2️⃣ Lokal signierte Release-APKs bauen
|
||||
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleStandardRelease
|
||||
```
|
||||
|
||||
Die signierten APKs findest du dann hier:
|
||||
```
|
||||
android/app/build/outputs/apk/standard/release/
|
||||
├── app-standard-universal-release.apk
|
||||
├── app-standard-arm64-v8a-release.apk
|
||||
└── app-standard-armeabi-v7a-release.apk
|
||||
```
|
||||
|
||||
### 3️⃣ F-Droid Flavor bauen (optional)
|
||||
|
||||
```bash
|
||||
./gradlew assembleFdroidRelease
|
||||
```
|
||||
|
||||
### 4️⃣ Beide Flavors gleichzeitig bauen
|
||||
|
||||
```bash
|
||||
./gradlew assembleStandardRelease assembleFdroidRelease
|
||||
```
|
||||
|
||||
## Verifizierung der Signatur
|
||||
|
||||
Um zu prüfen, ob dein lokaler Build die gleiche Signatur wie die Release-Builds hat:
|
||||
|
||||
```bash
|
||||
# Signatur von lokalem Build anzeigen
|
||||
keytool -printcert -jarfile app/build/outputs/apk/standard/release/app-standard-universal-release.apk
|
||||
|
||||
# Signatur von GitHub Release-APK anzeigen (zum Vergleich)
|
||||
keytool -printcert -jarfile ~/Downloads/simple-notes-sync-v1.1.0-standard-universal.apk
|
||||
```
|
||||
|
||||
Die **SHA256** Fingerprints müssen **identisch** sein!
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### ❌ Build schlägt fehl: "Keystore not found"
|
||||
|
||||
**Problem:** `key.properties` oder Keystore-Datei fehlt
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe, ob `key.properties` existiert: `ls -la key.properties`
|
||||
2. Prüfe, ob der Keystore existiert: `ls -la app/simple-notes-release.jks`
|
||||
|
||||
### ❌ "Signature mismatch" beim Update
|
||||
|
||||
**Problem:** Der lokale Build verwendet einen anderen Key als die Release-Builds
|
||||
|
||||
**Lösung:**
|
||||
1. Vergleiche die Signaturen mit `keytool` (siehe oben)
|
||||
2. Stelle sicher, dass `key.properties` die **exakten** GitHub Secret-Werte enthält
|
||||
3. Deinstalliere die alte Version und installiere die neue (als letzter Ausweg)
|
||||
|
||||
### ❌ Build verwendet Debug-Signatur
|
||||
|
||||
**Problem:** `build.gradle.kts` findet `key.properties` nicht
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
# Prüfe, ob die Datei im richtigen Verzeichnis liegt
|
||||
ls -la android/key.properties # ✅ Richtig
|
||||
ls -la android/app/key.properties # ❌ Falsch
|
||||
```
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
⚠️ **NIEMALS** diese Dateien committen:
|
||||
- `key.properties` (in `.gitignore`)
|
||||
- `*.jks` / `*.keystore` (in `.gitignore`)
|
||||
|
||||
✅ **Schon in `.gitignore`:**
|
||||
```gitignore
|
||||
key.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
```
|
||||
|
||||
⚠️ Die GitHub Secrets (`KEYSTORE_PASSWORD`, etc.) und die lokale `key.properties` müssen **synchron** bleiben!
|
||||
|
||||
## Workflow-Vergleich
|
||||
|
||||
### GitHub Actions Build
|
||||
```yaml
|
||||
- Lädt Keystore aus Base64 Secret
|
||||
- Erstellt key.properties aus Secrets
|
||||
- Baut mit: ./gradlew assembleStandardRelease
|
||||
- ✅ Produktions-signiert
|
||||
```
|
||||
|
||||
### Lokaler Build
|
||||
```bash
|
||||
# Mit key.properties konfiguriert:
|
||||
./gradlew assembleStandardRelease
|
||||
# ✅ Produktions-signiert (gleiche Signatur wie GitHub!)
|
||||
|
||||
# Ohne key.properties:
|
||||
./gradlew assembleStandardRelease
|
||||
# ⚠️ Debug-signiert (inkompatibel mit Releases!)
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Release-APK bauen (signiert, klein, optimiert)
|
||||
./gradlew assembleStandardRelease
|
||||
|
||||
# Debug-APK bauen (unsigniert, groß, debuggable)
|
||||
./gradlew assembleStandardDebug
|
||||
|
||||
# APK per HTTP Server verteilen
|
||||
cd app/build/outputs/apk/standard/release
|
||||
python3 -m http.server 8892
|
||||
```
|
||||
@@ -1,6 +1,9 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
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
|
||||
@@ -17,32 +20,27 @@ android {
|
||||
applicationId = "dev.dettmer.simplenotes"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 2 // 🔥 F-Droid Release v1.1.0
|
||||
versionName = "1.1.0" // 🔥 Configurable Sync Interval + About Section
|
||||
versionCode = 11 // 🚀 v1.4.0: Checklists Feature
|
||||
versionName = "1.4.0" // 🚀 v1.4.0: Checklists, Multi-Device Sync Fixes, UX Improvements
|
||||
|
||||
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
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("armeabi-v7a", "arm64-v8a")
|
||||
isUniversalApk = true // Also generate universal APK
|
||||
}
|
||||
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
||||
dependenciesInfo {
|
||||
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
|
||||
includeInBundle = false // Also disable for AAB (Google Play)
|
||||
}
|
||||
|
||||
// Product Flavors for F-Droid and standard builds
|
||||
// Note: APK splits are disabled to ensure single APK output
|
||||
flavorDimensions += "distribution"
|
||||
productFlavors {
|
||||
create("fdroid") {
|
||||
dimension = "distribution"
|
||||
// F-Droid builds have no proprietary dependencies
|
||||
// All dependencies in this project are already FOSS-compatible
|
||||
// No APK splits - F-Droid expects single universal APK
|
||||
}
|
||||
|
||||
create("standard") {
|
||||
@@ -69,6 +67,16 @@ android {
|
||||
}
|
||||
|
||||
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 {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
@@ -124,14 +132,35 @@ dependencies {
|
||||
// LocalBroadcastManager für UI Refresh
|
||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||
|
||||
// SwipeRefreshLayout für Pull-to-Refresh
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
// Testing (bleiben so)
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
|
||||
// 🔥 NEU: Helper function für Build Date
|
||||
fun getBuildDate(): String {
|
||||
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
return dateFormat.format(Date())
|
||||
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
|
||||
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
|
||||
// ktlint {
|
||||
// android = true
|
||||
// outputToConsole = true
|
||||
// ignoreFailures = true
|
||||
// enableExperimentalRules = false
|
||||
// filter {
|
||||
// exclude("**/generated/**")
|
||||
// exclude("**/build/**")
|
||||
// }
|
||||
// }
|
||||
|
||||
// ⚡ v1.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 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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 -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
@@ -27,7 +25,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SimpleNotes"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
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.snackbar.Snackbar
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import dev.dettmer.simplenotes.adapters.NotesAdapter
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.sync.SyncWorker
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import dev.dettmer.simplenotes.utils.showToast
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
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 kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||
import dev.dettmer.simplenotes.sync.SyncStateManager
|
||||
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() {
|
||||
|
||||
@@ -41,10 +54,21 @@ class MainActivity : AppCompatActivity() {
|
||||
private lateinit var emptyStateCard: MaterialCardView
|
||||
private lateinit var fabAddNote: FloatingActionButton
|
||||
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 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
|
||||
private val pendingDeletions = mutableSetOf<String>()
|
||||
|
||||
private val prefs by lazy {
|
||||
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -55,6 +79,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val REQUEST_SETTINGS = 1002
|
||||
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 SYNC_COMPLETED_DELAY_MS = 1500L
|
||||
private const val ERROR_DISPLAY_DELAY_MS = 3000L
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,11 +112,15 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// File Logging aktivieren wenn eingestellt
|
||||
if (prefs.getBoolean("file_logging_enabled", false)) {
|
||||
Logger.enableFileLogging(this)
|
||||
// Logger initialisieren und File-Logging aktivieren wenn eingestellt
|
||||
Logger.init(this)
|
||||
if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
|
||||
Logger.setFileLoggingEnabled(true)
|
||||
}
|
||||
|
||||
// Alte Sync-Notifications beim App-Start löschen
|
||||
NotificationHelper.clearSyncNotifications(this)
|
||||
|
||||
// Permission für Notifications (Android 13+)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
requestNotificationPermission()
|
||||
@@ -102,6 +132,65 @@ class MainActivity : AppCompatActivity() {
|
||||
setupFab()
|
||||
|
||||
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() {
|
||||
@@ -117,7 +206,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
|
||||
|
||||
// Reload notes
|
||||
// Reload notes (scroll to top wird in loadNotes() gemacht)
|
||||
loadNotes()
|
||||
|
||||
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
|
||||
@@ -137,15 +226,40 @@ class MainActivity : AppCompatActivity() {
|
||||
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)")
|
||||
|
||||
// Update last sync timestamp
|
||||
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
|
||||
|
||||
// GLEICHER Sync-Code wie manueller Sync (funktioniert!)
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
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)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
|
||||
SyncStateManager.reset()
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
@@ -153,6 +267,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// Feedback abhängig von Source
|
||||
if (result.isSuccess && result.syncedCount > 0) {
|
||||
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
|
||||
// onResume: Nur Success-Toast
|
||||
showToast("✅ Gesynct: ${result.syncedCount} Notizen")
|
||||
@@ -160,14 +275,17 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
} else if (result.isSuccess) {
|
||||
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
|
||||
SyncStateManager.markCompleted()
|
||||
|
||||
} else {
|
||||
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
// Kein Toast - App ist im Hintergrund
|
||||
}
|
||||
}
|
||||
@@ -203,6 +321,11 @@ class MainActivity : AppCompatActivity() {
|
||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||
fabAddNote = findViewById(R.id.fabAddNote)
|
||||
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() {
|
||||
@@ -216,10 +339,74 @@ class MainActivity : AppCompatActivity() {
|
||||
recyclerViewNotes.adapter = adapter
|
||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||
|
||||
// 🔥 v1.1.2: Setup Pull-to-Refresh
|
||||
setupPullToRefresh()
|
||||
|
||||
// Setup Swipe-to-Delete
|
||||
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() {
|
||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
0, // No drag
|
||||
@@ -232,31 +419,20 @@ class MainActivity : AppCompatActivity() {
|
||||
): Boolean = false
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
val position = viewHolder.adapterPosition
|
||||
val note = adapter.currentList[position]
|
||||
val notesCopy = adapter.currentList.toMutableList()
|
||||
val position = viewHolder.bindingAdapterPosition
|
||||
val swipedNote = adapter.currentList[position]
|
||||
|
||||
// Remove from list immediately for visual feedback
|
||||
notesCopy.removeAt(position)
|
||||
adapter.submitList(notesCopy)
|
||||
// Store original list BEFORE removing note
|
||||
val originalList = adapter.currentList.toList()
|
||||
|
||||
// Show Snackbar with UNDO
|
||||
Snackbar.make(
|
||||
recyclerViewNotes,
|
||||
"Notiz gelöscht",
|
||||
Snackbar.LENGTH_LONG
|
||||
).setAction("RÜCKGÄNGIG") {
|
||||
// UNDO: Restore note in list
|
||||
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)
|
||||
loadNotes()
|
||||
// Remove from list for visual feedback (NOT from storage yet!)
|
||||
val listWithoutNote = originalList.toMutableList().apply {
|
||||
removeAt(position)
|
||||
}
|
||||
}
|
||||
}).show()
|
||||
adapter.submitList(listWithoutNote)
|
||||
|
||||
// Show dialog with ability to restore
|
||||
showServerDeletionDialog(swipedNote, originalList)
|
||||
}
|
||||
|
||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||
@@ -268,18 +444,182 @@ class MainActivity : AppCompatActivity() {
|
||||
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
|
||||
}
|
||||
|
||||
private fun setupFab() {
|
||||
fabAddNote.setOnClickListener {
|
||||
openNoteEditor(null)
|
||||
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
|
||||
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
|
||||
|
||||
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() {
|
||||
val notes = storage.loadAllNotes()
|
||||
adapter.submitList(notes)
|
||||
|
||||
// Filter out notes that are pending deletion (prevent flicker)
|
||||
val filteredNotes = notes.filter { it.id !in pendingDeletions }
|
||||
|
||||
// Submit list with callback to scroll to top after list is updated
|
||||
adapter.submitList(filteredNotes) {
|
||||
// Scroll to top after list update is complete
|
||||
// Wichtig: Nach dem Erstellen/Bearbeiten einer Notiz
|
||||
if (filteredNotes.isNotEmpty()) {
|
||||
recyclerViewNotes.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Material 3 Empty State Card
|
||||
emptyStateCard.visibility = if (notes.isEmpty()) {
|
||||
emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
|
||||
android.view.View.VISIBLE
|
||||
} else {
|
||||
android.view.View.GONE
|
||||
@@ -301,32 +641,56 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun triggerManualSync() {
|
||||
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
|
||||
if (!SyncStateManager.tryStartSync("manual")) {
|
||||
return
|
||||
}
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
showToast("Starte Synchronisation...")
|
||||
|
||||
// Start sync
|
||||
// Create sync service
|
||||
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)
|
||||
val isReachable = withContext(Dispatchers.IO) {
|
||||
syncService.isServerReachable()
|
||||
}
|
||||
|
||||
if (!isReachable) {
|
||||
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
|
||||
SyncStateManager.markError("Server nicht erreichbar")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Server ist erreichbar → Sync durchführen
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
syncService.syncNotes()
|
||||
}
|
||||
|
||||
// Show result
|
||||
if (result.isSuccess) {
|
||||
showToast("Sync erfolgreich: ${result.syncedCount} Notizen")
|
||||
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
|
||||
loadNotes() // Reload notes
|
||||
} else {
|
||||
showToast("Sync Fehler: ${result.errorMessage}")
|
||||
SyncStateManager.markError(result.errorMessage)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
showToast("Sync Fehler: ${e.message}")
|
||||
SyncStateManager.markError(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
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.button.MaterialButton
|
||||
import com.google.android.material.color.DynamicColors
|
||||
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.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.storage.NotesStorage
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
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() {
|
||||
|
||||
// Views
|
||||
private lateinit var toolbar: MaterialToolbar
|
||||
private lateinit var tilTitle: TextInputLayout
|
||||
private lateinit var editTextTitle: TextInputEditText
|
||||
private lateinit var tilContent: TextInputLayout
|
||||
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
|
||||
|
||||
// State
|
||||
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 {
|
||||
private const val TAG = "NoteEditorActivity"
|
||||
const val EXTRA_NOTE_ID = "extra_note_id"
|
||||
const val EXTRA_NOTE_TYPE = "extra_note_type"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -36,38 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
storage = NotesStorage(this)
|
||||
|
||||
// Setup toolbar
|
||||
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
setSupportActionBar(toolbar)
|
||||
supportActionBar?.apply {
|
||||
setDisplayHomeAsUpEnabled(true)
|
||||
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
|
||||
findViews()
|
||||
setupToolbar()
|
||||
loadNoteOrDetermineType()
|
||||
setupUIForNoteType()
|
||||
}
|
||||
|
||||
// Find views
|
||||
private fun findViews() {
|
||||
toolbar = findViewById(R.id.toolbar)
|
||||
tilTitle = findViewById(R.id.tilTitle)
|
||||
editTextTitle = findViewById(R.id.editTextTitle)
|
||||
tilContent = findViewById(R.id.tilContent)
|
||||
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)
|
||||
|
||||
if (noteId != null) {
|
||||
// Existierende Notiz laden
|
||||
existingNote = storage.loadNote(noteId)
|
||||
existingNote?.let {
|
||||
editTextTitle.setText(it.title)
|
||||
editTextContent.setText(it.content)
|
||||
supportActionBar?.title = "Notiz bearbeiten"
|
||||
existingNote?.let { note ->
|
||||
editTextTitle.setText(note.title)
|
||||
currentNoteType = note.noteType
|
||||
|
||||
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 {
|
||||
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 {
|
||||
menuInflater.inflate(R.menu.menu_editor, menu)
|
||||
|
||||
// Show delete only for existing notes
|
||||
// Delete nur für existierende Notizen
|
||||
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -91,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
|
||||
|
||||
private fun saveNote() {
|
||||
val title = editTextTitle.text?.toString()?.trim() ?: ""
|
||||
|
||||
when (currentNoteType) {
|
||||
NoteType.TEXT -> {
|
||||
val content = editTextContent.text?.toString()?.trim() ?: ""
|
||||
|
||||
if (title.isEmpty() && content.isEmpty()) {
|
||||
showToast("Notiz ist leer")
|
||||
showToast(getString(R.string.note_is_empty))
|
||||
return
|
||||
}
|
||||
|
||||
val note = if (existingNote != null) {
|
||||
// Update existing note
|
||||
existingNote!!.copy(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
syncStatus = SyncStatus.PENDING
|
||||
)
|
||||
} else {
|
||||
// Create new note
|
||||
Note(
|
||||
title = title,
|
||||
content = content,
|
||||
noteType = NoteType.TEXT,
|
||||
checklistItems = null,
|
||||
deviceId = DeviceIdGenerator.getDeviceId(this),
|
||||
syncStatus = SyncStatus.LOCAL_ONLY
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
private fun confirmDelete() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("Notiz löschen?")
|
||||
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.")
|
||||
.setPositiveButton("Löschen") { _, _ ->
|
||||
.setTitle(getString(R.string.delete_note_title))
|
||||
.setMessage(getString(R.string.delete_note_message))
|
||||
.setPositiveButton(getString(R.string.delete)) { _, _ ->
|
||||
deleteNote()
|
||||
}
|
||||
.setNegativeButton("Abbrechen", null)
|
||||
.setNegativeButton(getString(R.string.cancel), null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteNote() {
|
||||
existingNote?.let {
|
||||
storage.deleteNote(it.id)
|
||||
showToast("Notiz gelöscht")
|
||||
showToast(getString(R.string.note_deleted))
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.dettmer.simplenotes.adapters
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -10,10 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import dev.dettmer.simplenotes.R
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.models.NoteType
|
||||
import dev.dettmer.simplenotes.models.SyncStatus
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
import dev.dettmer.simplenotes.utils.toReadableTime
|
||||
import dev.dettmer.simplenotes.utils.truncate
|
||||
|
||||
/**
|
||||
* Adapter für die Notizen-Liste
|
||||
*
|
||||
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
|
||||
*/
|
||||
class NotesAdapter(
|
||||
private val onNoteClick: (Note) -> Unit
|
||||
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
|
||||
@@ -29,16 +37,52 @@ class NotesAdapter(
|
||||
}
|
||||
|
||||
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 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 imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
|
||||
|
||||
fun bind(note: Note) {
|
||||
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
|
||||
textViewContent.text = note.content.truncate(100)
|
||||
// Titel
|
||||
textViewTitle.text = note.title.ifEmpty {
|
||||
itemView.context.getString(R.string.untitled)
|
||||
}
|
||||
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
|
||||
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||
val isSyncConfigured = !serverUrl.isNullOrEmpty()
|
||||
|
||||
if (isSyncConfigured) {
|
||||
// Sync status icon
|
||||
val syncIcon = when (note.syncStatus) {
|
||||
SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload
|
||||
@@ -47,6 +91,11 @@ class NotesAdapter(
|
||||
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save
|
||||
}
|
||||
imageViewSyncStatus.setImageResource(syncIcon)
|
||||
imageViewSyncStatus.visibility = View.VISIBLE
|
||||
} else {
|
||||
// Sync nicht konfiguriert → Icon verstecken
|
||||
imageViewSyncStatus.visibility = View.GONE
|
||||
}
|
||||
|
||||
itemView.setOnClickListener {
|
||||
onNoteClick(note)
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
data class Note(
|
||||
@@ -9,30 +14,234 @@ data class Note(
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val updatedAt: Long = System.currentTimeMillis(),
|
||||
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 {
|
||||
return """
|
||||
{
|
||||
"id": "$id",
|
||||
"title": "${title.escapeJson()}",
|
||||
"content": "${content.escapeJson()}",
|
||||
"createdAt": $createdAt,
|
||||
"updatedAt": $updatedAt,
|
||||
"deviceId": "$deviceId",
|
||||
"syncStatus": "${syncStatus.name}"
|
||||
val gson = com.google.gson.GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.create()
|
||||
return gson.toJson(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
|
||||
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 {
|
||||
private const val TAG = "Note"
|
||||
|
||||
/**
|
||||
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
|
||||
*/
|
||||
fun fromJson(json: String): Note? {
|
||||
return try {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
import android.content.Context
|
||||
import dev.dettmer.simplenotes.models.DeletionTracker
|
||||
import dev.dettmer.simplenotes.models.Note
|
||||
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import java.io.File
|
||||
|
||||
class NotesStorage(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "NotesStorage"
|
||||
}
|
||||
|
||||
private val notesDir: File = File(context.filesDir, "notes").apply {
|
||||
if (!exists()) mkdirs()
|
||||
}
|
||||
@@ -34,19 +41,89 @@ class NotesStorage(private val context: Context) {
|
||||
|
||||
fun deleteNote(id: String): Boolean {
|
||||
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 {
|
||||
return try {
|
||||
notesDir.listFiles()
|
||||
?.filter { it.extension == "json" }
|
||||
?.forEach { it.delete() }
|
||||
val notes = loadAllNotes()
|
||||
val deviceId = DeviceIdGenerator.getDeviceId(context)
|
||||
|
||||
for (note in notes) {
|
||||
deleteNote(note.id) // Uses trackDeletion() automatically
|
||||
}
|
||||
|
||||
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Logger.e(TAG, "Failed to delete all notes", e)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -227,7 +227,11 @@ class NetworkMonitor(private val context: Context) {
|
||||
if (isWifi) {
|
||||
lastConnectedNetworkId = activeNetwork.toString()
|
||||
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 {
|
||||
lastConnectedNetworkId = null
|
||||
Logger.d(TAG, " ⚠️ Not on WiFi at startup")
|
||||
@@ -268,7 +272,7 @@ class NetworkMonitor(private val context: Context) {
|
||||
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||
Logger.d(TAG, "✅ WiFi monitoring stopped")
|
||||
} 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
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
@@ -8,6 +9,7 @@ import androidx.work.WorkerParameters
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import dev.dettmer.simplenotes.utils.Logger
|
||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -21,6 +23,21 @@ class SyncWorker(
|
||||
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) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -52,7 +69,49 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 2: Before syncNotes() call")
|
||||
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
|
||||
// Verhindert Fehler-Notifications in fremden WiFi-Netzen
|
||||
// Wartet bis Netzwerk bereit ist (DHCP, Routing, Gateway)
|
||||
if (!syncService.isServerReachable()) {
|
||||
Logger.d(TAG, "⏭️ Server not reachable - skipping sync (no error)")
|
||||
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")
|
||||
|
||||
// 🔥 v1.1.2: Check if we should show warning (server unreachable for >24h)
|
||||
checkAndShowSyncWarning(syncService)
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
}
|
||||
|
||||
// Success zurückgeben (kein Fehler, Server ist halt nicht erreichbar)
|
||||
return@withContext Result.success()
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Server reachable - proceeding with sync")
|
||||
Logger.d(TAG, " SyncService: $syncService")
|
||||
}
|
||||
|
||||
@@ -73,18 +132,27 @@ class SyncWorker(
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 3: Processing result")
|
||||
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
|
||||
Logger.d(TAG, "📍 Step 4: Processing result")
|
||||
Logger.d(
|
||||
TAG,
|
||||
"📦 Sync result: success=${result.isSuccess}, " +
|
||||
"count=${result.syncedCount}, error=${result.errorMessage}"
|
||||
)
|
||||
}
|
||||
|
||||
if (result.isSuccess) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 4: Success path")
|
||||
Logger.d(TAG, "📍 Step 5: Success path")
|
||||
}
|
||||
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
|
||||
|
||||
// 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) {
|
||||
val appInForeground = isAppInForeground()
|
||||
if (appInForeground) {
|
||||
Logger.d(TAG, "ℹ️ App in foreground - skipping notification (UI shows changes)")
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, " Showing success notification...")
|
||||
}
|
||||
@@ -92,6 +160,7 @@ class SyncWorker(
|
||||
applicationContext,
|
||||
result.syncedCount
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Logger.d(TAG, "ℹ️ No changes to sync - no notification")
|
||||
}
|
||||
@@ -109,7 +178,7 @@ class SyncWorker(
|
||||
Result.success()
|
||||
} else {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "📍 Step 4: Failure path")
|
||||
Logger.d(TAG, "📍 Step 5: Failure path")
|
||||
}
|
||||
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
|
||||
NotificationHelper.showSyncError(
|
||||
@@ -126,6 +195,32 @@ class SyncWorker(
|
||||
}
|
||||
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) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Logger.d(TAG, "═══════════════════════════════════════")
|
||||
@@ -168,4 +263,69 @@ class SyncWorker(
|
||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||
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.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import dev.dettmer.simplenotes.utils.Constants
|
||||
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() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
@@ -22,33 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if connected to home WiFi
|
||||
if (isConnectedToHomeWifi(context)) {
|
||||
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
|
||||
if (isConnectedToWifi(context)) {
|
||||
scheduleSyncWork(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isConnectedToHomeWifi(context: Context): Boolean {
|
||||
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
|
||||
|
||||
/**
|
||||
* Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
|
||||
* Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
|
||||
*/
|
||||
private fun isConnectedToWifi(context: Context): Boolean {
|
||||
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
|
||||
as ConnectivityManager
|
||||
|
||||
val network = connectivityManager.activeNetwork ?: return false
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
|
||||
|
||||
if (!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
|
||||
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
}
|
||||
|
||||
private fun scheduleSyncWork(context: Context) {
|
||||
|
||||
@@ -6,14 +6,29 @@ object Constants {
|
||||
const val KEY_SERVER_URL = "server_url"
|
||||
const val KEY_USERNAME = "username"
|
||||
const val KEY_PASSWORD = "password"
|
||||
const val KEY_HOME_SSID = "home_ssid"
|
||||
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
||||
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
|
||||
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
||||
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
|
||||
const val SYNC_WORK_TAG = "notes_sync"
|
||||
const val SYNC_DELAY_SECONDS = 5L
|
||||
|
||||
@@ -7,6 +7,9 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private const val DAYS_THRESHOLD = 7L
|
||||
private const val TRUNCATE_SUFFIX_LENGTH = 3
|
||||
|
||||
// Toast Extensions
|
||||
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
|
||||
Toast.makeText(this, message, duration).show()
|
||||
@@ -27,7 +30,7 @@ fun Long.toReadableTime(): String {
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(diff)
|
||||
"Vor $hours Std"
|
||||
}
|
||||
diff < TimeUnit.DAYS.toMillis(7) -> {
|
||||
diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
|
||||
val days = TimeUnit.MILLISECONDS.toDays(diff)
|
||||
"Vor $days Tagen"
|
||||
}
|
||||
@@ -41,7 +44,7 @@ fun Long.toReadableTime(): String {
|
||||
// Truncate long strings
|
||||
fun String.truncate(maxLength: Int): String {
|
||||
return if (length > maxLength) {
|
||||
substring(0, maxLength - 3) + "..."
|
||||
substring(0, maxLength - TRUNCATE_SUFFIX_LENGTH) + "..."
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.util.Log
|
||||
import dev.dettmer.simplenotes.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
@@ -15,10 +14,34 @@ import java.util.*
|
||||
*/
|
||||
object Logger {
|
||||
|
||||
private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge
|
||||
|
||||
private var fileLoggingEnabled = false
|
||||
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 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
|
||||
@@ -50,11 +73,47 @@ object Logger {
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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 {
|
||||
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() {
|
||||
try {
|
||||
val lines = logFile?.readLines() ?: return
|
||||
if (lines.size > maxLogEntries) {
|
||||
val trimmed = lines.takeLast(maxLogEntries)
|
||||
if (lines.size > MAX_LOG_ENTRIES) {
|
||||
val trimmed = lines.takeLast(MAX_LOG_ENTRIES)
|
||||
logFile?.writeText(trimmed.joinToString("\n") + "\n")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -6,17 +6,21 @@ import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import dev.dettmer.simplenotes.MainActivity
|
||||
|
||||
object NotificationHelper {
|
||||
|
||||
private const val TAG = "NotificationHelper"
|
||||
private const val CHANNEL_ID = "notes_sync_channel"
|
||||
private const val CHANNEL_NAME = "Notizen Synchronisierung"
|
||||
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val SYNC_NOTIFICATION_ID = 2
|
||||
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
|
||||
|
||||
/**
|
||||
* Erstellt Notification Channel (Android 8.0+)
|
||||
@@ -38,6 +42,17 @@ object NotificationHelper {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht alle Sync-Notifications
|
||||
* Sollte beim App-Start aufgerufen werden um alte Notifications zu entfernen
|
||||
*/
|
||||
fun clearSyncNotifications(context: Context) {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
as NotificationManager
|
||||
manager.cancel(SYNC_NOTIFICATION_ID)
|
||||
Logger.d(TAG, "🗑️ Cleared old sync notifications")
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Erfolgs-Notification nach Sync
|
||||
*/
|
||||
@@ -240,6 +255,7 @@ object NotificationHelper {
|
||||
|
||||
/**
|
||||
* Zeigt Fehler-Notification
|
||||
* Auto-Cancel nach 30 Sekunden
|
||||
*/
|
||||
fun showSyncError(context: Context, message: String) {
|
||||
// PendingIntent für App-Öffnung
|
||||
@@ -266,5 +282,47 @@ object NotificationHelper {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
as NotificationManager
|
||||
manager.notify(SYNC_NOTIFICATION_ID, notification)
|
||||
|
||||
// ⭐ NEU: Auto-Cancel nach 30 Sekunden
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
manager.cancel(SYNC_NOTIFICATION_ID)
|
||||
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
|
||||
}, 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
|
||||
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="match_parent"
|
||||
android:orientation="vertical"
|
||||
@@ -14,12 +15,13 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:elevation="0dp"
|
||||
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
|
||||
app:navigationIcon="?attr/homeAsUpIndicator"
|
||||
app:title="@string/edit_note"
|
||||
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
|
||||
android:id="@+id/tilTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="16dp"
|
||||
@@ -44,8 +46,9 @@
|
||||
|
||||
</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
|
||||
android:id="@+id/tilContent"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
@@ -74,4 +77,39 @@
|
||||
|
||||
</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>
|
||||
|
||||
@@ -22,16 +22,57 @@
|
||||
app:title="@string/app_name"
|
||||
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>
|
||||
|
||||
<!-- 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 -->
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerViewNotes"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:padding="16dp"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
||||
android:padding="16dp" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<!-- Material 3 Empty State Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
|
||||
@@ -30,17 +30,6 @@
|
||||
android:orientation="vertical"
|
||||
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 -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
@@ -63,15 +52,65 @@
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<!-- Server URL with Icon -->
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
<!-- Protocol Selection -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
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"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
app:startIconDrawable="@android:drawable/ic_menu_compass"
|
||||
app:endIconMode="clear_text"
|
||||
app:helperText="z.B. http://192.168.0.188:8080/notes"
|
||||
app:helperTextEnabled="true"
|
||||
app:boxCornerRadiusTopStart="12dp"
|
||||
app:boxCornerRadiusTopEnd="12dp"
|
||||
app:boxCornerRadiusBottomStart="12dp"
|
||||
@@ -185,7 +224,7 @@
|
||||
|
||||
</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
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -230,6 +269,7 @@
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
@@ -247,87 +287,22 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Material 3 Card: Backup & Restore -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
<!-- Divider -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
style="@style/Widget.Material3.CardView.Elevated"
|
||||
app:cardCornerRadius="16dp">
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="16dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<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" />
|
||||
|
||||
<!-- 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 -->
|
||||
<!-- Sync Interval Section -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Sync-Intervall"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<!-- Info Card -->
|
||||
<!-- Interval Info Card -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
@@ -412,6 +387,201 @@
|
||||
|
||||
</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 -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
@@ -579,6 +749,107 @@
|
||||
|
||||
</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>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:src="@android:drawable/ic_menu_delete"
|
||||
android:tint="?attr/colorError"
|
||||
app:tint="?attr/colorError"
|
||||
android:contentDescription="@string/delete" />
|
||||
|
||||
<!-- 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"?>
|
||||
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
|
||||
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
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:layout_marginHorizontal="8dp"
|
||||
@@ -17,17 +19,37 @@
|
||||
android:orientation="vertical"
|
||||
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 -->
|
||||
<TextView
|
||||
android:id="@+id/textViewTitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/note_title_placeholder"
|
||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||
android:maxLines="2"
|
||||
android:ellipsize="end" />
|
||||
|
||||
<!-- Material 3 Typography: BodyMedium -->
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Content Preview (für TEXT Notizen) -->
|
||||
<TextView
|
||||
android:id="@+id/textViewContent"
|
||||
android:layout_width="match_parent"
|
||||
@@ -39,6 +61,18 @@
|
||||
android:maxLines="3"
|
||||
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 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
@@ -64,7 +98,7 @@
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:src="@android:drawable/ic_popup_sync"
|
||||
android:tint="?attr/colorPrimary"
|
||||
app:tint="?attr/colorPrimary"
|
||||
android:contentDescription="@string/sync_status" />
|
||||
|
||||
</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">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</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="white">#FFFFFFFF</color>
|
||||
|
||||
<!-- Adaptive Icon Background -->
|
||||
<color name="ic_launcher_background">#f9e9c8</color>
|
||||
|
||||
<!-- Material 3 Light Theme Colors -->
|
||||
<color name="md_theme_light_primary">#6750A4</color>
|
||||
<color name="md_theme_light_onPrimary">#FFFFFF</color>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<string name="note_title_placeholder">Note Title</string>
|
||||
<string name="note_content_placeholder">Note content preview…</string>
|
||||
<string name="note_timestamp_placeholder">Vor 2 Std</string>
|
||||
<string name="untitled">Ohne Titel</string>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<string name="delete_note_title">Notiz löschen?</string>
|
||||
@@ -42,10 +43,9 @@
|
||||
|
||||
<!-- Auto-Sync Settings -->
|
||||
<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="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 -->
|
||||
<string name="backup_restore_title">Backup & Wiederherstellung</string>
|
||||
@@ -57,4 +57,36 @@
|
||||
<string name="restore_progress">Stelle Notizen wieder her…</string>
|
||||
<string name="restore_success">✓ %d Notizen wiederhergestellt</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>
|
||||
|
||||
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 {
|
||||
alias(libs.plugins.android.application) 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 +0,0 @@
|
||||
Einfache Notizen-App mit WebDAV-Synchronisation
|
||||
@@ -9,6 +9,8 @@ appcompat = "1.6.1"
|
||||
material = "1.10.0"
|
||||
activity = "1.8.0"
|
||||
constraintlayout = "2.1.4"
|
||||
ktlint = "12.1.0"
|
||||
detekt = "1.23.4"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -23,4 +25,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
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
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
|
||||
6
android/gradlew
vendored
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -114,7 +114,7 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
4
android/gradlew.bat
vendored
@@ -70,11 +70,11 @@ goto fail
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@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
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
|
||||
21
android/key.properties.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# Android Signing Configuration
|
||||
#
|
||||
# ANLEITUNG FÜR LOKALE BUILDS:
|
||||
# 1. Kopiere diese Datei nach "key.properties" (ohne .example)
|
||||
# 2. Fülle die Werte mit deinen echten Keystore-Daten aus
|
||||
# 3. Die key.properties Datei ist in .gitignore und wird NICHT committet
|
||||
#
|
||||
# WICHTIG: Diese Datei darf NIEMALS ins Git-Repository gelangen!
|
||||
# Sie enthält sensible Signing-Keys für die App-Veröffentlichung.
|
||||
|
||||
# Pfad zum Keystore (relativ zum android/app Ordner)
|
||||
storeFile=simple-notes-release.jks
|
||||
|
||||
# Keystore Password
|
||||
storePassword=DEIN_KEYSTORE_PASSWORD
|
||||
|
||||
# Key Alias (meist "key0" oder ein selbst gewählter Name)
|
||||
keyAlias=DEIN_KEY_ALIAS
|
||||
|
||||
# Key Password
|
||||
keyPassword=DEIN_KEY_PASSWORD
|
||||