29 Commits

Author SHA1 Message Date
inventory69
6d135e8f0d fix: Use unique delimiter GHADELIMITER for multiline env vars 2026-01-04 08:28:31 +01:00
inventory69
5d82431bb6 fix: Remove emojis from F-Droid changelogs and fix EOF delimiter
- Removed emojis (🆕 📚) from F-Droid changelogs (better compatibility)
- Changed EOF to CHANGELOG_EOF in workflow (prevents delimiter conflicts)
2026-01-04 02:07:52 +01:00
inventory69
6bb87816f3 Release v1.2.0 - Local Backup & Markdown Desktop Integration
 New Features:
- Local backup/restore system with 3 modes (Merge/Replace/Overwrite)
- Markdown export for desktop access via WebDAV mount
- Dual-format architecture (JSON master + Markdown mirror)
- Settings UI extended with backup & desktop integration sections

📝 Changes:
- Server restore now asks for mode selection (user safety)
- WebDAV mount instructions for Windows/Mac/Linux in README
- Complete CHANGELOG.md with all version history

🔧 Technical:
- BackupManager.kt for complete backup/restore logic
- Note.toMarkdown/fromMarkdown with YAML frontmatter
- ISO8601 timestamps for desktop compatibility
- Last-Write-Wins conflict resolution

📚 Documentation:
- CHANGELOG.md (Keep a Changelog format)
- README updates (removed Joplin/Obsidian, added WebDAV-mount)
- F-Droid changelogs (DE+EN, under 500 chars)
- SYNC_ARCHITECTURE.md in project-docs
- MARKDOWN_DESKTOP_REALITY_CHECK.md strategic plan
- WEB_EDITOR_PLAN_v1.3.0.md for future web editor feature
2026-01-04 01:57:31 +01:00
inventory69
4802c3d979 Update changelog paths, enhance README features, and replace screenshots for v1.1.2 [skip ci] 2025-12-29 10:39:46 +01:00
Inventory69
85625b4f67 Merge release v1.1.2: UX improvements, HTTP restriction & stability fixes
Release v1.1.2: UX-Verbesserungen, HTTP-Restriktion & Stabilitätsfixes
2025-12-29 09:26:10 +01:00
inventory69
609da827c5 Refactor PR build check workflow for improved readability and structure [skip ci] 2025-12-29 09:22:55 +01:00
inventory69
539f17cdda Release v1.1.2: Improve UX, restrict HTTP to local networks, fix sync stability 2025-12-29 09:13:27 +01:00
inventory69
0bd686008d Add custom notepad icon and improve F-Droid metadata [skip ci]
- Replace default Android icon with custom notepad design
- Use PNG-based adaptive icons (mipmap) instead of vector drawables for better launcher compatibility
- Add ic_launcher_background.png (light blue #90CAF9) for all densities
- Add ic_launcher_foreground.png (transparent notepad design) for all densities
- Update legacy WebP icons (mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi) with new design
- Update Fastlane metadata icons (de-DE + en-US) with 512x512 PNG
- Improve F-Droid NonFreeNet AntiFeature documentation:
  * Clarify HTTP restricted to local networks only (RFC 1918 private IPs, localhost, .local domains)
  * Document upcoming v1.1.2 security restrictions
  * Emphasize HTTPS support and recommendation

Icon Design:
- White notepad paper with gray border
- Red header line (like real notepads)
- Three blue text bars (representing notes)
- Orange pencil with white tip in bottom-right corner
- Light blue background for adaptive icon

Technical Changes:
- Delete drawable/ic_launcher_background.xml (vector drawables)
- Delete drawable/ic_launcher_foreground.xml (vector drawables)
- Update mipmap-anydpi-v26/ic_launcher.xml: @drawable -> @mipmap
- Update mipmap-anydpi-v26/ic_launcher_round.xml: @drawable -> @mipmap
- Remove monochrome tag (not needed for this design)

Addresses IzzyOnDroid Issue #2 feedback
2025-12-27 20:11:37 +01:00
inventory69
65ce3746ca Fix IzzyOnDroid feedback (Issue #2) [skip ci]
1. Add en-US icon and screenshots as fallback for all languages
   - Convert app icon from WebP to PNG (512x512)
   - Copy phoneScreenshots from de-DE to en-US
   - Ensures non-German users see icon and screenshots

2. Disable Google DEPENDENCY_INFO_BLOCK
   - Add dependenciesInfo { includeInApk = false }
   - Removes encrypted Google blob from APK
   - Improves privacy and F-Droid compatibility

Fixes #2
2025-12-27 08:52:38 +01:00
inventory69
6079df3b1e Fix IzzyOnDroid feedback (Issue #2) [skip ci]
1. Add en-US screenshots as fallback for all languages
   - Copy phoneScreenshots from de-DE to en-US
   - Ensures non-German users see screenshots

2. Disable Google DEPENDENCY_INFO_BLOCK
   - Add dependenciesInfo { includeInApk = false }
   - Removes encrypted Google blob from APK
   - Improves privacy and F-Droid compatibility

Fixes #2
2025-12-27 08:12:57 +01:00
inventory69
5f0dc8a981 Fix image paths in EN README screenshots section [skip ci] 2025-12-26 21:39:51 +01:00
inventory69
d79a44491d Make feature list more compact and minimalist [skip ci]
- Remove bold formatting for cleaner look
- Shorten descriptions to essentials
- Keep 5 main categories
- More scannable and minimalist style
2025-12-26 21:34:55 +01:00
Inventory69
4a04b21975 Aktualisieren von README.md [skip ci] 2025-12-26 20:16:56 +01:00
inventory69
881162737b Shorten changelogs to meet F-Droid 500 char limit [skip ci]
- DE: 870 → 455 characters
- EN: 809 → 438 characters

Addresses F-Droid bot feedback in RFP #3458
2025-12-26 19:33:15 +01:00
inventory69
1f78953959 Fix F-Droid bot feedback issues [skip ci]
- Move fastlane metadata to repository root (was in android/fastlane)
- Add distributionSha256Sum to gradle-wrapper.properties for security
- Update Gradle Wrapper JAR to match version 8.13
- Document NonFreeNet anti-feature (HTTP support for local WebDAV servers)

Addresses F-Droid RFP issue #3458 bot feedback
2025-12-26 18:49:31 +01:00
inventory69
3092fcc6d3 Add screenshots and update README for v1.1.1 [skip ci]
- Add 3 app screenshots (phoneScreenshots)
- Update README.md with screenshot gallery
- Update README.en.md with screenshot gallery
- Update version reference to v1.1.1 in both READMEs
2025-12-26 18:10:54 +01:00
inventory69
60d6b1effc 📦 Add F-Droid metadata for v1.1.1 release [skip ci] 2025-12-26 15:36:27 +01:00
inventory69
9b6bf04954 🐛 Release v1.1.1 - Critical Bugfixes
 Server-Erreichbarkeits-Check vor jedem Sync
- Socket-Check mit 2s Timeout (DHCP/Routing-Init abwarten)
- Verhindert Fehler-Notifications in fremden WiFi-Netzen
- Verhindert Fehler bei Netzwerk-Initialisierung (WiFi-Connect)
- Stiller Abbruch wenn Server nicht erreichbar
- 80% schnellerer Abbruch: 2s statt 10+ Sekunden

🔧 Notification-Verbesserungen
- Alte Notifications werden beim App-Start gelöscht
- Fehler-Notifications verschwinden automatisch nach 30s
- Bessere Batterie-Effizienz

📱 UI-Bugfixes
- Sync-Icon nur anzeigen wenn Sync konfiguriert ist
- Swipe-to-Delete: Kein Flackern mehr bei schnellem Löschen
- Scroll-to-Top nach Note Save (ListAdapter async fix)

📡 Sync-Architektur Dokumentation
- SYNC_ARCHITECTURE.md mit allen 4 Sync-Triggern
- DOCS.md + DOCS.en.md aktualisiert
- GitHub Actions: F-Droid Changelogs statt Commit-Messages

🎯 Testing: BUGFIX_SPURIOUS_SYNC_ERROR_NOTIFICATIONS.md
📦 Version: 1.1.1 (versionCode=3)
2025-12-26 12:18:51 +01:00
inventory69
7644f5bf76 📝 Add CONTRIBUTING.md with PR workflow documentation
- Comprehensive contributor guide (bilingual DE/EN)
- Explains automated PR build checks
- Local build & test instructions
- Code style guidelines
- PR checklist
- What contributions are welcome
- Linked in README.md and README.en.md

[skip ci]
2025-12-24 00:12:11 +01:00
inventory69
300dc67a7c 🔧 Add PR build check workflow
- Builds debug APKs for pull requests (no signing required)
- Runs unit tests
- Uploads APKs as artifacts (30 days retention)
- Posts build status comment to PR
- No production releases for PRs (only on main merge)
- Gradle cache for faster builds

[skip ci]
2025-12-24 00:06:27 +01:00
inventory69
c42a9c84d7 Improve issue templates with structured forms
- Add proper GitHub Form templates with dropdowns and checkboxes
- Bug report: Android version, app version, sync/battery optimization status
- Feature request: Platform selection, priority, willingness to contribute
- Question: Documentation checklist, topic selection, context fields
- All forms bilingual (DE/EN) with app-specific questions

[skip ci]
2025-12-24 00:00:43 +01:00
inventory69
7942b73af3 fix: Enable blank issues in issue templates [skip ci] 2025-12-23 23:57:01 +01:00
inventory69
b4d868434f 📝 Add GitHub Issue Templates (Bug/Feature/Question)
- Bug report template with system info and reproduction steps
- Feature request template with priority selection
- Question template with documentation checklist
- Config with links to docs and troubleshooting
- All templates bilingual (DE/EN)

[skip ci]
2025-12-23 23:45:54 +01:00
inventory69
ad5fd0a313 fix: Update documentation and add English versions for all guides [skip ci] 2025-12-23 22:47:34 +01:00
inventory69
80a46e0e49 Update documentation for Simple Notes Sync
- Revamped QUICKSTART.md for clearer installation and setup instructions, including detailed steps for server setup and app configuration.
- Revised README.md to reflect new features and streamlined installation process, emphasizing offline capabilities and auto-sync functionality.
- Removed outdated README.old.md to maintain a clean repository. [skip ci]
2025-12-23 22:36:41 +01:00
inventory69
1338da9dde 🔐 Add keystore management scripts and documentation [skip ci]
- Add create-keystore.fish: Generate new release keystore with auto-generated passwords
- Add verify-secrets.fish: Verify GitHub Secrets and local keystore setup
- Add build-release-local.fish: Build signed release APKs locally
- Add LOCAL_BUILDS.md: Documentation for local release builds
- Add key.properties.example: Template for signing configuration
- Update android/.gitignore: Protect sensitive keystore files
- Integrate GitHub CLI for automatic secret management
- All scripts support both manual and automated workflows
2025-12-23 18:13:12 +01:00
inventory69
0c2d069443 fix: Update repository URL in setup instructions [skip ci] 2025-12-23 17:43:53 +01:00
inventory69
70efc13ea4 fix: Workflow für F-Droid APKs + Emoji-Fixes + Korrekturen [skip ci]
- 📦 F-Droid Flavor APKs werden jetzt mit gebaut (6 statt 3 APKs)
- 🎉 README Emoji-Darstellungsfehler behoben
- 🇩🇪 Workflow-Kommentare auf Deutsch
-  Korrekte Beschreibung: HTTP/HTTPS wählbar (nicht nur HTTPS)
- 💡 Klarstellung: Standard + F-Droid sind identisch (100% FOSS)
2025-12-22 14:54:49 +01:00
inventory69
55401977e3 fix: Korrigiere GitHub Actions Workflow für Standard-Flavor und semantische Versionierung
- Verwende assembleStandardRelease statt assembleRelease
- Korrigiere APK-Pfade: app-standard-*-release.apk
- Verwende versionName/versionCode aus build.gradle.kts (1.1.0/2)
- Keine Überschreibung mit Datums-Versionierung mehr
- F-Droid kompatible semantische Versionierung (v1.1.0)
2025-12-22 01:03:04 +01:00
95 changed files with 5651 additions and 3740 deletions

152
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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
View 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

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

View File

@@ -2,11 +2,11 @@ name: Build Android Production APK
on: on:
push: push:
branches: [ main ] # Trigger on push to main branch branches: [ main ] # Nur bei Push/Merge auf main triggern
workflow_dispatch: # Allow manual trigger workflow_dispatch: # Ermöglicht manuellen Trigger
permissions: permissions:
contents: write # Required for creating releases contents: write # Fuer Release-Erstellung erforderlich
jobs: jobs:
build: build:
@@ -14,185 +14,162 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Code auschecken
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Java - name: Java einrichten
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
- name: Generate Production version number - name: Semantic Versionsnummer aus build.gradle.kts extrahieren
run: | run: |
# Generate semantic version: YYYY.MM.DD # Version aus build.gradle.kts fuer F-Droid Kompatibilität
VERSION_NAME="$(date +'%Y.%m.%d')" 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 # Semantische Versionierung (nicht datums-basiert)
BUILD_NUMBER="${{ github.run_number }}" BUILD_NUMBER="$VERSION_CODE"
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV
echo "VERSION_TAG=v$VERSION_NAME-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: | run: |
# Update versionCode and versionName in build.gradle.kts echo "✅ Verwende Version aus 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:"
grep -E "versionCode|versionName" android/app/build.gradle.kts grep -E "versionCode|versionName" android/app/build.gradle.kts
- name: Setup Android signing - name: Android Signing konfigurieren
run: | run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/simple-notes-release.jks
echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties echo "storePassword=${{ secrets.KEYSTORE_PASSWORD }}" > android/key.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties
echo "storeFile=simple-notes-release.jks" >> android/key.properties echo "storeFile=simple-notes-release.jks" >> android/key.properties
echo "✅ Signing configuration created" echo "✅ Signing-Konfiguration erstellt"
- name: Build Production APK (Release) - name: Produktions-APK bauen (Standard + F-Droid Flavors)
run: | run: |
cd android 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: | run: |
mkdir -p apk-output mkdir -p apk-output
# === Standard Flavor (mit Google Services) ===
# Universal APK (funktioniert auf allen Geraeten)
cp android/app/build/outputs/apk/standard/release/app-standard-universal-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk
# ARM64 APK (moderne Geräte 2018+)
cp android/app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk
# ARMv7 APK (ältere Geräte)
cp android/app/build/outputs/apk/standard/release/app-standard-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk
# === F-Droid Flavor (ohne Google Services) ===
# Universal APK # Universal APK
cp android/app/build/outputs/apk/release/app-universal-release.apk \ cp android/app/build/outputs/apk/fdroid/release/app-fdroid-universal-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk
# ARM64 APK # ARM64 APK
cp android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \ cp android/app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk
# ARMv7 APK # ARMv7 APK
cp android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk \ cp android/app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk
echo "✅ APK files prepared:" echo "✅ APK-Dateien vorbereitet (Standard + F-Droid):"
ls -lh apk-output/ ls -lh apk-output/
- name: Upload APK artifacts - name: APK-Artefakte hochladen
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: simple-notes-sync-apks-v${{ env.VERSION_NAME }} name: simple-notes-sync-apks-v${{ env.VERSION_NAME }}
path: apk-output/*.apk path: apk-output/*.apk
retention-days: 90 # Keep production builds longer retention-days: 90 # Produktions-Builds länger aufbewahren
- name: Get commit info - name: Commit-Informationen auslesen
run: | run: |
echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV echo "SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV echo "COMMIT_DATE=$(git log -1 --format=%cd --date=iso-strict)" >> $GITHUB_ENV
- name: F-Droid Changelogs lesen
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
# Get full commit message preserving newlines and emojis (UTF-8) # Lese englische Changelog (optional)
{ if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
echo 'COMMIT_MSG<<EOF' CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
git -c core.quotepath=false log -1 --pretty=%B echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
echo 'EOF' echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
} >> $GITHUB_ENV echo "GHADELIMITER" >> $GITHUB_ENV
else
echo "CHANGELOG_EN=" >> $GITHUB_ENV
fi
- name: Create Production Release - name: Create Production Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:
tag_name: ${{ env.VERSION_TAG }} 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 files: apk-output/*.apk
draft: false draft: false
prerelease: false prerelease: false
generate_release_notes: false generate_release_notes: false
body: | 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-universal.apk` | Funktioniert auf allen Android-Geraeten |
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk` | Kleinere Dateigröße fuer 64-bit Geräte |
| Aelter (<2018) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk` | Fuer 32-bit ARM Geräte |
| F-Droid Universal | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk` | Fuer F-Droid Store |
| F-Droid ARM64 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk` | F-Droid 64-bit |
| F-Droid ARMv7 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk` | F-Droid 32-bit |
- **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }} 💡 **Nicht sicher?** → Nimm die **Universal** APK!
- **Build-Datum:** ${{ env.COMMIT_DATE }}
---
## 📋 Changelog / Release Notes
${{ env.CHANGELOG_EN }}
<details>
<summary><3E>🇪 Deutsche Version (zum Aufklappen)</summary>
${{ env.CHANGELOG_DE }}
</details>
---
## 📊 Build-Info
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
- **Datum:** ${{ env.COMMIT_DATE }}
- **Commit:** ${{ env.SHORT_SHA }} - **Commit:** ${{ env.SHORT_SHA }}
- **Umgebung:** 🟢 **PRODUKTION**
--- ---
## 📋 Änderungen **[📖 Dokumentation](https://github.com/inventory69/simple-notes-sync/blob/main/QUICKSTART.md)** · **[🐛 Issue melden](https://github.com/inventory69/simple-notes-sync/issues)**
${{ 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)
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

99
.github/workflows/pr-build-check.yml vendored Normal file
View 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 }})`
})

152
CHANGELOG.md Normal file
View File

@@ -0,0 +1,152 @@
# 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.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
View 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! 🎄**

529
DOCS.en.md Normal file
View File

@@ -0,0 +1,529 @@
# Simple Notes Sync - Technical Documentation
This file contains detailed technical information about implementation, architecture, and advanced features.
**🌍 Languages:** [Deutsch](DOCS.md) · **English**
---
## 📐 Architecture
### Overall Overview
```
┌─────────────────┐
│ Android App │
│ (Kotlin) │
└────────┬────────┘
│ WebDAV/HTTP
┌────────▼────────┐
│ WebDAV Server │
│ (Docker) │
└─────────────────┘
```
### Android App Architecture
```
app/
├── models/
│ ├── Note.kt # Data class for notes
│ └── SyncStatus.kt # Sync status enum
├── storage/
│ └── NotesStorage.kt # Local JSON file storage
├── sync/
│ ├── WebDavSyncService.kt # WebDAV sync logic
│ ├── NetworkMonitor.kt # WiFi detection
│ ├── SyncWorker.kt # WorkManager background worker
│ └── BootReceiver.kt # Device reboot handler
├── adapters/
│ └── NotesAdapter.kt # RecyclerView adapter
├── utils/
│ ├── Constants.kt # App constants
│ ├── NotificationHelper.kt# Notification management
│ └── Logger.kt # Debug/release logging
└── activities/
├── MainActivity.kt # Main view with list
├── NoteEditorActivity.kt# Note editor
└── SettingsActivity.kt # Server configuration
```
---
## 🔄 Auto-Sync Implementation
### WorkManager Periodic Task
Auto-sync is based on **WorkManager** with the following configuration:
```kotlin
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // WiFi only
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
30, TimeUnit.MINUTES, // Every 30 minutes
10, TimeUnit.MINUTES // Flex interval
)
.setConstraints(constraints)
.build()
```
**Why WorkManager?**
- ✅ Runs even when app is closed
- ✅ Automatic restart after device reboot
- ✅ Battery-efficient (Android managed)
- ✅ Guaranteed execution when constraints are met
### Network Detection
Instead of SSID-based detection (Android 13+ privacy issues), we use **Gateway IP Comparison**:
```kotlin
fun isInHomeNetwork(): Boolean {
val gatewayIP = getGatewayIP() // e.g. 192.168.0.1
val serverIP = extractIPFromUrl(serverUrl) // e.g. 192.168.0.188
return isSameNetwork(gatewayIP, serverIP) // Checks /24 network
}
```
**Advantages:**
- ✅ No location permissions needed
- ✅ Works with all Android versions
- ✅ Reliable and fast
### Sync Flow
```
1. WorkManager wakes up (every 30 min)
2. Check: WiFi connected?
3. Check: Same network as server?
4. Load local notes
5. Upload new/changed notes → Server
6. Download remote notes ← Server
7. Merge & resolve conflicts
8. Update local storage
9. Show notification (if changes)
```
---
## <20> Sync Trigger Overview
The app uses **4 different sync triggers** with different use cases:
| Trigger | File | Function | When? | Pre-Check? |
|---------|------|----------|-------|------------|
| **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes |
| **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi enabled/SSID changed | ✅ Yes |
### Server Reachability Check (Pre-Check)
**All 4 sync triggers** use a **pre-check** before the actual sync:
```kotlin
// WebDavSyncService.kt - isServerReachable()
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), 2000) // 2s Timeout
}
true
} catch (e: Exception) {
Logger.d(TAG, "Server not reachable: ${e.message}")
false
}
}
```
**Why Socket Check instead of HTTP Request?**
-**Faster:** Socket connect is instant, HTTP request takes longer
- 🔋 **Battery Efficient:** No HTTP overhead (headers, TLS handshake, etc.)
- 🎯 **More Precise:** Only checks network reachability, not server logic
- 🛡️ **Prevents Errors:** Detects foreign WiFi networks before sync error occurs
**When does the check fail?**
- ❌ Server offline/unreachable
- ❌ Wrong WiFi network (e.g. public café WiFi)
- ❌ Network not ready yet (DHCP/routing delay after WiFi connect)
- ❌ VPN blocks server access
- ❌ No WebDAV server URL configured
### Sync Behavior by Trigger Type
| Trigger | When server not reachable | On successful sync | Throttling |
|---------|--------------------------|-------------------|------------|
| Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None |
| Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min |
| Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min |
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | SSID-based |
---
## <20>🔋 Battery Optimization
### Usage Analysis
| Component | Frequency | Usage | Details |
|------------|----------|-----------|---------|
| WorkManager Wakeup | Every 30 min | ~0.15 mAh | System wakes up |
| Network Check | 48x/day | ~0.03 mAh | Gateway IP check |
| WebDAV Sync | 2-3x/day | ~1.5 mAh | Only when changes |
| **Total** | - | **~12 mAh/day** | **~0.4%** at 3000mAh |
### Optimizations
1. **IP Caching**
```kotlin
private var cachedServerIP: String? = null
// DNS lookup only once at start, not every check
```
2. **Throttling**
```kotlin
private var lastSyncTime = 0L
private const val MIN_SYNC_INTERVAL_MS = 60_000L // Max 1 sync/min
```
3. **Conditional Logging**
```kotlin
object Logger {
fun d(tag: String, msg: String) {
if (BuildConfig.DEBUG) Log.d(tag, msg)
}
}
```
4. **Network Constraints**
- WiFi only (not mobile data)
- Only when server is reachable
- No permanent listeners
---
## 📦 WebDAV Sync Details
### Upload Flow
```kotlin
suspend fun uploadNotes(): Int {
val localNotes = storage.loadAllNotes()
var uploadedCount = 0
for (note in localNotes) {
if (note.syncStatus == SyncStatus.PENDING) {
val jsonContent = note.toJson()
val remotePath = "$serverUrl/${note.id}.json"
sardine.put(remotePath, jsonContent.toByteArray())
note.syncStatus = SyncStatus.SYNCED
storage.saveNote(note)
uploadedCount++
}
}
return uploadedCount
}
```
### Download Flow
```kotlin
suspend fun downloadNotes(): DownloadResult {
val remoteFiles = sardine.list(serverUrl)
var downloadedCount = 0
var conflictCount = 0
for (file in remoteFiles) {
if (!file.name.endsWith(".json")) continue
val content = sardine.get(file.href)
val remoteNote = Note.fromJson(content)
val localNote = storage.loadNote(remoteNote.id)
if (localNote == null) {
// New note from server
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt < remoteNote.modifiedAt) {
// Server has newer version
storage.saveNote(remoteNote)
downloadedCount++
} else if (localNote.modifiedAt > remoteNote.modifiedAt) {
// Local version is newer → Conflict
resolveConflict(localNote, remoteNote)
conflictCount++
}
}
return DownloadResult(downloadedCount, conflictCount)
}
```
### Conflict Resolution
Strategy: **Last-Write-Wins** with **Conflict Copy**
```kotlin
fun resolveConflict(local: Note, remote: Note) {
// Rename remote note (conflict copy)
val conflictNote = remote.copy(
id = "${remote.id}_conflict_${System.currentTimeMillis()}",
title = "${remote.title} (Conflict)"
)
storage.saveNote(conflictNote)
// Local note remains
local.syncStatus = SyncStatus.SYNCED
storage.saveNote(local)
}
```
---
## 🔔 Notifications
### Notification Channels
```kotlin
val channel = NotificationChannel(
"notes_sync_channel",
"Notes Synchronization",
NotificationManager.IMPORTANCE_DEFAULT
)
```
### Success Notification
```kotlin
fun showSyncSuccess(context: Context, count: Int) {
val intent = Intent(context, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(context, 0, intent, FLAGS)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle("Sync successful")
.setContentText("$count notes synchronized")
.setContentIntent(pendingIntent) // Click opens app
.setAutoCancel(true) // Dismiss on click
.build()
notificationManager.notify(NOTIFICATION_ID, notification)
}
```
---
## 🛡️ Permissions
The app requires **minimal permissions**:
```xml
<!-- Network -->
<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" />
<!-- Boot Receiver -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Battery Optimization (optional) -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
```
**No Location Permissions!**
Earlier versions required `ACCESS_FINE_LOCATION` for SSID detection. Now we use Gateway IP Comparison.
---
## 🧪 Testing
### Test Server
```bash
# WebDAV server reachable?
curl -u noteuser:password http://192.168.0.188:8080/
# Upload file
echo '{"test":"data"}' > test.json
curl -u noteuser:password -T test.json http://192.168.0.188:8080/test.json
# Download file
curl -u noteuser:password http://192.168.0.188:8080/test.json
```
### Test Android App
**Unit Tests:**
```bash
cd android
./gradlew test
```
**Instrumented Tests:**
```bash
./gradlew connectedAndroidTest
```
**Manual Testing Checklist:**
- [ ] Create note → visible in list
- [ ] Edit note → changes saved
- [ ] Delete note → removed from list
- [ ] Manual sync → server status "Reachable"
- [ ] Auto-sync → notification after ~30 min
- [ ] Close app → auto-sync continues
- [ ] Device reboot → auto-sync starts automatically
- [ ] Server offline → error notification
- [ ] Notification click → app opens
---
## 🚀 Build & Deployment
### Debug Build
```bash
cd android
./gradlew assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
```
### Release Build
```bash
./gradlew assembleRelease
# APK: app/build/outputs/apk/release/app-release-unsigned.apk
```
### Sign (for Distribution)
```bash
# Create keystore
keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias
# Sign APK
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore my-release-key.jks \
app-release-unsigned.apk my-alias
# Optimize
zipalign -v 4 app-release-unsigned.apk app-release.apk
```
---
## 🐛 Debugging
### LogCat Filter
```bash
# Only app logs
adb logcat -s SimpleNotesApp NetworkMonitor SyncWorker WebDavSyncService
# With timestamps
adb logcat -v time -s SyncWorker
# Save to file
adb logcat -s SyncWorker > sync_debug.log
```
### Common Issues
**Problem: Auto-sync not working**
```
Solution: Disable battery optimization
Settings → Apps → Simple Notes → Battery → Don't optimize
```
**Problem: Server not reachable**
```
Check:
1. Server running? → docker-compose ps
2. IP correct? → ip addr show
3. Port open? → telnet 192.168.0.188 8080
4. Firewall? → sudo ufw allow 8080
```
**Problem: Notifications not appearing**
```
Check:
1. Notification permission granted?
2. Do Not Disturb active?
3. App in background? → Force stop & restart
```
---
## 📚 Dependencies
```gradle
// Core
androidx.core:core-ktx:1.12.0
androidx.appcompat:appcompat:1.6.1
com.google.android.material:material:1.11.0
// Lifecycle
androidx.lifecycle:lifecycle-runtime-ktx:2.7.0
// RecyclerView
androidx.recyclerview:recyclerview:1.3.2
// Coroutines
org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3
// WorkManager
androidx.work:work-runtime-ktx:2.9.0
// WebDAV Client
com.github.thegrizzlylabs:sardine-android:0.8
// Broadcast (deprecated but working)
androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
```
---
## 🔮 Roadmap
### v1.1
- [ ] Search & Filter
- [ ] Dark Mode
- [ ] Tags/Categories
- [ ] Markdown Preview
### v2.0
- [ ] Desktop Client (Flutter)
- [ ] End-to-End Encryption
- [ ] Shared Notes (Collaboration)
- [ ] Attachment Support
---
## 📖 Further Documentation
- [Project Docs](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync)
- [Sync Architecture](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SYNC_ARCHITECTURE.md) - **Detailed Sync Trigger Documentation**
- [Android Guide](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
- [Bugfix Documentation](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/BUGFIX_SYNC_SPAM_AND_NOTIFICATIONS.md)
---
**Last updated:** December 25, 2025

61
DOCS.md
View File

@@ -2,6 +2,8 @@
Diese Datei enthält detaillierte technische Informationen über die Implementierung, Architektur und erweiterte Funktionen. Diese Datei enthält detaillierte technische Informationen über die Implementierung, Architektur und erweiterte Funktionen.
**🌍 Sprachen:** **Deutsch** · [English](DOCS.en.md)
--- ---
## 📐 Architektur ## 📐 Architektur
@@ -116,7 +118,61 @@ fun isInHomeNetwork(): Boolean {
--- ---
## 🔋 Akku-Optimierung ## <EFBFBD> Sync-Trigger Übersicht
Die App verwendet **4 verschiedene Sync-Trigger** mit unterschiedlichen Anwendungsfällen:
| Trigger | Datei | Funktion | Wann? | Pre-Check? |
|---------|-------|----------|-------|------------|
| **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja |
| **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi an/SSID-Wechsel | ✅ Ja |
### Server-Erreichbarkeits-Check (Pre-Check)
**Alle 4 Sync-Trigger** verwenden vor dem eigentlichen Sync einen **Pre-Check**:
```kotlin
// WebDavSyncService.kt - isServerReachable()
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
Socket().use { socket ->
socket.connect(InetSocketAddress(host, port), 2000) // 2s Timeout
}
true
} catch (e: Exception) {
Logger.d(TAG, "Server not reachable: ${e.message}")
false
}
}
```
**Warum Socket-Check statt HTTP-Request?**
-**Schneller:** Socket-Connect ist instant, HTTP-Request dauert länger
- 🔋 **Akkuschonender:** Kein HTTP-Overhead (Headers, TLS Handshake, etc.)
- 🎯 **Präziser:** Prüft nur Netzwerk-Erreichbarkeit, nicht Server-Logik
- 🛡️ **Verhindert Fehler:** Erkennt fremde WiFi-Netze bevor Sync-Fehler entsteht
**Wann schlägt der Check fehl?**
- ❌ Server offline/nicht erreichbar
- ❌ Falsches WiFi-Netzwerk (z.B. öffentliches Café-WiFi)
- ❌ Netzwerk noch nicht bereit (DHCP/Routing-Delay nach WiFi-Connect)
- ❌ VPN blockiert Server-Zugriff
- ❌ Keine WebDAV-Server-URL konfiguriert
### Sync-Verhalten nach Trigger-Typ
| Trigger | Bei Server nicht erreichbar | Bei erfolgreichem Sync | Throttling |
|---------|----------------------------|----------------------|------------|
| Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins |
| Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min |
| Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min |
| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | SSID-basiert |
---
## <20>🔋 Akku-Optimierung
### Verbrauchsanalyse ### Verbrauchsanalyse
@@ -464,9 +520,10 @@ androidx.localbroadcastmanager:localbroadcastmanager:1.1.0
## 📖 Weitere Dokumentation ## 📖 Weitere Dokumentation
- [Project Docs](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync) - [Project Docs](https://github.com/inventory69/project-docs/tree/main/simple-notes-sync)
- [Sync Architecture](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/SYNC_ARCHITECTURE.md) - **Detaillierte Sync-Trigger Dokumentation**
- [Android Guide](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md) - [Android Guide](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/ANDROID_GUIDE.md)
- [Bugfix Documentation](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/BUGFIX_SYNC_SPAM_AND_NOTIFICATIONS.md) - [Bugfix Documentation](https://github.com/inventory69/project-docs/blob/main/simple-notes-sync/BUGFIX_SYNC_SPAM_AND_NOTIFICATIONS.md)
--- ---
**Letzte Aktualisierung:** 21. Dezember 2025 **Letzte Aktualisierung:** 25. Dezember 2025

View File

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

File diff suppressed because it is too large Load Diff

269
QUICKSTART.en.md Normal file
View 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`) |
| **Gateway SSID** | Name of your WiFi network |
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 WiFi?**
- Smartphone and server must be on same network
- Check SSID in app settings
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. **On correct WiFi?**
- Sync only works when SSID = Gateway SSID
- Check current SSID in Android settings
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

View File

@@ -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: **🌍 Sprachen:** **Deutsch** · [English](QUICKSTART.en.md)
- **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)
--- ---
**Server Status:** ✅ Running on `http://192.168.0.188:8080/` ## Voraussetzungen
**Next:** Android App in Android Studio erstellen
**Estimated Time:** 18-24 Stunden für vollständige App
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`) |
| **Gateway SSID** | Name deines WLAN-Netzwerks |
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. **Gleiche WLAN?**
- Smartphone und Server müssen im selben Netzwerk sein
- Prüfe SSID in App-Einstellungen
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. **Im richtigen WLAN?**
- Sync funktioniert nur wenn SSID = Gateway SSID
- Prüfe aktuelle SSID in Android-Einstellungen
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

216
README.en.md Normal file
View File

@@ -0,0 +1,216 @@
# Simple Notes Sync 📝
> Minimalist offline notes with auto-sync to your own server
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](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>
---
## Features
### 📝 Notes
* Simple text notes with auto-save
* Swipe-to-delete with confirmation
* Material Design 3 editor
### 💾 Backup & Restore **NEW in v1.2.0**
* **Local backup** - Export all notes as JSON file
* **Flexible restore** - 3 modes (Merge, Replace, Overwrite)
* **Automatic safety net** - Auto-backup before every restore
* **Independent from server** - Works completely offline
### 🖥️ Desktop Integration **NEW in v1.2.0**
* **Markdown export** - Notes are automatically exported as `.md` files
* **WebDAV access** - Mount WebDAV as network drive for direct access
* **Editor compatibility** - VS Code, Typora, Notepad++, or any Markdown editor
* **Last-Write-Wins** - Intelligent conflict resolution via timestamps
* **Dual-format** - JSON sync remains master, Markdown is optional mirror
### 🔄 Synchronization
* **Pull-to-refresh** for manual sync
* **Auto-sync** (15/30/60 min) only on home WiFi
* **Smart server check** - No errors on foreign networks
* **Conflict-free merging** - Your changes are never lost
* **6 sync triggers** - Periodic, app-start, WiFi, manual, pull-to-refresh, settings
### 🔒 Privacy & Self-Hosted
* **WebDAV server** (Nextcloud, ownCloud, etc.)
* **Docker setup guide** included in docs
* **Your data stays with you** - No tracking, no cloud
* **HTTP only local** - HTTPS for external servers
* **100% open source** (MIT License)
### 🔋 Performance
* **Battery-friendly** (~0.2-0.8% per day)
* **Offline-first** - Works without internet
* **Dark mode** & dynamic colors
---
## 🚀 Quick Start
### 1. Server Setup
```bash
cd server
cp .env.example .env
# Set password in .env
docker compose up -d
```
➡️ **Details:** [Server Setup Guide](server/README.en.md)
### 2. App Installation
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install & open
3. ⚙️ Settings → Configure server
4. Enable auto-sync
➡️ **Details:** [Complete guide](QUICKSTART.en.md)
---
## <20> Local Backup & Restore
### Create Backup
1. **Settings****Backup & Restore**
2. Tap **"📥 Create backup"**
3. Choose location (Downloads, SD card, cloud folder)
4. Done! All notes are saved in a `.json` file
**Filename:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
### Restore
1. **Settings****"📤 Restore from file"**
2. Select backup file
3. **Choose restore mode:**
- **Merge** _(Default)_ - Add new notes, keep existing ones
- **Replace** - Delete all and import backup
- **Overwrite duplicates** - Backup wins on ID conflicts
4. Confirm - _Automatic safety backup is created!_
**💡 Tip:** Before every restore, an automatic safety backup is created - your data is safe!
---
## 🖥️ Desktop Integration (WebDAV + Markdown)
### Why Markdown?
The app automatically exports your notes as `.md` files so you can edit them on desktop:
- **JSON remains master** - Primary sync mechanism (reliable, fast)
- **Markdown is mirror** - Additional export for desktop access
- **Dual-format** - Both formats are always in sync
### Setup: WebDAV as Network Drive
**With WebDAV mount ANY Markdown editor works!**
#### Windows:
1. **Open Explorer** → Right-click on "This PC"
2. **"Map network drive"**
3. **Enter WebDAV URL:** `http://YOUR-SERVER:8080/notes-md/`
4. Enter username/password
5. **Done!** - Folder appears as drive (e.g. Z:\)
#### macOS:
1. **Finder** → Menu "Go" → "Connect to Server" (⌘K)
2. **Server Address:** `http://YOUR-SERVER:8080/notes-md/`
3. Enter username/password
4. **Done!** - Folder appears under "Network"
#### Linux:
```bash
# Option 1: GNOME Files / Nautilus
Files → Other Locations → Connect to Server
Server Address: dav://YOUR-SERVER:8080/notes-md/
# Option 2: davfs2 (permanent mount)
sudo apt install davfs2
sudo mount -t davfs http://YOUR-SERVER:8080/notes-md/ /mnt/notes
```
### Workflow:
1. **Enable Markdown export** (App → Settings)
2. **Mount WebDAV** (see above)
3. **Open editor** (VS Code, Typora, Notepad++, etc.)
4. **Edit notes** - Changes are saved directly
5. **"Import Markdown Changes" in app** - Import desktop changes
**Recommended Editors:**
- **VS Code** - Free, powerful, with Markdown preview
- **Typora** - Minimalist, WYSIWYG Markdown
- **Notepad++** - Lightweight, fast
- **iA Writer** - Focused writing
- **VS Code** with WebDAV extension
- **Typora** (local copy)
- **iA Writer** (read/edit only, no auto-sync)
**⚠️ Important:**
- Markdown export is **optional** (toggle in settings)
- JSON sync **always** works - Markdown is additional
- All 6 sync triggers remain unchanged
---
## <20>📚 Documentation
- **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users
- **[Server Setup](server/README.en.md)** - Configure WebDAV server
- **[Complete Docs](DOCS.en.md)** - Features, troubleshooting, build instructions
---
## 🛠️ Development
```bash
cd android
./gradlew assembleStandardRelease
```
➡️ **Details:** [Build instructions in DOCS.en.md](DOCS.en.md)
---
## 🤝 Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
---
## <20> Changelog
All changes are documented in [CHANGELOG.md](CHANGELOG.md).
---
## <20>📄 License
MIT License - see [LICENSE](LICENSE)
**v1.2.0** · Built with Kotlin + Material Design 3

258
README.md
View File

@@ -1,137 +1,221 @@
# Simple Notes Sync 📝 # Simple Notes Sync 📝
> **Minimalistische Android Notiz-App mit automatischer WLAN-Synchronisierung** > Minimalistische Offline-Notizen mit Auto-Sync zu deinem eigenen Server
[![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/) [![Android](https://img.shields.io/badge/Android-8.0%2B-green.svg)](https://www.android.com/)
[![Kotlin](https://img.shields.io/badge/Kotlin-1.9%2B-blue.svg)](https://kotlinlang.org/)
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/) [![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
Schlanke Offline-Notizen ohne Schnickschnack - deine Daten bleiben bei dir. Automatische Synchronisierung zu deinem eigenen WebDAV-Server, kein Google, kein Microsoft, keine Cloud. **📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
## ✨ Features **🌍 Sprachen:** **Deutsch** · [English](README.en.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
## 📥 Quick Download
**Android APK:** [📱 Neueste Version herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
💡 **Tipp:** Nutze [Obtainium](https://github.com/ImranR98/Obtainium) für automatische Updates!
--- ---
## 🚀 Schnellstart ## 📱 Screenshots
### 1⃣ WebDAV Server starten <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>
```fish ---
## Features
### 📝 Notizen
* Einfache Textnotizen mit automatischem Speichern
* Swipe-to-Delete mit Bestätigung
* Material Design 3 Editor
### 💾 Backup & Wiederherstellung **NEU in v1.2.0**
* **Lokales Backup** - Exportiere alle Notizen als JSON-Datei
* **Flexible Wiederherstellung** - 3 Modi (Zusammenführen, Ersetzen, Überschreiben)
* **Automatisches Sicherheitsnetz** - Auto-Backup vor jeder Wiederherstellung
* **Unabhängig vom Server** - Funktioniert komplett offline
### 🖥️ Desktop-Integration **NEU in v1.2.0**
* **Markdown-Export** - Notizen werden automatisch als `.md` Dateien exportiert
* **WebDAV-Zugriff** - Mounte WebDAV als Netzlaufwerk für direkten Zugriff
* **Editor-Kompatibilität** - VS Code, Typora, Notepad++, oder beliebiger Markdown-Editor
* **Last-Write-Wins** - Intelligente Konfliktauflösung via Zeitstempel
* **Dual-Format** - JSON-Sync bleibt Master, Markdown ist optionaler Mirror
### 🔄 Synchronisation
* **Pull-to-Refresh** für manuellen Sync
* **Auto-Sync** (15/30/60 Min) nur im Heim-WLAN
* **Smart Server-Check** - Keine Fehler in fremden Netzwerken
* **Konfliktfreies Merging** - Deine Änderungen gehen nie verloren
* **6 Sync-Trigger** - Periodic, App-Start, WiFi, Manual, Pull-to-Refresh, Settings
### 🔒 Privacy & Self-Hosted
* **WebDAV-Server** (Nextcloud, ownCloud, etc.)
* **Docker Setup-Anleitung** in den Docs enthalten
* **Deine Daten bleiben bei dir** - Kein Tracking, keine Cloud
* **HTTP nur lokal** - HTTPS für externe Server
* **100% Open Source** (MIT Lizenz)
### 🔋 Performance
* **Akkuschonend** (~0.2-0.8% pro Tag)
* **Offline-First** - Funktioniert ohne Internet
* **Dark Mode** & Dynamic Colors
---
## 🚀 Quick Start
### 1. Server Setup
```bash
cd server cd server
cp .env.example .env cp .env.example .env
# Passwort in .env anpassen # Passwort in .env setzen
docker compose up -d docker compose up -d
``` ```
### 2⃣ App installieren & konfigurieren ➡️ **Details:** [Server Setup Guide](server/README.md)
1. APK herunterladen und installieren ### 2. App Installation
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.)
**Fertig!** Notizen werden automatisch synchronisiert 🎉 1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren & öffnen
3. ⚙️ Einstellungen → Server konfigurieren
4. Auto-Sync aktivieren
➡️ **Details:** [Vollständige Anleitung](QUICKSTART.md)
--- ---
## ⚙️ Sync-Intervalle ## <EFBFBD> Lokales Backup & Wiederherstellung
| Intervall | Akku/Tag | Anwendungsfall | ### Backup erstellen
|-----------|----------|----------------|
| **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). 1. **Einstellungen****Backup & Wiederherstellung**
2. Tippe auf **"📥 Backup erstellen"**
3. Wähle Speicherort (Downloads, SD-Karte, Cloud-Ordner)
4. Fertig! Alle Notizen sind in einer `.json` Datei gesichert
**Dateiname:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
### Wiederherstellen
1. **Einstellungen****"📤 Aus Datei wiederherstellen"**
2. Wähle Backup-Datei
3. **Wiederherstellungs-Modus auswählen:**
- **Zusammenführen** _(Standard)_ - Neue Notizen hinzufügen, bestehende behalten
- **Ersetzen** - Alle löschen und Backup importieren
- **Duplikate überschreiben** - Backup gewinnt bei ID-Konflikten
4. Bestätigen - _Automatisches Sicherheits-Backup wird erstellt!_
**💡 Tipp:** Vor jeder Wiederherstellung wird automatisch ein Auto-Backup erstellt - deine Daten sind sicher!
--- ---
## <EFBFBD> Neue Features in v1.1.0 ## 🖥️ Desktop-Integration (WebDAV + Markdown)
### Konfigurierbare Sync-Intervalle ### Warum Markdown?
- ⏱️ Wählbare Intervalle: 15/30/60 Minuten
- 📊 Transparente Akkuverbrauchs-Anzeige
- <20> Sofortige Anwendung ohne App-Neustart
### Über-Sektion Die App exportiert deine Notizen automatisch als `.md` Dateien, damit du sie auf dem Desktop bearbeiten kannst:
- <20> App-Version & Build-Datum
- 🌐 Links zu GitHub Repo & Entwickler
- ⚖️ Lizenz-Information
### Verbesserungen - **JSON bleibt Master** - Primärer Sync-Mechanismus (verlässlich, schnell)
- 🎯 Benutzerfreundliche Doze-Mode Erklärung - **Markdown ist Mirror** - Zusätzlicher Export für Desktop-Zugriff
- 🔕 Keine störenden Sync-Fehler Toasts im Hintergrund - **Dual-Format** - Beide Formate sind immer synchron
- 📝 Erweiterte Debug-Logs für Troubleshooting
### Setup: WebDAV als Netzlaufwerk
**Mit WebDAV-Mount funktioniert JEDER Markdown-Editor!**
#### Windows:
1. **Explorer öffnen** → Rechtsklick auf "Dieser PC"
2. **"Netzlaufwerk verbinden"** wählen
3. **WebDAV-URL eingeben:** `http://DEIN-SERVER:8080/notes-md/`
4. Benutzername/Passwort eingeben
5. **Fertig!** - Ordner erscheint als Laufwerk (z.B. Z:\)
#### macOS:
1. **Finder** → Menü "Gehe zu" → "Mit Server verbinden" (⌘K)
2. **Server-Adresse:** `http://DEIN-SERVER:8080/notes-md/`
3. Benutzername/Passwort eingeben
4. **Fertig!** - Ordner erscheint unter "Netzwerk"
#### Linux:
```bash
# Option 1: GNOME Files / Nautilus
Dateien → Andere Orte → Mit Server verbinden
Server-Adresse: dav://DEIN-SERVER:8080/notes-md/
# Option 2: davfs2 (permanent mount)
sudo apt install davfs2
sudo mount -t davfs http://DEIN-SERVER:8080/notes-md/ /mnt/notes
```
### Workflow:
1. **Markdown-Export aktivieren** (App → Einstellungen)
2. **WebDAV mounten** (siehe oben)
3. **Editor öffnen** (VS Code, Typora, Notepad++, etc.)
4. **Notizen bearbeiten** - Änderungen werden direkt gespeichert
5. **"Import Markdown Changes" in App** - Desktop-Änderungen importieren
**Empfohlene Editoren:**
- **VS Code** - Kostenlos, mächtig, mit Markdown-Preview
- **Typora** - Minimalistisch, WYSIWYG-Markdown
- **Notepad++** - Leichtgewichtig, schnell
- **iA Writer** - Fokussiertes Schreiben
3. Notizen bearbeiten - Änderungen via "Import Markdown Changes" in die App importieren
### Alternative: Direkter Zugriff
Du kannst die `.md` Dateien auch direkt mit jedem Markdown-Editor öffnen:
- **VS Code** mit WebDAV-Extension
- **Typora** (lokale Kopie)
- **iA Writer** (nur lesen/bearbeiten, kein Auto-Sync)
**⚠️ Wichtig:**
- Markdown-Export ist **optional** (in Einstellungen ein/ausschaltbar)
- JSON-Sync funktioniert **immer** - Markdown ist zusätzlich
- Alle 6 Sync-Trigger bleiben unverändert erhalten
--- ---
## 🛠️ Selbst bauen ## <EFBFBD>📚 Dokumentation
```fish - **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren
- **[Vollständige Docs](DOCS.md)** - Features, Troubleshooting, Build-Anleitung
---
## 🛠️ Entwicklung
```bash
cd android cd android
./gradlew assembleStandardRelease ./gradlew assembleStandardRelease
# APK: android/app/build/outputs/apk/standard/release/
``` ```
--- ➡️ **Details:** [Build-Anleitung in DOCS.md](DOCS.md)
## 🐛 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)
--- ---
## 🤝 Contributing ## 🤝 Contributing
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request. Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details.
--- ---
## 📄 Lizenz ## <EFBFBD> Changelog
Alle Änderungen sind in [CHANGELOG.md](CHANGELOG.md) dokumentiert.
---
## <20>📄 Lizenz
MIT License - siehe [LICENSE](LICENSE) MIT License - siehe [LICENSE](LICENSE)
--- **v1.2.0** · Gebaut mit Kotlin + Material Design 3
**Version:** 1.1.0 · **Status:** ✅ Produktiv · **Gebaut mit:** Kotlin + Material Design 3

View File

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

5
android/.gitignore vendored
View File

@@ -13,3 +13,8 @@
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
# Signing configuration (contains sensitive keys)
key.properties
*.jks
*.keystore

157
android/LOCAL_BUILDS.md Normal file
View 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
```

View File

@@ -17,8 +17,8 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 2 // 🔥 F-Droid Release v1.1.0 versionCode = 5 // 🔥 v1.2.0: Local Backup + Markdown Desktop Integration
versionName = "1.1.0" // 🔥 Configurable Sync Interval + About Section versionName = "1.2.0" // 🔥 v1.2.0: Backup/Restore + Joplin/Obsidian Support
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -26,6 +26,12 @@ android {
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"") buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
} }
// 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)
}
// Enable multiple APKs per ABI for smaller downloads // Enable multiple APKs per ABI for smaller downloads
splits { splits {
abi { abi {
@@ -124,6 +130,9 @@ dependencies {
// LocalBroadcastManager für UI Refresh // LocalBroadcastManager für UI Refresh
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
// SwipeRefreshLayout für Pull-to-Refresh
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// Testing (bleiben so) // Testing (bleiben so)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

View File

@@ -27,7 +27,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SimpleNotes" android:theme="@style/Theme.SimpleNotes"
android:usesCleartextTraffic="true" android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -41,10 +42,14 @@ class MainActivity : AppCompatActivity() {
private lateinit var emptyStateCard: MaterialCardView private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var adapter: NotesAdapter private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) } private val storage by lazy { NotesStorage(this) }
// Track pending deletions to prevent flicker when notes reload
private val pendingDeletions = mutableSetOf<String>()
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
} }
@@ -91,6 +96,9 @@ class MainActivity : AppCompatActivity() {
Logger.enableFileLogging(this) Logger.enableFileLogging(this)
} }
// Alte Sync-Notifications beim App-Start löschen
NotificationHelper.clearSyncNotifications(this)
// Permission für Notifications (Android 13+) // Permission für Notifications (Android 13+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestNotificationPermission() requestNotificationPermission()
@@ -117,7 +125,7 @@ class MainActivity : AppCompatActivity() {
Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)") Logger.d(TAG, "📡 BroadcastReceiver registered (sync-completed)")
// Reload notes // Reload notes (scroll to top wird in loadNotes() gemacht)
loadNotes() loadNotes()
// Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast) // Trigger Auto-Sync beim App-Wechsel in Vordergrund (Toast)
@@ -142,10 +150,27 @@ class MainActivity : AppCompatActivity() {
// Update last sync timestamp // Update last sync timestamp
prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply() prefs.edit().putLong(PREF_LAST_AUTO_SYNC_TIME, System.currentTimeMillis()).apply()
// GLEICHER Sync-Code wie manueller Sync (funktioniert!)
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val syncService = WebDavSyncService(this@MainActivity) val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
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")
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }
@@ -203,6 +228,7 @@ class MainActivity : AppCompatActivity() {
emptyStateCard = findViewById(R.id.emptyStateCard) emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote) fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
} }
private fun setupToolbar() { private fun setupToolbar() {
@@ -216,10 +242,72 @@ class MainActivity : AppCompatActivity() {
recyclerViewNotes.adapter = adapter recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this) recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// 🔥 v1.1.2: Setup Pull-to-Refresh
setupPullToRefresh()
// Setup Swipe-to-Delete // Setup Swipe-to-Delete
setupSwipeToDelete() setupSwipeToDelete()
} }
/**
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
*/
private fun setupPullToRefresh() {
swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
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")
swipeRefreshLayout.isRefreshing = false
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")
showToast("✅ Bereits synchronisiert")
swipeRefreshLayout.isRefreshing = false
return@launch
}
// Check if server is reachable
if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar")
swipeRefreshLayout.isRefreshing = false
return@launch
}
// Perform sync
val result = syncService.syncNotes()
if (result.isSuccess) {
showToast("${result.syncedCount} Notizen synchronisiert")
loadNotes()
} else {
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
}
} catch (e: Exception) {
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
showToast("❌ Fehler: ${e.message}")
} finally {
swipeRefreshLayout.isRefreshing = false
}
}
}
// Set Material 3 color scheme
swipeRefreshLayout.setColorSchemeResources(
com.google.android.material.R.color.material_dynamic_primary50
)
}
private fun setupSwipeToDelete() { private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag 0, // No drag
@@ -236,6 +324,9 @@ class MainActivity : AppCompatActivity() {
val note = adapter.currentList[position] val note = adapter.currentList[position]
val notesCopy = adapter.currentList.toMutableList() val notesCopy = adapter.currentList.toMutableList()
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Remove from list immediately for visual feedback // Remove from list immediately for visual feedback
notesCopy.removeAt(position) notesCopy.removeAt(position)
adapter.submitList(notesCopy) adapter.submitList(notesCopy)
@@ -246,13 +337,15 @@ class MainActivity : AppCompatActivity() {
"Notiz gelöscht", "Notiz gelöscht",
Snackbar.LENGTH_LONG Snackbar.LENGTH_LONG
).setAction("RÜCKGÄNGIG") { ).setAction("RÜCKGÄNGIG") {
// UNDO: Restore note in list // UNDO: Remove from pending deletions and restore
pendingDeletions.remove(note.id)
loadNotes() loadNotes()
}.addCallback(object : Snackbar.Callback() { }.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) { if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO → Actually delete the note // Snackbar dismissed without UNDO → Actually delete the note
storage.deleteNote(note.id) storage.deleteNote(note.id)
pendingDeletions.remove(note.id)
loadNotes() loadNotes()
} }
} }
@@ -276,10 +369,21 @@ class MainActivity : AppCompatActivity() {
private fun loadNotes() { private fun loadNotes() {
val notes = storage.loadAllNotes() 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 // Material 3 Empty State Card
emptyStateCard.visibility = if (notes.isEmpty()) { emptyStateCard.visibility = if (filteredNotes.isEmpty()) {
android.view.View.VISIBLE android.view.View.VISIBLE
} else { } else {
android.view.View.GONE android.view.View.GONE
@@ -303,10 +407,30 @@ class MainActivity : AppCompatActivity() {
private fun triggerManualSync() { private fun triggerManualSync() {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
// 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")
showToast("✅ Bereits synchronisiert")
return@launch
}
showToast("Starte Synchronisation...") showToast("Starte Synchronisation...")
// Start sync // ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val syncService = WebDavSyncService(this@MainActivity) val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
showToast("Server nicht erreichbar")
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }

View File

@@ -41,7 +41,8 @@ class NoteEditorActivity : AppCompatActivity() {
setSupportActionBar(toolbar) setSupportActionBar(toolbar)
supportActionBar?.apply { supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel) // 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
} }
// Find views // Find views

View File

@@ -10,8 +10,10 @@ import android.util.Log
import android.view.MenuItem import android.view.MenuItem
import android.widget.Button import android.widget.Button
import android.widget.EditText import android.widget.EditText
import android.widget.RadioButton
import android.widget.RadioGroup import android.widget.RadioGroup
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SwitchCompat import androidx.appcompat.widget.SwitchCompat
@@ -20,14 +22,14 @@ import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.chip.Chip
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.dettmer.simplenotes.backup.BackupManager
import dev.dettmer.simplenotes.backup.RestoreMode
import dev.dettmer.simplenotes.utils.UrlValidator
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.NetworkMonitor import dev.dettmer.simplenotes.sync.NetworkMonitor
@@ -49,15 +51,25 @@ class SettingsActivity : AppCompatActivity() {
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE" private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
} }
private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout
private lateinit var editTextServerUrl: EditText private lateinit var editTextServerUrl: EditText
private lateinit var editTextUsername: EditText private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText private lateinit var editTextPassword: EditText
private lateinit var switchAutoSync: SwitchCompat private lateinit var switchAutoSync: SwitchCompat
private lateinit var switchMarkdownExport: SwitchCompat
private lateinit var buttonTestConnection: Button private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button private lateinit var buttonSyncNow: Button
private lateinit var buttonCreateBackup: Button
private lateinit var buttonRestoreFromFile: Button
private lateinit var buttonRestoreFromServer: Button private lateinit var buttonRestoreFromServer: Button
private lateinit var buttonImportMarkdown: Button
private lateinit var textViewServerStatus: TextView private lateinit var textViewServerStatus: TextView
private lateinit var chipAutoSaveStatus: Chip
// Protocol Selection UI
private lateinit var protocolRadioGroup: RadioGroup
private lateinit var radioHttp: RadioButton
private lateinit var radioHttps: RadioButton
private lateinit var protocolHintText: TextView
// Sync Interval UI // Sync Interval UI
private lateinit var radioGroupSyncInterval: RadioGroup private lateinit var radioGroupSyncInterval: RadioGroup
@@ -68,7 +80,21 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var cardDeveloperProfile: MaterialCardView private lateinit var cardDeveloperProfile: MaterialCardView
private lateinit var cardLicense: MaterialCardView private lateinit var cardLicense: MaterialCardView
private var autoSaveIndicatorJob: Job? = null // Backup Manager
private val backupManager by lazy { BackupManager(this) }
// Activity Result Launchers
private val createBackupLauncher = registerForActivityResult(
ActivityResultContracts.CreateDocument("application/json")
) { uri ->
uri?.let { createBackup(it) }
}
private val restoreBackupLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { showRestoreDialog(RestoreSource.LOCAL_FILE, it) }
}
private val prefs by lazy { private val prefs by lazy {
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE) getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
@@ -98,15 +124,25 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun findViews() { private fun findViews() {
textInputLayoutServerUrl = findViewById(R.id.textInputLayoutServerUrl)
editTextServerUrl = findViewById(R.id.editTextServerUrl) editTextServerUrl = findViewById(R.id.editTextServerUrl)
editTextUsername = findViewById(R.id.editTextUsername) editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword) editTextPassword = findViewById(R.id.editTextPassword)
switchAutoSync = findViewById(R.id.switchAutoSync) switchAutoSync = findViewById(R.id.switchAutoSync)
switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
buttonTestConnection = findViewById(R.id.buttonTestConnection) buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow) buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer) buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
textViewServerStatus = findViewById(R.id.textViewServerStatus) textViewServerStatus = findViewById(R.id.textViewServerStatus)
chipAutoSaveStatus = findViewById(R.id.chipAutoSaveStatus)
// Protocol Selection UI
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
radioHttp = findViewById(R.id.radioHttp)
radioHttps = findViewById(R.id.radioHttps)
protocolHintText = findViewById(R.id.protocolHintText)
// Sync Interval UI // Sync Interval UI
radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval) radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval)
@@ -119,16 +155,92 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun loadSettings() { private fun loadSettings() {
editTextServerUrl.setText(prefs.getString(Constants.KEY_SERVER_URL, "")) val savedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
// Parse existing URL to extract protocol and host/path
if (savedUrl.isNotEmpty()) {
val (protocol, hostPath) = parseUrl(savedUrl)
// Set protocol radio button
when (protocol) {
"http" -> radioHttp.isChecked = true
"https" -> radioHttps.isChecked = true
else -> radioHttp.isChecked = true // Default to HTTP (most users have local servers)
}
// Set URL with protocol prefix in the text field
editTextServerUrl.setText("$protocol://$hostPath")
} else {
// Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix
radioHttp.isChecked = true
editTextServerUrl.setText("http://")
}
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, "")) editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, "")) editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false) switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first)
// Update hint text based on selected protocol
updateProtocolHint()
// Server Status prüfen // Server Status prüfen
checkServerStatus() checkServerStatus()
} }
/**
* Parse URL into protocol and host/path components
* @param url Full URL like "https://example.com:8080/webdav"
* @return Pair of (protocol, hostPath) like ("https", "example.com:8080/webdav")
*/
private fun parseUrl(url: String): Pair<String, String> {
return when {
url.startsWith("https://") -> "https" to url.removePrefix("https://")
url.startsWith("http://") -> "http" to url.removePrefix("http://")
else -> "http" to url // Default to HTTP if no protocol specified
}
}
/**
* Update the hint text below protocol selection based on selected protocol
*/
private fun updateProtocolHint() {
protocolHintText.text = if (radioHttp.isChecked) {
"HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
} else {
"HTTPS für sichere Verbindungen über das Internet"
}
}
/**
* Update protocol prefix in URL field when radio button changes
* Keeps the host/path part, only changes http:// <-> https://
*/
private fun updateProtocolInUrl() {
val currentText = editTextServerUrl.text.toString()
val newProtocol = if (radioHttp.isChecked) "http" else "https"
// Extract host/path without protocol
val hostPath = when {
currentText.startsWith("https://") -> currentText.removePrefix("https://")
currentText.startsWith("http://") -> currentText.removePrefix("http://")
else -> currentText
}
// Set new URL with correct protocol
editTextServerUrl.setText("$newProtocol://$hostPath")
// Move cursor to end
editTextServerUrl.setSelection(editTextServerUrl.text?.length ?: 0)
}
private fun setupListeners() { private fun setupListeners() {
// Protocol selection listener - update URL prefix when radio changes
protocolRadioGroup.setOnCheckedChangeListener { _, checkedId ->
updateProtocolInUrl()
updateProtocolHint()
}
buttonTestConnection.setOnClickListener { buttonTestConnection.setOnClickListener {
saveSettings() saveSettings()
testConnection() testConnection()
@@ -139,31 +251,51 @@ class SettingsActivity : AppCompatActivity() {
syncNow() syncNow()
} }
buttonCreateBackup.setOnClickListener {
// Dateiname mit Timestamp
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(java.util.Date())
val filename = "simplenotes_backup_$timestamp.json"
createBackupLauncher.launch(filename)
}
buttonRestoreFromFile.setOnClickListener {
restoreBackupLauncher.launch(arrayOf("application/json"))
}
buttonRestoreFromServer.setOnClickListener { buttonRestoreFromServer.setOnClickListener {
saveSettings() saveSettings()
showRestoreConfirmation() showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
}
buttonImportMarkdown.setOnClickListener {
saveSettings()
importMarkdownChanges()
} }
switchAutoSync.setOnCheckedChangeListener { _, isChecked -> switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked) onAutoSyncToggled(isChecked)
showAutoSaveIndicator()
} }
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
onMarkdownExportToggled(isChecked)
}
// Clear error when user starts typing again
editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
textInputLayoutServerUrl.error = null
}
override fun afterTextChanged(s: android.text.Editable?) {}
})
// Server Status Check bei Settings-Änderung // Server Status Check bei Settings-Änderung
editTextServerUrl.setOnFocusChangeListener { _, hasFocus -> editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) { if (!hasFocus) {
checkServerStatus() checkServerStatus()
showAutoSaveIndicator()
} }
} }
editTextUsername.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) showAutoSaveIndicator()
}
editTextPassword.setOnFocusChangeListener { _, hasFocus ->
if (!hasFocus) showAutoSaveIndicator()
}
} }
/** /**
@@ -258,8 +390,26 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun saveSettings() { private fun saveSettings() {
// URL is already complete with protocol in the text field (http:// or https://)
val fullUrl = editTextServerUrl.text.toString().trim()
// Clear previous error
textInputLayoutServerUrl.error = null
textInputLayoutServerUrl.isErrorEnabled = false
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
textInputLayoutServerUrl.error = errorMessage
return
}
}
prefs.edit().apply { prefs.edit().apply {
putString(Constants.KEY_SERVER_URL, editTextServerUrl.text.toString().trim()) putString(Constants.KEY_SERVER_URL, fullUrl)
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim()) putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim()) putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim())
putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked) putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked)
@@ -268,6 +418,24 @@ class SettingsActivity : AppCompatActivity() {
} }
private fun testConnection() { private fun testConnection() {
// URL is already complete with protocol in the text field (http:// or https://)
val fullUrl = editTextServerUrl.text.toString().trim()
// Clear previous error
textInputLayoutServerUrl.error = null
textInputLayoutServerUrl.isErrorEnabled = false
// 🔥 v1.1.2: Validate before testing
if (fullUrl.isNotEmpty()) {
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
if (!isValid) {
// Only show error in TextField (no Toast)
textInputLayoutServerUrl.isErrorEnabled = true
textInputLayoutServerUrl.error = errorMessage
return
}
}
lifecycleScope.launch { lifecycleScope.launch {
try { try {
showToast("Teste Verbindung...") showToast("Teste Verbindung...")
@@ -291,8 +459,23 @@ class SettingsActivity : AppCompatActivity() {
private fun syncNow() { private fun syncNow() {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
showToast("Synchronisiere...")
val syncService = WebDavSyncService(this@SettingsActivity) val syncService = WebDavSyncService(this@SettingsActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
showToast("✅ Bereits synchronisiert")
return@launch
}
showToast("Synchronisiere...")
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar")
checkServerStatus() // Server-Status aktualisieren
return@launch
}
val result = syncService.syncNotes() val result = syncService.syncNotes()
if (result.isSuccess) { if (result.isSuccess) {
@@ -364,6 +547,67 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
private fun onMarkdownExportToggled(enabled: Boolean) {
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
if (enabled) {
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
} else {
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
}
}
private fun importMarkdownChanges() {
// Prüfen ob Server konfiguriert ist
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
showToast("Bitte zuerst WebDAV-Server konfigurieren")
return
}
// Import-Dialog mit Warnung
AlertDialog.Builder(this)
.setTitle("Markdown-Import")
.setMessage(
"Importiert Änderungen aus .md-Dateien vom Server.\n\n" +
"⚠️ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" +
"Fortfahren?"
)
.setPositiveButton("Importieren") { _, _ ->
performMarkdownImport(serverUrl, username, password)
}
.setNegativeButton("Abbrechen", null)
.show()
}
private fun performMarkdownImport(serverUrl: String, username: String, password: String) {
showToast("Importiere Markdown-Dateien...")
lifecycleScope.launch(Dispatchers.IO) {
try {
val syncService = WebDavSyncService(this@SettingsActivity)
val importCount = syncService.syncMarkdownFiles(serverUrl, username, password)
withContext(Dispatchers.Main) {
if (importCount > 0) {
showToast("$importCount Notizen aus Markdown importiert")
// Benachrichtige MainActivity zum Neuladen
sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED"))
} else {
showToast("Keine Markdown-Änderungen gefunden")
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
showToast("Import-Fehler: ${e.message}")
}
}
}
}
private fun checkBatteryOptimization() { private fun checkBatteryOptimization() {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
val packageName = packageName val packageName = packageName
@@ -420,32 +664,6 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
private fun showAutoSaveIndicator() {
// Cancel previous job if still running
autoSaveIndicatorJob?.cancel()
// Show saving indicator
chipAutoSaveStatus.apply {
visibility = android.view.View.VISIBLE
text = "💾 Speichere..."
setChipBackgroundColorResource(android.R.color.darker_gray)
}
// Save settings
saveSettings()
// Show saved confirmation after short delay
autoSaveIndicatorJob = lifecycleScope.launch {
delay(300) // Short delay to show "Speichere..."
chipAutoSaveStatus.apply {
text = "✓ Gespeichert"
setChipBackgroundColorResource(android.R.color.holo_green_light)
}
delay(2000) // Show for 2 seconds
chipAutoSaveStatus.visibility = android.view.View.GONE
}
}
private fun showRestoreConfirmation() { private fun showRestoreConfirmation() {
android.app.AlertDialog.Builder(this) android.app.AlertDialog.Builder(this)
.setTitle(R.string.restore_confirmation_title) .setTitle(R.string.restore_confirmation_title)
@@ -504,4 +722,231 @@ class SettingsActivity : AppCompatActivity() {
super.onPause() super.onPause()
saveSettings() saveSettings()
} }
// ========================================
// BACKUP & RESTORE FUNCTIONS (v1.2.0)
// ========================================
/**
* Restore-Quelle (Lokale Datei oder WebDAV Server)
*/
private enum class RestoreSource {
LOCAL_FILE,
WEBDAV_SERVER
}
/**
* Erstellt Backup (Task #1.2.0-04)
*/
private fun createBackup(uri: Uri) {
lifecycleScope.launch {
try {
Logger.d(TAG, "📦 Creating backup...")
val result = backupManager.createBackup(uri)
if (result.success) {
showToast("${result.message}")
} else {
showErrorDialog("Backup fehlgeschlagen", result.error ?: "Unbekannter Fehler")
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e)
showErrorDialog("Backup fehlgeschlagen", e.message ?: "Unbekannter Fehler")
}
}
}
/**
* Universeller Restore-Dialog für beide Quellen (Task #1.2.0-05 + #1.2.0-05b)
*
* @param source Lokale Datei oder WebDAV Server
* @param fileUri URI der lokalen Datei (nur für LOCAL_FILE)
*/
private fun showRestoreDialog(source: RestoreSource, fileUri: Uri?) {
val sourceText = when (source) {
RestoreSource.LOCAL_FILE -> "Lokale Datei"
RestoreSource.WEBDAV_SERVER -> "WebDAV Server"
}
// Custom View mit Radio Buttons
val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null)
val radioGroup = android.widget.RadioGroup(this).apply {
orientation = android.widget.RadioGroup.VERTICAL
setPadding(50, 20, 50, 20)
}
// Radio Buttons erstellen
val radioMerge = android.widget.RadioButton(this).apply {
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
id = 0
isChecked = true
setPadding(10, 10, 10, 10)
}
val radioReplace = android.widget.RadioButton(this).apply {
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
id = 1
setPadding(10, 10, 10, 10)
}
val radioOverwrite = android.widget.RadioButton(this).apply {
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
id = 2
setPadding(10, 10, 10, 10)
}
radioGroup.addView(radioMerge)
radioGroup.addView(radioReplace)
radioGroup.addView(radioOverwrite)
// Hauptlayout
val mainLayout = android.widget.LinearLayout(this).apply {
orientation = android.widget.LinearLayout.VERTICAL
setPadding(50, 30, 50, 30)
}
// Info Text
val infoText = android.widget.TextView(this).apply {
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
textSize = 16f
setPadding(0, 0, 0, 20)
}
// Hinweis Text
val hintText = android.widget.TextView(this).apply {
text = "\n Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt."
textSize = 14f
setTypeface(null, android.graphics.Typeface.ITALIC)
setPadding(0, 20, 0, 0)
}
mainLayout.addView(infoText)
mainLayout.addView(radioGroup)
mainLayout.addView(hintText)
// Dialog erstellen
AlertDialog.Builder(this)
.setTitle("⚠️ Backup wiederherstellen?")
.setView(mainLayout)
.setPositiveButton("Wiederherstellen") { _, _ ->
val selectedMode = when (radioGroup.checkedRadioButtonId) {
1 -> RestoreMode.REPLACE
2 -> RestoreMode.OVERWRITE_DUPLICATES
else -> RestoreMode.MERGE
}
when (source) {
RestoreSource.LOCAL_FILE -> fileUri?.let { performRestoreFromFile(it, selectedMode) }
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
}
}
.setNegativeButton("Abbrechen", null)
.show()
}
/**
* Führt Restore aus lokaler Datei durch (Task #1.2.0-05)
*/
private fun performRestoreFromFile(uri: Uri, mode: RestoreMode) {
lifecycleScope.launch {
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
setMessage("Wiederherstellen...")
setCancelable(false)
show()
}
try {
Logger.d(TAG, "📥 Restoring from file: $uri (mode: $mode)")
val result = backupManager.restoreBackup(uri, mode)
progressDialog.dismiss()
if (result.success) {
val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen"
showToast("$message")
// Refresh MainActivity's note list
setResult(RESULT_OK)
broadcastNotesChanged()
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
}
} catch (e: Exception) {
progressDialog.dismiss()
Logger.e(TAG, "Failed to restore from file", e)
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
}
}
}
/**
* Führt Restore vom Server durch (Task #1.2.0-05b)
* Nutzt neues universelles Dialog-System mit Restore-Modi
*
* HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion
* unterstützt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet.
* TODO: WebDavSyncService.restoreFromServer() erweitern für v1.2.1+
*/
private fun performRestoreFromServer(mode: RestoreMode) {
lifecycleScope.launch {
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
setMessage("Wiederherstellen vom Server...")
setCancelable(false)
show()
}
try {
Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
Logger.w(TAG, "⚠️ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)")
// Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = backupManager.createAutoBackup()
if (autoBackupUri == null) {
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
}
// Server-Restore durchführen
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
// Nutzt alte Funktion (immer REPLACE)
webdavService.restoreFromServer()
}
progressDialog.dismiss()
if (result.isSuccess) {
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
setResult(RESULT_OK)
broadcastNotesChanged()
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
}
} catch (e: Exception) {
progressDialog.dismiss()
Logger.e(TAG, "Failed to restore from server", e)
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
}
}
}
/**
* Sendet Broadcast dass Notizen geändert wurden
*/
private fun broadcastNotesChanged() {
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
intent.putExtra("success", true)
intent.putExtra("syncedCount", 0)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
/**
* Zeigt Error-Dialog an
*/
private fun showErrorDialog(title: String, message: String) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("OK", null)
.show()
}
} }

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.adapters package dev.dettmer.simplenotes.adapters
import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -11,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.toReadableTime import dev.dettmer.simplenotes.utils.toReadableTime
import dev.dettmer.simplenotes.utils.truncate import dev.dettmer.simplenotes.utils.truncate
@@ -39,14 +41,25 @@ class NotesAdapter(
textViewContent.text = note.content.truncate(100) textViewContent.text = note.content.truncate(100)
textViewTimestamp.text = note.updatedAt.toReadableTime() textViewTimestamp.text = note.updatedAt.toReadableTime()
// Sync status icon // Sync Icon nur zeigen wenn Sync konfiguriert ist
val syncIcon = when (note.syncStatus) { val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync val isSyncConfigured = !serverUrl.isNullOrEmpty()
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
SyncStatus.LOCAL_ONLY -> android.R.drawable.ic_menu_save if (isSyncConfigured) {
// Sync status icon
val syncIcon = when (note.syncStatus) {
SyncStatus.SYNCED -> android.R.drawable.ic_menu_upload
SyncStatus.PENDING -> android.R.drawable.ic_popup_sync
SyncStatus.CONFLICT -> android.R.drawable.ic_dialog_alert
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
} }
imageViewSyncStatus.setImageResource(syncIcon)
itemView.setOnClickListener { itemView.setOnClickListener {
onNoteClick(note) onNoteClick(note)

View File

@@ -0,0 +1,361 @@
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(
backup_version = BACKUP_VERSION,
created_at = System.currentTimeMillis(),
notes_count = allNotes.size,
app_version = 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,
notes_count = 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(
backup_version = BACKUP_VERSION,
created_at = System.currentTimeMillis(),
notes_count = allNotes.size,
app_version = 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.notes_count} notes, version ${backupData.backup_version}")
// 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.imported_notes} imported, ${result.skipped_notes} 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.backup_version > BACKUP_VERSION) {
return ValidationResult(
isValid = false,
errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} 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,
imported_notes = newNotes.size,
skipped_notes = 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,
imported_notes = backupNotes.size,
skipped_notes = 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,
imported_notes = newNotes.size,
skipped_notes = 0,
overwritten_notes = 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)
*/
data class BackupData(
val backup_version: Int,
val created_at: Long,
val notes_count: Int,
val app_version: 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 notes_count: Int = 0,
val message: String? = null,
val error: String? = null
)
/**
* Restore-Ergebnis
*/
data class RestoreResult(
val success: Boolean,
val imported_notes: Int = 0,
val skipped_notes: Int = 0,
val overwritten_notes: Int = 0,
val message: String? = null,
val error: String? = null
)
/**
* Validierungs-Ergebnis
*/
data class ValidationResult(
val isValid: Boolean,
val errorMessage: String? = null
)

View File

@@ -1,5 +1,9 @@
package dev.dettmer.simplenotes.models package dev.dettmer.simplenotes.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID import java.util.UUID
data class Note( data class Note(
@@ -25,6 +29,25 @@ data class Note(
""".trimIndent() """.trimIndent()
} }
/**
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
* Format kompatibel mit Obsidian, Joplin, Typora
*/
fun toMarkdown(): String {
return """
---
id: $id
created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)}
device: $deviceId
---
# $title
$content
""".trimIndent()
}
companion object { companion object {
fun fromJson(json: String): Note? { fun fromJson(json: String): Note? {
return try { return try {
@@ -34,6 +57,78 @@ data class Note(
null null
} }
} }
/**
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
*
* @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"
// Extract content (everything after heading)
val content = contentBlock
.substringAfter("# $title\n\n", "")
.trim()
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
)
} catch (e: Exception) {
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) {
System.currentTimeMillis() // Fallback
}
}
} }
} }

View File

@@ -8,6 +8,7 @@ import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -52,7 +53,49 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { 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") Logger.d(TAG, " SyncService: $syncService")
} }
@@ -73,13 +116,13 @@ class SyncWorker(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Processing result") Logger.d(TAG, "📍 Step 4: Processing result")
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}") Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}")
} }
if (result.isSuccess) { if (result.isSuccess) {
if (BuildConfig.DEBUG) { 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") Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
@@ -109,7 +152,7 @@ class SyncWorker(
Result.success() Result.success()
} else { } else {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Failure path") Logger.d(TAG, "📍 Step 5: Failure path")
} }
Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}") Logger.e(TAG, "❌ Sync failed: ${result.errorMessage}")
NotificationHelper.showSyncError( NotificationHelper.showSyncError(
@@ -126,6 +169,32 @@ class SyncWorker(
} }
Result.failure() Result.failure()
} }
} catch (e: CancellationException) {
// ⭐ Job wurde gecancelt - KEIN FEHLER!
// Gründe: App-Update, Doze Mode, Battery Optimization, Network Constraint, etc.
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
}
Logger.d(TAG, "⏹️ Job was cancelled (normal - update/doze/constraints)")
Logger.d(TAG, " Reason could be: App update, Doze mode, Battery opt, Network disconnect")
Logger.d(TAG, " This is expected Android behavior - not an error!")
try {
// UI-Refresh trotzdem triggern (falls MainActivity geöffnet)
broadcastSyncCompleted(false, 0)
} catch (broadcastError: Exception) {
Logger.e(TAG, "Failed to broadcast after cancellation", broadcastError)
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cancelled, no error)")
Logger.d(TAG, "═══════════════════════════════════════")
}
// ⚠️ WICHTIG: Result.success() zurückgeben!
// Cancellation ist KEIN Fehler, WorkManager soll nicht retries machen
Result.success()
} catch (e: Exception) { } catch (e: Exception) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
@@ -168,4 +237,69 @@ class SyncWorker(
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent) LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count") Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
} }
/**
* Prüft ob Server längere Zeit unreachable und zeigt ggf. Warnung (v1.1.2)
* - Nur wenn Auto-Sync aktiviert
* - Nur wenn schon mal erfolgreich gesynct
* - Nur wenn >24h seit letztem erfolgreichen Sync
* - Throttling: Max. 1 Warnung pro 24h
*/
private fun checkAndShowSyncWarning(syncService: WebDavSyncService) {
try {
val prefs = applicationContext.getSharedPreferences(
dev.dettmer.simplenotes.utils.Constants.PREFS_NAME,
android.content.Context.MODE_PRIVATE
)
// Check 1: Auto-Sync aktiviert?
val autoSyncEnabled = prefs.getBoolean(
dev.dettmer.simplenotes.utils.Constants.KEY_AUTO_SYNC,
false
)
if (!autoSyncEnabled) {
Logger.d(TAG, "⏭️ Auto-Sync disabled - no warning needed")
return
}
// Check 2: Schon mal erfolgreich gesynct?
val lastSuccessfulSync = syncService.getLastSuccessfulSyncTimestamp()
if (lastSuccessfulSync == 0L) {
Logger.d(TAG, "⏭️ Never synced successfully - no warning needed")
return
}
// Check 3: >24h seit letztem erfolgreichen Sync?
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSuccessfulSync
if (timeSinceLastSync < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
Logger.d(TAG, "⏭️ Last successful sync <24h ago - no warning needed")
return
}
// Check 4: Throttling - schon Warnung in letzten 24h gezeigt?
val lastWarningShown = prefs.getLong(
dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN,
0L
)
if (now - lastWarningShown < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
Logger.d(TAG, "⏭️ Warning already shown in last 24h - throttling")
return
}
// Zeige Warnung
val hoursSinceLastSync = timeSinceLastSync / (1000 * 60 * 60)
NotificationHelper.showSyncWarning(applicationContext, hoursSinceLastSync)
// Speichere Zeitpunkt der Warnung
prefs.edit()
.putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, now)
.apply()
Logger.d(TAG, "⚠️ Sync warning shown: Server unreachable for ${hoursSinceLastSync}h")
} catch (e: Exception) {
Logger.e(TAG, "Failed to check/show sync warning", e)
}
}
} }

View File

@@ -20,6 +20,7 @@ import java.net.InetSocketAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.Proxy import java.net.Proxy
import java.net.Socket import java.net.Socket
import java.net.URL
import javax.net.SocketFactory import javax.net.SocketFactory
class WebDavSyncService(private val context: Context) { class WebDavSyncService(private val context: Context) {
@@ -188,6 +189,78 @@ class WebDavSyncService(private val context: Context) {
return prefs.getString(Constants.KEY_SERVER_URL, null) return prefs.getString(Constants.KEY_SERVER_URL, null)
} }
/**
* Prüft ob lokale Änderungen seit letztem Sync vorhanden sind (v1.1.2)
* Performance-Optimierung: Vermeidet unnötige Sync-Operationen
*
* @return true wenn unsynced changes vorhanden, false sonst
*/
suspend fun hasUnsyncedChanges(): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
val lastSyncTime = getLastSyncTimestamp()
// Wenn noch nie gesynct, dann haben wir Änderungen
if (lastSyncTime == 0L) {
Logger.d(TAG, "📝 Never synced - assuming changes exist")
return@withContext true
}
// Prüfe ob Notizen existieren die neuer sind als letzter Sync
val storage = dev.dettmer.simplenotes.storage.NotesStorage(context)
val allNotes = storage.loadAllNotes()
val hasChanges = allNotes.any { note ->
note.updatedAt > lastSyncTime
}
Logger.d(TAG, "📊 Unsynced changes check: $hasChanges (${allNotes.size} notes total)")
if (hasChanges) {
val unsyncedCount = allNotes.count { note -> note.updatedAt > lastSyncTime }
Logger.d(TAG, "$unsyncedCount notes modified since last sync")
}
hasChanges
} catch (e: Exception) {
Logger.e(TAG, "Failed to check for unsynced changes - assuming changes exist", e)
// Bei Fehler lieber sync durchführen (safe default)
true
}
}
/**
* Prüft ob WebDAV-Server erreichbar ist (ohne Sync zu starten)
* Verwendet Socket-Check für schnelle Erreichbarkeitsprüfung
*
* @return true wenn Server erreichbar ist, false sonst
*/
suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
val serverUrl = getServerUrl()
if (serverUrl == null) {
Logger.d(TAG, "❌ Server URL not configured")
return@withContext false
}
val url = URL(serverUrl)
val host = url.host
val port = if (url.port > 0) url.port else url.defaultPort
Logger.d(TAG, "🔍 Checking server reachability: $host:$port")
// Socket-Check mit 2s Timeout
// Gibt dem Netzwerk Zeit für Initialisierung (DHCP, Routing, Gateway)
val socket = Socket()
socket.connect(InetSocketAddress(host, port), 2000)
socket.close()
Logger.d(TAG, "✅ Server is reachable")
true
} catch (e: Exception) {
Logger.d(TAG, "❌ Server not reachable: ${e.message}")
false
}
}
suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) { suspend fun testConnection(): SyncResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
val sardine = getSardine() ?: return@withContext SyncResult( val sardine = getSardine() ?: return@withContext SyncResult(
@@ -371,9 +444,11 @@ class WebDavSyncService(private val context: Context) {
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int { private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
var uploadedCount = 0 var uploadedCount = 0
val localNotes = storage.loadAllNotes() val localNotes = storage.loadAllNotes()
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
for (note in localNotes) { for (note in localNotes) {
try { try {
// 1. JSON-Upload (bestehend, unverändert)
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) { if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
val noteUrl = "$serverUrl/${note.id}.json" val noteUrl = "$serverUrl/${note.id}.json"
val jsonBytes = note.toJson().toByteArray() val jsonBytes = note.toJson().toByteArray()
@@ -384,6 +459,18 @@ class WebDavSyncService(private val context: Context) {
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED) val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
storage.saveNote(updatedNote) storage.saveNote(updatedNote)
uploadedCount++ uploadedCount++
// 2. Markdown-Export (NEU in v1.2.0)
// Läuft NACH erfolgreichem JSON-Upload
if (markdownExportEnabled) {
try {
exportToMarkdown(sardine, serverUrl, note)
Logger.d(TAG, " 📝 MD exported: ${note.title}")
} catch (e: Exception) {
Logger.e(TAG, "MD-Export failed for ${note.id}: ${e.message}")
// Kein throw! JSON-Sync darf nicht blockiert werden
}
}
} }
} catch (e: Exception) { } catch (e: Exception) {
// Mark as pending for retry // Mark as pending for retry
@@ -395,6 +482,49 @@ class WebDavSyncService(private val context: Context) {
return uploadedCount return uploadedCount
} }
/**
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
*
* @param sardine Sardine-Client
* @param serverUrl Server-URL (notes/ Ordner)
* @param note Note zum Exportieren
*/
private fun exportToMarkdown(sardine: Sardine, serverUrl: String, note: Note) {
val mdUrl = serverUrl.replace("/notes", "/notes-md")
// Erstelle notes-md/ Ordner falls nicht vorhanden
if (!sardine.exists(mdUrl)) {
sardine.createDirectory(mdUrl)
Logger.d(TAG, "📁 Created notes-md/ directory")
}
// Sanitize Filename (Task #1.2.0-12)
val filename = sanitizeFilename(note.title) + ".md"
val noteUrl = "$mdUrl/$filename"
// Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray()
// Upload
sardine.put(noteUrl, mdContent, "text/markdown")
}
/**
* Sanitize Filename für sichere Dateinamen (Task #1.2.0-12)
*
* Entfernt Windows/Linux-verbotene Zeichen, begrenzt Länge
*
* @param title Original-Titel
* @return Sicherer Filename
*/
private fun sanitizeFilename(title: String): String {
return title
.replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen
.replace(Regex("\\s+"), " ") // Normalisiere Whitespace
.take(200) // Max 200 Zeichen (Reserve für .md)
.trim('_', ' ') // Trim Underscores/Spaces
}
private data class DownloadResult( private data class DownloadResult(
val downloadedCount: Int, val downloadedCount: Int,
val conflictCount: Int val conflictCount: Int
@@ -446,8 +576,10 @@ class WebDavSyncService(private val context: Context) {
} }
private fun saveLastSyncTimestamp() { private fun saveLastSyncTimestamp() {
val now = System.currentTimeMillis()
prefs.edit() prefs.edit()
.putLong(Constants.KEY_LAST_SYNC, System.currentTimeMillis()) .putLong(Constants.KEY_LAST_SYNC, now)
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // 🔥 v1.1.2: Track successful sync
.apply() .apply()
} }
@@ -455,6 +587,10 @@ class WebDavSyncService(private val context: Context) {
return prefs.getLong(Constants.KEY_LAST_SYNC, 0) return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
} }
fun getLastSuccessfulSyncTimestamp(): Long {
return prefs.getLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, 0)
}
/** /**
* Restore all notes from server - overwrites local storage * Restore all notes from server - overwrites local storage
* @return RestoreResult with count of restored notes * @return RestoreResult with count of restored notes
@@ -539,6 +675,86 @@ class WebDavSyncService(private val context: Context) {
) )
} }
} }
/**
* Synchronisiert Markdown-Dateien (Import von Desktop-Programmen) (Task #1.2.0-14)
*
* Last-Write-Wins Konfliktauflösung basierend auf updatedAt Timestamp
*
* @param serverUrl WebDAV Server-URL (notes/ Ordner)
* @param username WebDAV Username
* @param password WebDAV Password
* @return Anzahl importierter Notizen
*/
suspend fun syncMarkdownFiles(
serverUrl: String,
username: String,
password: String
): Int = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📝 Starting Markdown sync...")
val sardine = OkHttpSardine()
sardine.setCredentials(username, password)
val mdUrl = serverUrl.replace("/notes", "/notes-md")
// Check if notes-md/ exists
if (!sardine.exists(mdUrl)) {
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
return@withContext 0
}
val localNotes = storage.loadAllNotes()
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
var importedCount = 0
Logger.d(TAG, "📂 Found ${mdResources.size} markdown files")
for (resource in mdResources) {
try {
// Download MD-File
val mdContent = sardine.get(resource.href.toString())
.bufferedReader().use { it.readText() }
// Parse zu Note
val mdNote = Note.fromMarkdown(mdContent) ?: continue
val localNote = localNotes.find { it.id == mdNote.id }
// Konfliktauflösung: Last-Write-Wins
when {
localNote == null -> {
// Neue Notiz vom Desktop
storage.saveNote(mdNote)
importedCount++
Logger.d(TAG, " ✅ Imported new: ${mdNote.title}")
}
mdNote.updatedAt > localNote.updatedAt -> {
// Desktop-Version ist neuer (Last-Write-Wins)
storage.saveNote(mdNote)
importedCount++
Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}")
}
// Sonst: Lokale Version behalten
else -> {
Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}")
}
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to import ${resource.name}", e)
// Continue with other files
}
}
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
importedCount
} catch (e: Exception) {
Logger.e(TAG, "Markdown sync failed", e)
0
}
}
} }
data class RestoreResult( data class RestoreResult(

View File

@@ -10,10 +10,19 @@ object Constants {
const val KEY_AUTO_SYNC = "auto_sync_enabled" const val KEY_AUTO_SYNC = "auto_sync_enabled"
const val KEY_LAST_SYNC = "last_sync_timestamp" const val KEY_LAST_SYNC = "last_sync_timestamp"
// 🔥 v1.1.2: Last Successful Sync Monitoring
const val KEY_LAST_SUCCESSFUL_SYNC = "last_successful_sync_time"
const val KEY_LAST_SYNC_WARNING_SHOWN = "last_sync_warning_shown_time"
const val SYNC_WARNING_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24h
// 🔥 NEU: Sync Interval Configuration // 🔥 NEU: Sync Interval Configuration
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes" const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
// 🔥 v1.2.0: Markdown Export/Import
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
// WorkManager // WorkManager
const val SYNC_WORK_TAG = "notes_sync" const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L const val SYNC_DELAY_SECONDS = 5L

View File

@@ -6,12 +6,15 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import dev.dettmer.simplenotes.MainActivity import dev.dettmer.simplenotes.MainActivity
object NotificationHelper { object NotificationHelper {
private const val TAG = "NotificationHelper"
private const val CHANNEL_ID = "notes_sync_channel" private const val CHANNEL_ID = "notes_sync_channel"
private const val CHANNEL_NAME = "Notizen Synchronisierung" private const val CHANNEL_NAME = "Notizen Synchronisierung"
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status" private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
@@ -38,6 +41,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 * Zeigt Erfolgs-Notification nach Sync
*/ */
@@ -240,6 +254,7 @@ object NotificationHelper {
/** /**
* Zeigt Fehler-Notification * Zeigt Fehler-Notification
* Auto-Cancel nach 30 Sekunden
*/ */
fun showSyncError(context: Context, message: String) { fun showSyncError(context: Context, message: String) {
// PendingIntent für App-Öffnung // PendingIntent für App-Öffnung
@@ -266,5 +281,47 @@ object NotificationHelper {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager as NotificationManager
manager.notify(SYNC_NOTIFICATION_ID, notification) 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")
}, 30_000)
}
/**
* 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")
} }
} }

View File

@@ -0,0 +1,107 @@
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 {
/**
* 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)
*/
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 > 255 }) {
return false
}
val (o1, o2, o3, o4) = octets
// Check RFC 1918 private IP ranges
return when {
// 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
o1 == 10 -> true
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
o1 == 172 && o2 in 16..31 -> true
// 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
o1 == 192 && o2 == 168 -> true
// 127.0.0.0/8 (Localhost)
o1 == 127 -> true
else -> false
}
}
// Not a recognized local address
false
} catch (e: Exception) {
// Invalid URL format
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}")
}
}
}

View File

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

View File

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

View File

@@ -14,7 +14,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" android:layout_height="?attr/actionBarSize"
android:elevation="0dp" android:elevation="0dp"
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel" app:navigationIcon="?attr/homeAsUpIndicator"
app:title="@string/edit_note" app:title="@string/edit_note"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />

View File

@@ -24,14 +24,22 @@
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<!-- RecyclerView mit größerem Padding für Material 3 --> <!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->
<androidx.recyclerview.widget.RecyclerView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/recyclerViewNotes" android:id="@+id/swipeRefreshLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:clipToPadding="false" app:layout_behavior="@string/appbar_scrolling_view_behavior">
android:padding="16dp"
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" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Material 3 Empty State Card --> <!-- Material 3 Empty State Card -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView

View File

@@ -30,17 +30,6 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<!-- Auto-Save Status Indicator -->
<com.google.android.material.chip.Chip
android:id="@+id/chipAutoSaveStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="12dp"
android:visibility="gone"
android:textSize="12sp"
app:chipIconEnabled="false" />
<!-- Material 3 Card: Server Configuration --> <!-- Material 3 Card: Server Configuration -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -63,15 +52,65 @@
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="16dp" />
<!-- Server URL with Icon --> <!-- Protocol Selection -->
<com.google.android.material.textfield.TextInputLayout <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/server_url" android:text="Verbindungstyp"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="8dp" />
<RadioGroup
android:id="@+id/protocolRadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="16dp">
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/radioHttp"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🏠 Intern (HTTP)"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:checked="false" />
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/radioHttps"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🌐 Extern (HTTPS)"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:checked="true" />
</RadioGroup>
<!-- Helper Text for Protocol Selection -->
<TextView
android:id="@+id/protocolHintText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<!-- Server URL with Icon -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/textInputLayoutServerUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Server-Adresse"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox" style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:startIconDrawable="@android:drawable/ic_menu_compass" app:startIconDrawable="@android:drawable/ic_menu_compass"
app:endIconMode="clear_text" app:endIconMode="clear_text"
app:helperText="z.B. http://192.168.0.188:8080/webdav"
app:helperTextEnabled="true"
app:boxCornerRadiusTopStart="12dp" app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp" app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusBottomStart="12dp" app:boxCornerRadiusBottomStart="12dp"
@@ -185,7 +224,7 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Auto-Sync Settings --> <!-- Material 3 Card: Synchronisation Settings (Auto-Sync + Interval) -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -230,6 +269,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
@@ -247,87 +287,22 @@
</LinearLayout> </LinearLayout>
</LinearLayout> <!-- Divider -->
<View
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="1dp"
android:text="@string/backup_restore_title" android:layout_marginVertical="16dp"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" android:background="?attr/colorOutlineVariant" />
android:layout_marginBottom="12dp" />
<!-- Warning Info Card --> <!-- Sync Interval Section -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="@string/backup_restore_warning"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnErrorContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Restore Button -->
<Button
android:id="@+id/buttonRestoreFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/restore_from_server"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Sync Interval Configuration -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Sync-Intervall" android:text="Sync-Intervall"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="12dp" />
<!-- Info Card --> <!-- Interval Info Card -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -412,6 +387,188 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Markdown Desktop-Integration (v1.2.0) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Markdown Desktop-Integration"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text=" Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnPrimaryContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Markdown Export Toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="📝 Markdown Export (Desktop-Zugriff)"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchMarkdownExport"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
</LinearLayout>
<!-- Import Markdown Button -->
<Button
android:id="@+id/buttonImportMarkdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📥 Markdown-Änderungen importieren"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Import Info Text -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/backup_restore_title"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Info Card (anstatt Warning) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text=" Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt."
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnPrimaryContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Lokales Backup Sektion -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Lokales Backup"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="12dp" />
<!-- Backup erstellen Button -->
<Button
android:id="@+id/buttonCreateBackup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📥 Backup erstellen"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Aus Datei wiederherstellen Button -->
<Button
android:id="@+id/buttonRestoreFromFile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📤 Aus Datei wiederherstellen"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutline"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp" />
<!-- Server-Backup Sektion -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Server-Backup"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="12dp" />
<!-- Vom Server wiederherstellen Button -->
<Button
android:id="@+id/buttonRestoreFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔄 Vom Server wiederherstellen"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: About Section --> <!-- Material 3 Card: About Section -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

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

Binary file not shown.

View File

@@ -1,6 +1,6 @@
#Sat Dec 20 00:06:31 CET 2025
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true

6
android/gradlew vendored
View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
# #
# Copyright © 2015 the original authors. # Copyright © 2015-2021 the original authors.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -114,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;; NONSTOP* ) nonstop=true ;;
esac esac
CLASSPATH="\\\"\\\"" CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM. # Determine the Java command to use to start the JVM.
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \ -classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ org.gradle.wrapper.GradleWrapperMain \
"$@" "$@"
# Stop when "xargs" is not available. # Stop when "xargs" is not available.

188
android/gradlew.bat vendored
View File

@@ -1,94 +1,94 @@
@rem @rem
@rem Copyright 2015 the original author or authors. @rem Copyright 2015 the original author or authors.
@rem @rem
@rem Licensed under the Apache License, Version 2.0 (the "License"); @rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License. @rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at @rem You may obtain a copy of the License at
@rem @rem
@rem https://www.apache.org/licenses/LICENSE-2.0 @rem https://www.apache.org/licenses/LICENSE-2.0
@rem @rem
@rem Unless required by applicable law or agreed to in writing, software @rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS, @rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and @rem See the License for the specific language governing permissions and
@rem limitations under the License. @rem limitations under the License.
@rem @rem
@rem SPDX-License-Identifier: Apache-2.0 @rem SPDX-License-Identifier: Apache-2.0
@rem @rem
@if "%DEBUG%"=="" @echo off @if "%DEBUG%"=="" @echo off
@rem ########################################################################## @rem ##########################################################################
@rem @rem
@rem Gradle startup script for Windows @rem Gradle startup script for Windows
@rem @rem
@rem ########################################################################## @rem ##########################################################################
@rem Set local scope for the variables with windows NT shell @rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0 set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=. if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused @rem This is normally unused
set APP_BASE_NAME=%~n0 set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME% set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter. @rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe @rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1 %JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2 echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail goto fail
:findJavaFromJavaHome :findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=% set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute if exist "%JAVA_EXE%" goto execute
echo. 1>&2 echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2 echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2 echo location of your Java installation. 1>&2
goto fail goto fail
:execute :execute
@rem Setup the command line @rem Setup the command line
set CLASSPATH= set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle @rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end :end
@rem End local scope for the variables with windows NT shell @rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd if %ERRORLEVEL% equ 0 goto mainEnd
:fail :fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code! rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL% set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1 if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE% exit /b %EXIT_CODE%
:mainEnd :mainEnd
if "%OS%"=="Windows_NT" endlocal if "%OS%"=="Windows_NT" endlocal
:omega :omega

View 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

192
android/scripts/README.md Normal file
View File

@@ -0,0 +1,192 @@
# Android Build Scripts
Nützliche Scripts für die lokale Entwicklung und Release-Erstellung.
## 📜 Verfügbare Scripts
### 1. `create-keystore.fish` - Neuen Release-Keystore erstellen
**Wann verwenden:**
- ✅ Erstmaliges Setup des Projekts
- ✅ Keystore-Passwort vergessen
- ✅ Keystore beschädigt oder verloren
-**NICHT** verwenden, wenn bereits User existieren (macht alte APKs inkompatibel!)
**Verwendung:**
```bash
cd /home/liq/gitProjects/simple-notes-sync/android
./scripts/create-keystore.fish
```
**Das Script:**
1. Erstellt einen neuen 4096-Bit RSA-Keystore
2. Generiert `app/simple-notes-release.jks`
3. Erstellt `key.properties` mit den Zugangsdaten
4. Zeigt Base64-kodierten Keystore für GitHub Secrets
5. Gibt SHA256-Fingerprint zur Verifikation aus
**Output:**
-`app/simple-notes-release.jks` - Der Keystore
-`key.properties` - Lokale Signing-Konfiguration
- 📋 GitHub Secrets zum Kopieren
---
### 2. `verify-secrets.fish` - GitHub Secrets & Keystore verifizieren
**Wann verwenden:**
- ✅ Nach `create-keystore.fish` zur Verifikation
- ✅ Vor einem Release-Build zum Troubleshooting
- ✅ Um zu prüfen ob alles korrekt konfiguriert ist
**Verwendung:**
```bash
cd /home/liq/gitProjects/simple-notes-sync/android
./scripts/verify-secrets.fish
```
**Das Script prüft:**
- GitHub CLI Installation & Authentifizierung
- Ob alle 4 erforderlichen GitHub Secrets gesetzt sind
- Ob `key.properties` lokal existiert
- Ob der Keystore existiert
- Zeigt SHA256-Fingerprint des Keystores
**Output:**
- ✅ Status aller Secrets
- ✅ Status der lokalen Konfiguration
- 💡 Empfehlungen bei Problemen
---
### 3. `build-release-local.fish` - Lokal signierte Release-APKs bauen
**Wann verwenden:**
- ✅ Lokale Test-APKs erstellen, die mit Releases kompatibel sind
- ✅ APKs vor dem GitHub Release testen
- ✅ Schneller als GitHub Actions für Tests
**Voraussetzung:**
- `key.properties` muss existieren (via `create-keystore.fish` erstellt)
**Verwendung:**
```bash
cd /home/liq/gitProjects/simple-notes-sync/android
./scripts/build-release-local.fish
```
**Interaktive Auswahl:**
1. Standard Flavor (empfohlen)
2. F-Droid Flavor
3. Beide Flavors
**Output:**
- `app/build/outputs/apk/standard/release/` - Signierte Standard APKs
- `app/build/outputs/apk/fdroid/release/` - Signierte F-Droid APKs
---
## 🚀 Kompletter Workflow (von 0 auf Release)
### Erstmaliges Setup
```bash
cd /home/liq/gitProjects/simple-notes-sync/android
# 1. Keystore erstellen (mit automatischer GitHub Secrets-Konfiguration!)
./scripts/create-keystore.fish
# → Folge den Anweisungen, speichere die Passwörter!
# → GitHub Secrets werden automatisch via GitHub CLI gesetzt
# 2. Verifiziere die Konfiguration
./scripts/verify-secrets.fish
# → Prüft ob alle Secrets gesetzt sind
# → Zeigt Keystore-Informationen
# 3. Teste lokalen Build
./scripts/build-release-local.fish
# → Wähle "1" für Standard Flavor
# 4. Verifiziere Signatur
keytool -printcert -jarfile app/build/outputs/apk/standard/release/app-standard-universal-release.apk
```
### Vor jedem Release
```bash
# 1. Code committen und pushen
git add .
git commit -m "✨ Neue Features"
git push origin main
# 2. GitHub Actions erstellt automatisch Release
# → Workflow läuft: .github/workflows/build-production-apk.yml
# → Erstellt Release mit signierten APKs
# Optional: Lokalen Test-Build vorher
./scripts/build-release-local.fish
```
---
## 🔐 Sicherheitshinweise
### ⚠️ Diese Dateien NIEMALS committen:
- `key.properties` - Enthält Keystore-Passwörter
- `*.jks` / `*.keystore` - Der Keystore selbst
- Beide sind bereits in `.gitignore`
### ✅ Diese Werte sicher speichern:
- Keystore-Passwort
- Key-Alias
- Key-Passwort
- Base64-kodierter Keystore (für GitHub Secrets)
**Empfehlung:** Nutze einen Passwort-Manager (Bitwarden, 1Password, etc.)
---
## 🛠️ Troubleshooting
### "Keystore not found"
```bash
# Prüfe ob Keystore existiert
ls -la app/simple-notes-release.jks
# Falls nicht: Neu erstellen
./scripts/create-keystore.fish
```
### "key.properties not found"
```bash
# Prüfe ob Datei existiert
ls -la key.properties
# Falls nicht: Keystore neu erstellen oder manuell anlegen
./scripts/create-keystore.fish
```
### "Signature mismatch" beim App-Update
**Problem:** Lokaler Build hat andere Signatur als GitHub Release
**Ursache:** Unterschiedliche Keystores oder Passwörter
**Lösung:**
1. Vergleiche SHA256-Fingerprints:
```bash
# Lokal
keytool -list -v -keystore app/simple-notes-release.jks
# GitHub Release-APK
keytool -printcert -jarfile ~/Downloads/simple-notes-v1.1.0.apk
```
2. Müssen **identisch** sein!
3. Falls nicht: GitHub Secrets mit lokaler `key.properties` synchronisieren
---
## 📚 Weitere Dokumentation
- `../LOCAL_BUILDS.md` - Detaillierte Anleitung für lokale Builds
- `../.github/workflows/build-production-apk.yml` - GitHub Actions Workflow
- `../app/build.gradle.kts` - Build-Konfiguration

View File

@@ -0,0 +1,104 @@
#!/usr/bin/env fish
# Simple Notes Sync - Lokaler Release Build
# Erstellt signierte APKs, die mit GitHub Release-APKs kompatibel sind
set -l SCRIPT_DIR (dirname (status --current-filename))
set -l ANDROID_DIR (realpath "$SCRIPT_DIR/..")
set -l KEY_PROPERTIES "$ANDROID_DIR/key.properties"
echo "🔨 Simple Notes Sync - Lokaler Release Build"
echo ""
# 1. Prüfe ob key.properties existiert
if not test -f "$KEY_PROPERTIES"
echo "❌ Fehler: key.properties nicht gefunden!"
echo ""
echo "Bitte erstelle die Datei:"
echo " cd $ANDROID_DIR"
echo " cp key.properties.example key.properties"
echo ""
echo "Und fülle sie mit den echten Keystore-Daten aus."
echo "Siehe: android/LOCAL_BUILDS.md"
exit 1
end
# 2. Prüfe ob Keystore existiert
set -l KEYSTORE "$ANDROID_DIR/app/simple-notes-release.jks"
if not test -f "$KEYSTORE"
echo "❌ Fehler: Keystore nicht gefunden!"
echo " Erwartet: $KEYSTORE"
exit 1
end
echo "✅ key.properties gefunden"
echo "✅ Keystore gefunden"
echo ""
# 3. Build-Typ abfragen
echo "Welchen Build möchtest du erstellen?"
echo " 1) Standard Flavor (empfohlen)"
echo " 2) F-Droid Flavor"
echo " 3) Beide Flavors"
echo ""
read -P "Auswahl [1-3]: " -n 1 choice
echo ""
echo ""
switch $choice
case 1
echo "🏗️ Baue Standard Release APKs..."
cd "$ANDROID_DIR"
./gradlew assembleStandardRelease --no-daemon
if test $status -eq 0
echo ""
echo "✅ Build erfolgreich!"
echo ""
echo "📦 APKs findest du hier:"
echo " $ANDROID_DIR/app/build/outputs/apk/standard/release/"
ls -lh "$ANDROID_DIR/app/build/outputs/apk/standard/release/"*.apk
end
case 2
echo "🏗️ Baue F-Droid Release APKs..."
cd "$ANDROID_DIR"
./gradlew assembleFdroidRelease --no-daemon
if test $status -eq 0
echo ""
echo "✅ Build erfolgreich!"
echo ""
echo "📦 APKs findest du hier:"
echo " $ANDROID_DIR/app/build/outputs/apk/fdroid/release/"
ls -lh "$ANDROID_DIR/app/build/outputs/apk/fdroid/release/"*.apk
end
case 3
echo "🏗️ Baue Standard + F-Droid Release APKs..."
cd "$ANDROID_DIR"
./gradlew assembleStandardRelease assembleFdroidRelease --no-daemon
if test $status -eq 0
echo ""
echo "✅ Build erfolgreich!"
echo ""
echo "📦 Standard APKs:"
echo " $ANDROID_DIR/app/build/outputs/apk/standard/release/"
ls -lh "$ANDROID_DIR/app/build/outputs/apk/standard/release/"*.apk
echo ""
echo "📦 F-Droid APKs:"
echo " $ANDROID_DIR/app/build/outputs/apk/fdroid/release/"
ls -lh "$ANDROID_DIR/app/build/outputs/apk/fdroid/release/"*.apk
end
case '*'
echo "❌ Ungültige Auswahl"
exit 1
end
echo ""
echo "💡 Tipp: Du kannst die APK per HTTP Server verteilen:"
echo " cd app/build/outputs/apk/standard/release"
echo " python3 -m http.server 8892"

View File

@@ -0,0 +1,279 @@
#!/usr/bin/env fish
# Simple Notes Sync - Keystore Generator
# Erstellt einen neuen Release-Keystore für App-Signierung
set -l SCRIPT_DIR (dirname (status --current-filename))
set -l ANDROID_DIR (realpath "$SCRIPT_DIR/..")
set -l KEYSTORE_PATH "$ANDROID_DIR/app/simple-notes-release.jks"
set -l KEY_PROPERTIES "$ANDROID_DIR/key.properties"
echo "🔐 Simple Notes Sync - Keystore Generator"
echo ""
echo "⚠️ WICHTIG: Dieser Keystore wird für alle zukünftigen App-Releases verwendet!"
echo " Speichere die Zugangsdaten sicher ab (z.B. in einem Passwort-Manager)."
echo ""
# Prüfe ob Keystore bereits existiert
if test -f "$KEYSTORE_PATH"
echo "⚠️ Ein Keystore existiert bereits:"
echo " $KEYSTORE_PATH"
echo ""
read -P "Möchtest du ihn überschreiben? (Dies macht alte APKs inkompatibel!) [j/N]: " -n 1 overwrite
echo ""
if not string match -qi "j" $overwrite
echo "❌ Abgebrochen."
exit 1
end
echo "🗑️ Lösche alten Keystore..."
rm "$KEYSTORE_PATH"
end
echo ""
echo "📝 Bitte gib die folgenden Informationen ein:"
echo ""
# App-Informationen sammeln
read -P "Dein Name (z.B. 'Max Mustermann'): " developer_name
read -P "Organisation (z.B. 'dettmer.dev'): " organization
read -P "Stadt: " city
read -P "Land (z.B. 'DE'): " country
echo ""
echo "🔒 Keystore-Passwörter:"
echo ""
echo "Möchtest du sichere Passwörter automatisch generieren lassen?"
read -P "[J/n]: " -n 1 auto_generate
echo ""
if string match -qi "n" $auto_generate
# Manuelle Passwort-Eingabe
echo ""
echo "📝 Manuelle Passwort-Eingabe:"
echo ""
while true
read -sP "Keystore-Passwort: " keystore_password
echo ""
read -sP "Keystore-Passwort (Bestätigung): " keystore_password_confirm
echo ""
if test "$keystore_password" = "$keystore_password_confirm"
break
else
echo "❌ Passwörter stimmen nicht überein. Bitte erneut eingeben."
echo ""
end
end
while true
read -sP "Key-Passwort: " key_password
echo ""
read -sP "Key-Passwort (Bestätigung): " key_password_confirm
echo ""
if test "$key_password" = "$key_password_confirm"
break
else
echo "❌ Passwörter stimmen nicht überein. Bitte erneut eingeben."
echo ""
end
end
else
# Automatische Passwort-Generierung
echo ""
echo "🔐 Generiere sichere Passwörter (32 Zeichen, alphanumerisch)..."
# Generiere sichere, zufällige Passwörter (alphanumerisch, 32 Zeichen)
set keystore_password (openssl rand -base64 32 | tr -d '/+=' | head -c 32)
set key_password (openssl rand -base64 32 | tr -d '/+=' | head -c 32)
echo "✅ Passwörter generiert"
echo ""
echo "⚠️ WICHTIG: Speichere diese Passwörter jetzt in deinem Passwort-Manager!"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Keystore-Passwort: $keystore_password"
echo "Key-Passwort: $key_password"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
read -P "Passwörter gespeichert? Drücke Enter zum Fortfahren..."
end
set -l key_alias "simple-notes-key"
echo ""
echo "🏗️ Erstelle Keystore..."
echo ""
# Keystore erstellen
keytool -genkey \
-v \
-keystore "$KEYSTORE_PATH" \
-alias "$key_alias" \
-keyalg RSA \
-keysize 4096 \
-validity 10000 \
-storepass "$keystore_password" \
-keypass "$key_password" \
-dname "CN=$developer_name, OU=Simple Notes Sync, O=$organization, L=$city, C=$country"
if test $status -ne 0
echo ""
echo "❌ Fehler beim Erstellen des Keystores!"
exit 1
end
echo ""
echo "✅ Keystore erfolgreich erstellt!"
echo ""
# key.properties erstellen
echo "📝 Erstelle key.properties..."
echo "storeFile=simple-notes-release.jks" > "$KEY_PROPERTIES"
echo "storePassword=$keystore_password" >> "$KEY_PROPERTIES"
echo "keyAlias=$key_alias" >> "$KEY_PROPERTIES"
echo "keyPassword=$key_password" >> "$KEY_PROPERTIES"
echo "✅ key.properties erstellt"
echo ""
# Keystore-Info anzeigen
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "📋 KEYSTORE-INFORMATIONEN - SICHER SPEICHERN!"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Keystore-Pfad: $KEYSTORE_PATH"
echo "Key-Alias: $key_alias"
echo "Keystore-Passwort: $keystore_password"
echo "Key-Passwort: $key_password"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Base64-kodierten Keystore für GitHub Secrets
echo "🔐 Base64-kodierter Keystore für GitHub Secrets:"
echo ""
set -l keystore_base64 (base64 -w 0 "$KEYSTORE_PATH")
echo "$keystore_base64"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# GitHub Secrets konfigurieren
echo "<22> GitHub Secrets konfigurieren..."
echo ""
# Prüfe ob GitHub CLI installiert ist
if not command -v gh &> /dev/null
echo "⚠️ GitHub CLI (gh) nicht gefunden!"
echo ""
echo "📝 Manuelle Konfiguration erforderlich:"
echo ""
echo "1. Gehe zu: https://github.com/inentory69/simple-notes-sync/settings/secrets/actions"
echo "2. Erstelle/Aktualisiere folgende Secrets:"
echo ""
echo " KEYSTORE_BASE64:"
echo " $keystore_base64"
echo ""
echo " KEYSTORE_PASSWORD:"
echo " $keystore_password"
echo ""
echo " KEY_ALIAS:"
echo " $key_alias"
echo ""
echo " KEY_PASSWORD:"
echo " $key_password"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
else
# GitHub CLI verfügbar - automatisch Secrets erstellen
echo "✅ GitHub CLI gefunden"
echo ""
# Prüfe ob authentifiziert
if not gh auth status &> /dev/null
echo "⚠️ Nicht bei GitHub authentifiziert!"
echo ""
read -P "Möchtest du dich jetzt authentifizieren? [j/N]: " -n 1 do_auth
echo ""
if string match -qi "j" $do_auth
gh auth login
else
echo "❌ Überspringe automatische Secret-Konfiguration"
echo ""
echo "📝 Manuelle Konfiguration erforderlich (siehe oben)"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
return
end
end
echo "🔐 Erstelle/Aktualisiere GitHub Secrets..."
echo ""
set -l repo "inventory69/simple-notes-sync"
# KEYSTORE_BASE64
echo "$keystore_base64" | gh secret set KEYSTORE_BASE64 --repo $repo
if test $status -eq 0
echo "✅ KEYSTORE_BASE64 gesetzt"
else
echo "❌ Fehler beim Setzen von KEYSTORE_BASE64"
end
# KEYSTORE_PASSWORD
echo "$keystore_password" | gh secret set KEYSTORE_PASSWORD --repo $repo
if test $status -eq 0
echo "✅ KEYSTORE_PASSWORD gesetzt"
else
echo "❌ Fehler beim Setzen von KEYSTORE_PASSWORD"
end
# KEY_ALIAS
echo "$key_alias" | gh secret set KEY_ALIAS --repo $repo
if test $status -eq 0
echo "✅ KEY_ALIAS gesetzt"
else
echo "❌ Fehler beim Setzen von KEY_ALIAS"
end
# KEY_PASSWORD
echo "$key_password" | gh secret set KEY_PASSWORD --repo $repo
if test $status -eq 0
echo "✅ KEY_PASSWORD gesetzt"
else
echo "❌ Fehler beim Setzen von KEY_PASSWORD"
end
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "✅ GitHub Secrets erfolgreich konfiguriert!"
echo ""
echo "🔍 Verifizieren:"
echo " gh secret list --repo $repo"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
end
# Signatur-Fingerprint anzeigen
echo "🔑 SHA256-Fingerprint (zur Verifikation):"
keytool -list -v -keystore "$KEYSTORE_PATH" -storepass "$keystore_password" | grep "SHA256:"
echo ""
echo "✅ Setup abgeschlossen!"
echo ""
echo "💡 Nächste Schritte:"
echo " 1. Speichere die obigen Informationen in einem Passwort-Manager"
echo " 2. Konfiguriere die GitHub Secrets (siehe oben)"
echo " 3. Teste den lokalen Build:"
echo " cd $ANDROID_DIR"
echo " ./gradlew assembleStandardRelease"
echo ""

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env fish
# Simple Notes Sync - GitHub Secrets Verifier
# Verifiziert ob die GitHub Secrets korrekt konfiguriert sind
set -l repo "inventory69/simple-notes-sync"
echo "🔍 GitHub Secrets Verifier"
echo ""
# Prüfe ob GitHub CLI installiert ist
if not command -v gh &> /dev/null
echo "❌ GitHub CLI (gh) nicht gefunden!"
echo ""
echo "Installation:"
echo " Arch Linux: sudo pacman -S github-cli"
echo " Ubuntu: sudo apt install gh"
echo " macOS: brew install gh"
echo ""
exit 1
end
# Prüfe Authentifizierung
if not gh auth status &> /dev/null
echo "❌ Nicht bei GitHub authentifiziert!"
echo ""
echo "Authentifizierung starten:"
echo " gh auth login"
echo ""
exit 1
end
echo "✅ GitHub CLI authentifiziert"
echo ""
# Liste alle Secrets auf
echo "📋 Konfigurierte Secrets für $repo:"
echo ""
gh secret list --repo $repo
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Prüfe ob alle erforderlichen Secrets vorhanden sind
set -l required_secrets "KEYSTORE_BASE64" "KEYSTORE_PASSWORD" "KEY_ALIAS" "KEY_PASSWORD"
set -l missing_secrets
for secret in $required_secrets
if not gh secret list --repo $repo | grep -q "^$secret"
set -a missing_secrets $secret
end
end
if test (count $missing_secrets) -gt 0
echo "❌ Fehlende Secrets:"
for secret in $missing_secrets
echo " - $secret"
end
echo ""
echo "💡 Tipp: Führe create-keystore.fish aus, um die Secrets zu erstellen"
else
echo "✅ Alle erforderlichen Secrets sind konfiguriert!"
echo ""
echo "Required Secrets:"
for secret in $required_secrets
echo "$secret"
end
end
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Prüfe ob key.properties lokal existiert
set -l SCRIPT_DIR (dirname (status --current-filename))
set -l ANDROID_DIR (realpath "$SCRIPT_DIR/..")
set -l KEY_PROPERTIES "$ANDROID_DIR/key.properties"
if test -f "$KEY_PROPERTIES"
echo "✅ Lokale key.properties gefunden: $KEY_PROPERTIES"
echo ""
echo "📋 Inhalt (Passwörter verborgen):"
cat "$KEY_PROPERTIES" | sed 's/\(Password=\).*/\1***HIDDEN***/g'
else
echo "⚠️ Lokale key.properties nicht gefunden: $KEY_PROPERTIES"
echo ""
echo "💡 Tipp: Führe create-keystore.fish aus, um sie zu erstellen"
end
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Prüfe ob Keystore existiert
set -l KEYSTORE "$ANDROID_DIR/app/simple-notes-release.jks"
if test -f "$KEYSTORE"
echo "✅ Keystore gefunden: $KEYSTORE"
# Zeige Keystore-Info (wenn key.properties existiert)
if test -f "$KEY_PROPERTIES"
set -l store_password (grep "storePassword=" "$KEY_PROPERTIES" | cut -d'=' -f2)
echo ""
echo "🔑 Keystore-Informationen:"
keytool -list -v -keystore "$KEYSTORE" -storepass "$store_password" 2>/dev/null | grep -E "(Alias|Creation date|Valid|SHA256)" | head -10
end
else
echo "⚠️ Keystore nicht gefunden: $KEYSTORE"
echo ""
echo "💡 Tipp: Führe create-keystore.fish aus, um ihn zu erstellen"
end
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Zusammenfassung
set -l issues 0
if test (count $missing_secrets) -gt 0
set issues (math $issues + 1)
end
if not test -f "$KEY_PROPERTIES"
set issues (math $issues + 1)
end
if not test -f "$KEYSTORE"
set issues (math $issues + 1)
end
if test $issues -eq 0
echo "🎉 Alles konfiguriert! Du kannst jetzt Releases erstellen."
echo ""
echo "🚀 Nächste Schritte:"
echo " 1. Lokalen Build testen:"
echo " ./scripts/build-release-local.fish"
echo ""
echo " 2. Code committen und pushen:"
echo " git push origin main"
echo ""
echo " 3. GitHub Actions erstellt automatisch Release"
else
echo "⚠️ $issues Problem(e) gefunden - siehe oben"
echo ""
echo "💡 Lösung: Führe create-keystore.fish aus"
end
echo ""

View File

@@ -0,0 +1,18 @@
🐛 Bugfixes v1.1.1
✅ Keine Fehler-Notifications in fremden WiFi-Netzwerken
- Server-Check vor Sync (2s Timeout)
- Stiller Abbruch wenn Server offline
✅ WiFi-Connect Fixes
- Pre-Check wartet bis Netzwerk bereit ist
- Keine Fehler bei Netzwerk-Init
🔧 Notifications
- Alte Notifications beim Start gelöscht
- Fehler verschwinden nach 30s
📱 UI
- Sync-Icon nur wenn konfiguriert
- Swipe-to-Delete ohne Flackern
- Nach Speichern: Scroll to top

View File

@@ -0,0 +1,12 @@
v1.1.2 - UX & Performance
• "Job was cancelled" Fehler behoben
• Zurück-Pfeil statt X im Editor
• Pull-to-Refresh für manuellen Sync
• HTTP/HTTPS Protokoll-Auswahl (Radio Buttons)
• Inline Fehler-Anzeige (keine Toast-Spam)
• Settings gruppiert (Auto-Sync & Intervall)
• Sync nur bei tatsächlichen Änderungen (spart Batterie)
• 24h Server-Offline Warnung
• HTTP nur für lokale Netzwerke (RFC 1918 IPs)
• Auto-Save Benachrichtigungen entfernt

View File

@@ -0,0 +1,13 @@
v1.2.0 - Backup & Desktop-Integration
Lokales Backup/Restore
• Exportiere alle Notizen als JSON
• 3 Wiederherstellungs-Modi (Merge/Replace/Overwrite)
• Auto-Backup vor Restore
Markdown Desktop-Integration (optional)
• .md Export für Desktop-Editoren (WebDAV-Mount)
• Last-Write-Wins Sync
• Manueller Import
Sync-Architektur Doku

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -0,0 +1,18 @@
🐛 Bugfixes v1.1.1
✅ No error notifications in foreign WiFi networks
- Server check before sync (2s timeout)
- Silent abort when server offline
✅ WiFi-connect fixes
- Pre-check waits until network ready
- No errors during network init
🔧 Notifications
- Old notifications cleared on start
- Errors disappear after 30s
📱 UI
- Sync icon only when configured
- Swipe-to-delete without flickering
- After saving: Scroll to top

View File

@@ -0,0 +1,12 @@
v1.1.2 - UX & Performance
• Fixed "Job was cancelled" error notifications
• Back arrow instead of X in editor
• Pull-to-Refresh for manual sync
• HTTP/HTTPS protocol selector (radio buttons)
• Inline error display (no toast spam)
• Grouped settings (Auto-Sync & Interval)
• Sync only on actual changes (saves battery)
• 24h server offline warning
• HTTP only for local networks (RFC 1918 IPs)
• Removed auto-save notifications

View File

@@ -0,0 +1,13 @@
v1.2.0 - Backup & Desktop Integration
Local Backup/Restore
• Export all notes as JSON
• 3 restore modes (Merge/Replace/Overwrite)
• Auto-backup before restore
Markdown Desktop Integration (optional)
• .md export for desktop editors (WebDAV mount)
• Last-Write-Wins sync
• Manual import
Sync architecture docs

View File

@@ -0,0 +1,37 @@
Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization.
KEY FEATURES:
• Create and edit simple notes
• WebDAV synchronization with your own server
• Automatic synchronization on home WiFi
• Configurable sync interval (15/30/60 minutes)
• Transparent battery usage display
• Material Design 3 with Dynamic Colors (Android 12+)
• Swipe-to-delete with confirmation dialog
• Server backup & restore
• Fully usable offline
• No ads, no trackers
PRIVACY:
Your data stays with you! The app only communicates with your own WebDAV server. No cloud services, no tracking libraries, no analytics tools.
SYNCHRONIZATION:
• Supports all WebDAV servers (Nextcloud, ownCloud, etc.)
• Configurable interval: 15, 30, or 60 minutes
• Measured battery consumption: only ~0.4% per day (at 30min)
• Doze Mode optimized for reliable background syncs
• Manual synchronization available anytime
• Conflict-free merging through timestamps
MATERIAL DESIGN 3:
• Modern user interface
• Dynamic Colors (Material You) on Android 12+
• Dark Mode support
• Intuitive gestures (Swipe-to-delete)
Open Source under MIT License
Source code: https://github.com/inventory69/simple-notes-sync

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -0,0 +1 @@
Simple note-taking app with WebDAV synchronization

View File

@@ -0,0 +1 @@
Simple Notes Sync

View File

@@ -0,0 +1,69 @@
Categories:
- Writing
License: MIT
AuthorName: Liq Dettmer
AuthorEmail: liq@dettmer.dev
AuthorWebSite: https://dettmer.dev
SourceCode: https://github.com/inventory69/simple-notes-sync
IssueTracker: https://github.com/inventory69/simple-notes-sync/issues
Changelog: https://github.com/inventory69/simple-notes-sync/releases
AutoName: Simple Notes Sync
RepoType: git
Repo: https://github.com/inventory69/simple-notes-sync.git
AntiFeatures:
NonFreeNet:
en-US: |-
Allows unencrypted HTTP connections to self-hosted WebDAV servers on local networks.
Starting with v1.1.2, HTTP connections will be restricted to:
- Private IP ranges (RFC 1918: 10.x.x.x, 172.16-31.x.x, 192.168.x.x)
- Localhost (127.0.0.1, ::1)
- .local domains (mDNS)
HTTPS is recommended and supported for all connections.
de-DE: |-
Erlaubt unverschlüsselte HTTP-Verbindungen zu selbst gehosteten WebDAV-Servern in lokalen Netzwerken.
Ab Version 1.1.2 werden HTTP-Verbindungen eingeschränkt auf:
- Private IP-Bereiche (RFC 1918: 10.x.x.x, 172.16-31.x.x, 192.168.x.x)
- Localhost (127.0.0.1, ::1)
- .local-Domains (mDNS)
HTTPS wird empfohlen und für alle Verbindungen unterstützt.
Builds:
- versionName: 1.1.1
versionCode: 3
commit: v1.1.1
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
srclibs:
- reproducible-apk-tools@v0.2.8
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
- versionName: 1.2.0
versionCode: 5
commit: v1.2.0
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
srclibs:
- reproducible-apk-tools@v0.2.8
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 1.2.0
CurrentVersionCode: 5

86
server/README.en.md Normal file
View File

@@ -0,0 +1,86 @@
# WebDAV Server - Simple Notes Sync
**🌍 Languages:** [Deutsch](README.md) · **English**
---
## Quick Start
```bash
# 1. Adjust environment variables
cp .env.example .env
nano .env
# 2. Start server
docker-compose up -d
# 3. Check logs
docker-compose logs -f
# 4. Test
curl -u noteuser:your_password http://localhost:8080/
```
## Server URL
**Local:** `http://localhost:8080/`
**On network:** `http://YOUR_IP:8080/` (e.g. `http://192.168.1.100:8080/`)
Find IP address:
```bash
ip addr show | grep "inet " | grep -v 127.0.0.1
```
## Credentials
Default (see `.env`):
- Username: `noteuser`
- Password: See `.env` file
## Management
```bash
# Check status
docker-compose ps
# View logs
docker-compose logs -f
# Restart
docker-compose restart
# Stop
docker-compose down
# Update
docker-compose pull
docker-compose up -d
```
## Data Location
Your notes are stored in: `./data/`
**Backup:**
```bash
# Create backup
tar -czf notes-backup-$(date +%Y%m%d).tar.gz data/
# Restore backup
tar -xzf notes-backup-YYYYMMDD.tar.gz
```
## External Access (HTTPS)
For access from outside your home network, use a reverse proxy like Caddy or nginx:
**Example with Caddy:**
```Caddyfile
notes.yourdomain.com {
reverse_proxy localhost:8080
}
```
**Important:** Always use HTTPS for external access to protect your credentials!

View File

@@ -1,5 +1,9 @@
# WebDAV Server - Simple Notes Sync # WebDAV Server - Simple Notes Sync
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
---
## Quick Start ## Quick Start
```bash ```bash