42 Commits

Author SHA1 Message Date
inventory69
0e96757fab Merge feature/v1.3.2-lint-cleanup into main 2026-01-10 00:57:46 +01:00
inventory69
547c0a1011 v1.3.2: Lint Cleanup & Code Quality
- Complete lint cleanup (Phase 1-7)
- Replace magic numbers with constants
- Remove unused imports/members
- Add Logger.w() for swallowed exceptions
- Custom SyncException for better error handling
- ConstructorParameterNaming with @SerializedName
- ReturnCount & Destructuring with @Suppress
- F-Droid: Add privacy notice for file logging
- Update docs (FEATURES.md, README.md)
- Add fastlane changelogs for versionCode 10
2026-01-10 00:57:28 +01:00
inventory69
b79c0d25e6 [skip ci] fix: simplify workflow for single universal APK per flavor
- Remove APK splits logic from workflow
- Build only universal APKs for both standard and fdroid flavors
- Simplifies release process and fixes F-Droid compatibility
2026-01-09 13:38:43 +01:00
inventory69
cf1142afa2 [skip ci] fix: disable APK splits for F-Droid build
- F-Droid expects single universal APK, not multiple architecture-specific APKs
- Remove splits.abi configuration to fix build error in F-Droid CI
- Pipeline failed with: 'More than one resulting apks found' (armeabi-v7a, arm64-v8a, universal)
- Resolves F-Droid MR #31695 build failure
2026-01-09 13:19:47 +01:00
inventory69
359325bf64 [skip ci] chore: update author information in metadata 2026-01-09 11:33:18 +01:00
inventory69
c7d0f899e7 [skip ci] feat: new app icon with monochrome support & updated descriptions
🎨 New App Icon:
- Fresh adaptive icon design with warm background (#f9e9c8)
- Monochrome icon support for Android 13+ themed icons
- PNG format replacing WebP for better compatibility
- All densities: mdpi, hdpi, xhdpi, xxhdpi, xxxhdpi

📝 Updated Descriptions (EN/DE):
- Added Multi-Device Sync feature
- Added Markdown export for Obsidian/desktop editors
- Added deletion tracking (zombie notes prevention)
- Added E-Tag caching (20x faster checks)
- Added optimized performance (~2-3s sync time)
- Added live sync status indicator
- Added Server-Restore modes (Merge/Replace/Overwrite)

📦 F-Droid Metadata:
- Updated build entries for v1.2.1, v1.2.2, v1.3.0, v1.3.1
- CurrentVersion now 1.3.1 (versionCode 9)
- Prepared for F-Droid merge request submission
2026-01-09 10:55:39 +01:00
inventory69
5121a7b2b8 Fix F-Droid changelogs: Remove emojis and bullet points [skip ci] 2026-01-08 23:18:29 +01:00
inventory69
04664c8920 v1.3.1 - Multi-Device Sync Fix + Performance + Restore Bug Fix
🔧 Fixed:
- Multi-device JSON sync now works (thanks Thomas!)
- Restore from Server skipped files (timestamp bug)
- No duplicate downloads
- First MD sync after export now fast

 Performance:
- JSON sync: 12-14s → 2-3s
- Hybrid timestamp + E-Tag optimization
- Matches Markdown sync speed

 New:
- Sync status UI in MainActivity
- Content-based MD import
- Debug logging improvements
- SyncStateManager for sync coordination

🔧 Technical:
- Clear lastSyncTimestamp on restore
- Clear E-Tag caches on restore
- E-Tag refresh after upload
- Fixed timestamp update after MD export
2026-01-08 23:09:59 +01:00
Inventory69
2a56dd8128 Merge pull request #4 from inventory69/release/v1.3.0
Release v1.3.0: Multi-Device Sync
2026-01-07 12:28:31 +01:00
inventory69
63af7d30dc Release v1.3.0: Multi-Device Sync with Deletion Tracking
New Features:
- Multi-Device Sync with deletion tracking (prevents zombie notes)
- Server deletion via swipe gesture with confirmation dialog
- E-Tag performance optimization (~150ms vs 3s for no-change syncs)
- Markdown Auto-Sync toggle (unified Export + Auto-Import)
- Manual Markdown sync button for performance control
- Server-Restore modes (Merge/Replace/Overwrite)

Technical Implementation:
- DeletionTracker model with JSON persistence
- Intelligent server checks with E-Tag caching
- Deletion-aware download logic
- Two-stage swipe deletion with Material Design dialog
- Automatic Markdown import during sync
- YAML frontmatter scanning for robust file deletion

Thanks to Thomas from Bielefeld for reporting the multi-device sync issue!

Compatible with: v1.2.0-v1.3.0
2026-01-07 12:27:27 +01:00
inventory69
62423f5a5b Release v1.2.2: Backward compatibility for v1.2.0 users
- Added dual-mode download for server restore
- Scans both /notes/ (new) and Root (old v1.2.0) folders
- Normal sync only uses /notes/ for performance
- Fixed URL construction bugs
- Updated F-Droid changelogs
2026-01-05 16:46:07 +01:00
inventory69
9eabc9a5f0 [skip ci] 📚 Docs: Reorganize + Web Editor to v1.3.0
## 📁 Reorganization
- Moved all docs to docs/ folder (FEATURES, BACKUP, DESKTOP, DOCS)
- Updated all cross-references in README.md/en
- Fixed internal links in docs

## �� Corrections
- FEATURES.md: Fixed build variants - both are 100% FOSS (no Google Services)
- Clarified: App is completely FOSS with no proprietary libraries

##  Changes
- Web Editor moved from v1.6.0 to v1.3.0 (earlier implementation)
- Combined with organization features (tags, search, sorting)
2026-01-05 12:43:01 +01:00
inventory69
015b90d56e 🐛 v1.2.1: Markdown Initial Export Bugfix + URL Normalization + GitHub Workflow Fix
## 🐛 Fixed
- Initial Markdown export: Existing notes now exported when Desktop Integration activated
- Markdown directory structure: Files now land correctly in /notes-md/
- JSON URL normalization: Smart detection for both Root-URL and /notes-URL
- GitHub release notes: Fixed language order (DE primary, EN collapsible) and emoji

##  Improved
- Settings UI: Example URL shows /notes instead of /webdav
- Server config: Enter only base URL (app adds /notes/ and /notes-md/ automatically)
- Flexible URL input: Both http://server/ and http://server/notes/ work
- Changelogs: Shortened for F-Droid 500 char limit

## 🔧 Technical
- getNotesUrl() helper with smart /notes/ detection
- getMarkdownUrl() simplified to use getNotesUrl()
- All JSON operations updated to use normalized URLs
- exportAllNotesToMarkdown() with progress callback
- Workflow: Swapped CHANGELOG_DE/EN, replaced broken emoji with 🌍

versionCode: 6
versionName: 1.2.1
2026-01-05 11:46:25 +01:00
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
139 changed files with 10517 additions and 3954 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,138 @@ 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
# Universal APK # Standard Flavor - Universal APK
cp android/app/build/outputs/apk/release/app-universal-release.apk \ cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-universal.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
# ARM64 APK # F-Droid Flavor - Universal APK
cp android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \ cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-arm64-v8a.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
# ARMv7 APK echo "✅ APK-Dateien vorbereitet:"
cp android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-armeabi-v7a.apk
echo "✅ APK files prepared:"
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.apk` | Standard-Version (funktioniert auf allen Geraeten) |
| F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
- **Version:** ${{ env.VERSION_NAME }}+${{ env.BUILD_NUMBER }} ---
- **Build-Datum:** ${{ env.COMMIT_DATE }}
## 📋 Changelog / Release Notes
${{ env.CHANGELOG_DE }}
<details>
<summary>🌍 English Version</summary>
${{ env.CHANGELOG_EN }}
</details>
---
## 📊 Build-Info
- **Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.BUILD_NUMBER }})
- **Datum:** ${{ env.COMMIT_DATE }}
- **Commit:** ${{ env.SHORT_SHA }} - **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 }})`
})

387
CHANGELOG.md Normal file
View File

@@ -0,0 +1,387 @@
# 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.3.2] - 2026-01-10
### Changed
- **🧹 Code-Qualität: "Clean Slate" Release**
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
- Unused Imports und Members entfernt
- Magic Numbers durch benannte Konstanten ersetzt
- SwallowedExceptions mit Logger.w() versehen
- MaxLineLength-Verstöße reformatiert
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
### Added
- **📝 F-Droid Privacy Notice**
- Datenschutz-Hinweis für die Datei-Logging-Funktion
- Erklärt dass Logs nur lokal gespeichert werden
- Erfüllt F-Droid Opt-in Consent-Anforderungen
### Technical Improvements
- **⚡ Neue Konstanten für bessere Wartbarkeit**
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
- RFC 1918 IP-Range Konstanten (UrlValidator)
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
- **🔒 @Suppress Annotations für legitime Patterns**
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
### Notes
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
---
## [1.3.1] - 2026-01-08
### Fixed
- **🔧 Multi-Device JSON Sync (Danke an Thomas aus Bielefeld)**
- JSON-Dateien werden jetzt korrekt zwischen Geräten synchronisiert
- Funktioniert auch ohne aktiviertes Markdown
- Hybrid-Optimierung: Server-Timestamp (Primary) + E-Tag (Secondary) Checks
- E-Tag wird nach Upload gecached um Re-Download zu vermeiden
### Performance Improvements
- **⚡ JSON Sync Performance-Parität**
- JSON-Sync erreicht jetzt gleiche Performance wie Markdown (~2-3 Sekunden)
- Timestamp-basierte Skip-Logik für unveränderte Dateien (~500ms pro Datei gespart)
- E-Tag-Matching als Fallback für Dateien die seit letztem Sync modifiziert wurden
- **Beispiel:** 24 Dateien von 12-14s auf ~2.7s reduziert (keine Änderungen)
- **⏭️ Skip unveränderte Dateien** (Haupt-Performance-Fix!)
- JSON-Dateien: Überspringt alle Notizen, die seit letztem Sync nicht geändert wurden
- Markdown-Dateien: Überspringt unveränderte MD-Dateien basierend auf Server-Timestamp
- **Spart ~500ms pro Datei** bei Nextcloud (~20 Dateien = 10 Sekunden gespart!)
- Von 21 Sekunden Sync-Zeit auf 2-3 Sekunden reduziert
- **⚡ Session-Caching für WebDAV**
- Sardine-Client wird pro Sync-Session wiederverwendet (~600ms gespart)
- WiFi-IP-Adresse wird gecacht statt bei jeder Anfrage neu ermittelt (~300ms gespart)
- `/notes/` Ordner-Existenz wird nur einmal pro Sync geprüft (~500ms gespart)
- **Gesamt: ~1.4 Sekunden zusätzlich gespart**
- **📝 Content-basierte Markdown-Erkennung**
- Extern bearbeitete Markdown-Dateien werden auch erkannt wenn YAML-Timestamp nicht aktualisiert wurde
- Löst das Problem: Obsidian/Texteditor-Änderungen wurden nicht importiert
- Hybridansatz: Erst Timestamp-Check (schnell), dann Content-Vergleich (zuverlässig)
### Added
- **🔄 Sync-Status-Anzeige (UI)**
- Sichtbares Banner "Synchronisiere..." mit ProgressBar während Sync läuft
- Sync-Button und Pull-to-Refresh werden deaktiviert während Sync aktiv
- Verhindert versehentliche Doppel-Syncs durch visuelle Rückmeldung
- Auch in Einstellungen: "Jetzt synchronisieren" Button wird deaktiviert
### Fixed
- **🔧 Sync-Mutex verhindert doppelte Syncs**
- Keine doppelten Toast-Nachrichten mehr bei schnellem Pull-to-Refresh
- Concurrent Sync-Requests werden korrekt blockiert
- **🐛 Lint-Fehler behoben**
- `View.generateViewId()` statt hardcodierte IDs in RadioButtons
- `app:tint` statt `android:tint` für AppCompat-Kompatibilität
### Added
- **🔍 detekt Code-Analyse**
- Statische Code-Analyse mit detekt 1.23.4 integriert
- Pragmatische Konfiguration für Sync-intensive Codebasis
- 91 Issues identifiziert (als Baseline für v1.4.0)
- **🏗️ Debug Build mit separatem Package**
- Debug-APK kann parallel zur Release-Version installiert werden
- Package: `dev.dettmer.simplenotes.debug` (Debug) vs `dev.dettmer.simplenotes` (Release)
- App-Name zeigt "Simple Notes (Debug)" für einfache Unterscheidung
- **📊 Debug-Logging UI**
- Neuer "Debug Log" Button in Einstellungen → Erweitert
- Zeigt letzte Sync-Logs mit Zeitstempeln
- Export-Funktion für Fehlerberichte
### Technical
- `WebDavSyncService`: Hybrid-Optimierung für JSON-Downloads (Timestamp PRIMARY, E-Tag SECONDARY)
- `WebDavSyncService`: E-Tag refresh nach Upload statt Invalidierung (verhindert Re-Download)
- E-Tag Caching: `SharedPreferences` mit Key-Pattern `etag_json_{noteId}`
- Skip-Logik: `if (serverModified <= lastSync) skip` → ~1ms pro Datei
- Fallback E-Tag: `if (serverETag == cachedETag) skip` → für Dateien modifiziert nach lastSync
- PROPFIND nach PUT: Fetch E-Tag nach Upload für korrektes Caching
- `SyncStateManager`: Neuer Singleton mit `StateFlow<Boolean>` für Sync-Status
- `MainActivity`: Observer auf `SyncStateManager.isSyncing` für UI-Updates
- Layout: `sync_status_banner` mit `ProgressBar` + `TextView`
- `WebDavSyncService`: Skip-Logik für unveränderte JSON/MD Dateien basierend auf `lastSyncTimestamp`
- `WebDavSyncService`: Neue Session-Cache-Variablen (`sessionSardine`, `sessionWifiAddress`, `notesDirEnsured`)
- `getOrCreateSardine()`: Cached Sardine-Client mit automatischer Credentials-Konfiguration
- `getOrCacheWiFiAddress()`: WiFi-Adresse wird nur einmal pro Sync ermittelt
- `clearSessionCache()`: Aufräumen am Ende jeder Sync-Session
- `ensureNotesDirectoryExists()`: Cached Directory-Check
- Content-basierter Import: Vergleicht MD-Content mit lokaler Note wenn Timestamps gleich
- Build-Tooling: detekt aktiviert, ktlint vorbereitet (deaktiviert wegen Parser-Problemen)
- Debug BuildType: `applicationIdSuffix = ".debug"`, `versionNameSuffix = "-debug"`
---
## [1.3.0] - 2026-01-07
### Added
- **🚀 Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
- Automatic download of new notes from other devices
- Deletion tracking prevents "zombie notes" (deleted notes don't come back)
- Smart cleanup: Re-created notes (newer timestamp) are downloaded
- Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
- **🗑️ Server Deletion via Swipe Gesture**
- Swipe left on notes to delete from server (requires confirmation)
- Prevents duplicate notes on other devices
- Works with deletion tracking system
- Material Design confirmation dialog
- **⚡ E-Tag Performance Optimization**
- Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
- 20x faster when server has no updates
- E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
- Battery-friendly with minimal server requests
- **📥 Markdown Auto-Sync Toggle**
- NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
- When enabled: Notes export to Markdown AND import changes automatically
- When disabled: Manual sync button appears for on-demand synchronization
- Performance: Auto-Sync OFF = 0ms overhead
- **🔘 Manual Markdown Sync Button**
- Manual sync button for performance-conscious users
- Shows import/export counts after completion
- Only visible when Auto-Sync is disabled
- On-demand synchronization (~150-200ms only when triggered)
- **⚙️ Server-Restore Modes**
- MERGE: Keep local notes + add server notes
- REPLACE: Delete all local + download from server
- OVERWRITE: Update duplicates, keep non-duplicates
- Restore modes now work correctly for WebDAV restore
### Technical
- New `DeletionTracker` model with JSON persistence
- `NotesStorage`: Added deletion tracking methods
- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
- `WebDavSyncService.restoreFromServer()`: Support for restore modes
- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
- E-Tag caching in SharedPreferences for performance
---
## [1.2.2] - 2026-01-06
### Fixed
- **Backward Compatibility for v1.2.0 Users (Critical)**
- App now reads BOTH old (Root) AND new (`/notes/`) folder structures
- Users upgrading from v1.2.0 no longer lose their existing notes
- Server-Restore now finds notes from v1.2.0 stored in Root folder
- Automatic deduplication prevents loading the same note twice
- Graceful error handling if Root folder is not accessible
### Technical
- `WebDavSyncService.downloadRemoteNotes()` - Dual-mode download (Root + /notes/)
- `WebDavSyncService.restoreFromServer()` - Now uses dual-mode download
- Migration happens naturally: new uploads go to `/notes/`, old notes stay readable
---
## [1.2.1] - 2026-01-05
### Fixed
- **Markdown Initial Export Bugfix**
- Existing notes are now exported as Markdown when Desktop Integration is activated
- Previously, only new notes created after activation were exported
- Progress dialog shows export status with current/total counter
- Error handling for network issues during export
- Individual note failures don't abort the entire export
- **Markdown Directory Structure Fix**
- Markdown files now correctly land in `/notes-md/` folder
- Smart URL detection supports both Root-URL and `/notes` URL structures
- Previously, MD files were incorrectly placed in the root directory
- Markdown import now finds files correctly
- **JSON URL Normalization**
- Simplified server configuration: enter only base URL (e.g., `http://server:8080/`)
- App automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown
- Smart detection: both `http://server:8080/` and `http://server:8080/notes/` work correctly
- Backward compatible: existing setups with `/notes` in URL continue to work
- No migration required for existing users
### Changed
- **Markdown Directory Creation**
- `notes-md/` folder is now created on first sync (regardless of Desktop Integration setting)
- Prevents 404 errors when mounting WebDAV folder
- Better user experience: folder is visible before enabling the feature
- **Settings UI Improvements**
- Updated example URL from `/webdav` to `/notes` to match app behavior
- Example now shows: `http://192.168.0.188:8080/notes`
### Technical
- `WebDavSyncService.ensureMarkdownDirectoryExists()` - Creates MD folder early
- `WebDavSyncService.getMarkdownUrl()` - Smart URL detection for both structures
- `WebDavSyncService.exportAllNotesToMarkdown()` - Exports all local notes with progress callback
- `SettingsActivity.onMarkdownExportToggled()` - Triggers initial export with ProgressDialog
---
## [1.2.0] - 2026-01-04
### Added
- **Local Backup System**
- Export all notes as JSON file to any location (Downloads, SD card, cloud folder)
- Import backup with 3 modes: Merge, Replace, or Overwrite duplicates
- Automatic safety backup created before every restore
- Backup validation (format and version check)
- **Markdown Desktop Integration**
- Optional Markdown export parallel to JSON sync
- `.md` files synced to `notes-md/` folder on WebDAV
- YAML frontmatter with `id`, `created`, `updated`, `device`
- Manual import button to pull Markdown changes from server
- Last-Write-Wins conflict resolution via timestamps
- **Settings UI Extensions**
- New "Backup & Restore" section with local + server restore
- New "Desktop Integration" section with Markdown toggle
- Universal restore dialog with radio button mode selection
### Changed
- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all
### Technical
- `BackupManager.kt` - Complete backup/restore logic
- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter
- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror)
- ISO8601 timestamp formatting for desktop compatibility
- Filename sanitization for safe Markdown file names
### Documentation
- Added WebDAV mount instructions (Windows, macOS, Linux)
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
---
## [1.1.2] - 2025-12-28
### Fixed
- **"Job was cancelled" Error**
- Fixed coroutine cancellation in sync worker
- Proper error handling for interrupted syncs
- **UI Improvements**
- Back arrow instead of X in note editor (better UX)
- Pull-to-refresh for manual sync trigger
- HTTP/HTTPS protocol selection with radio buttons
- Inline error display (no toast spam)
- **Performance & Battery**
- Sync only on actual changes (saves battery)
- Auto-save notifications removed
- 24-hour server offline warning instead of instant error
### Changed
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
- HTTP only allowed for local networks (RFC 1918 IPs)
- Swipe-to-delete without UI flicker
---
## [1.1.1] - 2025-12-27
### Fixed
- **WiFi Connect Sync**
- No error notifications in foreign WiFi networks
- Server reachability check before sync (2s timeout)
- Silent abort when server offline
- Pre-check waits until network is ready
- No errors during network initialization
### Changed
- **Notifications**
- Old sync notifications cleared on app start
- Error notifications auto-dismiss after 30 seconds
### UI
- Sync icon only shown when sync is configured
- Swipe-to-delete without flicker
- Scroll to top after saving note
### Technical
- Server check with 2-second timeout before sync attempts
- Network readiness check in WiFi connect trigger
- Notification cleanup on MainActivity.onCreate()
---
## [1.1.0] - 2025-12-26
### Added
- **Configurable Sync Intervals**
- User choice: 15, 30, or 60 minutes
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
- Radio button selection in settings
- Doze Mode optimization (syncs batched in maintenance windows)
- **About Section**
- App version from BuildConfig
- Links to GitHub repository and developer profile
- MIT license information
- Material 3 card design
### Changed
- Settings UI redesigned with grouped sections
- Periodic sync updated dynamically when interval changes
- WorkManager uses selected interval for background sync
### Removed
- Debug/Logs section from settings (cleaner UI)
### Technical
- `PREF_SYNC_INTERVAL_MINUTES` preference key
- NetworkMonitor reads interval from SharedPreferences
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
---
## [1.0.0] - 2025-12-25
### Added
- Initial release
- WebDAV synchronization
- Note creation, editing, deletion
- 6 sync triggers:
- Periodic sync (configurable interval)
- App start sync
- WiFi connect sync
- Manual sync (menu button)
- Pull-to-refresh
- Settings "Sync Now" button
- Material 3 design
- Light/Dark theme support
- F-Droid compatible (100% FOSS)
---
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0

263
CONTRIBUTING.md Normal file
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! 🎄**

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

271
QUICKSTART.en.md Normal file
View File

@@ -0,0 +1,271 @@
# 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 |
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
4. **Press "Test connection"****
- ✅ Success? → Continue to step 4
- ❌ Error? → See [Troubleshooting](#troubleshooting)
5. **Enable auto-sync** (toggle switch)
6. **Choose sync interval:**
- **15 min** - Maximum currency (~0.8% battery/day)
- **30 min** - Recommended (~0.4% battery/day) ⭐
- **60 min** - Maximum battery life (~0.2% battery/day)
---
### Step 4: Create First Note
1. Back to main view (← arrow)
2. **"Add note"** (+ icon)
3. Enter title and text
4. **Save** (💾 icon)
5. **Wait for auto-sync** (or manually: ⚙️ → "Sync now")
🎉 **Done!** Your notes will be automatically synchronized!
---
## Option 2: Local notes only (no server) 📱
You can also use Simple Notes **without a server**:
1. **Install app** (see step 2 above)
2. **Use without server configuration:**
- Notes are only stored locally
- No auto-sync
- Perfect for offline-only use
---
## 🔋 Disable Battery Optimization
For reliable auto-sync:
1. **Settings****Apps****Simple Notes Sync**
2. **Battery****Battery usage**
3. Select: **"Don't optimize"** or **"Unrestricted"**
💡 **Note:** Android Doze Mode may still delay sync in standby (~60 min). This is normal and affects all apps.
---
## 📊 Sync Intervals in Detail
| Interval | Syncs/day | Battery/day | Battery/sync | Use case |
|-----------|-----------|----------|-----------|----------------|
| **15 min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximum currency (multiple devices) |
| **30 min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Recommended** - balanced |
| **60 min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximum battery life |
---
## 🐛 Troubleshooting
### Connection test fails
**Problem:** "Connection failed" during test
**Solutions:**
1. **Server running?**
```bash
docker compose ps
# Should show "Up"
```
2. **Same 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,271 @@
# 🚀 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 |
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
4. **"Verbindung testen"** drücken
- ✅ Erfolg? → Weiter zu Schritt 4
- ❌ Fehler? → Siehe [Troubleshooting](#troubleshooting)
5. **Auto-Sync aktivieren** (Toggle Switch)
6. **Sync-Intervall wählen:**
- **15 Min** - Maximale Aktualität (~0.8% Akku/Tag)
- **30 Min** - Empfohlen (~0.4% Akku/Tag) ⭐
- **60 Min** - Maximale Akkulaufzeit (~0.2% Akku/Tag)
---
### Schritt 4: Erste Notiz erstellen
1. Zurück zur Hauptansicht (← Pfeil)
2. **"Notiz hinzufügen"** (+ Icon)
3. Titel und Text eingeben
4. **Speichern** (💾 Icon)
5. **Warten auf Auto-Sync** (oder manuell: ⚙️ → "Jetzt synchronisieren")
🎉 **Fertig!** Deine Notizen werden automatisch synchronisiert!
---
## Option 2: Nur lokale Notizen (kein Server) 📱
Du kannst Simple Notes auch **ohne Server** nutzen:
1. **App installieren** (siehe Schritt 2 oben)
2. **Ohne Server-Konfiguration verwenden:**
- Notizen werden nur lokal gespeichert
- Kein Auto-Sync
- Perfekt für reine Offline-Nutzung
---
## 🔋 Akku-Optimierung deaktivieren
Für zuverlässigen Auto-Sync:
1. **Einstellungen****Apps****Simple Notes Sync**
2. **Akku****Akkuverbrauch**
3. Wähle: **"Nicht optimieren"** oder **"Unbeschränkt"**
💡 **Hinweis:** Android Doze Mode kann trotzdem Sync im Standby verzögern (~60 Min). Das ist normal und betrifft alle Apps.
---
## 📊 Sync-Intervalle im Detail
| Intervall | Syncs/Tag | Akku/Tag | Akku/Sync | Anwendungsfall |
|-----------|-----------|----------|-----------|----------------|
| **15 Min** | ~96 | ~0.8% (~23 mAh) | ~0.008% | ⚡ Maximal aktuell (mehrere Geräte) |
| **30 Min** | ~48 | ~0.4% (~12 mAh) | ~0.008% | ✓ **Empfohlen** - ausgewogen |
| **60 Min** | ~24 | ~0.2% (~6 mAh) | ~0.008% | 🔋 Maximale Akkulaufzeit |
---
## 🐛 Troubleshooting
### Verbindungstest schlägt fehl
**Problem:** "Verbindung fehlgeschlagen" beim Test
**Lösungen:**
1. **Server läuft?**
```bash
docker compose ps
# Sollte "Up" zeigen
```
2. **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

104
README.en.md Normal file
View File

@@ -0,0 +1,104 @@
# 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)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](docs/DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
**🌍 Languages:** [Deutsch](README.md) · **English**
---
## 📱 Screenshots
<p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notes list">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Edit note">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Settings">
</p>
---
## ✨ Highlights
- 📝 **Offline-first** - Works without internet
- 🔄 **Auto-sync** - Home WiFi only (15/30/60 min)
- 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file
- 🖥️ **Desktop integration** - Markdown export for VS Code, Typora, etc.
- 🔋 **Battery-friendly** - ~0.2-0.8% per day
- 🎨 **Material Design 3** - Dark mode & dynamic colors
➡️ **Complete feature list:** [FEATURES.en.md](docs/FEATURES.en.md)
---
## 🚀 Quick Start
### 1. Server Setup (5 minutes)
```bash
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
cp .env.example .env
# Set password in .env
docker compose up -d
```
➡️ **Details:** [Server Setup Guide](server/README.en.md)
### 2. App Installation (2 minutes)
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install & open
3. ⚙️ Settings → Configure server:
- **URL:** `http://YOUR-SERVER-IP:8080/` _(base URL only!)_
- **User:** `noteuser`
- **Password:** _(from .env)_
- **WiFi:** _(your network name)_
4. **Test connection** → Enable auto-sync
5. Done! 🎉
➡️ **Detailed guide:** [QUICKSTART.en.md](QUICKSTART.en.md)
---
## 📚 Documentation
| Document | Content |
|----------|---------|
| **[QUICKSTART.en.md](QUICKSTART.en.md)** | Step-by-step installation |
| **[FEATURES.en.md](docs/FEATURES.en.md)** | Complete feature list |
| **[BACKUP.en.md](docs/BACKUP.en.md)** | Backup & restore guide |
| **[DESKTOP.en.md](docs/DESKTOP.en.md)** | Desktop integration (Markdown) |
---
## 🛠️ Development
```bash
cd android
./gradlew assembleStandardRelease
```
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
---
## 🤝 Contributing
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
---
## 📄 License
MIT License - see [LICENSE](LICENSE)
---
**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3

144
README.md
View File

@@ -1,130 +1,100 @@
# 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. [<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
## ✨ Features **📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
- 📝 **Offline-First** - Notizen lokal gespeichert, immer verfügbar **🌍 Sprachen:** **Deutsch** · [English](README.en.md)
- 🔄 **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) ## 📱 Screenshots
💡 **Tipp:** Nutze [Obtainium](https://github.com/ImranR98/Obtainium) für automatische Updates! <p align="center">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notizliste">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Notiz bearbeiten">
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Einstellungen">
</p>
---
## ✨ Highlights
- 📝 **Offline-First** - Funktioniert ohne Internet
- 🔄 **Auto-Sync** - Nur im Heim-WLAN (15/30/60 Min)
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** - Export/Import als JSON-Datei
- 🖥️ **Desktop-Integration** - Markdown-Export für VS Code, Typora, etc.
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
➡️ **Vollständige Feature-Liste:** [FEATURES.md](docs/FEATURES.md)
--- ---
## 🚀 Schnellstart ## 🚀 Schnellstart
### 1️⃣ WebDAV Server starten ### 1. Server Setup (5 Minuten)
```fish ```bash
cd server git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
cp .env.example .env cp .env.example .env
# Passwort in .env 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 Minuten)
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:
- **URL:** `http://DEINE-SERVER-IP:8080/` _(nur Base-URL!)_
- **User:** `noteuser`
- **Passwort:** _(aus .env)_
- **WLAN:** _(dein Netzwerk-Name)_
4. **Verbindung testen** → Auto-Sync aktivieren
5. Fertig! 🎉
➡️ **Ausführliche Anleitung:** [QUICKSTART.md](QUICKSTART.md)
--- ---
## ⚙️ Sync-Intervalle ## 📚 Dokumentation
| Intervall | Akku/Tag | Anwendungsfall | | Dokument | Inhalt |
|-----------|----------|----------------| |----------|--------|
| **15 Min** | ~0.8% (~23 mAh) | ⚡ Maximale Aktualität | | **[QUICKSTART.md](QUICKSTART.md)** | Schritt-für-Schritt Installation |
| **30 Min** | ~0.4% (~12 mAh) | ✓ Empfohlen - Ausgewogen | | **[FEATURES.md](docs/FEATURES.md)** | Vollständige Feature-Liste |
| **60 Min** | ~0.2% (~6 mAh) | 🔋 Maximale Akkulaufzeit | | **[BACKUP.md](docs/BACKUP.md)** | Backup & Wiederherstellung |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop-Integration (Markdown) |
💡 **Hinweis:** Android Doze Mode kann Sync im Standby auf ~60 Min. verzögern (betrifft alle Apps). | **[DOCS.md](docs/DOCS.md)** | Technische Details & Troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Versionshistorie |
--- ---
## <EFBFBD> Neue Features in v1.1.0 ## 🛠️ Entwicklung
### Konfigurierbare Sync-Intervalle ```bash
- ⏱️ Wählbare Intervalle: 15/30/60 Minuten
- 📊 Transparente Akkuverbrauchs-Anzeige
- <20> Sofortige Anwendung ohne App-Neustart
### Über-Sektion
- <20> App-Version & Build-Datum
- 🌐 Links zu GitHub Repo & Entwickler
- ⚖️ Lizenz-Information
### Verbesserungen
- 🎯 Benutzerfreundliche Doze-Mode Erklärung
- 🔕 Keine störenden Sync-Fehler Toasts im Hintergrund
- 📝 Erweiterte Debug-Logs für Troubleshooting
---
## 🛠️ Selbst bauen
```fish
cd android cd android
./gradlew assembleStandardRelease ./gradlew assembleStandardRelease
# APK: android/app/build/outputs/apk/standard/release/
``` ```
--- ➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
## 🐛 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 willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
--- ---
@@ -134,4 +104,4 @@ MIT License - siehe [LICENSE](LICENSE)
--- ---
**Version:** 1.1.0 · **Status:** ✅ Produktiv · **Gebaut mit:** Kotlin + Material Design 3 **v1.3.2** · Built with ❤️ using 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

61
android/.editorconfig Normal file
View File

@@ -0,0 +1,61 @@
# ⚡ v1.3.1: EditorConfig for ktlint
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 120
trim_trailing_whitespace = true
[*.{kt,kts}]
# ktlint rules
ktlint_code_style = android_studio
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_package-name = enabled
ktlint_standard_filename = enabled
ktlint_standard_class-naming = enabled
ktlint_standard_function-naming = enabled
ktlint_standard_property-naming = enabled
ktlint_standard_backing-property-naming = enabled
ktlint_standard_enum-entry-name-case = enabled
ktlint_standard_multiline-if-else = enabled
ktlint_standard_no-empty-class-body = enabled
ktlint_standard_no-empty-first-line-in-class-body = enabled
ktlint_standard_blank-line-before-declaration = enabled
ktlint_standard_context-receiver-wrapping = enabled
ktlint_standard_function-expression-body = disabled
ktlint_standard_function-literal = enabled
ktlint_standard_function-type-modifier-spacing = enabled
ktlint_standard_kdoc-wrapping = enabled
ktlint_standard_modifier-list-spacing = enabled
ktlint_standard_no-blank-line-in-list = enabled
ktlint_standard_no-consecutive-comments = disabled
ktlint_standard_no-single-line-block-comment = enabled
ktlint_standard_parameter-list-spacing = enabled
ktlint_standard_parameter-list-wrapping = enabled
ktlint_standard_property-wrapping = enabled
ktlint_standard_spacing-between-function-name-and-opening-parenthesis = enabled
ktlint_standard_statement-wrapping = enabled
ktlint_standard_string-template-indent = disabled
ktlint_standard_try-catch-finally-spacing = enabled
ktlint_standard_type-argument-list-spacing = enabled
ktlint_standard_type-parameter-list-spacing = enabled
ktlint_standard_value-argument-comment = enabled
ktlint_standard_value-parameter-comment = enabled
[*.md]
trim_trailing_whitespace = false
[*.{xml,json}]
indent_size = 2
[*.yml]
indent_size = 2
[Makefile]
indent_style = tab

5
android/.gitignore vendored
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

@@ -1,6 +1,9 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen, aktivieren in v1.4.0
// alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
} }
import java.util.Properties import java.util.Properties
@@ -17,8 +20,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 = 10 // 🚀 v1.3.2: Lint-Cleanup "Clean Slate"
versionName = "1.1.0" // 🔥 Configurable Sync Interval + About Section versionName = "1.3.2" // 🚀 v1.3.2: Code-Qualität-Release (alle einfachen Lint-Issues behoben)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -26,23 +29,21 @@ android {
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"") buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
} }
// Enable multiple APKs per ABI for smaller downloads // Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
splits { dependenciesInfo {
abi { includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
isEnable = true includeInBundle = false // Also disable for AAB (Google Play)
reset()
include("armeabi-v7a", "arm64-v8a")
isUniversalApk = true // Also generate universal APK
}
} }
// Product Flavors for F-Droid and standard builds // Product Flavors for F-Droid and standard builds
// Note: APK splits are disabled to ensure single APK output
flavorDimensions += "distribution" flavorDimensions += "distribution"
productFlavors { productFlavors {
create("fdroid") { create("fdroid") {
dimension = "distribution" dimension = "distribution"
// F-Droid builds have no proprietary dependencies // F-Droid builds have no proprietary dependencies
// All dependencies in this project are already FOSS-compatible // All dependencies in this project are already FOSS-compatible
// No APK splits - F-Droid expects single universal APK
} }
create("standard") { create("standard") {
@@ -69,6 +70,16 @@ android {
} }
buildTypes { buildTypes {
debug {
// ⚡ v1.3.1: Debug-Builds können parallel zur Release-App installiert werden
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
isDebuggable = true
// Optionales separates Icon-Label für Debug-Builds
resValue("string", "app_name_debug", "Simple Notes (Debug)")
}
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
@@ -124,6 +135,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)
@@ -134,4 +148,28 @@ dependencies {
fun getBuildDate(): String { fun getBuildDate(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return dateFormat.format(Date()) return dateFormat.format(Date())
}
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
// ktlint {
// android = true
// outputToConsole = true
// ignoreFailures = true
// enableExperimentalRules = false
// filter {
// exclude("**/generated/**")
// exclude("**/build/**")
// }
// }
// ⚡ v1.3.1: detekt-Konfiguration
detekt {
buildUponDefaultConfig = true
allRules = false
config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
baseline = file("$rootDir/config/detekt/baseline.xml")
// Parallel-Verarbeitung für schnellere Checks
parallel = true
} }

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

@@ -21,19 +21,29 @@ import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.card.MaterialCardView import com.google.android.material.card.MaterialCardView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.dettmer.simplenotes.adapters.NotesAdapter import dev.dettmer.simplenotes.adapters.NotesAdapter
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView import android.widget.TextView
import android.widget.CheckBox
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService import dev.dettmer.simplenotes.sync.WebDavSyncService
import dev.dettmer.simplenotes.sync.SyncStateManager
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View
import android.widget.LinearLayout
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -41,10 +51,21 @@ class MainActivity : AppCompatActivity() {
private lateinit var emptyStateCard: MaterialCardView private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
// 🔄 v1.3.1: Sync Status Banner
private lateinit var syncStatusBanner: LinearLayout
private lateinit var syncStatusText: TextView
private lateinit var adapter: NotesAdapter private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) } private val storage by lazy { NotesStorage(this) }
// Menu reference for sync button state
private var optionsMenu: Menu? = null
// Track pending deletions to prevent flicker when notes reload
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)
} }
@@ -55,6 +76,8 @@ class MainActivity : AppCompatActivity() {
private const val REQUEST_SETTINGS = 1002 private const val REQUEST_SETTINGS = 1002
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp" private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
private const val SYNC_COMPLETED_DELAY_MS = 1500L
private const val ERROR_DISPLAY_DELAY_MS = 3000L
} }
/** /**
@@ -86,11 +109,15 @@ class MainActivity : AppCompatActivity() {
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
// File Logging aktivieren wenn eingestellt // Logger initialisieren und File-Logging aktivieren wenn eingestellt
if (prefs.getBoolean("file_logging_enabled", false)) { Logger.init(this)
Logger.enableFileLogging(this) if (prefs.getBoolean(Constants.KEY_FILE_LOGGING_ENABLED, false)) {
Logger.setFileLoggingEnabled(true)
} }
// Alte Sync-Notifications beim App-Start löschen
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()
@@ -102,6 +129,65 @@ class MainActivity : AppCompatActivity() {
setupFab() setupFab()
loadNotes() loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates
setupSyncStateObserver()
}
/**
* 🔄 v1.3.1: Beobachtet Sync-Status für UI-Feedback
*/
private fun setupSyncStateObserver() {
SyncStateManager.syncStatus.observe(this) { status ->
when (status.state) {
SyncStateManager.SyncState.SYNCING -> {
// Disable sync controls
setSyncControlsEnabled(false)
// 🔄 v1.3.1: Show sync status banner (ersetzt SwipeRefresh-Animation)
syncStatusText.text = getString(R.string.sync_status_syncing)
syncStatusBanner.visibility = View.VISIBLE
}
SyncStateManager.SyncState.COMPLETED -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show completed briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
lifecycleScope.launch {
kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.ERROR -> {
// Re-enable sync controls
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
// Show error briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
lifecycleScope.launch {
kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
syncStatusBanner.visibility = View.GONE
SyncStateManager.reset()
}
}
SyncStateManager.SyncState.IDLE -> {
setSyncControlsEnabled(true)
swipeRefreshLayout.isRefreshing = false
syncStatusBanner.visibility = View.GONE
}
}
}
}
/**
* 🔄 v1.3.1: Aktiviert/deaktiviert Sync-Controls (Button + SwipeRefresh)
*/
private fun setSyncControlsEnabled(enabled: Boolean) {
// Menu Sync-Button
optionsMenu?.findItem(R.id.action_sync)?.isEnabled = enabled
// SwipeRefresh
swipeRefreshLayout.isEnabled = enabled
} }
override fun onResume() { override fun onResume() {
@@ -117,7 +203,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)
@@ -137,15 +223,40 @@ class MainActivity : AppCompatActivity() {
return return
} }
// 🔄 v1.3.1: Check if sync already running
if (!SyncStateManager.tryStartSync("auto-$source")) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Another sync already in progress")
return
}
Logger.d(TAG, "🔄 Auto-sync triggered ($source)") Logger.d(TAG, "🔄 Auto-sync triggered ($source)")
// Update last sync timestamp // Update last sync timestamp
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")
SyncStateManager.reset()
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Auto-sync ($source): Server not reachable - skipping silently")
SyncStateManager.reset()
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }
@@ -153,6 +264,7 @@ class MainActivity : AppCompatActivity() {
// Feedback abhängig von Source // Feedback abhängig von Source
if (result.isSuccess && result.syncedCount > 0) { if (result.isSuccess && result.syncedCount > 0) {
Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes") Logger.d(TAG, "✅ Auto-sync successful ($source): ${result.syncedCount} notes")
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
// onResume: Nur Success-Toast // onResume: Nur Success-Toast
showToast("✅ Gesynct: ${result.syncedCount} Notizen") showToast("✅ Gesynct: ${result.syncedCount} Notizen")
@@ -160,14 +272,17 @@ class MainActivity : AppCompatActivity() {
} else if (result.isSuccess) { } else if (result.isSuccess) {
Logger.d(TAG, " Auto-sync ($source): No changes") Logger.d(TAG, " Auto-sync ($source): No changes")
SyncStateManager.markCompleted()
} else { } else {
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}") Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
SyncStateManager.markError(result.errorMessage)
// Kein Toast - App ist im Hintergrund // Kein Toast - App ist im Hintergrund
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}") Logger.e(TAG, "💥 Auto-sync exception ($source): ${e.message}")
SyncStateManager.markError(e.message)
// Kein Toast - App ist im Hintergrund // Kein Toast - App ist im Hintergrund
} }
} }
@@ -203,6 +318,11 @@ class MainActivity : AppCompatActivity() {
emptyStateCard = findViewById(R.id.emptyStateCard) emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote) fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar) toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
// 🔄 v1.3.1: Sync Status Banner
syncStatusBanner = findViewById(R.id.syncStatusBanner)
syncStatusText = findViewById(R.id.syncStatusText)
} }
private fun setupToolbar() { private fun setupToolbar() {
@@ -216,10 +336,74 @@ class MainActivity : AppCompatActivity() {
recyclerViewNotes.adapter = adapter recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this) recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// 🔥 v1.1.2: Setup Pull-to-Refresh
setupPullToRefresh()
// Setup Swipe-to-Delete // Setup Swipe-to-Delete
setupSwipeToDelete() setupSwipeToDelete()
} }
/**
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
*/
private fun setupPullToRefresh() {
swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("pullToRefresh")) {
swipeRefreshLayout.isRefreshing = false
return@setOnRefreshListener
}
lifecycleScope.launch {
try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert")
SyncStateManager.reset()
return@launch
}
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
SyncStateManager.markCompleted("Bereits synchronisiert")
return@launch
}
// Check if server is reachable
if (!syncService.isServerReachable()) {
SyncStateManager.markError("Server nicht erreichbar")
return@launch
}
// Perform sync
val result = syncService.syncNotes()
if (result.isSuccess) {
SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes()
} else {
SyncStateManager.markError(result.errorMessage)
}
} catch (e: Exception) {
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
SyncStateManager.markError(e.message)
}
}
}
// Set Material 3 color scheme
swipeRefreshLayout.setColorSchemeResources(
com.google.android.material.R.color.material_dynamic_primary50
)
}
private fun setupSwipeToDelete() { private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag 0, // No drag
@@ -232,31 +416,20 @@ class MainActivity : AppCompatActivity() {
): Boolean = false ): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.adapterPosition val position = viewHolder.bindingAdapterPosition
val note = adapter.currentList[position] val swipedNote = adapter.currentList[position]
val notesCopy = adapter.currentList.toMutableList()
// Remove from list immediately for visual feedback // Store original list BEFORE removing note
notesCopy.removeAt(position) val originalList = adapter.currentList.toList()
adapter.submitList(notesCopy)
// Show Snackbar with UNDO // Remove from list for visual feedback (NOT from storage yet!)
Snackbar.make( val listWithoutNote = originalList.toMutableList().apply {
recyclerViewNotes, removeAt(position)
"Notiz gelöscht", }
Snackbar.LENGTH_LONG adapter.submitList(listWithoutNote)
).setAction("RÜCKGÄNGIG") {
// UNDO: Restore note in list // Show dialog with ability to restore
loadNotes() showServerDeletionDialog(swipedNote, originalList)
}.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO → Actually delete the note
storage.deleteNote(note.id)
loadNotes()
}
}
}).show()
} }
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float { override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
@@ -268,6 +441,116 @@ class MainActivity : AppCompatActivity() {
itemTouchHelper.attachToRecyclerView(recyclerViewNotes) itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
} }
private fun showServerDeletionDialog(note: Note, originalList: List<Note>) {
val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
if (alwaysDeleteFromServer) {
// Auto-delete from server without asking
deleteNoteLocally(note, deleteFromServer = true)
return
}
val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
val checkboxAlways = dialogView.findViewById<CheckBox>(R.id.checkboxAlwaysDeleteFromServer)
MaterialAlertDialogBuilder(this)
.setTitle("Notiz löschen")
.setMessage("\"${note.title}\" wird lokal gelöscht.\n\nAuch vom Server löschen?")
.setView(dialogView)
.setNeutralButton("Abbrechen") { _, _ ->
// RESTORE: Re-submit original list (note is NOT deleted from storage)
adapter.submitList(originalList)
}
.setOnCancelListener {
// User pressed back - also restore
adapter.submitList(originalList)
}
.setPositiveButton("Nur lokal") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
}
// NOW actually delete from storage
deleteNoteLocally(note, deleteFromServer = false)
}
.setNegativeButton("Vom Server löschen") { _, _ ->
if (checkboxAlways.isChecked) {
prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
}
deleteNoteLocally(note, deleteFromServer = true)
}
.setCancelable(true)
.show()
}
private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
// Track pending deletion to prevent flicker
pendingDeletions.add(note.id)
// Delete from storage
storage.deleteNote(note.id)
// Reload to reflect changes
loadNotes()
// Show Snackbar with UNDO option
val message = if (deleteFromServer) {
"\"${note.title}\" wird lokal und vom Server gelöscht"
} else {
"\"${note.title}\" lokal gelöscht (Server bleibt)"
}
Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
.setAction("RÜCKGÄNGIG") {
// UNDO: Restore note
storage.saveNote(note)
pendingDeletions.remove(note.id)
loadNotes()
}
.addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
if (event != DISMISS_EVENT_ACTION) {
// Snackbar dismissed without UNDO
pendingDeletions.remove(note.id)
// Delete from server if requested
if (deleteFromServer) {
lifecycleScope.launch {
try {
val webdavService = WebDavSyncService(this@MainActivity)
val success = webdavService.deleteNoteFromServer(note.id)
if (success) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Vom Server gelöscht",
Toast.LENGTH_SHORT
).show()
}
} else {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Löschung fehlgeschlagen",
Toast.LENGTH_LONG
).show()
}
}
} catch (e: Exception) {
runOnUiThread {
Toast.makeText(
this@MainActivity,
"Server-Fehler: ${e.message}",
Toast.LENGTH_LONG
).show()
}
}
}
}
}
}
}).show()
}
private fun setupFab() { private fun setupFab() {
fabAddNote.setOnClickListener { fabAddNote.setOnClickListener {
openNoteEditor(null) openNoteEditor(null)
@@ -276,10 +559,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
@@ -301,32 +595,56 @@ class MainActivity : AppCompatActivity() {
} }
private fun triggerManualSync() { private fun triggerManualSync() {
// 🔄 v1.3.1: Check if sync already running (Banner zeigt Status)
if (!SyncStateManager.tryStartSync("manual")) {
return
}
lifecycleScope.launch { lifecycleScope.launch {
try { try {
showToast("Starte Synchronisation...") // Create sync service
// Start sync
val syncService = WebDavSyncService(this@MainActivity) val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
SyncStateManager.markCompleted("Bereits synchronisiert")
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
}
if (!isReachable) {
Logger.d(TAG, "⏭️ Manual Sync: Server not reachable - aborting")
SyncStateManager.markError("Server nicht erreichbar")
return@launch
}
// Server ist erreichbar → Sync durchführen
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
syncService.syncNotes() syncService.syncNotes()
} }
// Show result // Show result
if (result.isSuccess) { if (result.isSuccess) {
showToast("Sync erfolgreich: ${result.syncedCount} Notizen") SyncStateManager.markCompleted("${result.syncedCount} Notizen")
loadNotes() // Reload notes loadNotes() // Reload notes
} else { } else {
showToast("Sync Fehler: ${result.errorMessage}") SyncStateManager.markError(result.errorMessage)
} }
} catch (e: Exception) { } catch (e: Exception) {
showToast("Sync Fehler: ${e.message}") SyncStateManager.markError(e.message)
} }
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu) menuInflater.inflate(R.menu.menu_main, menu)
optionsMenu = menu // 🔄 v1.3.1: Store reference for sync button state
return true return true
} }
@@ -385,4 +703,4 @@ class MainActivity : AppCompatActivity() {
} }
} }
} }
} }

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

@@ -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,367 @@
package dev.dettmer.simplenotes.backup
import android.content.Context
import android.net.Uri
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Logger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* BackupManager: Lokale Backup & Restore Funktionalität
*
* Features:
* - Backup aller Notizen in JSON-Datei
* - Restore mit 3 Modi (Merge, Replace, Overwrite Duplicates)
* - Auto-Backup vor Restore (Sicherheitsnetz)
* - Backup-Validierung
*/
class BackupManager(private val context: Context) {
companion object {
private const val TAG = "BackupManager"
private const val BACKUP_VERSION = 1
private const val AUTO_BACKUP_DIR = "auto_backups"
private const val AUTO_BACKUP_RETENTION_DAYS = 7
}
private val storage = NotesStorage(context)
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
/**
* Erstellt Backup aller Notizen
*
* @param uri Output-URI (via Storage Access Framework)
* @return BackupResult mit Erfolg/Fehler Info
*/
suspend fun createBackup(uri: Uri): BackupResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📦 Creating backup to: $uri")
val allNotes = storage.loadAllNotes()
Logger.d(TAG, " Found ${allNotes.size} notes to backup")
val backupData = BackupData(
backupVersion = BACKUP_VERSION,
createdAt = System.currentTimeMillis(),
notesCount = allNotes.size,
appVersion = BuildConfig.VERSION_NAME,
notes = allNotes
)
val jsonString = gson.toJson(backupData)
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(jsonString.toByteArray())
Logger.d(TAG, "✅ Backup created successfully")
}
BackupResult(
success = true,
notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen"
)
} catch (e: Exception) {
Logger.e(TAG, "Failed to create backup", e)
BackupResult(
success = false,
error = "Backup fehlgeschlagen: ${e.message}"
)
}
}
/**
* Erstellt automatisches Backup (vor Restore)
* Gespeichert in app-internem Storage
*
* @return Uri des Auto-Backups oder null bei Fehler
*/
suspend fun createAutoBackup(): Uri? = withContext(Dispatchers.IO) {
return@withContext try {
val autoBackupDir = File(context.filesDir, AUTO_BACKUP_DIR).apply {
if (!exists()) mkdirs()
}
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
.format(Date())
val filename = "auto_backup_before_restore_$timestamp.json"
val file = File(autoBackupDir, filename)
Logger.d(TAG, "📦 Creating auto-backup: ${file.absolutePath}")
val allNotes = storage.loadAllNotes()
val backupData = BackupData(
backupVersion = BACKUP_VERSION,
createdAt = System.currentTimeMillis(),
notesCount = allNotes.size,
appVersion = BuildConfig.VERSION_NAME,
notes = allNotes
)
file.writeText(gson.toJson(backupData))
// Cleanup alte Auto-Backups
cleanupOldAutoBackups(autoBackupDir)
Logger.d(TAG, "✅ Auto-backup created: ${file.absolutePath}")
Uri.fromFile(file)
} catch (e: Exception) {
Logger.e(TAG, "Failed to create auto-backup", e)
null
}
}
/**
* Stellt Notizen aus Backup wieder her
*
* @param uri Backup-Datei URI
* @param mode Wiederherstellungs-Modus (Merge/Replace/Overwrite)
* @return RestoreResult mit Details
*/
suspend fun restoreBackup(uri: Uri, mode: RestoreMode): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📥 Restoring backup from: $uri (mode: $mode)")
// 1. Backup-Datei lesen
val jsonString = context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.bufferedReader().use { it.readText() }
} ?: return@withContext RestoreResult(
success = false,
error = "Datei konnte nicht gelesen werden"
)
// 2. Backup validieren & parsen
val validationResult = validateBackup(jsonString)
if (!validationResult.isValid) {
return@withContext RestoreResult(
success = false,
error = validationResult.errorMessage ?: "Ungültige Backup-Datei"
)
}
val backupData = gson.fromJson(jsonString, BackupData::class.java)
Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
// 3. Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = createAutoBackup()
if (autoBackupUri == null) {
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
}
// 4. Restore durchführen (je nach Modus)
val result = when (mode) {
RestoreMode.MERGE -> restoreMerge(backupData.notes)
RestoreMode.REPLACE -> restoreReplace(backupData.notes)
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
}
Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
result
} catch (e: Exception) {
Logger.e(TAG, "Failed to restore backup", e)
RestoreResult(
success = false,
error = "Wiederherstellung fehlgeschlagen: ${e.message}"
)
}
}
/**
* Validiert Backup-Datei
*/
private fun validateBackup(jsonString: String): ValidationResult {
return try {
val backupData = gson.fromJson(jsonString, BackupData::class.java)
// Version kompatibel?
if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult(
isValid = false,
errorMessage = "Backup-Version nicht unterstützt " +
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
)
}
// Notizen-Array vorhanden?
if (backupData.notes.isEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = "Backup enthält keine Notizen"
)
}
// Alle Notizen haben ID, title, content?
val invalidNotes = backupData.notes.filter { note ->
note.id.isBlank() || note.title.isBlank()
}
if (invalidNotes.isNotEmpty()) {
return ValidationResult(
isValid = false,
errorMessage = "Backup enthält ${invalidNotes.size} ungültige Notizen"
)
}
ValidationResult(isValid = true)
} catch (e: Exception) {
ValidationResult(
isValid = false,
errorMessage = "Backup-Datei beschädigt oder ungültig: ${e.message}"
)
}
}
/**
* Restore-Modus: MERGE
* Fügt neue Notizen hinzu, behält bestehende
*/
private fun restoreMerge(backupNotes: List<Note>): RestoreResult {
val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet()
val newNotes = backupNotes.filter { it.id !in existingIds }
val skippedNotes = backupNotes.size - newNotes.size
newNotes.forEach { note ->
storage.saveNote(note)
}
return RestoreResult(
success = true,
importedNotes = newNotes.size,
skippedNotes = skippedNotes,
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
)
}
/**
* Restore-Modus: REPLACE
* Löscht alle bestehenden Notizen, importiert Backup
*/
private fun restoreReplace(backupNotes: List<Note>): RestoreResult {
// Alle bestehenden Notizen löschen
storage.deleteAllNotes()
// Backup-Notizen importieren
backupNotes.forEach { note ->
storage.saveNote(note)
}
return RestoreResult(
success = true,
importedNotes = backupNotes.size,
skippedNotes = 0,
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
)
}
/**
* Restore-Modus: OVERWRITE_DUPLICATES
* Backup überschreibt bei ID-Konflikten
*/
private fun restoreOverwriteDuplicates(backupNotes: List<Note>): RestoreResult {
val existingNotes = storage.loadAllNotes()
val existingIds = existingNotes.map { it.id }.toSet()
val newNotes = backupNotes.filter { it.id !in existingIds }
val overwrittenNotes = backupNotes.filter { it.id in existingIds }
// Alle Backup-Notizen speichern (überschreibt automatisch)
backupNotes.forEach { note ->
storage.saveNote(note)
}
return RestoreResult(
success = true,
importedNotes = newNotes.size,
skippedNotes = 0,
overwrittenNotes = overwrittenNotes.size,
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
)
}
/**
* Löscht Auto-Backups älter als RETENTION_DAYS
*/
private fun cleanupOldAutoBackups(autoBackupDir: File) {
try {
val retentionTimeMs = AUTO_BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000L
val cutoffTime = System.currentTimeMillis() - retentionTimeMs
autoBackupDir.listFiles()?.forEach { file ->
if (file.lastModified() < cutoffTime) {
Logger.d(TAG, "🗑️ Deleting old auto-backup: ${file.name}")
file.delete()
}
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to cleanup old backups", e)
}
}
}
/**
* Backup-Daten Struktur (JSON)
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
*/
data class BackupData(
@com.google.gson.annotations.SerializedName("backup_version")
val backupVersion: Int,
@com.google.gson.annotations.SerializedName("created_at")
val createdAt: Long,
@com.google.gson.annotations.SerializedName("notes_count")
val notesCount: Int,
@com.google.gson.annotations.SerializedName("app_version")
val appVersion: String,
val notes: List<Note>
)
/**
* Wiederherstellungs-Modi
*/
enum class RestoreMode {
MERGE, // Bestehende + Neue (Standard)
REPLACE, // Alles löschen + Importieren
OVERWRITE_DUPLICATES // Backup überschreibt bei ID-Konflikten
}
/**
* Backup-Ergebnis
*/
data class BackupResult(
val success: Boolean,
val notesCount: Int = 0,
val message: String? = null,
val error: String? = null
)
/**
* Restore-Ergebnis
*/
data class RestoreResult(
val success: Boolean,
val importedNotes: Int = 0,
val skippedNotes: Int = 0,
val overwrittenNotes: Int = 0,
val message: String? = null,
val error: String? = null
)
/**
* Validierungs-Ergebnis
*/
data class ValidationResult(
val isValid: Boolean,
val errorMessage: String? = null
)

View File

@@ -0,0 +1,81 @@
package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import org.json.JSONArray
import org.json.JSONObject
data class DeletionRecord(
val id: String,
val deletedAt: Long,
val deviceId: String
)
data class DeletionTracker(
val version: Int = 1,
val deletedNotes: MutableList<DeletionRecord> = mutableListOf()
) {
fun addDeletion(noteId: String, deviceId: String) {
if (!deletedNotes.any { it.id == noteId }) {
deletedNotes.add(DeletionRecord(noteId, System.currentTimeMillis(), deviceId))
}
}
fun isDeleted(noteId: String): Boolean {
return deletedNotes.any { it.id == noteId }
}
fun getDeletionTimestamp(noteId: String): Long? {
return deletedNotes.find { it.id == noteId }?.deletedAt
}
fun removeDeletion(noteId: String) {
deletedNotes.removeIf { it.id == noteId }
}
fun toJson(): String {
val jsonObject = JSONObject()
jsonObject.put("version", version)
val notesArray = JSONArray()
for (record in deletedNotes) {
val recordObj = JSONObject()
recordObj.put("id", record.id)
recordObj.put("deletedAt", record.deletedAt)
recordObj.put("deviceId", record.deviceId)
notesArray.put(recordObj)
}
jsonObject.put("deletedNotes", notesArray)
return jsonObject.toString(2) // Pretty print with 2-space indent
}
companion object {
private const val TAG = "DeletionTracker"
fun fromJson(json: String): DeletionTracker? {
return try {
val jsonObject = JSONObject(json)
val version = jsonObject.optInt("version", 1)
val deletedNotes = mutableListOf<DeletionRecord>()
val notesArray = jsonObject.optJSONArray("deletedNotes")
if (notesArray != null) {
for (i in 0 until notesArray.length()) {
val recordObj = notesArray.getJSONObject(i)
val record = DeletionRecord(
id = recordObj.getString("id"),
deletedAt = recordObj.getLong("deletedAt"),
deviceId = recordObj.getString("deviceId")
)
deletedNotes.add(record)
}
}
DeletionTracker(version, deletedNotes)
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}")
null
}
}
}
}

View File

@@ -1,5 +1,10 @@
package dev.dettmer.simplenotes.models package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID import java.util.UUID
data class Note( data class Note(
@@ -25,15 +30,111 @@ 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 {
private const val TAG = "Note"
fun fromJson(json: String): Note? { fun fromJson(json: String): Note? {
return try { return try {
val gson = com.google.gson.Gson() val gson = com.google.gson.Gson()
gson.fromJson(json, Note::class.java) gson.fromJson(json, Note::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
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) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
null
}
}
/**
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
* Format: 2024-12-21T18:00:00Z (UTC)
*/
private fun formatISO8601(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.format(Date(timestamp))
}
/**
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
* Fallback: Aktueller Timestamp bei Fehler
*/
private fun parseISO8601(dateString: String): Long {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
System.currentTimeMillis() // Fallback
}
}
} }
} }

View File

@@ -1,11 +1,18 @@
package dev.dettmer.simplenotes.storage package dev.dettmer.simplenotes.storage
import android.content.Context import android.content.Context
import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import java.io.File import java.io.File
class NotesStorage(private val context: Context) { class NotesStorage(private val context: Context) {
companion object {
private const val TAG = "NotesStorage"
}
private val notesDir: File = File(context.filesDir, "notes").apply { private val notesDir: File = File(context.filesDir, "notes").apply {
if (!exists()) mkdirs() if (!exists()) mkdirs()
} }
@@ -34,19 +41,89 @@ class NotesStorage(private val context: Context) {
fun deleteNote(id: String): Boolean { fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json") val file = File(notesDir, "$id.json")
return file.delete() val deleted = file.delete()
if (deleted) {
Logger.d(TAG, "🗑️ Deleted note: $id")
// Track deletion to prevent zombie notes
val deviceId = DeviceIdGenerator.getDeviceId(context)
trackDeletion(id, deviceId)
}
return deleted
} }
fun deleteAllNotes(): Boolean { fun deleteAllNotes(): Boolean {
return try { return try {
notesDir.listFiles() val notes = loadAllNotes()
?.filter { it.extension == "json" } val deviceId = DeviceIdGenerator.getDeviceId(context)
?.forEach { it.delete() }
for (note in notes) {
deleteNote(note.id) // Uses trackDeletion() automatically
}
Logger.d(TAG, "🗑️ Deleted all notes (${notes.size} notes)")
true true
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to delete all notes", e)
false false
} }
} }
// === Deletion Tracking ===
private fun getDeletionTrackerFile(): File {
return File(context.filesDir, "deleted_notes.json")
}
fun loadDeletionTracker(): DeletionTracker {
val file = getDeletionTrackerFile()
if (!file.exists()) {
return DeletionTracker()
}
return try {
val json = file.readText()
DeletionTracker.fromJson(json) ?: DeletionTracker()
} catch (e: Exception) {
Logger.e(TAG, "Failed to load deletion tracker", e)
DeletionTracker()
}
}
fun saveDeletionTracker(tracker: DeletionTracker) {
try {
val file = getDeletionTrackerFile()
file.writeText(tracker.toJson())
if (tracker.deletedNotes.size > 1000) {
Logger.w(TAG, "⚠️ Deletion tracker large: ${tracker.deletedNotes.size} entries")
}
Logger.d(TAG, "✅ Deletion tracker saved (${tracker.deletedNotes.size} entries)")
} catch (e: Exception) {
Logger.e(TAG, "Failed to save deletion tracker", e)
}
}
fun trackDeletion(noteId: String, deviceId: String) {
val tracker = loadDeletionTracker()
tracker.addDeletion(noteId, deviceId)
saveDeletionTracker(tracker)
Logger.d(TAG, "📝 Tracked deletion: $noteId")
}
fun isNoteDeleted(noteId: String): Boolean {
val tracker = loadDeletionTracker()
return tracker.isDeleted(noteId)
}
fun clearDeletionTracker() {
saveDeletionTracker(DeletionTracker())
Logger.d(TAG, "🗑️ Deletion tracker cleared")
}
fun getNotesDir(): File = notesDir fun getNotesDir(): File = notesDir
} }

View File

@@ -227,7 +227,11 @@ class NetworkMonitor(private val context: Context) {
if (isWifi) { if (isWifi) {
lastConnectedNetworkId = activeNetwork.toString() lastConnectedNetworkId = activeNetwork.toString()
Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId") Logger.d(TAG, " ✅ Initial WiFi network: $lastConnectedNetworkId")
Logger.d(TAG, " 📡 WiFi already connected at startup - onAvailable() will only trigger on network change") Logger.d(
TAG,
" 📡 WiFi already connected at startup - " +
"onAvailable() will only trigger on network change"
)
} else { } else {
lastConnectedNetworkId = null lastConnectedNetworkId = null
Logger.d(TAG, " ⚠️ Not on WiFi at startup") Logger.d(TAG, " ⚠️ Not on WiFi at startup")
@@ -268,7 +272,7 @@ class NetworkMonitor(private val context: Context) {
connectivityManager.unregisterNetworkCallback(networkCallback) connectivityManager.unregisterNetworkCallback(networkCallback)
Logger.d(TAG, "✅ WiFi monitoring stopped") Logger.d(TAG, "✅ WiFi monitoring stopped")
} catch (e: Exception) { } catch (e: Exception) {
// Already unregistered Logger.w(TAG, "NetworkCallback already unregistered: ${e.message}")
} }
} }
} }

View File

@@ -0,0 +1,129 @@
package dev.dettmer.simplenotes.sync
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import dev.dettmer.simplenotes.utils.Logger
/**
* 🔄 v1.3.1: Zentrale Verwaltung des Sync-Status
*
* Verhindert doppelte Syncs und informiert die UI über den aktuellen Status.
* Thread-safe Singleton mit LiveData für UI-Reaktivität.
*/
object SyncStateManager {
private const val TAG = "SyncStateManager"
/**
* Mögliche Sync-Zustände
*/
enum class SyncState {
IDLE, // Kein Sync aktiv
SYNCING, // Sync läuft gerade
COMPLETED, // Sync erfolgreich abgeschlossen (kurz anzeigen)
ERROR // Sync fehlgeschlagen (kurz anzeigen)
}
/**
* Detaillierte Sync-Informationen für UI
*/
data class SyncStatus(
val state: SyncState = SyncState.IDLE,
val message: String? = null,
val source: String? = null, // "manual", "auto", "pullToRefresh", "background"
val timestamp: Long = System.currentTimeMillis()
)
// Private mutable LiveData
private val _syncStatus = MutableLiveData(SyncStatus())
// Public immutable LiveData für Observer
val syncStatus: LiveData<SyncStatus> = _syncStatus
// Lock für Thread-Sicherheit
private val lock = Any()
/**
* Prüft ob gerade ein Sync läuft
*/
val isSyncing: Boolean
get() = _syncStatus.value?.state == SyncState.SYNCING
/**
* Versucht einen Sync zu starten.
* @return true wenn Sync gestartet werden kann, false wenn bereits einer läuft
*/
fun tryStartSync(source: String): Boolean {
synchronized(lock) {
if (isSyncing) {
Logger.d(TAG, "⚠️ Sync already in progress, rejecting new sync from: $source")
return false
}
Logger.d(TAG, "🔄 Starting sync from: $source")
_syncStatus.postValue(
SyncStatus(
state = SyncState.SYNCING,
message = "Synchronisiere...",
source = source
)
)
return true
}
}
/**
* Markiert Sync als erfolgreich abgeschlossen
*/
fun markCompleted(message: String? = null) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.d(TAG, "✅ Sync completed from: $currentSource")
_syncStatus.postValue(
SyncStatus(
state = SyncState.COMPLETED,
message = message,
source = currentSource
)
)
}
}
/**
* Markiert Sync als fehlgeschlagen
*/
fun markError(errorMessage: String?) {
synchronized(lock) {
val currentSource = _syncStatus.value?.source
Logger.e(TAG, "❌ Sync failed from: $currentSource - $errorMessage")
_syncStatus.postValue(
SyncStatus(
state = SyncState.ERROR,
message = errorMessage,
source = currentSource
)
)
}
}
/**
* Setzt Status zurück auf IDLE
*/
fun reset() {
synchronized(lock) {
_syncStatus.postValue(SyncStatus())
}
}
/**
* Aktualisiert die Nachricht während des Syncs (z.B. Progress)
*/
fun updateMessage(message: String) {
synchronized(lock) {
val current = _syncStatus.value ?: return
if (current.state == SyncState.SYNCING) {
_syncStatus.postValue(current.copy(message = message))
}
}
}
}

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,17 @@ 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 +156,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 +173,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 +241,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

@@ -28,6 +28,7 @@ class WifiSyncReceiver : BroadcastReceiver() {
} }
} }
@Suppress("ReturnCount") // Early returns for WiFi validation checks
private fun isConnectedToHomeWifi(context: Context): Boolean { private fun isConnectedToHomeWifi(context: Context): Boolean {
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false

View File

@@ -10,10 +10,26 @@ 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"
// 🔥 v1.3.0: Performance & Multi-Device Sync
const val KEY_ALWAYS_CHECK_SERVER = "always_check_server"
const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server"
// 🔥 v1.3.1: Debug & Logging
const val KEY_FILE_LOGGING_ENABLED = "file_logging_enabled"
// WorkManager // WorkManager
const val SYNC_WORK_TAG = "notes_sync" const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L const val SYNC_DELAY_SECONDS = 5L

View File

@@ -7,6 +7,9 @@ import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
private const val DAYS_THRESHOLD = 7L
private const val TRUNCATE_SUFFIX_LENGTH = 3
// Toast Extensions // Toast Extensions
fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) { fun Context.showToast(message: String, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show() Toast.makeText(this, message, duration).show()
@@ -27,7 +30,7 @@ fun Long.toReadableTime(): String {
val hours = TimeUnit.MILLISECONDS.toHours(diff) val hours = TimeUnit.MILLISECONDS.toHours(diff)
"Vor $hours Std" "Vor $hours Std"
} }
diff < TimeUnit.DAYS.toMillis(7) -> { diff < TimeUnit.DAYS.toMillis(DAYS_THRESHOLD) -> {
val days = TimeUnit.MILLISECONDS.toDays(diff) val days = TimeUnit.MILLISECONDS.toDays(diff)
"Vor $days Tagen" "Vor $days Tagen"
} }
@@ -41,7 +44,7 @@ fun Long.toReadableTime(): String {
// Truncate long strings // Truncate long strings
fun String.truncate(maxLength: Int): String { fun String.truncate(maxLength: Int): String {
return if (length > maxLength) { return if (length > maxLength) {
substring(0, maxLength - 3) + "..." substring(0, maxLength - TRUNCATE_SUFFIX_LENGTH) + "..."
} else { } else {
this this
} }

View File

@@ -5,7 +5,6 @@ import android.util.Log
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import java.io.File import java.io.File
import java.io.FileWriter import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -15,10 +14,34 @@ import java.util.*
*/ */
object Logger { object Logger {
private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge
private var fileLoggingEnabled = false private var fileLoggingEnabled = false
private var logFile: File? = null private var logFile: File? = null
private var appContext: Context? = null
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
private val maxLogEntries = 500 // Nur letzte 500 Einträge
/**
* Setzt den File-Logging Status (für UI Toggle)
*/
fun setFileLoggingEnabled(enabled: Boolean) {
fileLoggingEnabled = enabled
if (!enabled) {
logFile = null
}
}
/**
* Gibt zurück, ob File-Logging aktiviert ist
*/
fun isFileLoggingEnabled(): Boolean = fileLoggingEnabled
/**
* Initialisiert den Logger mit App-Context
*/
fun init(context: Context) {
appContext = context.applicationContext
}
/** /**
* Aktiviert File-Logging für Debugging * Aktiviert File-Logging für Debugging
@@ -50,11 +73,47 @@ object Logger {
*/ */
fun getLogFile(): File? = logFile fun getLogFile(): File? = logFile
/**
* Gibt Log-Datei mit Context zurück (für SettingsActivity)
*/
fun getLogFile(context: Context): File? {
if (logFile == null && fileLoggingEnabled) {
logFile = File(context.filesDir, "simplenotes_debug.log")
}
return logFile
}
/**
* Löscht die Log-Datei
*/
fun clearLogFile(context: Context): Boolean {
return try {
val file = File(context.filesDir, "simplenotes_debug.log")
if (file.exists()) {
file.delete()
logFile = null
true
} else {
false
}
} catch (e: Exception) {
Log.e("Logger", "Failed to clear log file", e)
false
}
}
/** /**
* Schreibt Log-Eintrag in Datei * Schreibt Log-Eintrag in Datei
*/ */
private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) { private fun writeToFile(level: String, tag: String, message: String, throwable: Throwable? = null) {
if (!fileLoggingEnabled || logFile == null) return if (!fileLoggingEnabled) return
// Lazy-init logFile mit appContext
if (logFile == null && appContext != null) {
logFile = File(appContext!!.filesDir, "simplenotes_debug.log")
}
if (logFile == null) return
try { try {
val timestamp = dateFormat.format(Date()) val timestamp = dateFormat.format(Date())
@@ -80,13 +139,13 @@ object Logger {
} }
/** /**
* Begrenzt Log-Datei auf maxLogEntries * Begrenzt Log-Datei auf MAX_LOG_ENTRIES
*/ */
private fun trimLogFile() { private fun trimLogFile() {
try { try {
val lines = logFile?.readLines() ?: return val lines = logFile?.readLines() ?: return
if (lines.size > maxLogEntries) { if (lines.size > MAX_LOG_ENTRIES) {
val trimmed = lines.takeLast(maxLogEntries) val trimmed = lines.takeLast(MAX_LOG_ENTRIES)
logFile?.writeText(trimmed.joinToString("\n") + "\n") logFile?.writeText(trimmed.joinToString("\n") + "\n")
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -6,17 +6,21 @@ 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"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val SYNC_NOTIFICATION_ID = 2 private const val SYNC_NOTIFICATION_ID = 2
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
/** /**
* Erstellt Notification Channel (Android 8.0+) * Erstellt Notification Channel (Android 8.0+)
@@ -38,6 +42,17 @@ object NotificationHelper {
} }
} }
/**
* Löscht alle Sync-Notifications
* Sollte beim App-Start aufgerufen werden um alte Notifications zu entfernen
*/
fun clearSyncNotifications(context: Context) {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
manager.cancel(SYNC_NOTIFICATION_ID)
Logger.d(TAG, "🗑️ Cleared old sync notifications")
}
/** /**
* Zeigt Erfolgs-Notification nach Sync * Zeigt Erfolgs-Notification nach Sync
*/ */
@@ -240,6 +255,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 +282,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")
}, AUTO_CANCEL_TIMEOUT_MS)
}
/**
* Zeigt Warnung wenn Server längere Zeit nicht erreichbar (v1.1.2)
* Throttling: Max. 1 Warnung pro 24h
*/
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
// PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("⚠️ Sync-Warnung")
.setContentText("Server seit ${hoursSinceLastSync}h nicht erreichbar")
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Der WebDAV-Server ist seit ${hoursSinceLastSync} Stunden nicht erreichbar. " +
"Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen."))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
manager.notify(SYNC_NOTIFICATION_ID, notification)
Logger.d(TAG, "⚠️ Showed sync warning: Server unreachable for ${hoursSinceLastSync}h")
} }
} }

View File

@@ -0,0 +1,21 @@
package dev.dettmer.simplenotes.utils
/**
* Exception für Sync-spezifische Fehler
*
* Verwendet anstelle von generischen Exceptions für bessere
* Fehlerbehandlung und klarere Fehlermeldungen.
*/
class SyncException(
message: String,
cause: Throwable? = null
) : Exception(message, cause)
/**
* Exception für Validierungsfehler
*
* Verwendet für ungültige Eingaben oder Konfigurationsfehler.
*/
class ValidationException(
message: String
) : IllegalArgumentException(message)

View File

@@ -0,0 +1,122 @@
package dev.dettmer.simplenotes.utils
import java.net.URL
/**
* URL Validator für Network Security (v1.1.2)
* Erlaubt HTTP nur für lokale Netzwerke (RFC 1918 Private IPs)
*/
object UrlValidator {
// RFC 1918 Private IP Ranges
private const val PRIVATE_CLASS_A_FIRST_OCTET = 10
private const val PRIVATE_CLASS_B_FIRST_OCTET = 172
private const val PRIVATE_CLASS_B_SECOND_OCTET_MIN = 16
private const val PRIVATE_CLASS_B_SECOND_OCTET_MAX = 31
private const val PRIVATE_CLASS_C_FIRST_OCTET = 192
private const val PRIVATE_CLASS_C_SECOND_OCTET = 168
private const val LOCALHOST_FIRST_OCTET = 127
private const val OCTET_MAX_VALUE = 255
/**
* Prüft ob eine URL eine lokale/private Adresse ist
* Erlaubt:
* - 192.168.x.x (Class C private)
* - 10.x.x.x (Class A private)
* - 172.16.x.x - 172.31.x.x (Class B private)
* - 127.x.x.x (Localhost)
* - .local domains (mDNS/Bonjour)
*/
@Suppress("ReturnCount") // Early returns for validation checks are clearer
fun isLocalUrl(url: String): Boolean {
return try {
val parsedUrl = URL(url)
val host = parsedUrl.host.lowercase()
// Check for .local domains (e.g., nas.local)
if (host.endsWith(".local")) {
return true
}
// Check for localhost
if (host == "localhost" || host == "127.0.0.1") {
return true
}
// Parse IP address if it's numeric
val ipPattern = """^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$""".toRegex()
val match = ipPattern.find(host)
if (match != null) {
val octets = match.groupValues.drop(1).map { it.toInt() }
// Validate octets are in range 0-255
if (octets.any { it > OCTET_MAX_VALUE }) {
return false
}
// Extract octets individually (destructuring with 4 elements triggers detekt warning)
val o1 = octets[0]
val o2 = octets[1]
// Check RFC 1918 private IP ranges
return when {
// 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
o1 == PRIVATE_CLASS_A_FIRST_OCTET -> true
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
o1 == PRIVATE_CLASS_B_FIRST_OCTET &&
o2 in PRIVATE_CLASS_B_SECOND_OCTET_MIN..PRIVATE_CLASS_B_SECOND_OCTET_MAX -> true
// 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
o1 == PRIVATE_CLASS_C_FIRST_OCTET &&
o2 == PRIVATE_CLASS_C_SECOND_OCTET -> true
// 127.0.0.0/8 (Localhost)
o1 == LOCALHOST_FIRST_OCTET -> true
else -> false
}
}
// Not a recognized local address
false
} catch (e: Exception) {
Logger.w("UrlValidator", "Failed to parse URL: ${e.message}")
false
}
}
/**
* Validiert ob HTTP URL erlaubt ist
* @return Pair<Boolean, String?> - (isValid, errorMessage)
*/
fun validateHttpUrl(url: String): Pair<Boolean, String?> {
return try {
val parsedUrl = URL(url)
// HTTPS ist immer erlaubt
if (parsedUrl.protocol.equals("https", ignoreCase = true)) {
return Pair(true, null)
}
// HTTP nur für lokale URLs erlaubt
if (parsedUrl.protocol.equals("http", ignoreCase = true)) {
if (isLocalUrl(url)) {
return Pair(true, null)
} else {
return Pair(
false,
"HTTP ist nur für lokale Server erlaubt (z.B. 192.168.x.x, 10.x.x.x, nas.local). " +
"Für öffentliche Server verwende bitte HTTPS."
)
}
}
// Anderes Protokoll
Pair(false, "Ungültiges Protokoll: ${parsedUrl.protocol}. Bitte verwende HTTP oder HTTPS.")
} catch (e: Exception) {
Pair(false, "Ungültige URL: ${e.message}")
}
}
}

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

@@ -22,16 +22,57 @@
app:title="@string/app_name" app:title="@string/app_name"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
<!-- 🔄 v1.3.1: Sync Status Banner -->
<LinearLayout
android:id="@+id/syncStatusBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="8dp"
android:background="?attr/colorPrimaryContainer"
android:visibility="gone">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/syncProgressIndicator"
android:layout_width="24dp"
android:layout_height="24dp"
android:indeterminate="true"
app:indicatorSize="24dp"
app:trackThickness="3dp"
app:indicatorColor="?attr/colorOnPrimaryContainer" />
<TextView
android:id="@+id/syncStatusText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:text="@string/sync_status_syncing"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnPrimaryContainer" />
</LinearLayout>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
<!-- 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/notes"
app:helperTextEnabled="true"
app:boxCornerRadiusTopStart="12dp" app:boxCornerRadiusTopStart="12dp"
app:boxCornerRadiusTopEnd="12dp" app:boxCornerRadiusTopEnd="12dp"
app:boxCornerRadiusBottomStart="12dp" app:boxCornerRadiusBottomStart="12dp"
@@ -185,7 +224,7 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Auto-Sync Settings --> <!-- Material 3 Card: Synchronisation Settings (Auto-Sync + Interval) -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -230,6 +269,7 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
@@ -247,87 +287,22 @@
</LinearLayout> </LinearLayout>
</LinearLayout> <!-- Divider -->
<View
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_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,201 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Markdown Desktop-Integration (v1.2.0) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Markdown Desktop-Integration"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Info Card -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text=" Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnPrimaryContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Markdown Auto-Sync Toggle (fusioniert Export + Auto-Import) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🔄 Markdown Auto-Sync"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchMarkdownAutoSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="false" />
</LinearLayout>
<!-- Auto-Sync Info Text -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Manual Sync Info (nur sichtbar wenn Auto-Sync OFF) -->
<TextView
android:id="@+id/textViewManualSyncInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text="Oder synchronisiere Markdown-Dateien manuell:"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurface"
android:visibility="gone" />
<!-- Manual Sync Button (nur sichtbar wenn Auto-Sync OFF) -->
<Button
android:id="@+id/buttonManualMarkdownSync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Markdown synchronisieren"
android:visibility="gone"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Backup & Restore -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/backup_restore_title"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="12dp" />
<!-- Info Card (anstatt Warning) -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorPrimaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text=" Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt."
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnPrimaryContainer"
android:lineSpacingMultiplier="1.3" />
</com.google.android.material.card.MaterialCardView>
<!-- Lokales Backup Sektion -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Lokales Backup"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="12dp" />
<!-- Backup erstellen Button -->
<Button
android:id="@+id/buttonCreateBackup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📥 Backup erstellen"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Aus Datei wiederherstellen Button -->
<Button
android:id="@+id/buttonRestoreFromFile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📤 Aus Datei wiederherstellen"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutline"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp" />
<!-- Server-Backup Sektion -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Server-Backup"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:layout_marginBottom="12dp" />
<!-- Vom Server wiederherstellen Button -->
<Button
android:id="@+id/buttonRestoreFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🔄 Vom Server wiederherstellen"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: About Section --> <!-- Material 3 Card: About Section -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -579,6 +749,107 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- Material 3 Card: Debug Section -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/Widget.Material3.CardView.Elevated"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- Section Header -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Debug &amp; Diagnose"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:layout_marginBottom="16dp" />
<!-- File Logging Toggle -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardBackgroundColor="?attr/colorSurfaceVariant"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📝 Datei-Logging"
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
android:textColor="?attr/colorPrimary"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Sync-Logs in Datei speichern"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchFileLogging"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- F-Droid Privacy Notice -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/file_logging_privacy_notice"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<!-- Export Logs Button -->
<Button
android:id="@+id/buttonExportLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📤 Logs exportieren &amp; teilen"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_marginBottom="8dp" />
<!-- Clear Logs Button -->
<Button
android:id="@+id/buttonClearLogs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🗑️ Logs löschen"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@@ -15,7 +15,7 @@
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:src="@android:drawable/ic_menu_delete" android:src="@android:drawable/ic_menu_delete"
android:tint="?attr/colorError" app:tint="?attr/colorError"
android:contentDescription="@string/delete" /> android:contentDescription="@string/delete" />
<!-- Title --> <!-- Title -->

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<CheckBox
android:id="@+id/checkboxAlwaysDeleteFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Diese Entscheidung merken"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>

View File

@@ -64,7 +64,7 @@
android:layout_height="18dp" android:layout_height="18dp"
android:layout_gravity="center_vertical" android:layout_gravity="center_vertical"
android:src="@android:drawable/ic_popup_sync" android:src="@android:drawable/ic_popup_sync"
android:tint="?attr/colorPrimary" app:tint="?attr/colorPrimary"
android:contentDescription="@string/sync_status" /> android:contentDescription="@string/sync_status" />
</LinearLayout> </LinearLayout>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -4,6 +4,9 @@
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<!-- Adaptive Icon Background -->
<color name="ic_launcher_background">#f9e9c8</color>
<!-- Material 3 Light Theme Colors --> <!-- Material 3 Light Theme Colors -->
<color name="md_theme_light_primary">#6750A4</color> <color name="md_theme_light_primary">#6750A4</color>
<color name="md_theme_light_onPrimary">#FFFFFF</color> <color name="md_theme_light_onPrimary">#FFFFFF</color>

View File

@@ -57,4 +57,13 @@
<string name="restore_progress">Stelle Notizen wieder her…</string> <string name="restore_progress">Stelle Notizen wieder her…</string>
<string name="restore_success">✓ %d Notizen wiederhergestellt</string> <string name="restore_success">✓ %d Notizen wiederhergestellt</string>
<string name="restore_error">Fehler: %s</string> <string name="restore_error">Fehler: %s</string>
<!-- Sync Status Banner (v1.3.1) -->
<string name="sync_status_syncing">Synchronisiere…</string>
<string name="sync_status_completed">Synchronisierung abgeschlossen</string>
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
<string name="sync_already_running">Synchronisierung läuft bereits</string>
<!-- Debug/Logging Section (v1.3.2) -->
<string name="file_logging_privacy_notice"> Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
</resources> </resources>

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>

View File

@@ -14,4 +14,4 @@ class ExampleUnitTest {
fun addition_isCorrect() { fun addition_isCorrect() {
assertEquals(4, 2 + 2) assertEquals(4, 2 + 2)
} }
} }

View File

@@ -2,4 +2,6 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.ktlint) apply false
alias(libs.plugins.detekt) apply false
} }

View File

@@ -0,0 +1,136 @@
# ⚡ v1.3.1: detekt Configuration
# Pragmatic rules for simple-notes-sync
build:
maxIssues: 100 # Allow existing issues for v1.3.1 release, fix in v1.4.0
excludeCorrectable: false
config:
validation: true
warningsAsErrors: false
comments:
CommentOverPrivateProperty:
active: false
UndocumentedPublicClass:
active: false
UndocumentedPublicFunction:
active: false
complexity:
ComplexCondition:
active: true
threshold: 5
CyclomaticComplexMethod:
active: true
threshold: 15
ignoreSingleWhenExpression: true
LargeClass:
active: true
threshold: 600 # Increased for WebDavSyncService
LongMethod:
active: true
threshold: 80 # Increased for sync methods
LongParameterList:
active: true
functionThreshold: 6
constructorThreshold: 7
NestedBlockDepth:
active: true
threshold: 5
TooManyFunctions:
active: true
thresholdInFiles: 25
thresholdInClasses: 25
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 10
empty-blocks:
EmptyCatchBlock:
active: true
allowedExceptionNameRegex: "_|(ignore|expected).*"
EmptyFunctionBlock:
active: true
ignoreOverridden: true
exceptions:
SwallowedException:
active: true
ignoredExceptionTypes:
- "InterruptedException"
- "MalformedURLException"
- "NumberFormatException"
- "ParseException"
TooGenericExceptionCaught:
active: true
exceptionNames:
- "Error"
- "Throwable"
allowedExceptionNameRegex: "_|(ignore|expected).*"
naming:
FunctionNaming:
active: true
functionPattern: "[a-zA-Z][a-zA-Z0-9]*"
VariableNaming:
active: true
variablePattern: "[a-z][A-Za-z0-9]*"
PackageNaming:
active: true
packagePattern: "[a-z]+(\\.[a-z][A-Za-z0-9]*)*"
performance:
SpreadOperator:
active: false # Spread operator is fine in most cases
potential-bugs:
CastToNullableType:
active: true
EqualsWithHashCodeExist:
active: true
UnconditionalJumpStatementInLoop:
active: true
style:
ForbiddenComment:
active: true
comments:
- "FIXME:"
- "STOPSHIP:"
allowedPatterns: ""
MagicNumber:
active: true
ignoreNumbers:
- "-1"
- "0"
- "1"
- "2"
- "100"
- "1000"
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreLocalVariableDeclaration: true
ignoreAnnotation: true
ignoreEnums: true
ignoreRanges: true
ignoreExtensionFunctions: true
MaxLineLength:
active: true
maxLineLength: 120
excludePackageStatements: true
excludeImportStatements: true
ReturnCount:
active: true
max: 4
excludedFunctions: []
excludeLabeled: true
excludeReturnFromLambda: true
excludeGuardClauses: true
UnusedImports:
active: true
UnusedPrivateMember:
active: true
allowedNames: "_.*"
WildcardImport:
active: false # Allow wildcard imports

View File

@@ -9,6 +9,8 @@ appcompat = "1.6.1"
material = "1.10.0" material = "1.10.0"
activity = "1.8.0" activity = "1.8.0"
constraintlayout = "2.1.4" constraintlayout = "2.1.4"
ktlint = "12.1.0"
detekt = "1.23.4"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -23,4 +25,6 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }

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

324
docs/BACKUP.en.md Normal file
View File

@@ -0,0 +1,324 @@
# Backup & Restore 💾
**🌍 Languages:** [Deutsch](BACKUP.md) · **English**
> Secure your notes locally - independent from the server
---
## 📋 Overview
The backup system works **completely offline** and independent from the WebDAV server. Perfect for:
- 📥 Regular backups
- 📤 Migration to new server
- 🔄 Recovery after data loss
- 💾 Archiving old notes
---
## 📥 Create Backup
### Step-by-Step
1. **Open settings** (⚙️ icon top right)
2. **Find "Backup & Restore"** section
3. **Tap "📥 Create backup"**
4. **Choose location:**
- 📁 Downloads
- 💳 SD card
- ☁️ Cloud folder (Nextcloud, Google Drive, etc.)
- 📧 Email as attachment
5. **Done!** Backup file is saved
### File Format
**Filename:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
**Example:** `simplenotes_backup_2026-01-05_143022.json`
**Content:**
```json
{
"version": "1.2.1",
"exported_at": "2026-01-05T14:30:22Z",
"notes_count": 42,
"notes": [
{
"id": "abc-123-def",
"title": "Shopping List",
"content": "Milk\nBread\nCheese",
"createdAt": 1704467422000,
"updatedAt": 1704467422000
}
]
}
```
**Format details:**
- ✅ Human-readable (formatted JSON)
- ✅ All data included (title, content, IDs, timestamps)
- ✅ Version info for compatibility
- ✅ Note count for validation
---
## 📤 Restore Backup
### 3 Restore Modes
#### 1. Merge ⭐ _Recommended_
**What happens:**
- ✅ New notes from backup are added
- ✅ Existing notes remain unchanged
- ✅ No data loss
**When to use:**
- Import backup from another device
- Recover old notes
- Restore accidentally deleted notes
**Example:**
```
App: [Note A, Note B, Note C]
Backup: [Note A, Note D, Note E]
Result: [Note A, Note B, Note C, Note D, Note E]
```
#### 2. Replace
**What happens:**
- ❌ ALL existing notes are deleted
- ✅ Backup notes are imported
- ⚠️ Irreversible (except through auto-backup)
**When to use:**
- Server migration (complete restart)
- Return to old backup state
- App reinstallation
**Example:**
```
App: [Note A, Note B, Note C]
Backup: [Note X, Note Y]
Result: [Note X, Note Y]
```
**⚠️ Warning:** Automatic safety backup is created!
#### 3. Overwrite Duplicates
**What happens:**
- ✅ New notes from backup are added
- 🔄 On ID conflicts, backup wins
- ✅ Other notes remain unchanged
**When to use:**
- Backup is newer than app data
- Import desktop changes
- Conflict resolution
**Example:**
```
App: [Note A (v1), Note B, Note C]
Backup: [Note A (v2), Note D]
Result: [Note A (v2), Note B, Note C, Note D]
```
### Restore Process
1. **Settings****"📤 Restore from file"**
2. **Select backup file** (`.json`)
3. **Choose mode:**
- 🔵 Merge _(Default)_
- 🟡 Overwrite duplicates
- 🔴 Replace _(Caution!)_
4. **Confirm** - Automatic safety backup is created
5. **Wait** - Import runs
6. **Done!** - Success message with number of imported notes
---
## 🛡️ Automatic Safety Backup
**Before every restore:**
- ✅ Automatic backup is created
- 📁 Saved in: `Android/data/dev.dettmer.simplenotes/files/`
- 🏷️ Filename: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
- ⏱️ Timestamp: Right before restore
**Why?**
- Protection against accidental "Replace"
- Ability to undo
- Double security
**Access via file manager:**
```
/Android/data/dev.dettmer.simplenotes/files/auto_backup_before_restore_*.json
```
---
## 💡 Best Practices
### Backup Strategy
#### Regular Backups
```
Daily: ❌ Too often (server sync is enough)
Weekly: ✅ Recommended for important notes
Monthly: ✅ Archiving
Before updates: ✅ Safety
```
#### 3-2-1 Rule
1. **3 copies** - Original + 2 backups
2. **2 media** - e.g., SD card + cloud
3. **1 offsite** - e.g., cloud storage
### Backup Locations
**Local (fast):**
- 📱 Internal storage / Downloads
- 💳 SD card
- 🖥️ PC (via USB)
**Cloud (secure):**
- ☁️ Nextcloud (self-hosted)
- 📧 Email to yourself
- 🗄️ Syncthing (sync between devices)
**⚠️ Avoid:**
- ❌ Google Drive / Dropbox (privacy)
- ❌ Only one copy
- ❌ Only on server (if server fails)
---
## 🔧 Advanced Usage
### Edit Backup File
The `.json` file can be edited with any text editor:
1. **Open with:** VS Code, Notepad++, nano
2. **Add/remove notes**
3. **Change title/content**
4. **Adjust IDs** (for migration)
5. **Save** and import to app
**⚠️ Important:**
- Keep valid JSON format
- IDs must be unique (UUIDs)
- Timestamps in milliseconds (Unix Epoch)
### Bulk Import
Merge multiple backups:
1. Import backup 1 (Mode: Merge)
2. Import backup 2 (Mode: Merge)
3. Import backup 3 (Mode: Merge)
4. Result: All notes combined
### Server Migration
Step-by-step:
1. **Create backup** on old server
2. **Set up new server** (see [QUICKSTART.en.md](QUICKSTART.en.md))
3. **Change server URL** in app settings
4. **Restore backup** (Mode: Replace)
5. **Test sync** - All notes on new server
---
## ❌ Troubleshooting
### "Invalid backup file"
**Causes:**
- Corrupt JSON file
- Wrong file extension (must be `.json`)
- Incompatible app version
**Solution:**
1. Check JSON file with validator (e.g., jsonlint.com)
2. Verify file extension
3. Create backup with current app version
### "No permission to save"
**Causes:**
- Storage permission missing
- Write-protected folder
**Solution:**
1. Android: Settings → Apps → Simple Notes → Permissions
2. Activate "Storage"
3. Choose different location
### "Import failed"
**Causes:**
- Not enough storage space
- Corrupt backup file
- App crash during import
**Solution:**
1. Free up storage space
2. Create new backup file
3. Restart app and try again
---
## 🔒 Security & Privacy
### Data Protection
-**Locally stored** - No cloud upload without your action
-**No encryption** - Plain text format for readability
- ⚠️ **Sensitive data?** - Encrypt backup file yourself (e.g., 7-Zip with password)
### Recommendations
- 🔐 Store backup files in encrypted container
- 🗑️ Regularly delete old backups
- 📧 Don't send via unencrypted email
- ☁️ Use self-hosted cloud (Nextcloud)
---
## 📊 Technical Details
### Format Specification
**JSON structure:**
```json
{
"version": "string", // App version at export
"exported_at": "ISO8601", // Export timestamp
"notes_count": number, // Number of notes
"notes": [
{
"id": "UUID", // Unique ID
"title": "string", // Note title
"content": "string", // Note content
"createdAt": number, // Unix timestamp (ms)
"updatedAt": number // Unix timestamp (ms)
}
]
}
```
### Compatibility
- ✅ v1.2.0+ - Fully compatible
- ⚠️ v1.1.x - Basic functions (without auto-backup)
- ❌ v1.0.x - Not supported
---
**📚 See also:**
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App installation and setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [DESKTOP.en.md](DESKTOP.en.md) - Desktop integration with Markdown
**Last update:** v1.2.1 (2026-01-05)

324
docs/BACKUP.md Normal file
View File

@@ -0,0 +1,324 @@
# Backup & Wiederherstellung 💾
**🌍 Languages:** **Deutsch** · [English](BACKUP.en.md)
> Sichere deine Notizen lokal - unabhängig vom Server
---
## 📋 Übersicht
Das Backup-System funktioniert **komplett offline** und unabhängig vom WebDAV-Server. Perfekt für:
- 📥 Regelmäßige Sicherungen
- 📤 Migration zu neuem Server
- 🔄 Wiederherstellung nach Datenverlust
- 💾 Archivierung alter Notizen
---
## 📥 Backup erstellen
### Schritt-für-Schritt
1. **Einstellungen öffnen** (⚙️ Icon oben rechts)
2. **"Backup & Wiederherstellung"** Section finden
3. **"📥 Backup erstellen"** antippen
4. **Speicherort wählen:**
- 📁 Downloads
- 💳 SD-Karte
- ☁️ Cloud-Ordner (Nextcloud, Google Drive, etc.)
- 📧 E-Mail als Anhang
5. **Fertig!** Backup-Datei ist gespeichert
### Dateiformat
**Dateiname:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
**Beispiel:** `simplenotes_backup_2026-01-05_143022.json`
**Inhalt:**
```json
{
"version": "1.2.1",
"exported_at": "2026-01-05T14:30:22Z",
"notes_count": 42,
"notes": [
{
"id": "abc-123-def",
"title": "Einkaufsliste",
"content": "Milch\nBrot\nKäse",
"createdAt": 1704467422000,
"updatedAt": 1704467422000
}
]
}
```
**Format-Details:**
- ✅ Menschenlesbar (formatiertes JSON)
- ✅ Alle Daten inklusive (Titel, Inhalt, IDs, Timestamps)
- ✅ Versions-Info für Kompatibilität
- ✅ Anzahl der Notizen für Validierung
---
## 📤 Backup wiederherstellen
### 3 Wiederherstellungs-Modi
#### 1. Zusammenführen (Merge) ⭐ _Empfohlen_
**Was passiert:**
- ✅ Neue Notizen aus Backup werden hinzugefügt
- ✅ Bestehende Notizen bleiben unverändert
- ✅ Keine Datenverluste
**Wann nutzen:**
- Backup von anderem Gerät einspielen
- Alte Notizen zurückholen
- Versehentlich gelöschte Notizen wiederherstellen
**Beispiel:**
```
App: [Notiz A, Notiz B, Notiz C]
Backup: [Notiz A, Notiz D, Notiz E]
Ergebnis: [Notiz A, Notiz B, Notiz C, Notiz D, Notiz E]
```
#### 2. Ersetzen (Replace)
**Was passiert:**
- ❌ ALLE bestehenden Notizen werden gelöscht
- ✅ Backup-Notizen werden importiert
- ⚠️ Unwiderruflich (außer durch Auto-Backup)
**Wann nutzen:**
- Server-Wechsel (kompletter Neustart)
- Zurück zu altem Backup-Stand
- App-Neuinstallation
**Beispiel:**
```
App: [Notiz A, Notiz B, Notiz C]
Backup: [Notiz X, Notiz Y]
Ergebnis: [Notiz X, Notiz Y]
```
**⚠️ Warnung:** Automatisches Sicherheits-Backup wird erstellt!
#### 3. Duplikate überschreiben (Overwrite)
**Was passiert:**
- ✅ Neue Notizen aus Backup werden hinzugefügt
- 🔄 Bei ID-Konflikten gewinnt das Backup
- ✅ Andere Notizen bleiben unverändert
**Wann nutzen:**
- Backup ist neuer als App-Daten
- Desktop-Änderungen einspielen
- Konflikt-Auflösung
**Beispiel:**
```
App: [Notiz A (v1), Notiz B, Notiz C]
Backup: [Notiz A (v2), Notiz D]
Ergebnis: [Notiz A (v2), Notiz B, Notiz C, Notiz D]
```
### Wiederherstellungs-Prozess
1. **Einstellungen****"📤 Aus Datei wiederherstellen"**
2. **Backup-Datei auswählen** (`.json`)
3. **Modus wählen:**
- 🔵 Zusammenführen _(Standard)_
- 🟡 Duplikate überschreiben
- 🔴 Ersetzen _(Vorsicht!)_
4. **Bestätigen** - Automatisches Sicherheits-Backup wird erstellt
5. **Warten** - Import läuft
6. **Fertig!** - Erfolgsmeldung mit Anzahl importierter Notizen
---
## 🛡️ Automatisches Sicherheits-Backup
**Vor jeder Wiederherstellung:**
- ✅ Automatisches Backup wird erstellt
- 📁 Gespeichert in: `Android/data/dev.dettmer.simplenotes/files/`
- 🏷️ Dateiname: `auto_backup_before_restore_YYYY-MM-DD_HHmmss.json`
- ⏱️ Zeitstempel: Direkt vor Wiederherstellung
**Warum?**
- Schutz vor versehentlichem "Ersetzen"
- Möglichkeit zum Rückgängigmachen
- Doppelte Sicherheit
**Zugriff via Dateimanager:**
```
/Android/data/dev.dettmer.simplenotes/files/auto_backup_before_restore_*.json
```
---
## 💡 Best Practices
### Backup-Strategie
#### Regelmäßige Backups
```
Täglich: ❌ Zu oft (Server-Sync reicht)
Wöchentlich: ✅ Empfohlen für wichtige Notizen
Monatlich: ✅ Archivierung
Vor Updates: ✅ Sicherheit
```
#### 3-2-1 Regel
1. **3 Kopien** - Original + 2 Backups
2. **2 Medien** - z.B. SD-Karte + Cloud
3. **1 Offsite** - z.B. Cloud-Speicher
### Backup-Speicherorte
**Lokal (schnell):**
- 📱 Internal Storage / Downloads
- 💳 SD-Karte
- 🖥️ PC (via USB)
**Cloud (sicher):**
- ☁️ Nextcloud (Self-Hosted)
- 📧 E-Mail an sich selbst
- 🗄️ Syncthing (Sync zwischen Geräten)
**⚠️ Vermeiden:**
- ❌ Google Drive / Dropbox (Privacy)
- ❌ Nur eine Kopie
- ❌ Nur auf Server (wenn Server ausfällt)
---
## 🔧 Erweiterte Nutzung
### Backup-Datei bearbeiten
Die `.json` Datei kann mit jedem Texteditor bearbeitet werden:
1. **Öffnen mit:** VS Code, Notepad++, nano
2. **Notizen hinzufügen/entfernen**
3. **Titel/Inhalt ändern**
4. **IDs anpassen** (für Migration)
5. **Speichern** und in App importieren
**⚠️ Wichtig:**
- Valides JSON-Format behalten
- IDs müssen eindeutig sein (UUIDs)
- Timestamps in Millisekunden (Unix Epoch)
### Bulk-Import
Mehrere Backups zusammenführen:
1. Backup 1 importieren (Modus: Zusammenführen)
2. Backup 2 importieren (Modus: Zusammenführen)
3. Backup 3 importieren (Modus: Zusammenführen)
4. Ergebnis: Alle Notizen vereint
### Server-Migration
Schritt-für-Schritt:
1. **Backup erstellen** auf altem Server
2. **Neuen Server einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
3. **Server-URL ändern** in App-Einstellungen
4. **Backup wiederherstellen** (Modus: Ersetzen)
5. **Sync testen** - Alle Notizen auf neuem Server
---
## ❌ Fehlerbehebung
### "Backup-Datei ungültig"
**Ursachen:**
- Korrupte JSON-Datei
- Falsche Datei-Endung (muss `.json` sein)
- Inkompatible App-Version
**Lösung:**
1. JSON-Datei mit Validator prüfen (z.B. jsonlint.com)
2. Dateiendung überprüfen
3. Backup mit aktueller App-Version erstellen
### "Keine Berechtigung zum Speichern"
**Ursachen:**
- Speicher-Berechtigung fehlt
- Schreibgeschützter Ordner
**Lösung:**
1. Android: Einstellungen → Apps → Simple Notes → Berechtigungen
2. "Speicher" aktivieren
3. Anderen Speicherort wählen
### "Import fehlgeschlagen"
**Ursachen:**
- Zu wenig Speicherplatz
- Korrupte Backup-Datei
- App-Crash während Import
**Lösung:**
1. Speicherplatz freigeben
2. Backup-Datei neu erstellen
3. App neu starten und erneut importieren
---
## 🔒 Sicherheit & Privacy
### Daten-Schutz
-**Lokal gespeichert** - Kein Cloud-Upload ohne deine Aktion
-**Keine Verschlüsselung** - Klartextformat für Lesbarkeit
- ⚠️ **Sensible Daten?** - Backup-Datei selbst verschlüsseln (z.B. 7-Zip mit Passwort)
### Empfehlungen
- 🔐 Backup-Dateien in verschlüsseltem Container speichern
- 🗑️ Alte Backups regelmäßig löschen
- 📧 Nicht per unverschlüsselter E-Mail versenden
- ☁️ Self-Hosted Cloud nutzen (Nextcloud)
---
## 📊 Technische Details
### Format-Spezifikation
**JSON-Struktur:**
```json
{
"version": "string", // App-Version beim Export
"exported_at": "ISO8601", // Zeitstempel des Exports
"notes_count": number, // Anzahl der Notizen
"notes": [
{
"id": "UUID", // Eindeutige ID
"title": "string", // Notiz-Titel
"content": "string", // Notiz-Inhalt
"createdAt": number, // Unix Timestamp (ms)
"updatedAt": number // Unix Timestamp (ms)
}
]
}
```
### Kompatibilität
- ✅ v1.2.0+ - Vollständig kompatibel
- ⚠️ v1.1.x - Grundfunktionen (ohne Auto-Backup)
- ❌ v1.0.x - Nicht unterstützt
---
**📚 Siehe auch:**
- [QUICKSTART.md](../QUICKSTART.md) - App-Installation und Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [DESKTOP.md](DESKTOP.md) - Desktop-Integration mit Markdown
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)

505
docs/DESKTOP.en.md Normal file
View File

@@ -0,0 +1,505 @@
# Desktop Integration 🖥️
**🌍 Languages:** [Deutsch](DESKTOP.md) · **English**
> Edit your notes with any Markdown editor on desktop
---
## 📋 Overview
Desktop integration allows you to edit notes on PC/Mac:
- 📝 Any Markdown editor works
- 🔄 Automatic synchronization via WebDAV
- 💾 Dual-format: JSON (master) + Markdown (mirror)
- ⚡ Last-Write-Wins conflict resolution
---
## 🎯 Why Markdown?
### Dual-Format Architecture
```
┌─────────────────────────────────────┐
│ Android App │
│ │
│ ┌──────────┐ ┌─────────────┐ │
│ │ JSON │ ──→ │ Markdown │ │
│ │ (Master) │ │ (Mirror) │ │
│ └──────────┘ └─────────────┘ │
└────────┬────────────────┬───────────┘
│ │
↓ ↓
WebDAV Server
│ │
┌────┴────┐ ┌────┴──────┐
│ /notes/ │ │ /notes-md/│
│ *.json │ │ *.md │
└─────────┘ └───────────┘
↑ ↑
│ │
┌────┴────────────────┴───────────┐
│ Desktop Editor │
│ (VS Code, Typora, etc.) │
└──────────────────────────────────┘
```
### Advantages
**JSON (Master):**
- ✅ Reliable and fast
- ✅ Structured data (IDs, timestamps)
- ✅ Primary sync mechanism
- ✅ Always active
**Markdown (Mirror):**
- ✅ Human-readable
- ✅ Desktop editor compatible
- ✅ Syntax highlighting
- ✅ Optionally activatable
---
## 🚀 Quick Start
### 1. First Synchronization
**Important:** Perform a sync FIRST before activating desktop integration!
1. **Set up app** (see [QUICKSTART.en.md](QUICKSTART.en.md))
2. **Test server connection**
3. **Create first note**
4. **Synchronize** (pull-to-refresh or auto-sync)
5. ✅ Server automatically creates `/notes/` and `/notes-md/` folders
### 2. Activate Desktop Integration
1. **Settings****Desktop Integration**
2. **Toggle ON**
3. **Initial export starts** - Shows progress (X/Y)
4. ✅ All existing notes are exported as `.md`
### 3. Mount WebDAV as Network Drive
#### Windows
```
1. Open Explorer
2. Right-click on "This PC"
3. "Map network drive"
4. Enter URL: http://YOUR-SERVER:8080/notes-md/
5. Username: noteuser
6. Password: (your WebDAV password)
7. Drive letter: Z:\ (or any)
8. Done!
```
**Access:** `Z:\` in Explorer
#### macOS
```
1. Open Finder
2. Menu "Go" → "Connect to Server" (⌘K)
3. Server address: http://YOUR-SERVER:8080/notes-md/
4. Connect
5. Username: noteuser
6. Password: (your WebDAV password)
7. Done!
```
**Access:** Finder → Network → notes-md
#### Linux (GNOME)
```
1. Open Files / Nautilus
2. "Other Locations"
3. "Connect to Server"
4. Server address: dav://YOUR-SERVER:8080/notes-md/
5. Username: noteuser
6. Password: (your WebDAV password)
7. Done!
```
**Access:** `/run/user/1000/gvfs/dav:host=...`
#### Linux (davfs2 - permanent)
```bash
# Installation
sudo apt install davfs2
# Create mount point
sudo mkdir -p /mnt/notes-md
# Mount once
sudo mount -t davfs http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md
# Permanent in /etc/fstab
echo "http://YOUR-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
```
**Access:** `/mnt/notes-md/`
---
## 📝 Markdown Editors
### Recommended Editors
#### 1. VS Code ⭐ _Recommended_
**Advantages:**
- ✅ Free & open source
- ✅ Markdown preview (Ctrl+Shift+V)
- ✅ Syntax highlighting
- ✅ Git integration
- ✅ Extensions (spell check, etc.)
**Setup:**
```
1. Install VS Code
2. Mount WebDAV drive
3. Open folder: Z:\notes-md\ (Windows) or /mnt/notes-md (Linux)
4. Done! Edit Markdown files
```
**Extensions (optional):**
- `Markdown All in One` - Shortcuts & preview
- `Markdown Preview Enhanced` - Better preview
- `Code Spell Checker` - Spell checking
#### 2. Typora
**Advantages:**
- ✅ WYSIWYG Markdown editor
- ✅ Minimalist design
- ✅ Live preview
- ⚠️ Paid (~15€)
**Setup:**
```
1. Install Typora
2. Mount WebDAV
3. Open folder in Typora
4. Edit notes
```
#### 3. Notepad++
**Advantages:**
- ✅ Lightweight
- ✅ Fast
- ✅ Syntax highlighting
- ⚠️ No Markdown preview
**Setup:**
```
1. Install Notepad++
2. Mount WebDAV
3. Open files directly
```
#### 4. Obsidian
**Advantages:**
- ✅ Second brain philosophy
- ✅ Graph view for links
- ✅ Many plugins
- ⚠️ Sync conflicts possible (2 masters)
**Setup:**
```
1. Install Obsidian
2. Open WebDAV as vault
3. Caution: Obsidian creates own metadata!
```
**⚠️ Not recommended:** Can alter frontmatter
---
## 📄 Markdown File Format
### Structure
Each note is exported as `.md` file with YAML frontmatter:
```markdown
---
id: abc-123-def-456
created: 2026-01-05T14:30:22Z
updated: 2026-01-05T14:30:22Z
tags: []
---
# Note Title
Note content here...
```
### Frontmatter Fields
| Field | Type | Description | Required |
|-------|------|-------------|----------|
| `id` | UUID | Unique note ID | ✅ Yes |
| `created` | ISO8601 | Creation date | ✅ Yes |
| `updated` | ISO8601 | Modification date | ✅ Yes |
| `tags` | Array | Tags (future) | ❌ No |
### Filenames
**Sanitization rules:**
```
Title: "My Shopping List 🛒"
→ Filename: "My_Shopping_List.md"
Removed:
- Emojis: 🛒 → removed
- Special chars: / \ : * ? " < > | → removed
- Multiple spaces → single space
- Spaces → underscore _
```
**Examples:**
```
"Meeting Notes 2026" → "Meeting_Notes_2026.md"
"To-Do: Project" → "To-Do_Project.md"
"Vacation ☀️" → "Vacation.md"
```
---
## 🔄 Synchronization
### Workflow: Android → Desktop
1. **Create/edit note in app**
2. **Run sync** (auto or manual)
3. **JSON is uploaded** (`/notes/abc-123.json`)
4. **Markdown is exported** (`/notes-md/Note_Title.md`) _(only if Desktop Integration ON)_
5. **Desktop editor shows changes** (after refresh)
### Workflow: Desktop → Android
1. **Edit Markdown file** (in mounted folder)
2. **Save** - File is immediately on server
3. **In app: Run Markdown import**
- Settings → "Import Markdown Changes"
- Or: Auto-import on every sync (future)
4. **App adopts changes** (if desktop version is newer)
### Conflict Resolution: Last-Write-Wins
**Rule:** Newest version (by `updated` timestamp) wins
**Example:**
```
App version: updated: 2026-01-05 14:00
Desktop version: updated: 2026-01-05 14:30
→ Desktop wins (newer timestamp)
```
**Automatic:**
- ✅ On Markdown import
- ✅ On JSON sync
- ⚠️ No merge conflicts - only complete overwrite
---
## ⚙️ Settings
### Desktop Integration Toggle
**Settings → Desktop Integration**
**ON (activated):**
- ✅ New notes → automatically exported as `.md`
- ✅ Updated notes → `.md` update
- ✅ Deleted notes → `.md` remains (future: also delete)
**OFF (deactivated):**
- ❌ No Markdown export
- ✅ JSON sync continues normally
- ✅ Existing `.md` files remain
### Initial Export
**What happens on activation:**
1. All existing notes are scanned
2. Progress dialog shows progress (e.g., "23/42")
3. Each note is exported as `.md`
4. On errors: Individual note is skipped
5. Success message with number of exported notes
**Time:** ~1-2 seconds per 50 notes
---
## 🛠️ Advanced Usage
### Manual Markdown Creation
You can create `.md` files manually:
```markdown
---
id: 00000000-0000-0000-0000-000000000001
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
# New Desktop Note
Content here...
```
**⚠️ Important:**
- `id` must be valid UUID (e.g., with uuidgen.io)
- Timestamps in ISO8601 format
- Frontmatter enclosed with `---`
### Bulk Operations
**Edit multiple notes at once:**
1. Mount WebDAV
2. Open all `.md` files in VS Code
3. Find & Replace across all files (Ctrl+Shift+H)
4. Save
5. In app: "Import Markdown Changes"
### Scripting
**Example: Sort all notes by date**
```bash
#!/bin/bash
cd /mnt/notes-md/
# Sort all .md files by update date
for file in *.md; do
updated=$(grep "^updated:" "$file" | cut -d' ' -f2)
echo "$updated $file"
done | sort
```
---
## ❌ Troubleshooting
### "404 Not Found" when mounting WebDAV
**Cause:** `/notes-md/` folder doesn't exist
**Solution:**
1. **Perform first sync** - Folder is created automatically
2. OR: Create manually via terminal:
```bash
curl -X MKCOL -u noteuser:password http://server:8080/notes-md/
```
### Markdown files don't appear
**Cause:** Desktop integration not activated
**Solution:**
1. Settings → "Desktop Integration" ON
2. Wait for initial export
3. Refresh WebDAV folder
### Changes from desktop don't appear in app
**Cause:** Markdown import not executed
**Solution:**
1. Settings → "Import Markdown Changes"
2. OR: Wait for auto-sync (future feature)
### "Frontmatter missing" error
**Cause:** `.md` file without valid YAML frontmatter
**Solution:**
1. Open file in editor
2. Add frontmatter at the beginning:
```yaml
---
id: NEW-UUID-HERE
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
```
3. Save and import again
---
## 🔒 Security & Best Practices
### Do's ✅
- ✅ **Backup before bulk edits** - Create local backup
- ✅ **One editor at a time** - Don't edit in app AND desktop in parallel
- ✅ **Wait for sync** - Run sync before desktop editing
- ✅ **Respect frontmatter** - Don't change manually (unless you know what you're doing)
### Don'ts ❌
- ❌ **Parallel editing** - App and desktop simultaneously → conflicts
- ❌ **Delete frontmatter** - Note can't be imported anymore
- ❌ **Change IDs** - Note is recognized as new
- ❌ **Manipulate timestamps** - Conflict resolution doesn't work
### Recommended Workflow
```
1. Sync in app (pull-to-refresh)
2. Open desktop
3. Make changes
4. Save
5. In app: "Import Markdown Changes"
6. Verify
7. Run another sync
```
---
## 📊 Comparison: JSON vs Markdown
| Aspect | JSON | Markdown |
|--------|------|----------|
| **Format** | Structured | Flowing text |
| **Readability (human)** | ⚠️ Medium | ✅ Good |
| **Readability (machine)** | ✅ Perfect | ⚠️ Parsing needed |
| **Metadata** | Native | Frontmatter |
| **Editors** | Code editors | All text editors |
| **Sync speed** | ✅ Fast | ⚠️ Slower |
| **Reliability** | ✅ 100% | ⚠️ Frontmatter errors possible |
| **Mobile-first** | ✅ Yes | ❌ No |
| **Desktop-first** | ❌ No | ✅ Yes |
**Conclusion:** Using both formats = Best experience on both platforms!
---
## 🔮 Future Features
Planned for v1.3.0+:
-**Auto-Markdown-import** - Automatically on every sync
-**Bidirectional sync** - Without manual import
-**Markdown preview** - In the app
-**Conflict UI** - On simultaneous changes
-**Tags in frontmatter** - Synchronized with app
-**Attachments** - Images/files in Markdown
---
**📚 See also:**
- [QUICKSTART.en.md](../QUICKSTART.en.md) - App setup
- [FEATURES.en.md](FEATURES.en.md) - Complete feature list
- [BACKUP.en.md](BACKUP.en.md) - Backup & restore
**Last update:** v1.2.1 (2026-01-05)

505
docs/DESKTOP.md Normal file
View File

@@ -0,0 +1,505 @@
# Desktop-Integration 🖥️
**🌍 Languages:** **Deutsch** · [English](DESKTOP.en.md)
> Bearbeite deine Notizen mit jedem Markdown-Editor auf dem Desktop
---
## 📋 Übersicht
Die Desktop-Integration ermöglicht dir, Notizen auf dem PC/Mac zu bearbeiten:
- 📝 Jeder Markdown-Editor funktioniert
- 🔄 Automatische Synchronisation über WebDAV
- 💾 Dual-Format: JSON (Master) + Markdown (Mirror)
- ⚡ Last-Write-Wins Konfliktauflösung
---
## 🎯 Warum Markdown?
### Dual-Format Architektur
```
┌─────────────────────────────────────┐
│ Android App │
│ │
│ ┌──────────┐ ┌─────────────┐ │
│ │ JSON │ ──→ │ Markdown │ │
│ │ (Master) │ │ (Mirror) │ │
│ └──────────┘ └─────────────┘ │
└────────┬────────────────┬───────────┘
│ │
↓ ↓
WebDAV Server
│ │
┌────┴────┐ ┌────┴──────┐
│ /notes/ │ │ /notes-md/│
│ *.json │ │ *.md │
└─────────┘ └───────────┘
↑ ↑
│ │
┌────┴────────────────┴───────────┐
│ Desktop Editor │
│ (VS Code, Typora, etc.) │
└──────────────────────────────────┘
```
### Vorteile
**JSON (Master):**
- ✅ Zuverlässig und schnell
- ✅ Strukturierte Daten (IDs, Timestamps)
- ✅ Primärer Sync-Mechanismus
- ✅ Immer aktiv
**Markdown (Mirror):**
- ✅ Menschenlesbar
- ✅ Desktop-Editor kompatibel
- ✅ Syntax-Highlighting
- ✅ Optional aktivierbar
---
## 🚀 Schnellstart
### 1. Erste Synchronisation
**Wichtig:** Führe ZUERST einen Sync durch, bevor du Desktop-Integration aktivierst!
1. **App einrichten** (siehe [QUICKSTART.md](QUICKSTART.md))
2. **Server-Verbindung testen**
3. **Erste Notiz erstellen**
4. **Synchronisieren** (Pull-to-Refresh oder Auto-Sync)
5. ✅ Server erstellt automatisch `/notes/` und `/notes-md/` Ordner
### 2. Desktop-Integration aktivieren
1. **Einstellungen****Desktop-Integration**
2. **Toggle aktivieren**
3. **Initial Export startet** - Zeigt Progress (X/Y)
4. ✅ Alle bestehenden Notizen werden als `.md` exportiert
### 3. WebDAV als Netzlaufwerk mounten
#### Windows
```
1. Explorer öffnen
2. Rechtsklick auf "Dieser PC"
3. "Netzlaufwerk verbinden"
4. URL eingeben: http://DEIN-SERVER:8080/notes-md/
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Laufwerksbuchstabe: Z:\ (oder beliebig)
8. Fertig!
```
**Zugriff:** `Z:\` im Explorer
#### macOS
```
1. Finder öffnen
2. Menü "Gehe zu" → "Mit Server verbinden" (⌘K)
3. Server-Adresse: http://DEIN-SERVER:8080/notes-md/
4. Verbinden
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Fertig!
```
**Zugriff:** Finder → Netzwerk → notes-md
#### Linux (GNOME)
```
1. Files / Nautilus öffnen
2. "Andere Orte"
3. "Mit Server verbinden"
4. Server-Adresse: dav://DEIN-SERVER:8080/notes-md/
5. Benutzername: noteuser
6. Passwort: (dein WebDAV-Passwort)
7. Fertig!
```
**Zugriff:** `/run/user/1000/gvfs/dav:host=...`
#### Linux (davfs2 - permanent)
```bash
# Installation
sudo apt install davfs2
# Mount-Point erstellen
sudo mkdir -p /mnt/notes-md
# Einmalig mounten
sudo mount -t davfs http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md
# Permanent in /etc/fstab
echo "http://DEIN-SERVER:8080/notes-md/ /mnt/notes-md davfs rw,user,noauto 0 0" | sudo tee -a /etc/fstab
```
**Zugriff:** `/mnt/notes-md/`
---
## 📝 Markdown-Editoren
### Empfohlene Editoren
#### 1. VS Code ⭐ _Empfohlen_
**Vorteile:**
- ✅ Kostenlos & Open Source
- ✅ Markdown-Preview (Ctrl+Shift+V)
- ✅ Syntax-Highlighting
- ✅ Git-Integration
- ✅ Erweiterungen (Spell Check, etc.)
**Setup:**
```
1. VS Code installieren
2. WebDAV-Laufwerk mounten
3. Ordner öffnen: Z:\notes-md\ (Windows) oder /mnt/notes-md (Linux)
4. Fertig! Markdown-Dateien bearbeiten
```
**Extensions (optional):**
- `Markdown All in One` - Shortcuts & Preview
- `Markdown Preview Enhanced` - Bessere Preview
- `Code Spell Checker` - Rechtschreibprüfung
#### 2. Typora
**Vorteile:**
- ✅ WYSIWYG Markdown-Editor
- ✅ Minimalistisches Design
- ✅ Live-Preview
- ⚠️ Kostenpflichtig (~15€)
**Setup:**
```
1. Typora installieren
2. WebDAV mounten
3. Ordner in Typora öffnen
4. Notizen bearbeiten
```
#### 3. Notepad++
**Vorteile:**
- ✅ Leichtgewichtig
- ✅ Schnell
- ✅ Syntax-Highlighting
- ⚠️ Keine Markdown-Preview
**Setup:**
```
1. Notepad++ installieren
2. WebDAV mounten
3. Dateien direkt öffnen
```
#### 4. Obsidian
**Vorteile:**
- ✅ Zweite Gehirn-Philosophie
- ✅ Graph-View für Verlinkungen
- ✅ Viele Plugins
- ⚠️ Sync-Konflikte möglich (2 Master)
**Setup:**
```
1. Obsidian installieren
2. WebDAV als Vault öffnen
3. Vorsicht: Obsidian erstellt eigene Metadaten!
```
**⚠️ Nicht empfohlen:** Kann Frontmatter verändern
---
## 📄 Markdown-Dateiformat
### Struktur
Jede Notiz wird als `.md` Datei mit YAML-Frontmatter exportiert:
```markdown
---
id: abc-123-def-456
created: 2026-01-05T14:30:22Z
updated: 2026-01-05T14:30:22Z
tags: []
---
# Notiz-Titel
Notiz-Inhalt hier...
```
### Frontmatter-Felder
| Feld | Typ | Beschreibung | Pflicht |
|------|-----|--------------|---------|
| `id` | UUID | Eindeutige Notiz-ID | ✅ Ja |
| `created` | ISO8601 | Erstellungsdatum | ✅ Ja |
| `updated` | ISO8601 | Änderungsdatum | ✅ Ja |
| `tags` | Array | Tags (zukünftig) | ❌ Nein |
### Dateinamen
**Sanitization-Regeln:**
```
Titel: "Meine Einkaufsliste 🛒"
→ Dateiname: "Meine_Einkaufsliste.md"
Entfernt werden:
- Emojis: 🛒 → entfernt
- Sonderzeichen: / \ : * ? " < > | → entfernt
- Mehrfache Leerzeichen → einzelnes Leerzeichen
- Leerzeichen → Unterstrich _
```
**Beispiele:**
```
"Meeting Notes 2026" → "Meeting_Notes_2026.md"
"To-Do: Projekt" → "To-Do_Projekt.md"
"Urlaub ☀️" → "Urlaub.md"
```
---
## 🔄 Synchronisation
### Workflow: Android → Desktop
1. **Notiz in App erstellen/bearbeiten**
2. **Sync ausführen** (Auto oder manuell)
3. **JSON wird hochgeladen** (`/notes/abc-123.json`)
4. **Markdown wird exportiert** (`/notes-md/Notiz_Titel.md`) _(nur wenn Desktop-Integration AN)_
5. **Desktop-Editor zeigt Änderungen** (nach Refresh)
### Workflow: Desktop → Android
1. **Markdown-Datei bearbeiten** (im gemounteten Ordner)
2. **Speichern** - Datei liegt sofort auf Server
3. **In App: Markdown-Import ausführen**
- Einstellungen → "Import Markdown Changes"
- Oder: Auto-Import bei jedem Sync (zukünftig)
4. **App übernimmt Änderungen** (wenn Desktop-Version neuer)
### Konfliktauflösung: Last-Write-Wins
**Regel:** Neueste Version (nach `updated` Timestamp) gewinnt
**Beispiel:**
```
App-Version: updated: 2026-01-05 14:00
Desktop-Version: updated: 2026-01-05 14:30
→ Desktop gewinnt (neuerer Timestamp)
```
**Automatisch:**
- ✅ Beim Markdown-Import
- ✅ Beim JSON-Sync
- ⚠️ Keine Merge-Konflikte - nur komplettes Überschreiben
---
## ⚙️ Einstellungen
### Desktop-Integration Toggle
**Einstellungen → Desktop-Integration**
**AN (aktiviert):**
- ✅ Neue Notizen → automatisch als `.md` exportiert
- ✅ Aktualisierte Notizen → `.md` Update
- ✅ Gelöschte Notizen → `.md` bleibt (zukünftig: auch löschen)
**AUS (deaktiviert):**
- ❌ Kein Markdown-Export
- ✅ JSON-Sync läuft normal weiter
- ✅ Bestehende `.md` Dateien bleiben erhalten
### Initial Export
**Was passiert beim Aktivieren:**
1. Alle bestehenden Notizen werden gescannt
2. Progress-Dialog zeigt Fortschritt (z.B. "23/42")
3. Jede Notiz wird als `.md` exportiert
4. Bei Fehlern: Einzelne Notiz wird übersprungen
5. Erfolgsmeldung mit Anzahl exportierter Notizen
**Zeit:** ~1-2 Sekunden pro 50 Notizen
---
## 🛠️ Erweiterte Nutzung
### Manuelle Markdown-Erstellung
Du kannst `.md` Dateien manuell erstellen:
```markdown
---
id: 00000000-0000-0000-0000-000000000001
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
# Neue Desktop-Notiz
Inhalt hier...
```
**⚠️ Wichtig:**
- `id` muss gültige UUID sein (z.B. mit uuidgen.io)
- Timestamps in ISO8601-Format
- Frontmatter mit `---` umschließen
### Bulk-Operations
**Mehrere Notizen auf einmal bearbeiten:**
1. WebDAV mounten
2. Alle `.md` Dateien in VS Code öffnen
3. Suchen & Ersetzen über alle Dateien (Ctrl+Shift+H)
4. Speichern
5. In App: "Import Markdown Changes"
### Scripting
**Beispiel: Alle Notizen nach Datum sortieren**
```bash
#!/bin/bash
cd /mnt/notes-md/
# Alle .md Dateien nach Update-Datum sortieren
for file in *.md; do
updated=$(grep "^updated:" "$file" | cut -d' ' -f2)
echo "$updated $file"
done | sort
```
---
## ❌ Fehlerbehebung
### "404 Not Found" beim WebDAV-Mount
**Ursache:** `/notes-md/` Ordner existiert nicht
**Lösung:**
1. **Erste Sync durchführen** - Ordner wird automatisch erstellt
2. ODER: Manuell erstellen via Terminal:
```bash
curl -X MKCOL -u noteuser:password http://server:8080/notes-md/
```
### Markdown-Dateien erscheinen nicht
**Ursache:** Desktop-Integration nicht aktiviert
**Lösung:**
1. Einstellungen → "Desktop-Integration" AN
2. Warten auf Initial Export
3. WebDAV-Ordner refreshen
### Änderungen vom Desktop erscheinen nicht in App
**Ursache:** Markdown-Import nicht ausgeführt
**Lösung:**
1. Einstellungen → "Import Markdown Changes"
2. ODER: Auto-Sync abwarten (zukünftiges Feature)
### "Frontmatter fehlt" Fehler
**Ursache:** `.md` Datei ohne gültiges YAML-Frontmatter
**Lösung:**
1. Datei in Editor öffnen
2. Frontmatter am Anfang hinzufügen:
```yaml
---
id: NEUE-UUID-HIER
created: 2026-01-05T12:00:00Z
updated: 2026-01-05T12:00:00Z
---
```
3. Speichern und erneut importieren
---
## 🔒 Sicherheit & Best Practices
### Do's ✅
- ✅ **Backup vor Bulk-Edits** - Lokales Backup erstellen
- ✅ **Ein Editor zur Zeit** - Nicht parallel in App UND Desktop bearbeiten
- ✅ **Sync abwarten** - Vor Desktop-Bearbeitung Sync durchführen
- ✅ **Frontmatter respektieren** - Nicht manuell ändern (außer du weißt was du tust)
### Don'ts ❌
- ❌ **Parallel bearbeiten** - App und Desktop gleichzeitig → Konflikte
- ❌ **Frontmatter löschen** - Notiz kann nicht mehr importiert werden
- ❌ **IDs ändern** - Notiz wird als neue erkannt
- ❌ **Timestamps manipulieren** - Konfliktauflösung funktioniert nicht
### Empfohlener Workflow
```
1. Sync in App (Pull-to-Refresh)
2. Desktop öffnen
3. Änderungen machen
4. Speichern
5. In App: "Import Markdown Changes"
6. Überprüfen
7. Weiteren Sync durchführen
```
---
## 📊 Vergleich: JSON vs Markdown
| Aspekt | JSON | Markdown |
|--------|------|----------|
| **Format** | Strukturiert | Fließtext |
| **Lesbarkeit (Mensch)** | ⚠️ Mittel | ✅ Gut |
| **Lesbarkeit (Maschine)** | ✅ Perfekt | ⚠️ Parsing nötig |
| **Metadata** | Native | Frontmatter |
| **Editoren** | Code-Editoren | Alle Text-Editoren |
| **Sync-Geschwindigkeit** | ✅ Schnell | ⚠️ Langsamer |
| **Zuverlässigkeit** | ✅ 100% | ⚠️ Frontmatter-Fehler möglich |
| **Mobile-First** | ✅ Ja | ❌ Nein |
| **Desktop-First** | ❌ Nein | ✅ Ja |
**Fazit:** Beide Formate nutzen = Beste Erfahrung auf beiden Plattformen!
---
## 🔮 Zukünftige Features
Geplant für v1.3.0+:
-**Auto-Markdown-Import** - Bei jedem Sync automatisch
-**Bidirektionaler Sync** - Ohne manuellen Import
-**Markdown-Vorschau** - In der App
-**Konflikts-UI** - Bei gleichzeitigen Änderungen
-**Tags in Frontmatter** - Synchronisiert mit App
-**Attachments** - Bilder/Dateien in Markdown
---
**📚 Siehe auch:**
- [QUICKSTART.md](../QUICKSTART.md) - App-Einrichtung
- [FEATURES.md](FEATURES.md) - Vollständige Feature-Liste
- [BACKUP.md](BACKUP.md) - Backup & Wiederherstellung
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)

529
docs/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

Some files were not shown because too many files have changed in this diff Show More