20 Commits

Author SHA1 Message Date
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
89 changed files with 4973 additions and 653 deletions

View File

@@ -104,24 +104,22 @@ jobs:
- name: F-Droid Changelogs lesen
run: |
# Lese deutsche Changelog (Hauptsprache)
if [ -f "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
{
echo 'CHANGELOG_DE<<EOF'
cat "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt"
echo 'EOF'
} >> $GITHUB_ENV
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
echo "GHADELIMITER" >> $GITHUB_ENV
else
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
fi
# Lese englische Changelog (optional)
if [ -f "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
{
echo 'CHANGELOG_EN<<EOF'
cat "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt"
echo 'EOF'
} >> $GITHUB_ENV
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
echo "GHADELIMITER" >> $GITHUB_ENV
else
echo "CHANGELOG_EN=" >> $GITHUB_ENV
fi
@@ -153,12 +151,12 @@ jobs:
## 📋 Changelog / Release Notes
${{ env.CHANGELOG_EN }}
${{ env.CHANGELOG_DE }}
<details>
<summary><EFBFBD>🇪 Deutsche Version (zum Aufklappen)</summary>
<summary>🌍 English Version</summary>
${{ env.CHANGELOG_DE }}
${{ env.CHANGELOG_EN }}
</details>

View File

@@ -1,27 +1,22 @@
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:
@@ -31,7 +26,6 @@ jobs:
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/')
@@ -39,18 +33,15 @@ jobs:
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
@@ -60,7 +51,6 @@ jobs:
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
@@ -68,7 +58,6 @@ jobs:
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
exit 1
fi
- name: Debug APKs hochladen (Artefakte)
uses: actions/upload-artifact@v4
with:
@@ -77,7 +66,6 @@ jobs:
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()
@@ -88,26 +76,24 @@ jobs:
.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 }})`
})
**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 }})`
})

210
CHANGELOG.md Normal file
View File

@@ -0,0 +1,210 @@
# Changelog
All notable changes to Simple Notes Sync will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [1.2.2] - TBD
### 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

View File

@@ -76,7 +76,9 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **Password** | (your password from `.env`) |
| **Gateway SSID** | Name of your WiFi network |
4. **Press "Test connection"**
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
4. **Press "Test connection"****
- ✅ Success? → Continue to step 4
- ❌ Error? → See [Troubleshooting](#troubleshooting)

View File

@@ -76,6 +76,8 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **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)

View File

@@ -6,28 +6,43 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Documentation](DOCS.en.md)** · **🚀 [Quick Start](QUICKSTART.en.md)**
**📱 [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**
---
## Features
## 📱 Screenshots
- 📝 Offline-First - Notes always available
- 🔄 Auto-Sync - Configurable intervals (15/30/60 min)
- 🏠 Self-Hosted - WebDAV on your server
- 🔐 Privacy-First - No cloud, no tracking
- 🔋 Battery-friendly - ~0.2-0.8% per day
<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
### 1. Server Setup (5 minutes)
```bash
cd server
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
@@ -35,23 +50,30 @@ docker compose up -d
➡️ **Details:** [Server Setup Guide](server/README.en.md)
### 2. App Installation
### 2. App Installation (2 minutes)
1. [Download APK](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Install & open
3. ⚙️ Settings → Configure server
4. Enable auto-sync
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! 🎉
➡️ **Details:** [Complete guide](QUICKSTART.en.md)
➡️ **Detailed guide:** [QUICKSTART.en.md](QUICKSTART.en.md)
---
## 📚 Documentation
- **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users
- **[Server Setup](server/README.en.md)** - Configure WebDAV server
- **[Complete Docs](DOCS.en.md)** - Features, troubleshooting, build instructions
| 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
@@ -61,13 +83,13 @@ cd android
./gradlew assembleStandardRelease
```
➡️ **Details:** [Build instructions in DOCS.en.md](DOCS.en.md)
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md)
---
## 🤝 Contributing
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md)
---
@@ -75,4 +97,6 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
MIT License - see [LICENSE](LICENSE)
**v1.1.0** · Built with Kotlin + Material Design 3
---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -6,28 +6,43 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
**📱 [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** · **📖 [Dokumentation](docs/DOCS.md)** · **🚀 [Quick Start](QUICKSTART.md)**
**🌍 Sprachen:** **Deutsch** · [English](README.en.md)
---
## Features
## 📱 Screenshots
- 📝 Offline-First - Notizen immer verfügbar
- 🔄 Auto-Sync - Konfigurierbare Intervalle (15/30/60 Min)
- 🏠 Self-Hosted - WebDAV auf deinem Server
- 🔐 Privacy-First - Keine Cloud, kein Tracking
- 🔋 Akkuschonend - ~0.2-0.8% pro Tag
<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>
---
## 🚀 Quick Start
## ✨ Highlights
### 1. Server Setup
- 📝 **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
### 1. Server Setup (5 Minuten)
```bash
cd server
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync/server
cp .env.example .env
# Passwort in .env setzen
docker compose up -d
@@ -35,22 +50,32 @@ docker compose up -d
➡️ **Details:** [Server Setup Guide](server/README.md)
### 2. App Installation
### 2. App Installation (2 Minuten)
1. [APK herunterladen](https://github.com/inventory69/simple-notes-sync/releases/latest)
2. Installieren & öffnen
3. ⚙️ Einstellungen → Server konfigurieren
4. Auto-Sync aktivieren
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! 🎉
➡️ **Details:** [Vollständige Anleitung](QUICKSTART.md)
➡️ **Ausführliche Anleitung:** [QUICKSTART.md](QUICKSTART.md)
---
## 📚 Dokumentation
- **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren
- **[Vollständige Docs](DOCS.md)** - Features, Troubleshooting, Build-Anleitung
| Dokument | Inhalt |
|----------|--------|
| **[QUICKSTART.md](QUICKSTART.md)** | Schritt-für-Schritt Installation |
| **[FEATURES.md](docs/FEATURES.md)** | Vollständige Feature-Liste |
| **[BACKUP.md](docs/BACKUP.md)** | Backup & Wiederherstellung |
| **[DESKTOP.md](docs/DESKTOP.md)** | Desktop-Integration (Markdown) |
| **[DOCS.md](docs/DOCS.md)** | Technische Details & Troubleshooting |
| **[CHANGELOG.md](CHANGELOG.md)** | Versionshistorie |
---
@@ -61,13 +86,13 @@ cd android
./gradlew assembleStandardRelease
```
➡️ **Details:** [Build-Anleitung in DOCS.md](DOCS.md)
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md)
---
## 🤝 Contributing
Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details.
Beiträge willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md)
---
@@ -75,4 +100,6 @@ Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details
MIT License - siehe [LICENSE](LICENSE)
**v1.1.0** · Gebaut mit Kotlin + Material Design 3
---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -17,8 +17,8 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
versionCode = 3 // 🔥 Bugfix: Spurious Sync Error Notifications + Sync Icon Bug
versionName = "1.1.1" // 🔥 Bugfix: Server-Erreichbarkeits-Check + Notification-Improvements
versionCode = 7 // 🔧 v1.2.2: Backward compatibility for v1.2.0 migration
versionName = "1.2.2" // 🔧 v1.2.2: Dual-mode download (Root + /notes/)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -26,6 +26,12 @@ android {
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
}
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
dependenciesInfo {
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
includeInBundle = false // Also disable for AAB (Google Play)
}
// Enable multiple APKs per ABI for smaller downloads
splits {
abi {
@@ -124,6 +130,9 @@ dependencies {
// LocalBroadcastManager für UI Refresh
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
// SwipeRefreshLayout für Pull-to-Refresh
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// Testing (bleiben so)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

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

View File

@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import dev.dettmer.simplenotes.sync.WebDavSyncService
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
class MainActivity : AppCompatActivity() {
@@ -41,6 +42,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var emptyStateCard: MaterialCardView
private lateinit var fabAddNote: FloatingActionButton
private lateinit var toolbar: MaterialToolbar
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
private lateinit var adapter: NotesAdapter
private val storage by lazy { NotesStorage(this) }
@@ -152,6 +154,12 @@ class MainActivity : AppCompatActivity() {
try {
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
return@launch
}
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()
@@ -220,6 +228,7 @@ class MainActivity : AppCompatActivity() {
emptyStateCard = findViewById(R.id.emptyStateCard)
fabAddNote = findViewById(R.id.fabAddNote)
toolbar = findViewById(R.id.toolbar)
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
}
private fun setupToolbar() {
@@ -233,10 +242,72 @@ class MainActivity : AppCompatActivity() {
recyclerViewNotes.adapter = adapter
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
// 🔥 v1.1.2: Setup Pull-to-Refresh
setupPullToRefresh()
// Setup Swipe-to-Delete
setupSwipeToDelete()
}
/**
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
*/
private fun setupPullToRefresh() {
swipeRefreshLayout.setOnRefreshListener {
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
lifecycleScope.launch {
try {
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
if (serverUrl.isNullOrEmpty()) {
showToast("⚠️ Server noch nicht konfiguriert")
swipeRefreshLayout.isRefreshing = false
return@launch
}
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
showToast("✅ Bereits synchronisiert")
swipeRefreshLayout.isRefreshing = false
return@launch
}
// Check if server is reachable
if (!syncService.isServerReachable()) {
showToast("⚠️ Server nicht erreichbar")
swipeRefreshLayout.isRefreshing = false
return@launch
}
// Perform sync
val result = syncService.syncNotes()
if (result.isSuccess) {
showToast("${result.syncedCount} Notizen synchronisiert")
loadNotes()
} else {
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
}
} catch (e: Exception) {
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
showToast("❌ Fehler: ${e.message}")
} finally {
swipeRefreshLayout.isRefreshing = false
}
}
}
// Set Material 3 color scheme
swipeRefreshLayout.setColorSchemeResources(
com.google.android.material.R.color.material_dynamic_primary50
)
}
private fun setupSwipeToDelete() {
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
0, // No drag
@@ -336,11 +407,18 @@ class MainActivity : AppCompatActivity() {
private fun triggerManualSync() {
lifecycleScope.launch {
try {
showToast("Starte Synchronisation...")
// Create sync service
val syncService = WebDavSyncService(this@MainActivity)
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
showToast("✅ Bereits synchronisiert")
return@launch
}
showToast("Starte Synchronisation...")
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
val isReachable = withContext(Dispatchers.IO) {
syncService.isServerReachable()

View File

@@ -41,7 +41,8 @@ class NoteEditorActivity : AppCompatActivity() {
setSupportActionBar(toolbar)
supportActionBar?.apply {
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

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
package dev.dettmer.simplenotes.models
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
data class Note(
@@ -25,6 +29,25 @@ data class Note(
""".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 {
fun fromJson(json: String): Note? {
return try {
@@ -34,6 +57,78 @@ data class Note(
null
}
}
/**
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
*
* @param md Markdown-String mit YAML Frontmatter
* @return Note-Objekt oder null bei Parse-Fehler
*/
fun fromMarkdown(md: String): Note? {
return try {
// Parse YAML Frontmatter + Markdown Content
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
val match = frontmatterRegex.find(md) ?: return null
val yamlBlock = match.groupValues[1]
val contentBlock = match.groupValues[2]
// Parse YAML (einfach per String-Split für MVP)
val metadata = yamlBlock.lines()
.mapNotNull { line ->
val parts = line.split(":", limit = 2)
if (parts.size == 2) {
parts[0].trim() to parts[1].trim()
} else null
}.toMap()
// Extract title from first # heading
val title = contentBlock.lines()
.firstOrNull { it.startsWith("# ") }
?.removePrefix("# ")?.trim() ?: "Untitled"
// Extract content (everything after heading)
val content = contentBlock
.substringAfter("# $title\n\n", "")
.trim()
Note(
id = metadata["id"] ?: UUID.randomUUID().toString(),
title = title,
content = content,
createdAt = parseISO8601(metadata["created"] ?: ""),
updatedAt = parseISO8601(metadata["updated"] ?: ""),
deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert
)
} catch (e: Exception) {
null
}
}
/**
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
* Format: 2024-12-21T18:00:00Z (UTC)
*/
private fun formatISO8601(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
return sdf.format(Date(timestamp))
}
/**
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
* Fallback: Aktueller Timestamp bei Fehler
*/
private fun parseISO8601(dateString: String): Long {
return try {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) {
System.currentTimeMillis() // Fallback
}
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.work.WorkerParameters
import dev.dettmer.simplenotes.BuildConfig
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.NotificationHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -52,7 +53,25 @@ class SyncWorker(
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 2: Checking server reachability (Pre-Check)")
Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)")
}
// 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
// Spart Batterie + Netzwerk-Traffic + Server-Last
if (!syncService.hasUnsyncedChanges()) {
Logger.d(TAG, "⏭️ No local changes - skipping sync (performance optimization)")
Logger.d(TAG, " Saves battery, network traffic, and server load")
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (no changes to sync)")
Logger.d(TAG, "═══════════════════════════════════════")
}
return@withContext Result.success()
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
}
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
@@ -63,6 +82,9 @@ class SyncWorker(
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, "═══════════════════════════════════════")
@@ -147,6 +169,32 @@ class SyncWorker(
}
Result.failure()
}
} catch (e: CancellationException) {
// ⭐ Job wurde gecancelt - KEIN FEHLER!
// Gründe: App-Update, Doze Mode, Battery Optimization, Network Constraint, etc.
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
}
Logger.d(TAG, "⏹️ Job was cancelled (normal - update/doze/constraints)")
Logger.d(TAG, " Reason could be: App update, Doze mode, Battery opt, Network disconnect")
Logger.d(TAG, " This is expected Android behavior - not an error!")
try {
// UI-Refresh trotzdem triggern (falls MainActivity geöffnet)
broadcastSyncCompleted(false, 0)
} catch (broadcastError: Exception) {
Logger.e(TAG, "Failed to broadcast after cancellation", broadcastError)
}
if (BuildConfig.DEBUG) {
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cancelled, no error)")
Logger.d(TAG, "═══════════════════════════════════════")
}
// ⚠️ WICHTIG: Result.success() zurückgeben!
// Cancellation ist KEIN Fehler, WorkManager soll nicht retries machen
Result.success()
} catch (e: Exception) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
@@ -189,4 +237,69 @@ class SyncWorker(
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
}
/**
* Prüft ob Server längere Zeit unreachable und zeigt ggf. Warnung (v1.1.2)
* - Nur wenn Auto-Sync aktiviert
* - Nur wenn schon mal erfolgreich gesynct
* - Nur wenn >24h seit letztem erfolgreichen Sync
* - Throttling: Max. 1 Warnung pro 24h
*/
private fun checkAndShowSyncWarning(syncService: WebDavSyncService) {
try {
val prefs = applicationContext.getSharedPreferences(
dev.dettmer.simplenotes.utils.Constants.PREFS_NAME,
android.content.Context.MODE_PRIVATE
)
// Check 1: Auto-Sync aktiviert?
val autoSyncEnabled = prefs.getBoolean(
dev.dettmer.simplenotes.utils.Constants.KEY_AUTO_SYNC,
false
)
if (!autoSyncEnabled) {
Logger.d(TAG, "⏭️ Auto-Sync disabled - no warning needed")
return
}
// Check 2: Schon mal erfolgreich gesynct?
val lastSuccessfulSync = syncService.getLastSuccessfulSyncTimestamp()
if (lastSuccessfulSync == 0L) {
Logger.d(TAG, "⏭️ Never synced successfully - no warning needed")
return
}
// Check 3: >24h seit letztem erfolgreichen Sync?
val now = System.currentTimeMillis()
val timeSinceLastSync = now - lastSuccessfulSync
if (timeSinceLastSync < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
Logger.d(TAG, "⏭️ Last successful sync <24h ago - no warning needed")
return
}
// Check 4: Throttling - schon Warnung in letzten 24h gezeigt?
val lastWarningShown = prefs.getLong(
dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN,
0L
)
if (now - lastWarningShown < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
Logger.d(TAG, "⏭️ Warning already shown in last 24h - throttling")
return
}
// Zeige Warnung
val hoursSinceLastSync = timeSinceLastSync / (1000 * 60 * 60)
NotificationHelper.showSyncWarning(applicationContext, hoursSinceLastSync)
// Speichere Zeitpunkt der Warnung
prefs.edit()
.putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, now)
.apply()
Logger.d(TAG, "⚠️ Sync warning shown: Server unreachable for ${hoursSinceLastSync}h")
} catch (e: Exception) {
Logger.e(TAG, "Failed to check/show sync warning", e)
}
}
}

View File

@@ -31,6 +31,7 @@ class WebDavSyncService(private val context: Context) {
private val storage: NotesStorage
private val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
private var markdownDirEnsured = false // Cache für Ordner-Existenz
init {
if (BuildConfig.DEBUG) {
@@ -189,6 +190,110 @@ class WebDavSyncService(private val context: Context) {
return prefs.getString(Constants.KEY_SERVER_URL, null)
}
/**
* Erzeugt notes/ URL aus Base-URL mit Smart Detection (Task #1.2.1-12)
*
* Beispiele:
* - http://server:8080/ → http://server:8080/notes/
* - http://server:8080/notes/ → http://server:8080/notes/
* - http://server:8080/notes → http://server:8080/notes/
* - http://server:8080/my-path/ → http://server:8080/my-path/notes/
*
* @param baseUrl Base Server-URL
* @return notes/ Ordner-URL (mit trailing /)
*/
private fun getNotesUrl(baseUrl: String): String {
val normalized = baseUrl.trimEnd('/')
// Wenn URL bereits mit /notes endet → direkt nutzen
return if (normalized.endsWith("/notes")) {
"$normalized/"
} else {
"$normalized/notes/"
}
}
/**
* Erzeugt Markdown-Ordner-URL basierend auf getNotesUrl() (Task #1.2.1-14)
*
* Beispiele:
* - http://server:8080/ → http://server:8080/notes-md/
* - http://server:8080/notes/ → http://server:8080/notes-md/
* - http://server:8080/notes → http://server:8080/notes-md/
*
* @param baseUrl Base Server-URL
* @return Markdown-Ordner-URL (mit trailing /)
*/
private fun getMarkdownUrl(baseUrl: String): String {
val notesUrl = getNotesUrl(baseUrl)
val normalized = notesUrl.trimEnd('/')
// Ersetze /notes mit /notes-md
return normalized.replace("/notes", "/notes-md") + "/"
}
/**
* Stellt sicher dass notes-md/ Ordner existiert
*
* Wird beim ersten erfolgreichen Sync aufgerufen (unabhängig von MD-Feature).
* Cached in Memory - nur einmal pro App-Session.
*/
private fun ensureMarkdownDirectoryExists(sardine: Sardine, serverUrl: String) {
if (markdownDirEnsured) return
try {
val mdUrl = getMarkdownUrl(serverUrl)
if (!sardine.exists(mdUrl)) {
sardine.createDirectory(mdUrl)
Logger.d(TAG, "📁 Created notes-md/ directory (for future use)")
}
markdownDirEnsured = true
} catch (e: Exception) {
Logger.e(TAG, "Failed to create notes-md/: ${e.message}")
// Nicht kritisch - User kann später manuell erstellen
}
}
/**
* Prüft ob lokale Änderungen seit letztem Sync vorhanden sind (v1.1.2)
* Performance-Optimierung: Vermeidet unnötige Sync-Operationen
*
* @return true wenn unsynced changes vorhanden, false sonst
*/
suspend fun hasUnsyncedChanges(): Boolean = withContext(Dispatchers.IO) {
return@withContext try {
val lastSyncTime = getLastSyncTimestamp()
// Wenn noch nie gesynct, dann haben wir Änderungen
if (lastSyncTime == 0L) {
Logger.d(TAG, "📝 Never synced - assuming changes exist")
return@withContext true
}
// Prüfe ob Notizen existieren die neuer sind als letzter Sync
val storage = dev.dettmer.simplenotes.storage.NotesStorage(context)
val allNotes = storage.loadAllNotes()
val hasChanges = allNotes.any { note ->
note.updatedAt > lastSyncTime
}
Logger.d(TAG, "📊 Unsynced changes check: $hasChanges (${allNotes.size} notes total)")
if (hasChanges) {
val unsyncedCount = allNotes.count { note -> note.updatedAt > lastSyncTime }
Logger.d(TAG, "$unsyncedCount notes modified since last sync")
}
hasChanges
} catch (e: Exception) {
Logger.e(TAG, "Failed to check for unsynced changes - assuming changes exist", e)
// Bei Fehler lieber sync durchführen (safe default)
true
}
}
/**
* Prüft ob WebDAV-Server erreichbar ist (ohne Sync zu starten)
* Verwendet Socket-Check für schnelle Erreichbarkeitsprüfung
@@ -312,20 +417,24 @@ class WebDavSyncService(private val context: Context) {
var conflictCount = 0
Logger.d(TAG, "📍 Step 3: Checking server directory")
// Ensure server directory exists
// Ensure notes/ directory exists
val notesUrl = getNotesUrl(serverUrl)
try {
Logger.d(TAG, "🔍 Checking if server directory exists...")
if (!sardine.exists(serverUrl)) {
Logger.d(TAG, "📁 Creating server directory...")
sardine.createDirectory(serverUrl)
Logger.d(TAG, "🔍 Checking if notes/ directory exists...")
if (!sardine.exists(notesUrl)) {
Logger.d(TAG, "📁 Creating notes/ directory...")
sardine.createDirectory(notesUrl)
}
Logger.d(TAG, "Server directory ready")
Logger.d(TAG, "notes/ directory ready")
} catch (e: Exception) {
Logger.e(TAG, "💥 CRASH checking/creating server directory!", e)
Logger.e(TAG, "💥 CRASH checking/creating notes/ directory!", e)
e.printStackTrace()
throw e
}
// Ensure notes-md/ directory exists (for Markdown export)
ensureMarkdownDirectoryExists(sardine, serverUrl)
Logger.d(TAG, "📍 Step 4: Uploading local notes")
// Upload local notes
try {
@@ -406,11 +515,14 @@ class WebDavSyncService(private val context: Context) {
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
var uploadedCount = 0
val localNotes = storage.loadAllNotes()
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
for (note in localNotes) {
try {
// 1. JSON-Upload (Task #1.2.1-13: nutzt getNotesUrl())
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
val noteUrl = "$serverUrl/${note.id}.json"
val notesUrl = getNotesUrl(serverUrl)
val noteUrl = "$notesUrl${note.id}.json"
val jsonBytes = note.toJson().toByteArray()
sardine.put(noteUrl, jsonBytes, "application/json")
@@ -419,6 +531,18 @@ class WebDavSyncService(private val context: Context) {
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
storage.saveNote(updatedNote)
uploadedCount++
// 2. Markdown-Export (NEU in v1.2.0)
// Läuft NACH erfolgreichem JSON-Upload
if (markdownExportEnabled) {
try {
exportToMarkdown(sardine, serverUrl, note)
Logger.d(TAG, " 📝 MD exported: ${note.title}")
} catch (e: Exception) {
Logger.e(TAG, "MD-Export failed for ${note.id}: ${e.message}")
// Kein throw! JSON-Sync darf nicht blockiert werden
}
}
}
} catch (e: Exception) {
// Mark as pending for retry
@@ -430,59 +554,273 @@ class WebDavSyncService(private val context: Context) {
return uploadedCount
}
/**
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
*
* @param sardine Sardine-Client
* @param serverUrl Server-URL (notes/ Ordner)
* @param note Note zum Exportieren
*/
private fun exportToMarkdown(sardine: Sardine, serverUrl: String, note: Note) {
val mdUrl = getMarkdownUrl(serverUrl)
// Erstelle notes-md/ Ordner falls nicht vorhanden
if (!sardine.exists(mdUrl)) {
sardine.createDirectory(mdUrl)
Logger.d(TAG, "📁 Created notes-md/ directory")
}
// Sanitize Filename (Task #1.2.0-12)
val filename = sanitizeFilename(note.title) + ".md"
val noteUrl = "$mdUrl/$filename"
// Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray()
// Upload
sardine.put(noteUrl, mdContent, "text/markdown")
}
/**
* Sanitize Filename für sichere Dateinamen (Task #1.2.0-12)
*
* Entfernt Windows/Linux-verbotene Zeichen, begrenzt Länge
*
* @param title Original-Titel
* @return Sicherer Filename
*/
private fun sanitizeFilename(title: String): String {
return title
.replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen
.replace(Regex("\\s+"), " ") // Normalisiere Whitespace
.take(200) // Max 200 Zeichen (Reserve für .md)
.trim('_', ' ') // Trim Underscores/Spaces
}
/**
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
*
* Wird beim ersten Aktivieren der Desktop-Integration aufgerufen.
* Exportiert auch bereits synchronisierte Notizen.
*
* @return Anzahl exportierter Notizen
*/
suspend fun exportAllNotesToMarkdown(
serverUrl: String,
username: String,
password: String,
onProgress: (current: Int, total: Int) -> Unit = { _, _ -> }
): Int = withContext(Dispatchers.IO) {
Logger.d(TAG, "🔄 Starting initial Markdown export for all notes...")
// Erstelle Sardine-Client mit gegebenen Credentials
val wifiAddress = getWiFiInetAddress()
val okHttpClient = if (wifiAddress != null) {
Logger.d(TAG, "✅ Using WiFi-bound socket factory")
OkHttpClient.Builder()
.socketFactory(WiFiSocketFactory(wifiAddress))
.build()
} else {
Logger.d(TAG, "⚠️ Using default OkHttpClient (no WiFi binding)")
OkHttpClient.Builder().build()
}
val sardine = OkHttpSardine(okHttpClient).apply {
setCredentials(username, password)
}
val mdUrl = getMarkdownUrl(serverUrl)
// Ordner sollte bereits existieren (durch #1.2.1-00), aber Sicherheitscheck
ensureMarkdownDirectoryExists(sardine, serverUrl)
// Hole ALLE lokalen Notizen (inklusive SYNCED)
val allNotes = storage.loadAllNotes()
val totalCount = allNotes.size
var exportedCount = 0
Logger.d(TAG, "📝 Found $totalCount notes to export")
allNotes.forEachIndexed { index, note ->
try {
// Progress-Callback
onProgress(index + 1, totalCount)
// Sanitize Filename
val filename = sanitizeFilename(note.title) + ".md"
val noteUrl = "$mdUrl/$filename"
// Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray()
// Upload (überschreibt falls vorhanden)
sardine.put(noteUrl, mdContent, "text/markdown")
exportedCount++
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}")
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
// Continue mit nächster Note (keine Abbruch bei Einzelfehlern)
}
}
Logger.d(TAG, "✅ Initial export completed: $exportedCount/$totalCount notes")
return@withContext exportedCount
}
private data class DownloadResult(
val downloadedCount: Int,
val conflictCount: Int
)
private fun downloadRemoteNotes(sardine: Sardine, serverUrl: String): DownloadResult {
private fun downloadRemoteNotes(
sardine: Sardine,
serverUrl: String,
includeRootFallback: Boolean = false // 🆕 v1.2.2: Only for restore from server
): DownloadResult {
var downloadedCount = 0
var conflictCount = 0
val processedIds = mutableSetOf<String>() // 🆕 v1.2.2: Track already loaded notes
try {
val resources = sardine.list(serverUrl)
// 🆕 PHASE 1: Download from /notes/ (new structure v1.2.1+)
val notesUrl = getNotesUrl(serverUrl)
Logger.d(TAG, "🔍 Phase 1: Checking /notes/ at: $notesUrl")
for (resource in resources) {
if (resource.isDirectory || !resource.name.endsWith(".json")) {
continue
}
if (sardine.exists(notesUrl)) {
Logger.d(TAG, " ✅ /notes/ exists, scanning...")
val resources = sardine.list(notesUrl)
val noteUrl = resource.href.toString()
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val remoteNote = Note.fromJson(jsonContent) ?: continue
val localNote = storage.loadNote(remoteNote.id)
when {
localNote == null -> {
// New note from server
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++
for (resource in resources) {
if (resource.isDirectory || !resource.name.endsWith(".json")) {
continue
}
localNote.updatedAt < remoteNote.updatedAt -> {
// Remote is newer
if (localNote.syncStatus == SyncStatus.PENDING) {
// Conflict detected
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
conflictCount++
} else {
// Safe to overwrite
// 🔧 Fix: Build full URL instead of using href directly
val noteUrl = notesUrl.trimEnd('/') + "/" + resource.name
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val remoteNote = Note.fromJson(jsonContent) ?: continue
processedIds.add(remoteNote.id) // 🆕 Mark as processed
val localNote = storage.loadNote(remoteNote.id)
when {
localNote == null -> {
// New note from server
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++
Logger.d(TAG, " ✅ Downloaded from /notes/: ${remoteNote.id}")
}
localNote.updatedAt < remoteNote.updatedAt -> {
// Remote is newer
if (localNote.syncStatus == SyncStatus.PENDING) {
// Conflict detected
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
conflictCount++
} else {
// Safe to overwrite
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++
Logger.d(TAG, " ✅ Updated from /notes/: ${remoteNote.id}")
}
}
}
}
Logger.d(TAG, " 📊 Phase 1 complete: $downloadedCount notes from /notes/")
} else {
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
}
// 🆕 PHASE 2: BACKWARD-COMPATIBILITY - Download from Root (old structure v1.2.0)
// ⚠️ ONLY for restore from server! Normal sync should NOT scan Root
if (includeRootFallback) {
val rootUrl = serverUrl.trimEnd('/')
Logger.d(TAG, "🔍 Phase 2: Checking ROOT at: $rootUrl (Restore mode)")
try {
val rootResources = sardine.list(rootUrl)
Logger.d(TAG, " 📂 Found ${rootResources.size} resources in ROOT")
val oldNotes = rootResources.filter { resource ->
!resource.isDirectory &&
resource.name.endsWith(".json") &&
!resource.path.contains("/notes/") && // Not from /notes/ subdirectory
!resource.path.contains("/notes-md/") // Not from /notes-md/
}
Logger.d(TAG, " 🔎 Filtered to ${oldNotes.size} .json files (excluding /notes/ and /notes-md/)")
if (oldNotes.isNotEmpty()) {
Logger.w(TAG, "⚠️ Found ${oldNotes.size} notes in ROOT (old v1.2.0 structure)")
for (resource in oldNotes) {
// 🔧 Fix: Build full URL instead of using href directly
val noteUrl = rootUrl.trimEnd('/') + "/" + resource.name
Logger.d(TAG, " 📄 Processing: ${resource.name} from ${resource.path}")
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val remoteNote = Note.fromJson(jsonContent) ?: continue
// Skip if already loaded from /notes/
if (processedIds.contains(remoteNote.id)) {
Logger.d(TAG, " ⏭️ Skipping ${remoteNote.id} (already loaded from /notes/)")
continue
}
processedIds.add(remoteNote.id)
val localNote = storage.loadNote(remoteNote.id)
when {
localNote == null -> {
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++
Logger.d(TAG, " ✅ Downloaded from ROOT: ${remoteNote.id}")
}
localNote.updatedAt < remoteNote.updatedAt -> {
if (localNote.syncStatus == SyncStatus.PENDING) {
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
conflictCount++
} else {
storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
downloadedCount++
Logger.d(TAG, " ✅ Updated from ROOT: ${remoteNote.id}")
}
}
else -> {
// Local is newer - do nothing
Logger.d(TAG, " ⏭️ Local is newer: ${remoteNote.id}")
}
}
}
Logger.d(TAG, " 📊 Phase 2 complete: downloaded ${oldNotes.size} notes from ROOT")
} else {
Logger.d(TAG, " No old notes found in ROOT")
}
} catch (e: Exception) {
Logger.e(TAG, "⚠️ Failed to scan ROOT directory: ${e.message}", e)
Logger.e(TAG, " Stack trace: ${e.stackTraceToString()}")
// Not fatal - new users may not have root access
}
} else {
Logger.d(TAG, "⏭️ Skipping Phase 2 (Root scan) - only enabled for restore from server")
}
} catch (e: Exception) {
// Log error but don't fail entire sync
Logger.e(TAG, "❌ downloadRemoteNotes failed", e)
}
Logger.d(TAG, "📊 Total download result: $downloadedCount notes, $conflictCount conflicts")
return DownloadResult(downloadedCount, conflictCount)
}
private fun saveLastSyncTimestamp() {
val now = System.currentTimeMillis()
prefs.edit()
.putLong(Constants.KEY_LAST_SYNC, System.currentTimeMillis())
.putLong(Constants.KEY_LAST_SYNC, now)
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // 🔥 v1.1.2: Track successful sync
.apply()
}
@@ -490,6 +828,10 @@ class WebDavSyncService(private val context: Context) {
return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
}
fun getLastSuccessfulSyncTimestamp(): Long {
return prefs.getLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, 0)
}
/**
* Restore all notes from server - overwrites local storage
* @return RestoreResult with count of restored notes
@@ -510,36 +852,18 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "🔄 Starting restore from server...")
// List all files on server
val resources = sardine.list(serverUrl)
val jsonFiles = resources.filter {
!it.isDirectory && it.name.endsWith(".json")
}
// Clear local storage FIRST
Logger.d(TAG, "🗑️ Clearing local storage...")
storage.deleteAllNotes()
Logger.d(TAG, "📂 Found ${jsonFiles.size} files on server")
// 🆕 v1.2.2: Use downloadRemoteNotes() with Root fallback enabled
val result = downloadRemoteNotes(
sardine = sardine,
serverUrl = serverUrl,
includeRootFallback = true // ✅ Enable backward compatibility for restore
)
val restoredNotes = mutableListOf<Note>()
// Download and parse each file
for (resource in jsonFiles) {
try {
val fileUrl = serverUrl.trimEnd('/') + "/" + resource.name
val content = sardine.get(fileUrl).bufferedReader().use { it.readText() }
val note = Note.fromJson(content)
if (note != null) {
restoredNotes.add(note)
Logger.d(TAG, "✅ Downloaded: ${note.title}")
} else {
Logger.e(TAG, "❌ Failed to parse ${resource.name}: Note.fromJson returned null")
}
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to download ${resource.name}", e)
// Continue with other files
}
}
if (restoredNotes.isEmpty()) {
if (result.downloadedCount == 0) {
return@withContext RestoreResult(
isSuccess = false,
errorMessage = "Keine Notizen auf Server gefunden",
@@ -547,22 +871,14 @@ class WebDavSyncService(private val context: Context) {
)
}
// Clear local storage
Logger.d(TAG, "🗑️ Clearing local storage...")
storage.deleteAllNotes()
saveLastSyncTimestamp()
// Save all restored notes
Logger.d(TAG, "💾 Saving ${restoredNotes.size} notes...")
restoredNotes.forEach { note ->
storage.saveNote(note.copy(syncStatus = SyncStatus.SYNCED))
}
Logger.d(TAG, "✅ Restore completed: ${restoredNotes.size} notes")
Logger.d(TAG, "✅ Restore completed: ${result.downloadedCount} notes")
RestoreResult(
isSuccess = true,
errorMessage = null,
restoredCount = restoredNotes.size
restoredCount = result.downloadedCount
)
} catch (e: Exception) {
@@ -574,6 +890,86 @@ class WebDavSyncService(private val context: Context) {
)
}
}
/**
* Synchronisiert Markdown-Dateien (Import von Desktop-Programmen) (Task #1.2.0-14)
*
* Last-Write-Wins Konfliktauflösung basierend auf updatedAt Timestamp
*
* @param serverUrl WebDAV Server-URL (notes/ Ordner)
* @param username WebDAV Username
* @param password WebDAV Password
* @return Anzahl importierter Notizen
*/
suspend fun syncMarkdownFiles(
serverUrl: String,
username: String,
password: String
): Int = withContext(Dispatchers.IO) {
return@withContext try {
Logger.d(TAG, "📝 Starting Markdown sync...")
val sardine = OkHttpSardine()
sardine.setCredentials(username, password)
val mdUrl = getMarkdownUrl(serverUrl)
// Check if notes-md/ exists
if (!sardine.exists(mdUrl)) {
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
return@withContext 0
}
val localNotes = storage.loadAllNotes()
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
var importedCount = 0
Logger.d(TAG, "📂 Found ${mdResources.size} markdown files")
for (resource in mdResources) {
try {
// Download MD-File
val mdContent = sardine.get(resource.href.toString())
.bufferedReader().use { it.readText() }
// Parse zu Note
val mdNote = Note.fromMarkdown(mdContent) ?: continue
val localNote = localNotes.find { it.id == mdNote.id }
// Konfliktauflösung: Last-Write-Wins
when {
localNote == null -> {
// Neue Notiz vom Desktop
storage.saveNote(mdNote)
importedCount++
Logger.d(TAG, " ✅ Imported new: ${mdNote.title}")
}
mdNote.updatedAt > localNote.updatedAt -> {
// Desktop-Version ist neuer (Last-Write-Wins)
storage.saveNote(mdNote)
importedCount++
Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}")
}
// Sonst: Lokale Version behalten
else -> {
Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}")
}
}
} catch (e: Exception) {
Logger.e(TAG, "Failed to import ${resource.name}", e)
// Continue with other files
}
}
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
importedCount
} catch (e: Exception) {
Logger.e(TAG, "Markdown sync failed", e)
0
}
}
}
data class RestoreResult(

View File

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

View File

@@ -288,4 +288,40 @@ object NotificationHelper {
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
}, 30_000)
}
/**
* Zeigt Warnung wenn Server längere Zeit nicht erreichbar (v1.1.2)
* Throttling: Max. 1 Warnung pro 24h
*/
fun showSyncWarning(context: Context, hoursSinceLastSync: Long) {
// PendingIntent für App-Öffnung
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setContentTitle("⚠️ Sync-Warnung")
.setContentText("Server seit ${hoursSinceLastSync}h nicht erreichbar")
.setStyle(NotificationCompat.BigTextStyle()
.bigText("Der WebDAV-Server ist seit ${hoursSinceLastSync} Stunden nicht erreichbar. " +
"Bitte prüfe deine Netzwerkverbindung oder Server-Einstellungen."))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE)
as NotificationManager
manager.notify(SYNC_NOTIFICATION_ID, notification)
Logger.d(TAG, "⚠️ Showed sync warning: Server unreachable for ${hoursSinceLastSync}h")
}
}

View File

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

View File

@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow HTTP for all connections during development/testing -->
<!-- Production validation happens in UrlValidator.kt to restrict HTTP to:
- Private IP ranges: 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 127.x.x.x
- .local domains (mDNS/Bonjour)
This permissive config is necessary because Android's Network Security Config
doesn't support IP-based rules, only domain patterns.
We handle security through application-level validation instead. -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -1,20 +0,0 @@
🐛 Bugfixes v1.1.1
✅ Keine Fehler-Notifications mehr in fremden WiFi-Netzwerken!
- Server-Erreichbarkeits-Check vor jedem Sync (2s Timeout)
- Stiller Abbruch wenn Server nicht erreichbar
- 80% schnellerer Abbruch: 2s statt 10+ Sekunden
✅ Keine Fehler beim WiFi-Connect / Nach-Hause-Kommen!
- Pre-Check wartet bis Netzwerk bereit ist (DHCP, Routing, Gateway)
- Kein Fehler mehr bei Netzwerk-Initialisierung
🔧 Notification-Verbesserungen:
- Alte Notifications werden beim App-Start gelöscht
- Fehler-Notifications verschwinden automatisch nach 30 Sekunden
- Bessere Batterie-Effizienz (keine langen Timeouts mehr)
📱 UI-Fixes:
- Sync-Icon wird nicht mehr angezeigt wenn Sync nicht konfiguriert ist
- Swipe-to-Delete: Kein Flackern mehr beim schnellen Löschen mehrerer Notizen
- Nach dem Speichern einer Notiz landet man automatisch ganz oben in der Liste

View File

@@ -1,20 +0,0 @@
🐛 Bugfixes v1.1.1
✅ No more error notifications in foreign WiFi networks!
- Server reachability check before each sync (2s timeout)
- Silent abort when server is unreachable
- 80% faster abort: 2s instead of 10+ seconds
✅ No more errors when connecting to WiFi / arriving home!
- Pre-check waits until network is ready (DHCP, routing, gateway)
- No more errors during network initialization
🔧 Notification improvements:
- Old notifications are cleared on app start
- Error notifications disappear automatically after 30 seconds
- Better battery efficiency (no more long timeouts)
📱 UI fixes:
- Sync icon no longer shown when sync is not configured
- Swipe-to-delete: No more flickering when quickly deleting multiple notes
- After saving a note, you automatically land at the top of the list

Binary file not shown.

View File

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

6
android/gradlew vendored
View File

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

188
android/gradlew.bat vendored
View File

@@ -1,94 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@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 obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
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.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 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
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@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" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem
@rem Copyright 2015 the original author or authors.
@rem
@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 obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@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 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
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.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 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 Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 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
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

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)

274
docs/FEATURES.en.md Normal file
View File

@@ -0,0 +1,274 @@
# Complete Feature List 📋
**🌍 Languages:** [Deutsch](FEATURES.md) · **English**
> All features of Simple Notes Sync in detail
---
## 📝 Note Management
### Basic Features
-**Simple text notes** - Focus on content, no distractions
-**Auto-save** - No manual saving needed
-**Title + content** - Clear structure for each note
-**Timestamps** - Creation and modification date automatically
-**Swipe-to-delete** - Intuitive gesture for deletion
-**Confirmation dialog** - Protection against accidental deletion
-**Material Design 3** - Modern, clean UI
-**Dark mode** - Automatically based on system settings
-**Dynamic colors** - Adapts to your Android theme
### Editor
-**Minimalist editor** - No bells and whistles
-**Auto-focus** - Start writing immediately
-**Fullscreen mode** - Maximum writing space
-**Save button** - Manual confirmation possible
-**Back navigation** - Saves automatically
---
## 💾 Backup & Restore
### Local Backup System
-**JSON export** - All notes in one file
-**Free location choice** - Downloads, SD card, cloud folder
-**Filenames with timestamp** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
-**Complete export** - Title, content, timestamps, IDs
-**Human-readable format** - JSON with formatting
-**Independent from server** - Works completely offline
### Restore Modes
-**Merge** - Add new notes, keep existing ones _(Default)_
-**Replace** - Delete all and import backup
-**Overwrite duplicates** - Backup wins on ID conflicts
-**Automatic safety backup** - Before every restore
-**Backup validation** - Checks format and version
-**Error handling** - Clear error messages on issues
---
## 🖥️ Desktop Integration
### Markdown Export
-**Automatic export** - Each note → `.md` file
-**Dual-format** - JSON (master) + Markdown (mirror)
-**Filename sanitization** - Safe filenames from titles
-**Frontmatter metadata** - YAML with ID, timestamps, tags
-**WebDAV sync** - Parallel to JSON sync
-**Optional** - Toggle in settings
-**Initial export** - All existing notes when activated
-**Progress indicator** - Shows X/Y during export
### Markdown Import
-**Desktop → App** - Import changes from desktop
-**Last-Write-Wins** - Conflict resolution via timestamp
-**Frontmatter parsing** - Reads metadata from `.md` files
-**Detect new notes** - Automatically adopt to app
-**Detect updates** - Only if desktop version is newer
-**Error tolerance** - Individual errors don't abort import
### WebDAV Access
-**Network drive mount** - Windows, macOS, Linux
-**Any Markdown editor** - VS Code, Typora, Notepad++, iA Writer
-**Live editing** - Direct access to `.md` files
-**Folder structure** - `/notes/` for JSON, `/notes-md/` for Markdown
-**Automatic folder creation** - On first sync
---
## 🔄 Synchronization
### Auto-Sync
-**Interval selection** - 15, 30 or 60 minutes
-**WiFi binding** - Only in configured home WiFi
-**Battery-friendly** - ~0.2-0.8% per day
-**Smart server check** - No errors on foreign networks
-**WorkManager** - Reliable background execution
-**Battery optimization compatible** - Works even with Doze mode
### Sync Triggers (6 total)
1.**Periodic sync** - Automatically after interval
2.**App-start sync** - When opening the app
3.**WiFi-connect sync** - When home WiFi connects
4.**Manual sync** - Button in settings
5.**Pull-to-refresh** - Swipe gesture in notes list
6.**Settings-save sync** - After server configuration
### Sync Mechanism
-**Upload** - Local changes to server
-**Download** - Server changes to app
-**Conflict detection** - On simultaneous changes
-**Conflict-free merging** - Last-Write-Wins via timestamp
-**Sync status tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Error handling** - Retry on network issues
-**Offline-first** - App works without server
### Server Connection
-**WebDAV protocol** - Standard protocol
-**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication
-**Connection test** - Test in settings
-**Gateway SSID** - WiFi name for auto-sync
-**Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_
-**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
---
## 🔒 Privacy & Security
### Self-Hosted
-**Own server** - Full control over data
-**No cloud** - No third parties
-**No tracking** - No analytics, no telemetry
-**No account** - Only server credentials
-**100% open source** - MIT License
### Data Security
-**Local storage** - App-private storage (Android)
-**WebDAV encryption** - HTTPS for external servers
-**Password storage** - Android SharedPreferences (encrypted)
-**No third-party libs** - Only Android SDK + Sardine (WebDAV)
---
## 🔋 Performance & Optimization
### Battery Efficiency
-**Optimized sync intervals** - 15/30/60 min
-**WiFi-only** - No mobile data sync
-**Smart server check** - Only in home WiFi
-**WorkManager** - System-optimized execution
-**Doze mode compatible** - Sync runs even in standby
-**Measured consumption:**
- 15 min: ~0.8% / day (~23 mAh)
- 30 min: ~0.4% / day (~12 mAh) ⭐ _Recommended_
- 60 min: ~0.2% / day (~6 mAh)
### App Performance
-**Offline-first** - Works without internet
-**Instant-load** - Notes load in <100ms
- **Smooth scrolling** - RecyclerView with ViewHolder
- **Material Design 3** - Native Android UI
- **Kotlin Coroutines** - Asynchronous operations
- **Minimal APK size** - ~2 MB
---
## 🛠️ Technical Details
### Platform
- **Android 8.0+** (API 26+)
- **Target SDK 36** (Android 15)
- **Kotlin** - Modern programming language
- **Material Design 3** - Latest design guidelines
- **ViewBinding** - Type-safe view references
### Architecture
- **MVVM-Light** - Simple architecture
- **Single Activity** - Modern navigation
- **Kotlin Coroutines** - Async/Await pattern
- **Dispatchers.IO** - Background operations
- **SharedPreferences** - Settings storage
- **File-based storage** - JSON files locally
### Dependencies
- **AndroidX** - Jetpack libraries
- **Material Components** - Material Design 3
- **Sardine** - WebDAV client (com.thegrizzlylabs)
- **Gson** - JSON serialization
- **WorkManager** - Background tasks
- **OkHttp** - HTTP client (via Sardine)
### Build Variants
- **Standard** - Universal APK (100% FOSS, no Google dependencies)
- **F-Droid** - Identical to Standard (100% FOSS)
- **Debug/Release** - Development and production
- **No Google Services** - Completely FOSS, no proprietary libraries
---
## 📦 Server Compatibility
### Tested WebDAV Servers
- **Docker WebDAV** (recommended for self-hosting)
- **Nextcloud** - Fully compatible
- **ownCloud** - Works perfectly
- **Apache mod_dav** - Standard WebDAV
- **nginx + WebDAV** - With correct configuration
### Server Features
- **Basic Auth** - Username/password
- **Directory listing** - For download
- **PUT/GET** - Upload/download
- **MKCOL** - Create folders
- **DELETE** - Delete notes (future)
---
## 🔮 Future Features
Planned for upcoming versions (see [TODO.md](project-docs/simple-notes-sync/planning/TODO.md)):
### v1.3.0 - Web Editor & Organization
- **Browser-based editor** - Edit notes in web browser
- **WebDAV access via browser** - No mount needed
- **Mobile-optimized** - Responsive design
- **Offline-capable** - Progressive Web App (PWA)
- **Tags/labels** - Categorize notes
- **Search** - Full-text search in all notes
- **Sorting** - By date, title, tags
- **Filter** - Filter by tags
### v1.4.0 - Sharing & Export
- **Share note** - Via share intent
- **Export single note** - As .txt or .md
- **Import from text** - Via share intent
### v1.5.0 - Advanced Editor Features
- **Markdown preview** - In-app rendering
- **Checklists** - TODO lists in notes
- **Syntax highlighting** - For code snippets
---
## 📊 Comparison with Other Apps
| Feature | Simple Notes Sync | Google Keep | Nextcloud Notes |
|---------|------------------|-------------|-----------------|
| Offline-first | | Limited | Limited |
| Self-hosted | | | |
| Auto-sync | | | |
| Markdown export | | | |
| Desktop access | (WebDAV) | (Web) | (Web + WebDAV) |
| Local backup | | | Server backup |
| No Google account | | | |
| Open source | MIT | | AGPL |
| APK size | ~2 MB | ~50 MB | ~8 MB |
| Battery usage | ~0.4%/day | ~1-2%/day | ~0.5%/day |
---
## ❓ FAQ
**Q: Do I need a server?**
A: No! The app works completely offline. The server is optional for sync.
**Q: Which server is best?**
A: For beginners: Docker WebDAV (simple, easy). For pros: Nextcloud (many features).
**Q: Does Markdown export work without Desktop Integration?**
A: No, you need to activate the feature in settings.
**Q: Will my data be lost if I switch servers?**
A: No! Create a local backup, switch servers, restore.
**Q: Why JSON + Markdown?**
A: JSON is reliable and fast (master). Markdown is human-readable (mirror for desktop).
**Q: Can I use the app without Google Play?**
A: Yes! Download the APK directly from GitHub or use F-Droid.
---
**Last update:** v1.2.1 (2026-01-05)

274
docs/FEATURES.md Normal file
View File

@@ -0,0 +1,274 @@
# Vollständige Feature-Liste 📋
**🌍 Languages:** **Deutsch** · [English](FEATURES.en.md)
> Alle Features von Simple Notes Sync im Detail
---
## 📝 Notiz-Verwaltung
### Basis-Funktionen
-**Einfache Textnotizen** - Fokus auf Inhalt, keine Ablenkung
-**Automatisches Speichern** - Kein manuelles Speichern nötig
-**Titel + Inhalt** - Klare Struktur für jede Notiz
-**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
-**Swipe-to-Delete** - Intuitive Geste zum Löschen
-**Bestätigungs-Dialog** - Schutz vor versehentlichem Löschen
-**Material Design 3** - Moderne, saubere UI
-**Dark Mode** - Automatisch je nach System-Einstellung
-**Dynamic Colors** - Passt sich deinem Android-Theme an
### Editor
-**Minimalistischer Editor** - Kein Schnickschnack
-**Auto-Fokus** - Direkt losschreiben
-**Vollbild-Modus** - Maximale Schreibfläche
-**Speichern-Button** - Manuelle Bestätigung möglich
-**Zurück-Navigation** - Speichert automatisch
---
## 💾 Backup & Wiederherstellung
### Lokales Backup System
-**JSON-Export** - Alle Notizen in einer Datei
-**Freie Speicherort-Wahl** - Downloads, SD-Karte, Cloud-Ordner
-**Dateinamen mit Zeitstempel** - `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
-**Vollständiger Export** - Titel, Inhalt, Timestamps, IDs
-**Menschenlesbares Format** - JSON mit Formatierung
-**Unabhängig vom Server** - Funktioniert komplett offline
### Wiederherstellungs-Modi
-**Zusammenführen (Merge)** - Neue Notizen hinzufügen, bestehende behalten _(Standard)_
-**Ersetzen (Replace)** - Alle löschen und Backup importieren
-**Duplikate überschreiben (Overwrite)** - Backup gewinnt bei ID-Konflikten
-**Automatisches Sicherheits-Backup** - Vor jeder Wiederherstellung
-**Backup-Validierung** - Prüft Format und Version
-**Fehlerbehandlung** - Klare Fehlermeldungen bei Problemen
---
## 🖥️ Desktop-Integration
### Markdown-Export
-**Automatischer Export** - Jede Notiz → `.md` Datei
-**Dual-Format** - JSON (Master) + Markdown (Mirror)
-**Dateinamen-Sanitization** - Sichere Dateinamen aus Titeln
-**Frontmatter-Metadata** - YAML mit ID, Timestamps, Tags
-**WebDAV-Sync** - Parallel zum JSON-Sync
-**Optional** - In Einstellungen ein/ausschaltbar
-**Initial Export** - Alle bestehenden Notizen beim Aktivieren
-**Progress-Anzeige** - Zeigt X/Y beim Export
### Markdown-Import
-**Desktop → App** - Änderungen vom Desktop importieren
-**Last-Write-Wins** - Konfliktauflösung via Timestamp
-**Frontmatter-Parsing** - Liest Metadata aus `.md` Dateien
-**Neue Notizen erkennen** - Automatisch in App übernehmen
-**Updates erkennen** - Nur wenn Desktop-Version neuer ist
-**Fehlertoleranz** - Einzelne Fehler brechen Import nicht ab
### WebDAV-Zugriff
-**Network Drive Mount** - Windows, macOS, Linux
-**Jeder Markdown-Editor** - VS Code, Typora, Notepad++, iA Writer
-**Live-Bearbeitung** - Direkter Zugriff auf `.md` Dateien
-**Ordner-Struktur** - `/notes/` für JSON, `/notes-md/` für Markdown
-**Automatische Ordner-Erstellung** - Beim ersten Sync
---
## 🔄 Synchronisation
### Auto-Sync
-**Intervall-Auswahl** - 15, 30 oder 60 Minuten
-**WLAN-Bindung** - Nur im konfigurierten Heim-WLAN
-**Akkuschonend** - ~0.2-0.8% pro Tag
-**Smart Server-Check** - Keine Fehler in fremden Netzwerken
-**WorkManager** - Zuverlässige Background-Ausführung
-**Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode
### Sync-Trigger (6 Stück)
1.**Periodic Sync** - Automatisch nach Intervall
2.**App-Start Sync** - Beim Öffnen der App
3.**WiFi-Connect Sync** - Wenn Heim-WLAN verbindet
4.**Manual Sync** - Button in Einstellungen
5.**Pull-to-Refresh** - Wisch-Geste in Notizliste
6.**Settings-Save Sync** - Nach Server-Konfiguration
### Sync-Mechanismus
-**Upload** - Lokale Änderungen zum Server
-**Download** - Server-Änderungen in App
-**Konflikt-Erkennung** - Bei gleichzeitigen Änderungen
-**Konfliktfreies Merging** - Last-Write-Wins via Timestamp
-**Sync-Status Tracking** - LOCAL_ONLY, PENDING, SYNCED, CONFLICT
-**Fehlerbehandlung** - Retry bei Netzwerkproblemen
-**Offline-First** - App funktioniert ohne Server
### Server-Verbindung
-**WebDAV-Protokoll** - Standard-Protokoll
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen
-**Gateway SSID** - WLAN-Name für Auto-Sync
-**Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_
-**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
---
## 🔒 Privacy & Sicherheit
### Self-Hosted
-**Eigener Server** - Volle Kontrolle über Daten
-**Keine Cloud** - Keine Drittanbieter
-**Kein Tracking** - Keine Analytik, keine Telemetrie
-**Kein Account** - Nur Server-Zugangsdaten
-**100% Open Source** - MIT Lizenz
### Daten-Sicherheit
-**Lokale Speicherung** - App-Private Storage (Android)
-**WebDAV-Verschlüsselung** - HTTPS für externe Server
-**Passwort-Speicherung** - Android SharedPreferences (verschlüsselt)
-**Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV)
---
## 🔋 Performance & Optimierung
### Akku-Effizienz
-**Optimierte Sync-Intervalle** - 15/30/60 Min
-**WLAN-Only** - Kein Mobile Data Sync
-**Smart Server-Check** - Nur im Heim-WLAN
-**WorkManager** - System-optimierte Ausführung
-**Doze Mode kompatibel** - Sync läuft auch im Standby
-**Gemessener Verbrauch:**
- 15 Min: ~0.8% / Tag (~23 mAh)
- 30 Min: ~0.4% / Tag (~12 mAh) ⭐ _Empfohlen_
- 60 Min: ~0.2% / Tag (~6 mAh)
### App-Performance
-**Offline-First** - Funktioniert ohne Internet
-**Instant-Load** - Notizen laden in <100ms
- **Smooth Scrolling** - RecyclerView mit ViewHolder
- **Material Design 3** - Native Android UI
- **Kotlin Coroutines** - Asynchrone Operationen
- **Minimale APK-Größe** - ~2 MB
---
## 🛠️ Technische Details
### Plattform
- **Android 8.0+** (API 26+)
- **Target SDK 36** (Android 15)
- **Kotlin** - Moderne Programmiersprache
- **Material Design 3** - Neueste Design-Richtlinien
- **ViewBinding** - Typ-sichere View-Referenzen
### Architektur
- **MVVM-Light** - Einfache Architektur
- **Single Activity** - Moderne Navigation
- **Kotlin Coroutines** - Async/Await Pattern
- **Dispatchers.IO** - Background-Operationen
- **SharedPreferences** - Settings-Speicherung
- **File-Based Storage** - JSON-Dateien lokal
### Abhängigkeiten
- **AndroidX** - Jetpack Libraries
- **Material Components** - Material Design 3
- **Sardine** - WebDAV Client (com.thegrizzlylabs)
- **Gson** - JSON Serialization
- **WorkManager** - Background Tasks
- **OkHttp** - HTTP Client (via Sardine)
### Build-Varianten
- **Standard** - Universal APK (100% FOSS, keine Google-Dependencies)
- **F-Droid** - Identisch mit Standard (100% FOSS)
- **Debug/Release** - Entwicklung und Production
- **Keine Google Services** - Komplett FOSS, keine proprietären Bibliotheken
---
## 📦 Server-Kompatibilität
### Getestete WebDAV-Server
- **Docker WebDAV** (empfohlen für Self-Hosting)
- **Nextcloud** - Vollständig kompatibel
- **ownCloud** - Funktioniert einwandfrei
- **Apache mod_dav** - Standard WebDAV
- **nginx + WebDAV** - Mit korrekter Konfiguration
### Server-Features
- **Basic Auth** - Username/Password
- **Directory Listing** - Für Download
- **PUT/GET** - Upload/Download
- **MKCOL** - Ordner erstellen
- **DELETE** - Notizen löschen (zukünftig)
---
## 🔮 Zukünftige Features
Geplant für kommende Versionen (siehe [TODO.md](project-docs/simple-notes-sync/planning/TODO.md)):
### v1.3.0 - Web Editor & Organisation
- **Browser-basierter Editor** - Notizen im Webbrowser bearbeiten
- **WebDAV-Zugriff via Browser** - Kein Mount nötig
- **Mobile-optimiert** - Responsive Design
- **Offline-fähig** - Progressive Web App (PWA)
- **Tags/Labels** - Kategorisierung von Notizen
- **Suche** - Volltextsuche in allen Notizen
- **Sortierung** - Nach Datum, Titel, Tags
- **Filter** - Nach Tags filtern
### v1.4.0 - Sharing & Export
- **Notiz teilen** - Via Share-Intent
- **Einzelne Notiz exportieren** - Als .txt oder .md
- **Import von Text** - Via Share-Intent
### v1.5.0 - Erweiterte Editor-Features
- **Markdown-Vorschau** - In-App Rendering
- **Checklisten** - TODO-Listen in Notizen
- **Syntax-Highlighting** - Für Code-Snippets
---
## 📊 Vergleich mit anderen Apps
| Feature | Simple Notes Sync | Google Keep | Nextcloud Notes |
|---------|------------------|-------------|-----------------|
| Offline-First | | Eingeschränkt | Eingeschränkt |
| Self-Hosted | | | |
| Auto-Sync | | | |
| Markdown-Export | | | |
| Desktop-Zugriff | (WebDAV) | (Web) | (Web + WebDAV) |
| Lokales Backup | | | Server-Backup |
| Kein Google-Account | | | |
| Open Source | MIT | | AGPL |
| APK-Größe | ~2 MB | ~50 MB | ~8 MB |
| Akku-Verbrauch | ~0.4%/Tag | ~1-2%/Tag | ~0.5%/Tag |
---
## ❓ FAQ
**Q: Brauche ich einen Server?**
A: Nein! Die App funktioniert auch komplett offline. Der Server ist optional für Sync.
**Q: Welcher Server ist am besten?**
A: Für Einstieg: Docker WebDAV (einfach, leicht). Für Profis: Nextcloud (viele Features).
**Q: Funktioniert Markdown-Export ohne Desktop-Integration?**
A: Nein, du musst das Feature in den Einstellungen aktivieren.
**Q: Gehen meine Daten verloren wenn ich den Server wechsle?**
A: Nein! Erstelle ein lokales Backup, wechsle Server, stelle wieder her.
**Q: Warum JSON + Markdown?**
A: JSON ist zuverlässig und schnell (Master). Markdown ist menschenlesbar (Mirror für Desktop).
**Q: Kann ich die App ohne Google Play nutzen?**
A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
---
**Letzte Aktualisierung:** v1.2.1 (2026-01-05)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
v1.2.1 - Initial Export + URL Normalization
Fehlerbehebung
• Bestehende Notizen werden beim Aktivieren der Desktop-Integration exportiert
• Markdown-Dateien landen korrekt im /notes-md/ Ordner
• Vereinfachte Server-Konfiguration: Nur Base-URL eingeben (z.B. http://server:8080/)
• App erstellt automatisch /notes/ und /notes-md/
• Beide URL-Varianten funktionieren: mit und ohne /notes
Verbesserungen
• Beispiel-URL zeigt jetzt /notes statt /webdav
• Progress-Dialog beim Export

View File

@@ -0,0 +1,12 @@
v1.2.2 - Rückwärtskompatibilität für v1.2.0 User
Kritische Fehlerbehebung
• Server-Wiederherstellung findet jetzt ALLE Notizen (Root + /notes/)
• User die von v1.2.0 upgraden verlieren keine Daten mehr
• Alte Notizen aus Root-Ordner werden beim Restore gefunden
Technische Details
• Dual-Mode Download nur bei Server-Restore aktiv
• Normale Syncs bleiben schnell (scannen nur /notes/)
• Automatische Deduplication verhindert Duplikate
• Sanfte Migration: Neue Uploads gehen in /notes/, alte bleiben lesbar

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
v1.2.1 - Initial Export + URL Normalization
Bugfixes
• Existing notes are now exported when Desktop Integration is enabled
• Markdown files now correctly land in /notes-md/ folder
• Simplified server config: Enter only base URL (e.g. http://server:8080/)
• App automatically creates /notes/ and /notes-md/
• Both URL variants work: with and without /notes
Improvements
• Example URL now shows /notes instead of /webdav
• Progress dialog during export

View File

@@ -0,0 +1,12 @@
v1.2.2 - Backward Compatibility for v1.2.0 Users
Critical Bugfix
• Server restore now finds ALL notes (Root + /notes/)
• Users upgrading from v1.2.0 no longer lose data
• Old notes from Root folder are found during restore
Technical Details
• Dual-mode download only active for server restore
• Normal syncs remain fast (scan only /notes/)
• Automatic deduplication prevents duplicates
• Smooth migration: New uploads go to /notes/, old ones remain readable

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

View File

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