Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d135e8f0d | ||
|
|
5d82431bb6 | ||
|
|
6bb87816f3 | ||
|
|
4802c3d979 | ||
|
|
85625b4f67 | ||
|
|
609da827c5 | ||
|
|
539f17cdda | ||
|
|
0bd686008d | ||
|
|
65ce3746ca | ||
|
|
6079df3b1e | ||
|
|
5f0dc8a981 | ||
|
|
d79a44491d | ||
|
|
4a04b21975 | ||
|
|
881162737b | ||
|
|
1f78953959 | ||
|
|
3092fcc6d3 | ||
|
|
60d6b1effc |
24
.github/workflows/build-production-apk.yml
vendored
@@ -104,24 +104,22 @@ jobs:
|
|||||||
|
|
||||||
- name: F-Droid Changelogs lesen
|
- name: F-Droid Changelogs lesen
|
||||||
run: |
|
run: |
|
||||||
# Lese deutsche Changelog (Hauptsprache)
|
# Lese deutsche Changelog (Hauptsprache) - Use printf to ensure proper formatting
|
||||||
if [ -f "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
if [ -f "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||||
{
|
CHANGELOG_CONTENT=$(cat "fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||||
echo 'CHANGELOG_DE<<EOF'
|
echo "CHANGELOG_DE<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
cat "android/fastlane/metadata/android/de-DE/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
echo "$CHANGELOG_CONTENT" >> $GITHUB_ENV
|
||||||
echo 'EOF'
|
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||||
} >> $GITHUB_ENV
|
|
||||||
else
|
else
|
||||||
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
echo "CHANGELOG_DE=Keine deutschen Release Notes verfügbar." >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Lese englische Changelog (optional)
|
# Lese englische Changelog (optional)
|
||||||
if [ -f "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
if [ -f "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt" ]; then
|
||||||
{
|
CHANGELOG_CONTENT_EN=$(cat "fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt")
|
||||||
echo 'CHANGELOG_EN<<EOF'
|
echo "CHANGELOG_EN<<GHADELIMITER" >> $GITHUB_ENV
|
||||||
cat "android/fastlane/metadata/android/en-US/changelogs/${{ env.BUILD_NUMBER }}.txt"
|
echo "$CHANGELOG_CONTENT_EN" >> $GITHUB_ENV
|
||||||
echo 'EOF'
|
echo "GHADELIMITER" >> $GITHUB_ENV
|
||||||
} >> $GITHUB_ENV
|
|
||||||
else
|
else
|
||||||
echo "CHANGELOG_EN=" >> $GITHUB_ENV
|
echo "CHANGELOG_EN=" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|||||||
32
.github/workflows/pr-build-check.yml
vendored
@@ -1,27 +1,22 @@
|
|||||||
name: PR Build Check
|
name: PR Build Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
paths:
|
paths:
|
||||||
- 'android/**'
|
- 'android/**'
|
||||||
- '.github/workflows/pr-build-check.yml'
|
- '.github/workflows/pr-build-check.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build & Test APK
|
name: Build & Test APK
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Code auschecken
|
- name: Code auschecken
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Java einrichten
|
- name: Java einrichten
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
|
|
||||||
- name: Gradle Cache
|
- name: Gradle Cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
@@ -31,7 +26,6 @@ jobs:
|
|||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-gradle-
|
${{ runner.os }}-gradle-
|
||||||
|
|
||||||
- name: Version auslesen
|
- name: Version auslesen
|
||||||
run: |
|
run: |
|
||||||
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
VERSION_NAME=$(grep "versionName = " android/app/build.gradle.kts | sed 's/.*versionName = "\(.*\)".*/\1/')
|
||||||
@@ -39,18 +33,15 @@ jobs:
|
|||||||
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
echo "VERSION_NAME=$VERSION_NAME" >> $GITHUB_ENV
|
||||||
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV
|
||||||
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
|
echo "📱 Version: $VERSION_NAME (Code: $VERSION_CODE)"
|
||||||
|
|
||||||
- name: Debug Build erstellen (ohne Signing)
|
- name: Debug Build erstellen (ohne Signing)
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
./gradlew assembleStandardDebug assembleFdroidDebug --no-daemon --stacktrace
|
||||||
|
|
||||||
- name: Unit Tests ausfuehren
|
- name: Unit Tests ausfuehren
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
./gradlew test --no-daemon --stacktrace
|
./gradlew test --no-daemon --stacktrace
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Build-Ergebnis pruefen
|
- name: Build-Ergebnis pruefen
|
||||||
run: |
|
run: |
|
||||||
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
|
if [ -f "android/app/build/outputs/apk/standard/debug/app-standard-universal-debug.apk" ]; then
|
||||||
@@ -60,7 +51,6 @@ jobs:
|
|||||||
echo "❌ Standard Debug APK Build fehlgeschlagen"
|
echo "❌ Standard Debug APK Build fehlgeschlagen"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
|
if [ -f "android/app/build/outputs/apk/fdroid/debug/app-fdroid-universal-debug.apk" ]; then
|
||||||
echo "✅ F-Droid Debug APK erfolgreich gebaut"
|
echo "✅ F-Droid Debug APK erfolgreich gebaut"
|
||||||
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
|
ls -lh android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||||
@@ -68,7 +58,6 @@ jobs:
|
|||||||
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
|
echo "❌ F-Droid Debug APK Build fehlgeschlagen"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Debug APKs hochladen (Artefakte)
|
- name: Debug APKs hochladen (Artefakte)
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -77,7 +66,6 @@ jobs:
|
|||||||
android/app/build/outputs/apk/standard/debug/*.apk
|
android/app/build/outputs/apk/standard/debug/*.apk
|
||||||
android/app/build/outputs/apk/fdroid/debug/*.apk
|
android/app/build/outputs/apk/fdroid/debug/*.apk
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- name: Kommentar zu PR hinzufuegen
|
- name: Kommentar zu PR hinzufuegen
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
if: success()
|
if: success()
|
||||||
@@ -88,26 +76,24 @@ jobs:
|
|||||||
.filter(f => f.endsWith('.apk'));
|
.filter(f => f.endsWith('.apk'));
|
||||||
const fdroidApk = fs.readdirSync('android/app/build/outputs/apk/fdroid/debug/')
|
const fdroidApk = fs.readdirSync('android/app/build/outputs/apk/fdroid/debug/')
|
||||||
.filter(f => f.endsWith('.apk'));
|
.filter(f => f.endsWith('.apk'));
|
||||||
|
|
||||||
github.rest.issues.createComment({
|
github.rest.issues.createComment({
|
||||||
issue_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
body: `## ✅ Build erfolgreich!
|
body: `## ✅ Build erfolgreich!
|
||||||
|
|
||||||
**Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})
|
**Version:** ${{ env.VERSION_NAME }} (Code: ${{ env.VERSION_CODE }})
|
||||||
|
|
||||||
### 📦 Debug APKs (Test-Builds)
|
### 📦 Debug APKs (Test-Builds)
|
||||||
|
Die Debug-APKs wurden erfolgreich gebaut und sind als Artefakte verfuegbar:
|
||||||
|
|
||||||
Die Debug-APKs wurden erfolgreich gebaut und sind als Artefakte verfuegbar:
|
**Standard Flavor:**
|
||||||
|
${standardApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||||
|
|
||||||
**Standard Flavor:**
|
**F-Droid Flavor:**
|
||||||
${standardApk.map(f => '- `' + f + '`').join('\n')}
|
${fdroidApk.map(f => '- \`' + f + '\`').join('\n')}
|
||||||
|
|
||||||
**F-Droid Flavor:**
|
> ⚠️ **Hinweis:** Dies sind unsigned Debug-Builds zum Testen. Production Releases werden nur bei Merge auf \`main\` erstellt.
|
||||||
${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 }})`
|
||||||
|
|
||||||
[📥 Download Artefakte](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})`
|
|
||||||
})
|
})
|
||||||
152
CHANGELOG.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Simple Notes Sync will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-01-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Local Backup System**
|
||||||
|
- Export all notes as JSON file to any location (Downloads, SD card, cloud folder)
|
||||||
|
- Import backup with 3 modes: Merge, Replace, or Overwrite duplicates
|
||||||
|
- Automatic safety backup created before every restore
|
||||||
|
- Backup validation (format and version check)
|
||||||
|
|
||||||
|
- **Markdown Desktop Integration**
|
||||||
|
- Optional Markdown export parallel to JSON sync
|
||||||
|
- `.md` files synced to `notes-md/` folder on WebDAV
|
||||||
|
- YAML frontmatter with `id`, `created`, `updated`, `device`
|
||||||
|
- Manual import button to pull Markdown changes from server
|
||||||
|
- Last-Write-Wins conflict resolution via timestamps
|
||||||
|
|
||||||
|
- **Settings UI Extensions**
|
||||||
|
- New "Backup & Restore" section with local + server restore
|
||||||
|
- New "Desktop Integration" section with Markdown toggle
|
||||||
|
- Universal restore dialog with radio button mode selection
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Server Restore Behavior**: Users now choose restore mode (Merge/Replace/Overwrite) instead of hard-coded replace-all
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `BackupManager.kt` - Complete backup/restore logic
|
||||||
|
- `Note.toMarkdown()` / `Note.fromMarkdown()` - Markdown conversion with YAML frontmatter
|
||||||
|
- `WebDavSyncService` - Extended for dual-format sync (JSON master + Markdown mirror)
|
||||||
|
- ISO8601 timestamp formatting for desktop compatibility
|
||||||
|
- Filename sanitization for safe Markdown file names
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- Added WebDAV mount instructions (Windows, macOS, Linux)
|
||||||
|
- Created [SYNC_ARCHITECTURE.md](../project-docs/simple-notes-sync/architecture/SYNC_ARCHITECTURE.md) - Complete sync documentation
|
||||||
|
- Created [MARKDOWN_DESKTOP_REALITY_CHECK.md](../project-docs/simple-notes-sync/markdown-desktop-plan/MARKDOWN_DESKTOP_REALITY_CHECK.md) - Desktop integration analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.2] - 2025-12-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Job was cancelled" Error**
|
||||||
|
- Fixed coroutine cancellation in sync worker
|
||||||
|
- Proper error handling for interrupted syncs
|
||||||
|
|
||||||
|
- **UI Improvements**
|
||||||
|
- Back arrow instead of X in note editor (better UX)
|
||||||
|
- Pull-to-refresh for manual sync trigger
|
||||||
|
- HTTP/HTTPS protocol selection with radio buttons
|
||||||
|
- Inline error display (no toast spam)
|
||||||
|
|
||||||
|
- **Performance & Battery**
|
||||||
|
- Sync only on actual changes (saves battery)
|
||||||
|
- Auto-save notifications removed
|
||||||
|
- 24-hour server offline warning instead of instant error
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Settings grouped into "Auto-Sync" and "Sync Interval" sections
|
||||||
|
- HTTP only allowed for local networks (RFC 1918 IPs)
|
||||||
|
- Swipe-to-delete without UI flicker
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.1] - 2025-12-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **WiFi Connect Sync**
|
||||||
|
- No error notifications in foreign WiFi networks
|
||||||
|
- Server reachability check before sync (2s timeout)
|
||||||
|
- Silent abort when server offline
|
||||||
|
- Pre-check waits until network is ready
|
||||||
|
- No errors during network initialization
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Notifications**
|
||||||
|
- Old sync notifications cleared on app start
|
||||||
|
- Error notifications auto-dismiss after 30 seconds
|
||||||
|
|
||||||
|
### UI
|
||||||
|
- Sync icon only shown when sync is configured
|
||||||
|
- Swipe-to-delete without flicker
|
||||||
|
- Scroll to top after saving note
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- Server check with 2-second timeout before sync attempts
|
||||||
|
- Network readiness check in WiFi connect trigger
|
||||||
|
- Notification cleanup on MainActivity.onCreate()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-12-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Configurable Sync Intervals**
|
||||||
|
- User choice: 15, 30, or 60 minutes
|
||||||
|
- Real-world battery impact displayed (15min: ~0.8%/day, 30min: ~0.4%/day, 60min: ~0.2%/day)
|
||||||
|
- Radio button selection in settings
|
||||||
|
- Doze Mode optimization (syncs batched in maintenance windows)
|
||||||
|
|
||||||
|
- **About Section**
|
||||||
|
- App version from BuildConfig
|
||||||
|
- Links to GitHub repository and developer profile
|
||||||
|
- MIT license information
|
||||||
|
- Material 3 card design
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Settings UI redesigned with grouped sections
|
||||||
|
- Periodic sync updated dynamically when interval changes
|
||||||
|
- WorkManager uses selected interval for background sync
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Debug/Logs section from settings (cleaner UI)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
- `PREF_SYNC_INTERVAL_MINUTES` preference key
|
||||||
|
- NetworkMonitor reads interval from SharedPreferences
|
||||||
|
- `ExistingPeriodicWorkPolicy.UPDATE` for live interval changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [1.0.0] - 2025-12-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release
|
||||||
|
- WebDAV synchronization
|
||||||
|
- Note creation, editing, deletion
|
||||||
|
- 6 sync triggers:
|
||||||
|
- Periodic sync (configurable interval)
|
||||||
|
- App start sync
|
||||||
|
- WiFi connect sync
|
||||||
|
- Manual sync (menu button)
|
||||||
|
- Pull-to-refresh
|
||||||
|
- Settings "Sync Now" button
|
||||||
|
- Material 3 design
|
||||||
|
- Light/Dark theme support
|
||||||
|
- F-Droid compatible (100% FOSS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[1.2.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.2.0
|
||||||
|
[1.1.2]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.2
|
||||||
|
[1.1.1]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.1
|
||||||
|
[1.1.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.1.0
|
||||||
|
[1.0.0]: https://github.com/inventory69/simple-notes-sync/releases/tag/v1.0.0
|
||||||
154
README.en.md
@@ -12,13 +12,54 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📱 Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg" width="250" alt="Notes list">
|
||||||
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg" width="250" alt="Edit note">
|
||||||
|
<img src="fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg" width="250" alt="Settings">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📝 Offline-First - Notes always available
|
### 📝 Notes
|
||||||
- 🔄 Auto-Sync - Configurable intervals (15/30/60 min)
|
* Simple text notes with auto-save
|
||||||
- 🏠 Self-Hosted - WebDAV on your server
|
* Swipe-to-delete with confirmation
|
||||||
- 🔐 Privacy-First - No cloud, no tracking
|
* Material Design 3 editor
|
||||||
- 🔋 Battery-friendly - ~0.2-0.8% per day
|
|
||||||
|
### 💾 Backup & Restore **NEW in v1.2.0**
|
||||||
|
* **Local backup** - Export all notes as JSON file
|
||||||
|
* **Flexible restore** - 3 modes (Merge, Replace, Overwrite)
|
||||||
|
* **Automatic safety net** - Auto-backup before every restore
|
||||||
|
* **Independent from server** - Works completely offline
|
||||||
|
|
||||||
|
### 🖥️ Desktop Integration **NEW in v1.2.0**
|
||||||
|
* **Markdown export** - Notes are automatically exported as `.md` files
|
||||||
|
* **WebDAV access** - Mount WebDAV as network drive for direct access
|
||||||
|
* **Editor compatibility** - VS Code, Typora, Notepad++, or any Markdown editor
|
||||||
|
* **Last-Write-Wins** - Intelligent conflict resolution via timestamps
|
||||||
|
* **Dual-format** - JSON sync remains master, Markdown is optional mirror
|
||||||
|
|
||||||
|
### 🔄 Synchronization
|
||||||
|
* **Pull-to-refresh** for manual sync
|
||||||
|
* **Auto-sync** (15/30/60 min) only on home WiFi
|
||||||
|
* **Smart server check** - No errors on foreign networks
|
||||||
|
* **Conflict-free merging** - Your changes are never lost
|
||||||
|
* **6 sync triggers** - Periodic, app-start, WiFi, manual, pull-to-refresh, settings
|
||||||
|
|
||||||
|
### 🔒 Privacy & Self-Hosted
|
||||||
|
* **WebDAV server** (Nextcloud, ownCloud, etc.)
|
||||||
|
* **Docker setup guide** included in docs
|
||||||
|
* **Your data stays with you** - No tracking, no cloud
|
||||||
|
* **HTTP only local** - HTTPS for external servers
|
||||||
|
* **100% open source** (MIT License)
|
||||||
|
|
||||||
|
### 🔋 Performance
|
||||||
|
* **Battery-friendly** (~0.2-0.8% per day)
|
||||||
|
* **Offline-first** - Works without internet
|
||||||
|
* **Dark mode** & dynamic colors
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,7 +87,98 @@ docker compose up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Documentation
|
## <EFBFBD> Local Backup & Restore
|
||||||
|
|
||||||
|
### Create Backup
|
||||||
|
|
||||||
|
1. **Settings** → **Backup & Restore**
|
||||||
|
2. Tap **"📥 Create backup"**
|
||||||
|
3. Choose location (Downloads, SD card, cloud folder)
|
||||||
|
4. Done! All notes are saved in a `.json` file
|
||||||
|
|
||||||
|
**Filename:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
1. **Settings** → **"📤 Restore from file"**
|
||||||
|
2. Select backup file
|
||||||
|
3. **Choose restore mode:**
|
||||||
|
- **Merge** _(Default)_ - Add new notes, keep existing ones
|
||||||
|
- **Replace** - Delete all and import backup
|
||||||
|
- **Overwrite duplicates** - Backup wins on ID conflicts
|
||||||
|
4. Confirm - _Automatic safety backup is created!_
|
||||||
|
|
||||||
|
**💡 Tip:** Before every restore, an automatic safety backup is created - your data is safe!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Desktop Integration (WebDAV + Markdown)
|
||||||
|
|
||||||
|
### Why Markdown?
|
||||||
|
|
||||||
|
The app automatically exports your notes as `.md` files so you can edit them on desktop:
|
||||||
|
|
||||||
|
- **JSON remains master** - Primary sync mechanism (reliable, fast)
|
||||||
|
- **Markdown is mirror** - Additional export for desktop access
|
||||||
|
- **Dual-format** - Both formats are always in sync
|
||||||
|
|
||||||
|
### Setup: WebDAV as Network Drive
|
||||||
|
|
||||||
|
**With WebDAV mount ANY Markdown editor works!**
|
||||||
|
|
||||||
|
#### Windows:
|
||||||
|
|
||||||
|
1. **Open Explorer** → Right-click on "This PC"
|
||||||
|
2. **"Map network drive"**
|
||||||
|
3. **Enter WebDAV URL:** `http://YOUR-SERVER:8080/notes-md/`
|
||||||
|
4. Enter username/password
|
||||||
|
5. **Done!** - Folder appears as drive (e.g. Z:\)
|
||||||
|
|
||||||
|
#### macOS:
|
||||||
|
|
||||||
|
1. **Finder** → Menu "Go" → "Connect to Server" (⌘K)
|
||||||
|
2. **Server Address:** `http://YOUR-SERVER:8080/notes-md/`
|
||||||
|
3. Enter username/password
|
||||||
|
4. **Done!** - Folder appears under "Network"
|
||||||
|
|
||||||
|
#### Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: GNOME Files / Nautilus
|
||||||
|
Files → Other Locations → Connect to Server
|
||||||
|
Server Address: dav://YOUR-SERVER:8080/notes-md/
|
||||||
|
|
||||||
|
# Option 2: davfs2 (permanent mount)
|
||||||
|
sudo apt install davfs2
|
||||||
|
sudo mount -t davfs http://YOUR-SERVER:8080/notes-md/ /mnt/notes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow:
|
||||||
|
|
||||||
|
1. **Enable Markdown export** (App → Settings)
|
||||||
|
2. **Mount WebDAV** (see above)
|
||||||
|
3. **Open editor** (VS Code, Typora, Notepad++, etc.)
|
||||||
|
4. **Edit notes** - Changes are saved directly
|
||||||
|
5. **"Import Markdown Changes" in app** - Import desktop changes
|
||||||
|
|
||||||
|
**Recommended Editors:**
|
||||||
|
- **VS Code** - Free, powerful, with Markdown preview
|
||||||
|
- **Typora** - Minimalist, WYSIWYG Markdown
|
||||||
|
- **Notepad++** - Lightweight, fast
|
||||||
|
- **iA Writer** - Focused writing
|
||||||
|
|
||||||
|
- **VS Code** with WebDAV extension
|
||||||
|
- **Typora** (local copy)
|
||||||
|
- **iA Writer** (read/edit only, no auto-sync)
|
||||||
|
|
||||||
|
**⚠️ Important:**
|
||||||
|
- Markdown export is **optional** (toggle in settings)
|
||||||
|
- JSON sync **always** works - Markdown is additional
|
||||||
|
- All 6 sync triggers remain unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## <20>📚 Documentation
|
||||||
|
|
||||||
- **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users
|
- **[Quick Start Guide](QUICKSTART.en.md)** - Step-by-step guide for end users
|
||||||
- **[Server Setup](server/README.en.md)** - Configure WebDAV server
|
- **[Server Setup](server/README.en.md)** - Configure WebDAV server
|
||||||
@@ -71,8 +203,14 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
## <EFBFBD> Changelog
|
||||||
|
|
||||||
|
All changes are documented in [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## <20>📄 License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
MIT License - see [LICENSE](LICENSE)
|
||||||
|
|
||||||
**v1.1.0** · Built with Kotlin + Material Design 3
|
**v1.2.0** · Built with Kotlin + Material Design 3
|
||||||
|
|||||||
159
README.md
@@ -12,13 +12,54 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📱 Screenshots
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📝 Offline-First - Notizen immer verfügbar
|
### 📝 Notizen
|
||||||
- 🔄 Auto-Sync - Konfigurierbare Intervalle (15/30/60 Min)
|
* Einfache Textnotizen mit automatischem Speichern
|
||||||
- 🏠 Self-Hosted - WebDAV auf deinem Server
|
* Swipe-to-Delete mit Bestätigung
|
||||||
- 🔐 Privacy-First - Keine Cloud, kein Tracking
|
* Material Design 3 Editor
|
||||||
- 🔋 Akkuschonend - ~0.2-0.8% pro Tag
|
|
||||||
|
### 💾 Backup & Wiederherstellung **NEU in v1.2.0**
|
||||||
|
* **Lokales Backup** - Exportiere alle Notizen als JSON-Datei
|
||||||
|
* **Flexible Wiederherstellung** - 3 Modi (Zusammenführen, Ersetzen, Überschreiben)
|
||||||
|
* **Automatisches Sicherheitsnetz** - Auto-Backup vor jeder Wiederherstellung
|
||||||
|
* **Unabhängig vom Server** - Funktioniert komplett offline
|
||||||
|
|
||||||
|
### 🖥️ Desktop-Integration **NEU in v1.2.0**
|
||||||
|
* **Markdown-Export** - Notizen werden automatisch als `.md` Dateien exportiert
|
||||||
|
* **WebDAV-Zugriff** - Mounte WebDAV als Netzlaufwerk für direkten Zugriff
|
||||||
|
* **Editor-Kompatibilität** - VS Code, Typora, Notepad++, oder beliebiger Markdown-Editor
|
||||||
|
* **Last-Write-Wins** - Intelligente Konfliktauflösung via Zeitstempel
|
||||||
|
* **Dual-Format** - JSON-Sync bleibt Master, Markdown ist optionaler Mirror
|
||||||
|
|
||||||
|
### 🔄 Synchronisation
|
||||||
|
* **Pull-to-Refresh** für manuellen Sync
|
||||||
|
* **Auto-Sync** (15/30/60 Min) nur im Heim-WLAN
|
||||||
|
* **Smart Server-Check** - Keine Fehler in fremden Netzwerken
|
||||||
|
* **Konfliktfreies Merging** - Deine Änderungen gehen nie verloren
|
||||||
|
* **6 Sync-Trigger** - Periodic, App-Start, WiFi, Manual, Pull-to-Refresh, Settings
|
||||||
|
|
||||||
|
### 🔒 Privacy & Self-Hosted
|
||||||
|
* **WebDAV-Server** (Nextcloud, ownCloud, etc.)
|
||||||
|
* **Docker Setup-Anleitung** in den Docs enthalten
|
||||||
|
* **Deine Daten bleiben bei dir** - Kein Tracking, keine Cloud
|
||||||
|
* **HTTP nur lokal** - HTTPS für externe Server
|
||||||
|
* **100% Open Source** (MIT Lizenz)
|
||||||
|
|
||||||
|
### 🔋 Performance
|
||||||
|
* **Akkuschonend** (~0.2-0.8% pro Tag)
|
||||||
|
* **Offline-First** - Funktioniert ohne Internet
|
||||||
|
* **Dark Mode** & Dynamic Colors
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,7 +87,103 @@ docker compose up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📚 Dokumentation
|
## <EFBFBD> Lokales Backup & Wiederherstellung
|
||||||
|
|
||||||
|
### Backup erstellen
|
||||||
|
|
||||||
|
1. **Einstellungen** → **Backup & Wiederherstellung**
|
||||||
|
2. Tippe auf **"📥 Backup erstellen"**
|
||||||
|
3. Wähle Speicherort (Downloads, SD-Karte, Cloud-Ordner)
|
||||||
|
4. Fertig! Alle Notizen sind in einer `.json` Datei gesichert
|
||||||
|
|
||||||
|
**Dateiname:** `simplenotes_backup_YYYY-MM-DD_HHmmss.json`
|
||||||
|
|
||||||
|
### Wiederherstellen
|
||||||
|
|
||||||
|
1. **Einstellungen** → **"📤 Aus Datei wiederherstellen"**
|
||||||
|
2. Wähle Backup-Datei
|
||||||
|
3. **Wiederherstellungs-Modus auswählen:**
|
||||||
|
- **Zusammenführen** _(Standard)_ - Neue Notizen hinzufügen, bestehende behalten
|
||||||
|
- **Ersetzen** - Alle löschen und Backup importieren
|
||||||
|
- **Duplikate überschreiben** - Backup gewinnt bei ID-Konflikten
|
||||||
|
4. Bestätigen - _Automatisches Sicherheits-Backup wird erstellt!_
|
||||||
|
|
||||||
|
**💡 Tipp:** Vor jeder Wiederherstellung wird automatisch ein Auto-Backup erstellt - deine Daten sind sicher!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ Desktop-Integration (WebDAV + Markdown)
|
||||||
|
|
||||||
|
### Warum Markdown?
|
||||||
|
|
||||||
|
Die App exportiert deine Notizen automatisch als `.md` Dateien, damit du sie auf dem Desktop bearbeiten kannst:
|
||||||
|
|
||||||
|
- **JSON bleibt Master** - Primärer Sync-Mechanismus (verlässlich, schnell)
|
||||||
|
- **Markdown ist Mirror** - Zusätzlicher Export für Desktop-Zugriff
|
||||||
|
- **Dual-Format** - Beide Formate sind immer synchron
|
||||||
|
|
||||||
|
### Setup: WebDAV als Netzlaufwerk
|
||||||
|
|
||||||
|
**Mit WebDAV-Mount funktioniert JEDER Markdown-Editor!**
|
||||||
|
|
||||||
|
#### Windows:
|
||||||
|
|
||||||
|
1. **Explorer öffnen** → Rechtsklick auf "Dieser PC"
|
||||||
|
2. **"Netzlaufwerk verbinden"** wählen
|
||||||
|
3. **WebDAV-URL eingeben:** `http://DEIN-SERVER:8080/notes-md/`
|
||||||
|
4. Benutzername/Passwort eingeben
|
||||||
|
5. **Fertig!** - Ordner erscheint als Laufwerk (z.B. Z:\)
|
||||||
|
|
||||||
|
#### macOS:
|
||||||
|
|
||||||
|
1. **Finder** → Menü "Gehe zu" → "Mit Server verbinden" (⌘K)
|
||||||
|
2. **Server-Adresse:** `http://DEIN-SERVER:8080/notes-md/`
|
||||||
|
3. Benutzername/Passwort eingeben
|
||||||
|
4. **Fertig!** - Ordner erscheint unter "Netzwerk"
|
||||||
|
|
||||||
|
#### Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Option 1: GNOME Files / Nautilus
|
||||||
|
Dateien → Andere Orte → Mit Server verbinden
|
||||||
|
Server-Adresse: dav://DEIN-SERVER:8080/notes-md/
|
||||||
|
|
||||||
|
# Option 2: davfs2 (permanent mount)
|
||||||
|
sudo apt install davfs2
|
||||||
|
sudo mount -t davfs http://DEIN-SERVER:8080/notes-md/ /mnt/notes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow:
|
||||||
|
|
||||||
|
1. **Markdown-Export aktivieren** (App → Einstellungen)
|
||||||
|
2. **WebDAV mounten** (siehe oben)
|
||||||
|
3. **Editor öffnen** (VS Code, Typora, Notepad++, etc.)
|
||||||
|
4. **Notizen bearbeiten** - Änderungen werden direkt gespeichert
|
||||||
|
5. **"Import Markdown Changes" in App** - Desktop-Änderungen importieren
|
||||||
|
|
||||||
|
**Empfohlene Editoren:**
|
||||||
|
- **VS Code** - Kostenlos, mächtig, mit Markdown-Preview
|
||||||
|
- **Typora** - Minimalistisch, WYSIWYG-Markdown
|
||||||
|
- **Notepad++** - Leichtgewichtig, schnell
|
||||||
|
- **iA Writer** - Fokussiertes Schreiben
|
||||||
|
3. Notizen bearbeiten - Änderungen via "Import Markdown Changes" in die App importieren
|
||||||
|
|
||||||
|
### Alternative: Direkter Zugriff
|
||||||
|
|
||||||
|
Du kannst die `.md` Dateien auch direkt mit jedem Markdown-Editor öffnen:
|
||||||
|
|
||||||
|
- **VS Code** mit WebDAV-Extension
|
||||||
|
- **Typora** (lokale Kopie)
|
||||||
|
- **iA Writer** (nur lesen/bearbeiten, kein Auto-Sync)
|
||||||
|
|
||||||
|
**⚠️ Wichtig:**
|
||||||
|
- Markdown-Export ist **optional** (in Einstellungen ein/ausschaltbar)
|
||||||
|
- JSON-Sync funktioniert **immer** - Markdown ist zusätzlich
|
||||||
|
- Alle 6 Sync-Trigger bleiben unverändert erhalten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## <20>📚 Dokumentation
|
||||||
|
|
||||||
- **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer
|
- **[Quick Start Guide](QUICKSTART.md)** - Schritt-für-Schritt Anleitung für Endbenutzer
|
||||||
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren
|
- **[Server Setup](server/README.md)** - WebDAV Server konfigurieren
|
||||||
@@ -71,8 +208,14 @@ Beiträge sind willkommen! Siehe [CONTRIBUTING.md](CONTRIBUTING.md) für Details
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 Lizenz
|
## <EFBFBD> Changelog
|
||||||
|
|
||||||
|
Alle Änderungen sind in [CHANGELOG.md](CHANGELOG.md) dokumentiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## <20>📄 Lizenz
|
||||||
|
|
||||||
MIT License - siehe [LICENSE](LICENSE)
|
MIT License - siehe [LICENSE](LICENSE)
|
||||||
|
|
||||||
**v1.1.0** · Gebaut mit Kotlin + Material Design 3
|
**v1.2.0** · Gebaut mit Kotlin + Material Design 3
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ android {
|
|||||||
applicationId = "dev.dettmer.simplenotes"
|
applicationId = "dev.dettmer.simplenotes"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 3 // 🔥 Bugfix: Spurious Sync Error Notifications + Sync Icon Bug
|
versionCode = 5 // 🔥 v1.2.0: Local Backup + Markdown Desktop Integration
|
||||||
versionName = "1.1.1" // 🔥 Bugfix: Server-Erreichbarkeits-Check + Notification-Improvements
|
versionName = "1.2.0" // 🔥 v1.2.0: Backup/Restore + Joplin/Obsidian Support
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@@ -26,6 +26,12 @@ android {
|
|||||||
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
|
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
|
||||||
|
dependenciesInfo {
|
||||||
|
includeInApk = false // Removes DEPENDENCY_INFO_BLOCK from APK
|
||||||
|
includeInBundle = false // Also disable for AAB (Google Play)
|
||||||
|
}
|
||||||
|
|
||||||
// Enable multiple APKs per ABI for smaller downloads
|
// Enable multiple APKs per ABI for smaller downloads
|
||||||
splits {
|
splits {
|
||||||
abi {
|
abi {
|
||||||
@@ -124,6 +130,9 @@ dependencies {
|
|||||||
// LocalBroadcastManager für UI Refresh
|
// LocalBroadcastManager für UI Refresh
|
||||||
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0")
|
||||||
|
|
||||||
|
// SwipeRefreshLayout für Pull-to-Refresh
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
// Testing (bleiben so)
|
// Testing (bleiben so)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SimpleNotes"
|
android:theme="@style/Theme.SimpleNotes"
|
||||||
android:usesCleartextTraffic="true"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private lateinit var emptyStateCard: MaterialCardView
|
private lateinit var emptyStateCard: MaterialCardView
|
||||||
private lateinit var fabAddNote: FloatingActionButton
|
private lateinit var fabAddNote: FloatingActionButton
|
||||||
private lateinit var toolbar: MaterialToolbar
|
private lateinit var toolbar: MaterialToolbar
|
||||||
|
private lateinit var swipeRefreshLayout: SwipeRefreshLayout
|
||||||
|
|
||||||
private lateinit var adapter: NotesAdapter
|
private lateinit var adapter: NotesAdapter
|
||||||
private val storage by lazy { NotesStorage(this) }
|
private val storage by lazy { NotesStorage(this) }
|
||||||
@@ -152,6 +154,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
try {
|
try {
|
||||||
val syncService = WebDavSyncService(this@MainActivity)
|
val syncService = WebDavSyncService(this@MainActivity)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-sync ($source): No unsynced changes - skipping")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
syncService.isServerReachable()
|
syncService.isServerReachable()
|
||||||
@@ -220,6 +228,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
emptyStateCard = findViewById(R.id.emptyStateCard)
|
emptyStateCard = findViewById(R.id.emptyStateCard)
|
||||||
fabAddNote = findViewById(R.id.fabAddNote)
|
fabAddNote = findViewById(R.id.fabAddNote)
|
||||||
toolbar = findViewById(R.id.toolbar)
|
toolbar = findViewById(R.id.toolbar)
|
||||||
|
swipeRefreshLayout = findViewById<SwipeRefreshLayout>(R.id.swipeRefreshLayout)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupToolbar() {
|
private fun setupToolbar() {
|
||||||
@@ -233,10 +242,72 @@ class MainActivity : AppCompatActivity() {
|
|||||||
recyclerViewNotes.adapter = adapter
|
recyclerViewNotes.adapter = adapter
|
||||||
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
recyclerViewNotes.layoutManager = LinearLayoutManager(this)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Setup Pull-to-Refresh
|
||||||
|
setupPullToRefresh()
|
||||||
|
|
||||||
// Setup Swipe-to-Delete
|
// Setup Swipe-to-Delete
|
||||||
setupSwipeToDelete()
|
setupSwipeToDelete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup Pull-to-Refresh für manuellen Sync (v1.1.2)
|
||||||
|
*/
|
||||||
|
private fun setupPullToRefresh() {
|
||||||
|
swipeRefreshLayout.setOnRefreshListener {
|
||||||
|
Logger.d(TAG, "🔄 Pull-to-Refresh triggered - starting manual sync")
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val prefs = getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
|
|
||||||
|
if (serverUrl.isNullOrEmpty()) {
|
||||||
|
showToast("⚠️ Server noch nicht konfiguriert")
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val syncService = WebDavSyncService(this@MainActivity)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ No unsynced changes, skipping server reachability check")
|
||||||
|
showToast("✅ Bereits synchronisiert")
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server is reachable
|
||||||
|
if (!syncService.isServerReachable()) {
|
||||||
|
showToast("⚠️ Server nicht erreichbar")
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform sync
|
||||||
|
val result = syncService.syncNotes()
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
showToast("✅ ${result.syncedCount} Notizen synchronisiert")
|
||||||
|
loadNotes()
|
||||||
|
} else {
|
||||||
|
showToast("❌ Sync fehlgeschlagen: ${result.errorMessage}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Pull-to-Refresh sync failed", e)
|
||||||
|
showToast("❌ Fehler: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
swipeRefreshLayout.isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Material 3 color scheme
|
||||||
|
swipeRefreshLayout.setColorSchemeResources(
|
||||||
|
com.google.android.material.R.color.material_dynamic_primary50
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupSwipeToDelete() {
|
private fun setupSwipeToDelete() {
|
||||||
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||||
0, // No drag
|
0, // No drag
|
||||||
@@ -336,11 +407,18 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private fun triggerManualSync() {
|
private fun triggerManualSync() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
showToast("Starte Synchronisation...")
|
|
||||||
|
|
||||||
// Create sync service
|
// Create sync service
|
||||||
val syncService = WebDavSyncService(this@MainActivity)
|
val syncService = WebDavSyncService(this@MainActivity)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ Manual Sync: No unsynced changes - skipping")
|
||||||
|
showToast("✅ Bereits synchronisiert")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast("Starte Synchronisation...")
|
||||||
|
|
||||||
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in SyncWorker)
|
||||||
val isReachable = withContext(Dispatchers.IO) {
|
val isReachable = withContext(Dispatchers.IO) {
|
||||||
syncService.isServerReachable()
|
syncService.isServerReachable()
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class NoteEditorActivity : AppCompatActivity() {
|
|||||||
setSupportActionBar(toolbar)
|
setSupportActionBar(toolbar)
|
||||||
supportActionBar?.apply {
|
supportActionBar?.apply {
|
||||||
setDisplayHomeAsUpEnabled(true)
|
setDisplayHomeAsUpEnabled(true)
|
||||||
setHomeAsUpIndicator(android.R.drawable.ic_menu_close_clear_cancel)
|
// 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
|
||||||
|
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find views
|
// Find views
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import android.util.Log
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
|
import android.widget.RadioButton
|
||||||
import android.widget.RadioGroup
|
import android.widget.RadioGroup
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.widget.SwitchCompat
|
import androidx.appcompat.widget.SwitchCompat
|
||||||
@@ -20,14 +22,14 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import com.google.android.material.appbar.MaterialToolbar
|
import com.google.android.material.appbar.MaterialToolbar
|
||||||
import com.google.android.material.card.MaterialCardView
|
import com.google.android.material.card.MaterialCardView
|
||||||
import com.google.android.material.chip.Chip
|
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import dev.dettmer.simplenotes.backup.BackupManager
|
||||||
|
import dev.dettmer.simplenotes.backup.RestoreMode
|
||||||
|
import dev.dettmer.simplenotes.utils.UrlValidator
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
import dev.dettmer.simplenotes.sync.WebDavSyncService
|
||||||
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
import dev.dettmer.simplenotes.sync.NetworkMonitor
|
||||||
@@ -49,15 +51,25 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
private const val LICENSE_URL = "https://github.com/inventory69/simple-notes-sync/blob/main/LICENSE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout
|
||||||
private lateinit var editTextServerUrl: EditText
|
private lateinit var editTextServerUrl: EditText
|
||||||
private lateinit var editTextUsername: EditText
|
private lateinit var editTextUsername: EditText
|
||||||
private lateinit var editTextPassword: EditText
|
private lateinit var editTextPassword: EditText
|
||||||
private lateinit var switchAutoSync: SwitchCompat
|
private lateinit var switchAutoSync: SwitchCompat
|
||||||
|
private lateinit var switchMarkdownExport: SwitchCompat
|
||||||
private lateinit var buttonTestConnection: Button
|
private lateinit var buttonTestConnection: Button
|
||||||
private lateinit var buttonSyncNow: Button
|
private lateinit var buttonSyncNow: Button
|
||||||
|
private lateinit var buttonCreateBackup: Button
|
||||||
|
private lateinit var buttonRestoreFromFile: Button
|
||||||
private lateinit var buttonRestoreFromServer: Button
|
private lateinit var buttonRestoreFromServer: Button
|
||||||
|
private lateinit var buttonImportMarkdown: Button
|
||||||
private lateinit var textViewServerStatus: TextView
|
private lateinit var textViewServerStatus: TextView
|
||||||
private lateinit var chipAutoSaveStatus: Chip
|
|
||||||
|
// Protocol Selection UI
|
||||||
|
private lateinit var protocolRadioGroup: RadioGroup
|
||||||
|
private lateinit var radioHttp: RadioButton
|
||||||
|
private lateinit var radioHttps: RadioButton
|
||||||
|
private lateinit var protocolHintText: TextView
|
||||||
|
|
||||||
// Sync Interval UI
|
// Sync Interval UI
|
||||||
private lateinit var radioGroupSyncInterval: RadioGroup
|
private lateinit var radioGroupSyncInterval: RadioGroup
|
||||||
@@ -68,7 +80,21 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
private lateinit var cardDeveloperProfile: MaterialCardView
|
private lateinit var cardDeveloperProfile: MaterialCardView
|
||||||
private lateinit var cardLicense: MaterialCardView
|
private lateinit var cardLicense: MaterialCardView
|
||||||
|
|
||||||
private var autoSaveIndicatorJob: Job? = null
|
// Backup Manager
|
||||||
|
private val backupManager by lazy { BackupManager(this) }
|
||||||
|
|
||||||
|
// Activity Result Launchers
|
||||||
|
private val createBackupLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.CreateDocument("application/json")
|
||||||
|
) { uri ->
|
||||||
|
uri?.let { createBackup(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val restoreBackupLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocument()
|
||||||
|
) { uri ->
|
||||||
|
uri?.let { showRestoreDialog(RestoreSource.LOCAL_FILE, it) }
|
||||||
|
}
|
||||||
|
|
||||||
private val prefs by lazy {
|
private val prefs by lazy {
|
||||||
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
|
getSharedPreferences(Constants.PREFS_NAME, MODE_PRIVATE)
|
||||||
@@ -98,15 +124,25 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findViews() {
|
private fun findViews() {
|
||||||
|
textInputLayoutServerUrl = findViewById(R.id.textInputLayoutServerUrl)
|
||||||
editTextServerUrl = findViewById(R.id.editTextServerUrl)
|
editTextServerUrl = findViewById(R.id.editTextServerUrl)
|
||||||
editTextUsername = findViewById(R.id.editTextUsername)
|
editTextUsername = findViewById(R.id.editTextUsername)
|
||||||
editTextPassword = findViewById(R.id.editTextPassword)
|
editTextPassword = findViewById(R.id.editTextPassword)
|
||||||
switchAutoSync = findViewById(R.id.switchAutoSync)
|
switchAutoSync = findViewById(R.id.switchAutoSync)
|
||||||
|
switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
|
||||||
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
buttonTestConnection = findViewById(R.id.buttonTestConnection)
|
||||||
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
buttonSyncNow = findViewById(R.id.buttonSyncNow)
|
||||||
|
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
|
||||||
|
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
|
||||||
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
|
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
|
||||||
|
buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
|
||||||
textViewServerStatus = findViewById(R.id.textViewServerStatus)
|
textViewServerStatus = findViewById(R.id.textViewServerStatus)
|
||||||
chipAutoSaveStatus = findViewById(R.id.chipAutoSaveStatus)
|
|
||||||
|
// Protocol Selection UI
|
||||||
|
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
|
||||||
|
radioHttp = findViewById(R.id.radioHttp)
|
||||||
|
radioHttps = findViewById(R.id.radioHttps)
|
||||||
|
protocolHintText = findViewById(R.id.protocolHintText)
|
||||||
|
|
||||||
// Sync Interval UI
|
// Sync Interval UI
|
||||||
radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval)
|
radioGroupSyncInterval = findViewById(R.id.radioGroupSyncInterval)
|
||||||
@@ -119,16 +155,92 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun loadSettings() {
|
private fun loadSettings() {
|
||||||
editTextServerUrl.setText(prefs.getString(Constants.KEY_SERVER_URL, ""))
|
val savedUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||||
|
|
||||||
|
// Parse existing URL to extract protocol and host/path
|
||||||
|
if (savedUrl.isNotEmpty()) {
|
||||||
|
val (protocol, hostPath) = parseUrl(savedUrl)
|
||||||
|
|
||||||
|
// Set protocol radio button
|
||||||
|
when (protocol) {
|
||||||
|
"http" -> radioHttp.isChecked = true
|
||||||
|
"https" -> radioHttps.isChecked = true
|
||||||
|
else -> radioHttp.isChecked = true // Default to HTTP (most users have local servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set URL with protocol prefix in the text field
|
||||||
|
editTextServerUrl.setText("$protocol://$hostPath")
|
||||||
|
} else {
|
||||||
|
// Default: HTTP selected (lokale Server sind häufiger), empty URL with prefix
|
||||||
|
radioHttp.isChecked = true
|
||||||
|
editTextServerUrl.setText("http://")
|
||||||
|
}
|
||||||
|
|
||||||
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
|
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
|
||||||
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
|
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
|
||||||
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
|
||||||
|
switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first)
|
||||||
|
|
||||||
|
// Update hint text based on selected protocol
|
||||||
|
updateProtocolHint()
|
||||||
|
|
||||||
// Server Status prüfen
|
// Server Status prüfen
|
||||||
checkServerStatus()
|
checkServerStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse URL into protocol and host/path components
|
||||||
|
* @param url Full URL like "https://example.com:8080/webdav"
|
||||||
|
* @return Pair of (protocol, hostPath) like ("https", "example.com:8080/webdav")
|
||||||
|
*/
|
||||||
|
private fun parseUrl(url: String): Pair<String, String> {
|
||||||
|
return when {
|
||||||
|
url.startsWith("https://") -> "https" to url.removePrefix("https://")
|
||||||
|
url.startsWith("http://") -> "http" to url.removePrefix("http://")
|
||||||
|
else -> "http" to url // Default to HTTP if no protocol specified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the hint text below protocol selection based on selected protocol
|
||||||
|
*/
|
||||||
|
private fun updateProtocolHint() {
|
||||||
|
protocolHintText.text = if (radioHttp.isChecked) {
|
||||||
|
"HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
|
||||||
|
} else {
|
||||||
|
"HTTPS für sichere Verbindungen über das Internet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update protocol prefix in URL field when radio button changes
|
||||||
|
* Keeps the host/path part, only changes http:// <-> https://
|
||||||
|
*/
|
||||||
|
private fun updateProtocolInUrl() {
|
||||||
|
val currentText = editTextServerUrl.text.toString()
|
||||||
|
val newProtocol = if (radioHttp.isChecked) "http" else "https"
|
||||||
|
|
||||||
|
// Extract host/path without protocol
|
||||||
|
val hostPath = when {
|
||||||
|
currentText.startsWith("https://") -> currentText.removePrefix("https://")
|
||||||
|
currentText.startsWith("http://") -> currentText.removePrefix("http://")
|
||||||
|
else -> currentText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new URL with correct protocol
|
||||||
|
editTextServerUrl.setText("$newProtocol://$hostPath")
|
||||||
|
|
||||||
|
// Move cursor to end
|
||||||
|
editTextServerUrl.setSelection(editTextServerUrl.text?.length ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupListeners() {
|
private fun setupListeners() {
|
||||||
|
// Protocol selection listener - update URL prefix when radio changes
|
||||||
|
protocolRadioGroup.setOnCheckedChangeListener { _, checkedId ->
|
||||||
|
updateProtocolInUrl()
|
||||||
|
updateProtocolHint()
|
||||||
|
}
|
||||||
|
|
||||||
buttonTestConnection.setOnClickListener {
|
buttonTestConnection.setOnClickListener {
|
||||||
saveSettings()
|
saveSettings()
|
||||||
testConnection()
|
testConnection()
|
||||||
@@ -139,31 +251,51 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
syncNow()
|
syncNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buttonCreateBackup.setOnClickListener {
|
||||||
|
// Dateiname mit Timestamp
|
||||||
|
val timestamp = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||||
|
.format(java.util.Date())
|
||||||
|
val filename = "simplenotes_backup_$timestamp.json"
|
||||||
|
createBackupLauncher.launch(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonRestoreFromFile.setOnClickListener {
|
||||||
|
restoreBackupLauncher.launch(arrayOf("application/json"))
|
||||||
|
}
|
||||||
|
|
||||||
buttonRestoreFromServer.setOnClickListener {
|
buttonRestoreFromServer.setOnClickListener {
|
||||||
saveSettings()
|
saveSettings()
|
||||||
showRestoreConfirmation()
|
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonImportMarkdown.setOnClickListener {
|
||||||
|
saveSettings()
|
||||||
|
importMarkdownChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
|
||||||
onAutoSyncToggled(isChecked)
|
onAutoSyncToggled(isChecked)
|
||||||
showAutoSaveIndicator()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
onMarkdownExportToggled(isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear error when user starts typing again
|
||||||
|
editTextServerUrl.addTextChangedListener(object : android.text.TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
|
||||||
|
textInputLayoutServerUrl.error = null
|
||||||
|
}
|
||||||
|
override fun afterTextChanged(s: android.text.Editable?) {}
|
||||||
|
})
|
||||||
|
|
||||||
// Server Status Check bei Settings-Änderung
|
// Server Status Check bei Settings-Änderung
|
||||||
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
|
editTextServerUrl.setOnFocusChangeListener { _, hasFocus ->
|
||||||
if (!hasFocus) {
|
if (!hasFocus) {
|
||||||
checkServerStatus()
|
checkServerStatus()
|
||||||
showAutoSaveIndicator()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
editTextUsername.setOnFocusChangeListener { _, hasFocus ->
|
|
||||||
if (!hasFocus) showAutoSaveIndicator()
|
|
||||||
}
|
|
||||||
|
|
||||||
editTextPassword.setOnFocusChangeListener { _, hasFocus ->
|
|
||||||
if (!hasFocus) showAutoSaveIndicator()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,8 +390,26 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun saveSettings() {
|
private fun saveSettings() {
|
||||||
|
// URL is already complete with protocol in the text field (http:// or https://)
|
||||||
|
val fullUrl = editTextServerUrl.text.toString().trim()
|
||||||
|
|
||||||
|
// Clear previous error
|
||||||
|
textInputLayoutServerUrl.error = null
|
||||||
|
textInputLayoutServerUrl.isErrorEnabled = false
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Validate HTTP URL (only allow for local networks)
|
||||||
|
if (fullUrl.isNotEmpty()) {
|
||||||
|
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
|
||||||
|
if (!isValid) {
|
||||||
|
// Only show error in TextField (no Toast)
|
||||||
|
textInputLayoutServerUrl.isErrorEnabled = true
|
||||||
|
textInputLayoutServerUrl.error = errorMessage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
prefs.edit().apply {
|
prefs.edit().apply {
|
||||||
putString(Constants.KEY_SERVER_URL, editTextServerUrl.text.toString().trim())
|
putString(Constants.KEY_SERVER_URL, fullUrl)
|
||||||
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
|
putString(Constants.KEY_USERNAME, editTextUsername.text.toString().trim())
|
||||||
putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim())
|
putString(Constants.KEY_PASSWORD, editTextPassword.text.toString().trim())
|
||||||
putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked)
|
putBoolean(Constants.KEY_AUTO_SYNC, switchAutoSync.isChecked)
|
||||||
@@ -268,6 +418,24 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun testConnection() {
|
private fun testConnection() {
|
||||||
|
// URL is already complete with protocol in the text field (http:// or https://)
|
||||||
|
val fullUrl = editTextServerUrl.text.toString().trim()
|
||||||
|
|
||||||
|
// Clear previous error
|
||||||
|
textInputLayoutServerUrl.error = null
|
||||||
|
textInputLayoutServerUrl.isErrorEnabled = false
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Validate before testing
|
||||||
|
if (fullUrl.isNotEmpty()) {
|
||||||
|
val (isValid, errorMessage) = UrlValidator.validateHttpUrl(fullUrl)
|
||||||
|
if (!isValid) {
|
||||||
|
// Only show error in TextField (no Toast)
|
||||||
|
textInputLayoutServerUrl.isErrorEnabled = true
|
||||||
|
textInputLayoutServerUrl.error = errorMessage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
showToast("Teste Verbindung...")
|
showToast("Teste Verbindung...")
|
||||||
@@ -291,8 +459,23 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
private fun syncNow() {
|
private fun syncNow() {
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
showToast("Synchronisiere...")
|
|
||||||
val syncService = WebDavSyncService(this@SettingsActivity)
|
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if there are unsynced changes first (performance optimization)
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
showToast("✅ Bereits synchronisiert")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast("Synchronisiere...")
|
||||||
|
|
||||||
|
// ⭐ WICHTIG: Server-Erreichbarkeits-Check VOR Sync (wie in anderen Triggern)
|
||||||
|
if (!syncService.isServerReachable()) {
|
||||||
|
showToast("⚠️ Server nicht erreichbar")
|
||||||
|
checkServerStatus() // Server-Status aktualisieren
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
val result = syncService.syncNotes()
|
val result = syncService.syncNotes()
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
@@ -364,6 +547,67 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onMarkdownExportToggled(enabled: Boolean) {
|
||||||
|
prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
|
||||||
|
} else {
|
||||||
|
showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importMarkdownChanges() {
|
||||||
|
// Prüfen ob Server konfiguriert ist
|
||||||
|
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
|
||||||
|
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
|
||||||
|
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
|
||||||
|
|
||||||
|
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
|
||||||
|
showToast("Bitte zuerst WebDAV-Server konfigurieren")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import-Dialog mit Warnung
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Markdown-Import")
|
||||||
|
.setMessage(
|
||||||
|
"Importiert Änderungen aus .md-Dateien vom Server.\n\n" +
|
||||||
|
"⚠️ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" +
|
||||||
|
"Fortfahren?"
|
||||||
|
)
|
||||||
|
.setPositiveButton("Importieren") { _, _ ->
|
||||||
|
performMarkdownImport(serverUrl, username, password)
|
||||||
|
}
|
||||||
|
.setNegativeButton("Abbrechen", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performMarkdownImport(serverUrl: String, username: String, password: String) {
|
||||||
|
showToast("Importiere Markdown-Dateien...")
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val syncService = WebDavSyncService(this@SettingsActivity)
|
||||||
|
val importCount = syncService.syncMarkdownFiles(serverUrl, username, password)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
if (importCount > 0) {
|
||||||
|
showToast("$importCount Notizen aus Markdown importiert")
|
||||||
|
// Benachrichtige MainActivity zum Neuladen
|
||||||
|
sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED"))
|
||||||
|
} else {
|
||||||
|
showToast("Keine Markdown-Änderungen gefunden")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
showToast("Import-Fehler: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun checkBatteryOptimization() {
|
private fun checkBatteryOptimization() {
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
val packageName = packageName
|
val packageName = packageName
|
||||||
@@ -420,32 +664,6 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showAutoSaveIndicator() {
|
|
||||||
// Cancel previous job if still running
|
|
||||||
autoSaveIndicatorJob?.cancel()
|
|
||||||
|
|
||||||
// Show saving indicator
|
|
||||||
chipAutoSaveStatus.apply {
|
|
||||||
visibility = android.view.View.VISIBLE
|
|
||||||
text = "💾 Speichere..."
|
|
||||||
setChipBackgroundColorResource(android.R.color.darker_gray)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save settings
|
|
||||||
saveSettings()
|
|
||||||
|
|
||||||
// Show saved confirmation after short delay
|
|
||||||
autoSaveIndicatorJob = lifecycleScope.launch {
|
|
||||||
delay(300) // Short delay to show "Speichere..."
|
|
||||||
chipAutoSaveStatus.apply {
|
|
||||||
text = "✓ Gespeichert"
|
|
||||||
setChipBackgroundColorResource(android.R.color.holo_green_light)
|
|
||||||
}
|
|
||||||
delay(2000) // Show for 2 seconds
|
|
||||||
chipAutoSaveStatus.visibility = android.view.View.GONE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showRestoreConfirmation() {
|
private fun showRestoreConfirmation() {
|
||||||
android.app.AlertDialog.Builder(this)
|
android.app.AlertDialog.Builder(this)
|
||||||
.setTitle(R.string.restore_confirmation_title)
|
.setTitle(R.string.restore_confirmation_title)
|
||||||
@@ -504,4 +722,231 @@ class SettingsActivity : AppCompatActivity() {
|
|||||||
super.onPause()
|
super.onPause()
|
||||||
saveSettings()
|
saveSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// BACKUP & RESTORE FUNCTIONS (v1.2.0)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore-Quelle (Lokale Datei oder WebDAV Server)
|
||||||
|
*/
|
||||||
|
private enum class RestoreSource {
|
||||||
|
LOCAL_FILE,
|
||||||
|
WEBDAV_SERVER
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt Backup (Task #1.2.0-04)
|
||||||
|
*/
|
||||||
|
private fun createBackup(uri: Uri) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
Logger.d(TAG, "📦 Creating backup...")
|
||||||
|
val result = backupManager.createBackup(uri)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showToast("✅ ${result.message}")
|
||||||
|
} else {
|
||||||
|
showErrorDialog("Backup fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to create backup", e)
|
||||||
|
showErrorDialog("Backup fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universeller Restore-Dialog für beide Quellen (Task #1.2.0-05 + #1.2.0-05b)
|
||||||
|
*
|
||||||
|
* @param source Lokale Datei oder WebDAV Server
|
||||||
|
* @param fileUri URI der lokalen Datei (nur für LOCAL_FILE)
|
||||||
|
*/
|
||||||
|
private fun showRestoreDialog(source: RestoreSource, fileUri: Uri?) {
|
||||||
|
val sourceText = when (source) {
|
||||||
|
RestoreSource.LOCAL_FILE -> "Lokale Datei"
|
||||||
|
RestoreSource.WEBDAV_SERVER -> "WebDAV Server"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom View mit Radio Buttons
|
||||||
|
val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null)
|
||||||
|
val radioGroup = android.widget.RadioGroup(this).apply {
|
||||||
|
orientation = android.widget.RadioGroup.VERTICAL
|
||||||
|
setPadding(50, 20, 50, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radio Buttons erstellen
|
||||||
|
val radioMerge = android.widget.RadioButton(this).apply {
|
||||||
|
text = "⚪ Zusammenführen (Standard)\n → Neue hinzufügen, Bestehende behalten"
|
||||||
|
id = 0
|
||||||
|
isChecked = true
|
||||||
|
setPadding(10, 10, 10, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
val radioReplace = android.widget.RadioButton(this).apply {
|
||||||
|
text = "⚪ Ersetzen\n → Alle löschen & Backup importieren"
|
||||||
|
id = 1
|
||||||
|
setPadding(10, 10, 10, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
val radioOverwrite = android.widget.RadioButton(this).apply {
|
||||||
|
text = "⚪ Duplikate überschreiben\n → Backup gewinnt bei Konflikten"
|
||||||
|
id = 2
|
||||||
|
setPadding(10, 10, 10, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
radioGroup.addView(radioMerge)
|
||||||
|
radioGroup.addView(radioReplace)
|
||||||
|
radioGroup.addView(radioOverwrite)
|
||||||
|
|
||||||
|
// Hauptlayout
|
||||||
|
val mainLayout = android.widget.LinearLayout(this).apply {
|
||||||
|
orientation = android.widget.LinearLayout.VERTICAL
|
||||||
|
setPadding(50, 30, 50, 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info Text
|
||||||
|
val infoText = android.widget.TextView(this).apply {
|
||||||
|
text = "Quelle: $sourceText\n\nWiederherstellungs-Modus:"
|
||||||
|
textSize = 16f
|
||||||
|
setPadding(0, 0, 0, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hinweis Text
|
||||||
|
val hintText = android.widget.TextView(this).apply {
|
||||||
|
text = "\nℹ️ Ein Sicherheits-Backup wird vor dem Wiederherstellen automatisch erstellt."
|
||||||
|
textSize = 14f
|
||||||
|
setTypeface(null, android.graphics.Typeface.ITALIC)
|
||||||
|
setPadding(0, 20, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
mainLayout.addView(infoText)
|
||||||
|
mainLayout.addView(radioGroup)
|
||||||
|
mainLayout.addView(hintText)
|
||||||
|
|
||||||
|
// Dialog erstellen
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("⚠️ Backup wiederherstellen?")
|
||||||
|
.setView(mainLayout)
|
||||||
|
.setPositiveButton("Wiederherstellen") { _, _ ->
|
||||||
|
val selectedMode = when (radioGroup.checkedRadioButtonId) {
|
||||||
|
1 -> RestoreMode.REPLACE
|
||||||
|
2 -> RestoreMode.OVERWRITE_DUPLICATES
|
||||||
|
else -> RestoreMode.MERGE
|
||||||
|
}
|
||||||
|
|
||||||
|
when (source) {
|
||||||
|
RestoreSource.LOCAL_FILE -> fileUri?.let { performRestoreFromFile(it, selectedMode) }
|
||||||
|
RestoreSource.WEBDAV_SERVER -> performRestoreFromServer(selectedMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.setNegativeButton("Abbrechen", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt Restore aus lokaler Datei durch (Task #1.2.0-05)
|
||||||
|
*/
|
||||||
|
private fun performRestoreFromFile(uri: Uri, mode: RestoreMode) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
|
||||||
|
setMessage("Wiederherstellen...")
|
||||||
|
setCancelable(false)
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Logger.d(TAG, "📥 Restoring from file: $uri (mode: $mode)")
|
||||||
|
val result = backupManager.restoreBackup(uri, mode)
|
||||||
|
|
||||||
|
progressDialog.dismiss()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen"
|
||||||
|
showToast("✅ $message")
|
||||||
|
|
||||||
|
// Refresh MainActivity's note list
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
broadcastNotesChanged()
|
||||||
|
} else {
|
||||||
|
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
progressDialog.dismiss()
|
||||||
|
Logger.e(TAG, "Failed to restore from file", e)
|
||||||
|
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt Restore vom Server durch (Task #1.2.0-05b)
|
||||||
|
* Nutzt neues universelles Dialog-System mit Restore-Modi
|
||||||
|
*
|
||||||
|
* HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion
|
||||||
|
* unterstützt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet.
|
||||||
|
* TODO: WebDavSyncService.restoreFromServer() erweitern für v1.2.1+
|
||||||
|
*/
|
||||||
|
private fun performRestoreFromServer(mode: RestoreMode) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val progressDialog = android.app.ProgressDialog(this@SettingsActivity).apply {
|
||||||
|
setMessage("Wiederherstellen vom Server...")
|
||||||
|
setCancelable(false)
|
||||||
|
show()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Logger.d(TAG, "📥 Restoring from server (mode: $mode)")
|
||||||
|
Logger.w(TAG, "⚠️ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)")
|
||||||
|
|
||||||
|
// Auto-Backup erstellen (Sicherheitsnetz)
|
||||||
|
val autoBackupUri = backupManager.createAutoBackup()
|
||||||
|
if (autoBackupUri == null) {
|
||||||
|
Logger.w(TAG, "⚠️ Auto-backup failed, but continuing with restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-Restore durchführen
|
||||||
|
val webdavService = WebDavSyncService(this@SettingsActivity)
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
// Nutzt alte Funktion (immer REPLACE)
|
||||||
|
webdavService.restoreFromServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
progressDialog.dismiss()
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
showToast("✅ Wiederhergestellt: ${result.restoredCount} Notizen")
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
broadcastNotesChanged()
|
||||||
|
} else {
|
||||||
|
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
progressDialog.dismiss()
|
||||||
|
Logger.e(TAG, "Failed to restore from server", e)
|
||||||
|
showErrorDialog("Wiederherstellung fehlgeschlagen", e.message ?: "Unbekannter Fehler")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet Broadcast dass Notizen geändert wurden
|
||||||
|
*/
|
||||||
|
private fun broadcastNotesChanged() {
|
||||||
|
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
|
||||||
|
intent.putExtra("success", true)
|
||||||
|
intent.putExtra("syncedCount", 0)
|
||||||
|
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt Error-Dialog an
|
||||||
|
*/
|
||||||
|
private fun showErrorDialog(title: String, message: String) {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle(title)
|
||||||
|
.setMessage(message)
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package dev.dettmer.simplenotes.models
|
package dev.dettmer.simplenotes.models
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
data class Note(
|
data class Note(
|
||||||
@@ -25,6 +29,25 @@ data class Note(
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
|
||||||
|
* Format kompatibel mit Obsidian, Joplin, Typora
|
||||||
|
*/
|
||||||
|
fun toMarkdown(): String {
|
||||||
|
return """
|
||||||
|
---
|
||||||
|
id: $id
|
||||||
|
created: ${formatISO8601(createdAt)}
|
||||||
|
updated: ${formatISO8601(updatedAt)}
|
||||||
|
device: $deviceId
|
||||||
|
---
|
||||||
|
|
||||||
|
# $title
|
||||||
|
|
||||||
|
$content
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromJson(json: String): Note? {
|
fun fromJson(json: String): Note? {
|
||||||
return try {
|
return try {
|
||||||
@@ -34,6 +57,78 @@ data class Note(
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
|
||||||
|
*
|
||||||
|
* @param md Markdown-String mit YAML Frontmatter
|
||||||
|
* @return Note-Objekt oder null bei Parse-Fehler
|
||||||
|
*/
|
||||||
|
fun fromMarkdown(md: String): Note? {
|
||||||
|
return try {
|
||||||
|
// Parse YAML Frontmatter + Markdown Content
|
||||||
|
val frontmatterRegex = Regex("^---\\n(.+?)\\n---\\n(.*)$", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
val match = frontmatterRegex.find(md) ?: return null
|
||||||
|
|
||||||
|
val yamlBlock = match.groupValues[1]
|
||||||
|
val contentBlock = match.groupValues[2]
|
||||||
|
|
||||||
|
// Parse YAML (einfach per String-Split für MVP)
|
||||||
|
val metadata = yamlBlock.lines()
|
||||||
|
.mapNotNull { line ->
|
||||||
|
val parts = line.split(":", limit = 2)
|
||||||
|
if (parts.size == 2) {
|
||||||
|
parts[0].trim() to parts[1].trim()
|
||||||
|
} else null
|
||||||
|
}.toMap()
|
||||||
|
|
||||||
|
// Extract title from first # heading
|
||||||
|
val title = contentBlock.lines()
|
||||||
|
.firstOrNull { it.startsWith("# ") }
|
||||||
|
?.removePrefix("# ")?.trim() ?: "Untitled"
|
||||||
|
|
||||||
|
// Extract content (everything after heading)
|
||||||
|
val content = contentBlock
|
||||||
|
.substringAfter("# $title\n\n", "")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
Note(
|
||||||
|
id = metadata["id"] ?: UUID.randomUUID().toString(),
|
||||||
|
title = title,
|
||||||
|
content = content,
|
||||||
|
createdAt = parseISO8601(metadata["created"] ?: ""),
|
||||||
|
updatedAt = parseISO8601(metadata["updated"] ?: ""),
|
||||||
|
deviceId = metadata["device"] ?: "desktop",
|
||||||
|
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert Timestamp zu ISO8601 (Task #1.2.0-10)
|
||||||
|
* Format: 2024-12-21T18:00:00Z (UTC)
|
||||||
|
*/
|
||||||
|
private fun formatISO8601(timestamp: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
return sdf.format(Date(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst ISO8601 zurück zu Timestamp (Task #1.2.0-10)
|
||||||
|
* Fallback: Aktueller Timestamp bei Fehler
|
||||||
|
*/
|
||||||
|
private fun parseISO8601(dateString: String): Long {
|
||||||
|
return try {
|
||||||
|
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
sdf.parse(dateString)?.time ?: System.currentTimeMillis()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
System.currentTimeMillis() // Fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.work.WorkerParameters
|
|||||||
import dev.dettmer.simplenotes.BuildConfig
|
import dev.dettmer.simplenotes.BuildConfig
|
||||||
import dev.dettmer.simplenotes.utils.Logger
|
import dev.dettmer.simplenotes.utils.Logger
|
||||||
import dev.dettmer.simplenotes.utils.NotificationHelper
|
import dev.dettmer.simplenotes.utils.NotificationHelper
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -52,7 +53,25 @@ class SyncWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "📍 Step 2: Checking server reachability (Pre-Check)")
|
Logger.d(TAG, "📍 Step 2: Checking for unsynced changes (Performance Pre-Check)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Performance-Optimierung - Skip Sync wenn keine lokalen Änderungen
|
||||||
|
// Spart Batterie + Netzwerk-Traffic + Server-Last
|
||||||
|
if (!syncService.hasUnsyncedChanges()) {
|
||||||
|
Logger.d(TAG, "⏭️ No local changes - skipping sync (performance optimization)")
|
||||||
|
Logger.d(TAG, " Saves battery, network traffic, and server load")
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (no changes to sync)")
|
||||||
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "📍 Step 3: Checking server reachability (Pre-Check)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
|
// ⭐ KRITISCH: Server-Erreichbarkeits-Check VOR Sync
|
||||||
@@ -63,6 +82,9 @@ class SyncWorker(
|
|||||||
Logger.d(TAG, " Reason: Server offline/wrong network/network not ready/not configured")
|
Logger.d(TAG, " Reason: Server offline/wrong network/network not ready/not configured")
|
||||||
Logger.d(TAG, " This is normal in foreign WiFi or during network initialization")
|
Logger.d(TAG, " This is normal in foreign WiFi or during network initialization")
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Check if we should show warning (server unreachable for >24h)
|
||||||
|
checkAndShowSyncWarning(syncService)
|
||||||
|
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (silent skip)")
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
@@ -147,6 +169,32 @@ class SyncWorker(
|
|||||||
}
|
}
|
||||||
Result.failure()
|
Result.failure()
|
||||||
}
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
// ⭐ Job wurde gecancelt - KEIN FEHLER!
|
||||||
|
// Gründe: App-Update, Doze Mode, Battery Optimization, Network Constraint, etc.
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
Logger.d(TAG, "⏹️ Job was cancelled (normal - update/doze/constraints)")
|
||||||
|
Logger.d(TAG, " Reason could be: App update, Doze mode, Battery opt, Network disconnect")
|
||||||
|
Logger.d(TAG, " This is expected Android behavior - not an error!")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// UI-Refresh trotzdem triggern (falls MainActivity geöffnet)
|
||||||
|
broadcastSyncCompleted(false, 0)
|
||||||
|
} catch (broadcastError: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to broadcast after cancellation", broadcastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
Logger.d(TAG, "✅ SyncWorker.doWork() SUCCESS (cancelled, no error)")
|
||||||
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ WICHTIG: Result.success() zurückgeben!
|
||||||
|
// Cancellation ist KEIN Fehler, WorkManager soll nicht retries machen
|
||||||
|
Result.success()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (BuildConfig.DEBUG) {
|
if (BuildConfig.DEBUG) {
|
||||||
Logger.d(TAG, "═══════════════════════════════════════")
|
Logger.d(TAG, "═══════════════════════════════════════")
|
||||||
@@ -189,4 +237,69 @@ class SyncWorker(
|
|||||||
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent)
|
||||||
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
|
Logger.d(TAG, "📡 Broadcast sent: success=$success, count=$count")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob Server längere Zeit unreachable und zeigt ggf. Warnung (v1.1.2)
|
||||||
|
* - Nur wenn Auto-Sync aktiviert
|
||||||
|
* - Nur wenn schon mal erfolgreich gesynct
|
||||||
|
* - Nur wenn >24h seit letztem erfolgreichen Sync
|
||||||
|
* - Throttling: Max. 1 Warnung pro 24h
|
||||||
|
*/
|
||||||
|
private fun checkAndShowSyncWarning(syncService: WebDavSyncService) {
|
||||||
|
try {
|
||||||
|
val prefs = applicationContext.getSharedPreferences(
|
||||||
|
dev.dettmer.simplenotes.utils.Constants.PREFS_NAME,
|
||||||
|
android.content.Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check 1: Auto-Sync aktiviert?
|
||||||
|
val autoSyncEnabled = prefs.getBoolean(
|
||||||
|
dev.dettmer.simplenotes.utils.Constants.KEY_AUTO_SYNC,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
if (!autoSyncEnabled) {
|
||||||
|
Logger.d(TAG, "⏭️ Auto-Sync disabled - no warning needed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 2: Schon mal erfolgreich gesynct?
|
||||||
|
val lastSuccessfulSync = syncService.getLastSuccessfulSyncTimestamp()
|
||||||
|
if (lastSuccessfulSync == 0L) {
|
||||||
|
Logger.d(TAG, "⏭️ Never synced successfully - no warning needed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 3: >24h seit letztem erfolgreichen Sync?
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val timeSinceLastSync = now - lastSuccessfulSync
|
||||||
|
if (timeSinceLastSync < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
|
||||||
|
Logger.d(TAG, "⏭️ Last successful sync <24h ago - no warning needed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check 4: Throttling - schon Warnung in letzten 24h gezeigt?
|
||||||
|
val lastWarningShown = prefs.getLong(
|
||||||
|
dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN,
|
||||||
|
0L
|
||||||
|
)
|
||||||
|
if (now - lastWarningShown < dev.dettmer.simplenotes.utils.Constants.SYNC_WARNING_THRESHOLD_MS) {
|
||||||
|
Logger.d(TAG, "⏭️ Warning already shown in last 24h - throttling")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeige Warnung
|
||||||
|
val hoursSinceLastSync = timeSinceLastSync / (1000 * 60 * 60)
|
||||||
|
NotificationHelper.showSyncWarning(applicationContext, hoursSinceLastSync)
|
||||||
|
|
||||||
|
// Speichere Zeitpunkt der Warnung
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(dev.dettmer.simplenotes.utils.Constants.KEY_LAST_SYNC_WARNING_SHOWN, now)
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
Logger.d(TAG, "⚠️ Sync warning shown: Server unreachable for ${hoursSinceLastSync}h")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to check/show sync warning", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,44 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
return prefs.getString(Constants.KEY_SERVER_URL, null)
|
return prefs.getString(Constants.KEY_SERVER_URL, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft ob lokale Änderungen seit letztem Sync vorhanden sind (v1.1.2)
|
||||||
|
* Performance-Optimierung: Vermeidet unnötige Sync-Operationen
|
||||||
|
*
|
||||||
|
* @return true wenn unsynced changes vorhanden, false sonst
|
||||||
|
*/
|
||||||
|
suspend fun hasUnsyncedChanges(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
val lastSyncTime = getLastSyncTimestamp()
|
||||||
|
|
||||||
|
// Wenn noch nie gesynct, dann haben wir Änderungen
|
||||||
|
if (lastSyncTime == 0L) {
|
||||||
|
Logger.d(TAG, "📝 Never synced - assuming changes exist")
|
||||||
|
return@withContext true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Notizen existieren die neuer sind als letzter Sync
|
||||||
|
val storage = dev.dettmer.simplenotes.storage.NotesStorage(context)
|
||||||
|
val allNotes = storage.loadAllNotes()
|
||||||
|
|
||||||
|
val hasChanges = allNotes.any { note ->
|
||||||
|
note.updatedAt > lastSyncTime
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "📊 Unsynced changes check: $hasChanges (${allNotes.size} notes total)")
|
||||||
|
if (hasChanges) {
|
||||||
|
val unsyncedCount = allNotes.count { note -> note.updatedAt > lastSyncTime }
|
||||||
|
Logger.d(TAG, " → $unsyncedCount notes modified since last sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasChanges
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to check for unsynced changes - assuming changes exist", e)
|
||||||
|
// Bei Fehler lieber sync durchführen (safe default)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prüft ob WebDAV-Server erreichbar ist (ohne Sync zu starten)
|
* Prüft ob WebDAV-Server erreichbar ist (ohne Sync zu starten)
|
||||||
* Verwendet Socket-Check für schnelle Erreichbarkeitsprüfung
|
* Verwendet Socket-Check für schnelle Erreichbarkeitsprüfung
|
||||||
@@ -406,9 +444,11 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
private fun uploadLocalNotes(sardine: Sardine, serverUrl: String): Int {
|
||||||
var uploadedCount = 0
|
var uploadedCount = 0
|
||||||
val localNotes = storage.loadAllNotes()
|
val localNotes = storage.loadAllNotes()
|
||||||
|
val markdownExportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
|
||||||
|
|
||||||
for (note in localNotes) {
|
for (note in localNotes) {
|
||||||
try {
|
try {
|
||||||
|
// 1. JSON-Upload (bestehend, unverändert)
|
||||||
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
if (note.syncStatus == SyncStatus.LOCAL_ONLY || note.syncStatus == SyncStatus.PENDING) {
|
||||||
val noteUrl = "$serverUrl/${note.id}.json"
|
val noteUrl = "$serverUrl/${note.id}.json"
|
||||||
val jsonBytes = note.toJson().toByteArray()
|
val jsonBytes = note.toJson().toByteArray()
|
||||||
@@ -419,6 +459,18 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
val updatedNote = note.copy(syncStatus = SyncStatus.SYNCED)
|
||||||
storage.saveNote(updatedNote)
|
storage.saveNote(updatedNote)
|
||||||
uploadedCount++
|
uploadedCount++
|
||||||
|
|
||||||
|
// 2. Markdown-Export (NEU in v1.2.0)
|
||||||
|
// Läuft NACH erfolgreichem JSON-Upload
|
||||||
|
if (markdownExportEnabled) {
|
||||||
|
try {
|
||||||
|
exportToMarkdown(sardine, serverUrl, note)
|
||||||
|
Logger.d(TAG, " 📝 MD exported: ${note.title}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "MD-Export failed for ${note.id}: ${e.message}")
|
||||||
|
// Kein throw! JSON-Sync darf nicht blockiert werden
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Mark as pending for retry
|
// Mark as pending for retry
|
||||||
@@ -430,6 +482,49 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
return uploadedCount
|
return uploadedCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exportiert einzelne Note als Markdown (Task #1.2.0-11)
|
||||||
|
*
|
||||||
|
* @param sardine Sardine-Client
|
||||||
|
* @param serverUrl Server-URL (notes/ Ordner)
|
||||||
|
* @param note Note zum Exportieren
|
||||||
|
*/
|
||||||
|
private fun exportToMarkdown(sardine: Sardine, serverUrl: String, note: Note) {
|
||||||
|
val mdUrl = serverUrl.replace("/notes", "/notes-md")
|
||||||
|
|
||||||
|
// Erstelle notes-md/ Ordner falls nicht vorhanden
|
||||||
|
if (!sardine.exists(mdUrl)) {
|
||||||
|
sardine.createDirectory(mdUrl)
|
||||||
|
Logger.d(TAG, "📁 Created notes-md/ directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize Filename (Task #1.2.0-12)
|
||||||
|
val filename = sanitizeFilename(note.title) + ".md"
|
||||||
|
val noteUrl = "$mdUrl/$filename"
|
||||||
|
|
||||||
|
// Konvertiere zu Markdown
|
||||||
|
val mdContent = note.toMarkdown().toByteArray()
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
sardine.put(noteUrl, mdContent, "text/markdown")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize Filename für sichere Dateinamen (Task #1.2.0-12)
|
||||||
|
*
|
||||||
|
* Entfernt Windows/Linux-verbotene Zeichen, begrenzt Länge
|
||||||
|
*
|
||||||
|
* @param title Original-Titel
|
||||||
|
* @return Sicherer Filename
|
||||||
|
*/
|
||||||
|
private fun sanitizeFilename(title: String): String {
|
||||||
|
return title
|
||||||
|
.replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen
|
||||||
|
.replace(Regex("\\s+"), " ") // Normalisiere Whitespace
|
||||||
|
.take(200) // Max 200 Zeichen (Reserve für .md)
|
||||||
|
.trim('_', ' ') // Trim Underscores/Spaces
|
||||||
|
}
|
||||||
|
|
||||||
private data class DownloadResult(
|
private data class DownloadResult(
|
||||||
val downloadedCount: Int,
|
val downloadedCount: Int,
|
||||||
val conflictCount: Int
|
val conflictCount: Int
|
||||||
@@ -481,8 +576,10 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLastSyncTimestamp() {
|
private fun saveLastSyncTimestamp() {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putLong(Constants.KEY_LAST_SYNC, System.currentTimeMillis())
|
.putLong(Constants.KEY_LAST_SYNC, now)
|
||||||
|
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // 🔥 v1.1.2: Track successful sync
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,6 +587,10 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
|
return prefs.getLong(Constants.KEY_LAST_SYNC, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLastSuccessfulSyncTimestamp(): Long {
|
||||||
|
return prefs.getLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, 0)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restore all notes from server - overwrites local storage
|
* Restore all notes from server - overwrites local storage
|
||||||
* @return RestoreResult with count of restored notes
|
* @return RestoreResult with count of restored notes
|
||||||
@@ -574,6 +675,86 @@ class WebDavSyncService(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronisiert Markdown-Dateien (Import von Desktop-Programmen) (Task #1.2.0-14)
|
||||||
|
*
|
||||||
|
* Last-Write-Wins Konfliktauflösung basierend auf updatedAt Timestamp
|
||||||
|
*
|
||||||
|
* @param serverUrl WebDAV Server-URL (notes/ Ordner)
|
||||||
|
* @param username WebDAV Username
|
||||||
|
* @param password WebDAV Password
|
||||||
|
* @return Anzahl importierter Notizen
|
||||||
|
*/
|
||||||
|
suspend fun syncMarkdownFiles(
|
||||||
|
serverUrl: String,
|
||||||
|
username: String,
|
||||||
|
password: String
|
||||||
|
): Int = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
Logger.d(TAG, "📝 Starting Markdown sync...")
|
||||||
|
|
||||||
|
val sardine = OkHttpSardine()
|
||||||
|
sardine.setCredentials(username, password)
|
||||||
|
|
||||||
|
val mdUrl = serverUrl.replace("/notes", "/notes-md")
|
||||||
|
|
||||||
|
// Check if notes-md/ exists
|
||||||
|
if (!sardine.exists(mdUrl)) {
|
||||||
|
Logger.d(TAG, "⚠️ notes-md/ directory not found - skipping MD import")
|
||||||
|
return@withContext 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val localNotes = storage.loadAllNotes()
|
||||||
|
val mdResources = sardine.list(mdUrl).filter { it.name.endsWith(".md") }
|
||||||
|
var importedCount = 0
|
||||||
|
|
||||||
|
Logger.d(TAG, "📂 Found ${mdResources.size} markdown files")
|
||||||
|
|
||||||
|
for (resource in mdResources) {
|
||||||
|
try {
|
||||||
|
// Download MD-File
|
||||||
|
val mdContent = sardine.get(resource.href.toString())
|
||||||
|
.bufferedReader().use { it.readText() }
|
||||||
|
|
||||||
|
// Parse zu Note
|
||||||
|
val mdNote = Note.fromMarkdown(mdContent) ?: continue
|
||||||
|
|
||||||
|
val localNote = localNotes.find { it.id == mdNote.id }
|
||||||
|
|
||||||
|
// Konfliktauflösung: Last-Write-Wins
|
||||||
|
when {
|
||||||
|
localNote == null -> {
|
||||||
|
// Neue Notiz vom Desktop
|
||||||
|
storage.saveNote(mdNote)
|
||||||
|
importedCount++
|
||||||
|
Logger.d(TAG, " ✅ Imported new: ${mdNote.title}")
|
||||||
|
}
|
||||||
|
mdNote.updatedAt > localNote.updatedAt -> {
|
||||||
|
// Desktop-Version ist neuer (Last-Write-Wins)
|
||||||
|
storage.saveNote(mdNote)
|
||||||
|
importedCount++
|
||||||
|
Logger.d(TAG, " ✅ Updated from MD: ${mdNote.title}")
|
||||||
|
}
|
||||||
|
// Sonst: Lokale Version behalten
|
||||||
|
else -> {
|
||||||
|
Logger.d(TAG, " ⏭️ Local newer, skipping: ${mdNote.title}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to import ${resource.name}", e)
|
||||||
|
// Continue with other files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.d(TAG, "✅ Markdown sync completed: $importedCount imported")
|
||||||
|
importedCount
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Markdown sync failed", e)
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class RestoreResult(
|
data class RestoreResult(
|
||||||
|
|||||||
@@ -10,10 +10,19 @@ object Constants {
|
|||||||
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
const val KEY_AUTO_SYNC = "auto_sync_enabled"
|
||||||
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
const val KEY_LAST_SYNC = "last_sync_timestamp"
|
||||||
|
|
||||||
|
// 🔥 v1.1.2: Last Successful Sync Monitoring
|
||||||
|
const val KEY_LAST_SUCCESSFUL_SYNC = "last_successful_sync_time"
|
||||||
|
const val KEY_LAST_SYNC_WARNING_SHOWN = "last_sync_warning_shown_time"
|
||||||
|
const val SYNC_WARNING_THRESHOLD_MS = 24 * 60 * 60 * 1000L // 24h
|
||||||
|
|
||||||
// 🔥 NEU: Sync Interval Configuration
|
// 🔥 NEU: Sync Interval Configuration
|
||||||
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
const val PREF_SYNC_INTERVAL_MINUTES = "sync_interval_minutes"
|
||||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
|
const val DEFAULT_SYNC_INTERVAL_MINUTES = 30L
|
||||||
|
|
||||||
|
// 🔥 v1.2.0: Markdown Export/Import
|
||||||
|
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
|
||||||
|
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
|
||||||
|
|
||||||
// WorkManager
|
// WorkManager
|
||||||
const val SYNC_WORK_TAG = "notes_sync"
|
const val SYNC_WORK_TAG = "notes_sync"
|
||||||
const val SYNC_DELAY_SECONDS = 5L
|
const val SYNC_DELAY_SECONDS = 5L
|
||||||
|
|||||||
@@ -288,4 +288,40 @@ object NotificationHelper {
|
|||||||
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
|
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
|
||||||
}, 30_000)
|
}, 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path
|
|
||||||
android:fillColor="#3DDC84"
|
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M9,0L9,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,0L19,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,0L29,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,0L39,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,0L49,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,0L59,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,0L69,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,0L79,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M89,0L89,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M99,0L99,108"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,9L108,9"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,19L108,19"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,29L108,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,39L108,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,49L108,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,59L108,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,69L108,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,79L108,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,89L108,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M0,99L108,99"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,29L89,29"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,39L89,39"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,49L89,49"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,59L89,59"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,69L89,69"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M19,79L89,79"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M29,19L29,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M39,19L39,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M49,19L49,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M59,19L59,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M69,19L69,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
<path
|
|
||||||
android:fillColor="#00000000"
|
|
||||||
android:pathData="M79,19L79,89"
|
|
||||||
android:strokeWidth="0.8"
|
|
||||||
android:strokeColor="#33FFFFFF" />
|
|
||||||
</vector>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:aapt="http://schemas.android.com/aapt"
|
|
||||||
android:width="108dp"
|
|
||||||
android:height="108dp"
|
|
||||||
android:viewportWidth="108"
|
|
||||||
android:viewportHeight="108">
|
|
||||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
|
||||||
<aapt:attr name="android:fillColor">
|
|
||||||
<gradient
|
|
||||||
android:endX="85.84757"
|
|
||||||
android:endY="92.4963"
|
|
||||||
android:startX="42.9492"
|
|
||||||
android:startY="49.59793"
|
|
||||||
android:type="linear">
|
|
||||||
<item
|
|
||||||
android:color="#44000000"
|
|
||||||
android:offset="0.0" />
|
|
||||||
<item
|
|
||||||
android:color="#00000000"
|
|
||||||
android:offset="1.0" />
|
|
||||||
</gradient>
|
|
||||||
</aapt:attr>
|
|
||||||
</path>
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:fillType="nonZero"
|
|
||||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
|
||||||
android:strokeWidth="1"
|
|
||||||
android:strokeColor="#00000000" />
|
|
||||||
</vector>
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="?attr/actionBarSize"
|
android:layout_height="?attr/actionBarSize"
|
||||||
android:elevation="0dp"
|
android:elevation="0dp"
|
||||||
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
|
app:navigationIcon="?attr/homeAsUpIndicator"
|
||||||
app:title="@string/edit_note"
|
app:title="@string/edit_note"
|
||||||
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,22 @@
|
|||||||
|
|
||||||
</com.google.android.material.appbar.AppBarLayout>
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<!-- SwipeRefreshLayout für Pull-to-Refresh (v1.1.2) -->
|
||||||
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
|
android:id="@+id/swipeRefreshLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
<!-- RecyclerView mit größerem Padding für Material 3 -->
|
<!-- RecyclerView mit größerem Padding für Material 3 -->
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerViewNotes"
|
android:id="@+id/recyclerViewNotes"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:clipToPadding="false"
|
android:clipToPadding="false"
|
||||||
android:padding="16dp"
|
android:padding="16dp" />
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
|
||||||
|
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||||
|
|
||||||
<!-- Material 3 Empty State Card -->
|
<!-- Material 3 Empty State Card -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
|||||||
@@ -30,17 +30,6 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<!-- Auto-Save Status Indicator -->
|
|
||||||
<com.google.android.material.chip.Chip
|
|
||||||
android:id="@+id/chipAutoSaveStatus"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center_horizontal"
|
|
||||||
android:layout_marginBottom="12dp"
|
|
||||||
android:visibility="gone"
|
|
||||||
android:textSize="12sp"
|
|
||||||
app:chipIconEnabled="false" />
|
|
||||||
|
|
||||||
<!-- Material 3 Card: Server Configuration -->
|
<!-- Material 3 Card: Server Configuration -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -63,15 +52,65 @@
|
|||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
android:layout_marginBottom="16dp" />
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
<!-- Server URL with Icon -->
|
<!-- Protocol Selection -->
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="@string/server_url"
|
android:text="Verbindungstyp"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
android:id="@+id/protocolRadioGroup"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.radiobutton.MaterialRadioButton
|
||||||
|
android:id="@+id/radioHttp"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="🏠 Intern (HTTP)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:checked="false" />
|
||||||
|
|
||||||
|
<com.google.android.material.radiobutton.MaterialRadioButton
|
||||||
|
android:id="@+id/radioHttps"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="🌐 Extern (HTTPS)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
|
||||||
|
android:checked="true" />
|
||||||
|
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<!-- Helper Text for Protocol Selection -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/protocolHintText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="HTTP nur für lokale Netzwerke (z.B. 192.168.x.x, 10.x.x.x)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:paddingStart="4dp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<!-- Server URL with Icon -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/textInputLayoutServerUrl"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Server-Adresse"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
app:startIconDrawable="@android:drawable/ic_menu_compass"
|
app:startIconDrawable="@android:drawable/ic_menu_compass"
|
||||||
app:endIconMode="clear_text"
|
app:endIconMode="clear_text"
|
||||||
|
app:helperText="z.B. http://192.168.0.188:8080/webdav"
|
||||||
|
app:helperTextEnabled="true"
|
||||||
app:boxCornerRadiusTopStart="12dp"
|
app:boxCornerRadiusTopStart="12dp"
|
||||||
app:boxCornerRadiusTopEnd="12dp"
|
app:boxCornerRadiusTopEnd="12dp"
|
||||||
app:boxCornerRadiusBottomStart="12dp"
|
app:boxCornerRadiusBottomStart="12dp"
|
||||||
@@ -185,7 +224,7 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Material 3 Card: Auto-Sync Settings -->
|
<!-- Material 3 Card: Synchronisation Settings (Auto-Sync + Interval) -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -230,6 +269,7 @@
|
|||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
@@ -247,87 +287,22 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
<!-- Divider -->
|
||||||
|
<View
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Material 3 Card: Backup & Restore -->
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="1dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginVertical="16dp"
|
||||||
style="@style/Widget.Material3.CardView.Elevated"
|
android:background="?attr/colorOutlineVariant" />
|
||||||
app:cardCornerRadius="16dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<!-- Sync Interval Section -->
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="20dp">
|
|
||||||
|
|
||||||
<!-- Section Header -->
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/backup_restore_title"
|
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
|
||||||
android:layout_marginBottom="12dp" />
|
|
||||||
|
|
||||||
<!-- Warning Info Card -->
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="0dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:padding="16dp"
|
|
||||||
android:text="@string/backup_restore_warning"
|
|
||||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
|
||||||
android:textColor="?attr/colorOnErrorContainer"
|
|
||||||
android:lineSpacingMultiplier="1.3" />
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Restore Button -->
|
|
||||||
<Button
|
|
||||||
android:id="@+id/buttonRestoreFromServer"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/restore_from_server"
|
|
||||||
style="@style/Widget.Material3.Button.TonalButton" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Material 3 Card: Sync Interval Configuration -->
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="16dp"
|
|
||||||
style="@style/Widget.Material3.CardView.Elevated"
|
|
||||||
app:cardCornerRadius="16dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="20dp">
|
|
||||||
|
|
||||||
<!-- Section Header -->
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Sync-Intervall"
|
android:text="Sync-Intervall"
|
||||||
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
android:layout_marginBottom="12dp" />
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
<!-- Info Card -->
|
<!-- Interval Info Card -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -412,6 +387,188 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Material 3 Card: Markdown Desktop-Integration (v1.2.0) -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
style="@style/Widget.Material3.CardView.Elevated"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<!-- Section Header -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Markdown Desktop-Integration"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Info Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:text="ℹ️ Exportiert Notizen zusätzlich als .md Dateien. Mounte WebDAV als Netzlaufwerk um mit VS Code, Typora oder jedem Markdown-Editor zu bearbeiten. JSON-Sync bleibt primäres Format."
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnPrimaryContainer"
|
||||||
|
android:lineSpacingMultiplier="1.3" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Markdown Export Toggle -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="📝 Markdown Export (Desktop-Zugriff)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
|
android:id="@+id/switchMarkdownExport"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:checked="true" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Import Markdown Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonImportMarkdown"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📥 Markdown-Änderungen importieren"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
<!-- Import Info Text -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Material 3 Card: Backup & Restore -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
style="@style/Widget.Material3.CardView.Elevated"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<!-- Section Header -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/backup_restore_title"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Info Card (anstatt Warning) -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:text="ℹ️ Bei jeder Wiederherstellung wird automatisch ein Sicherheits-Backup erstellt."
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||||
|
android:textColor="?attr/colorOnPrimaryContainer"
|
||||||
|
android:lineSpacingMultiplier="1.3" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Lokales Backup Sektion -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Lokales Backup"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Backup erstellen Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonCreateBackup"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📥 Backup erstellen"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
<!-- Aus Datei wiederherstellen Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonRestoreFromFile"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="📤 Aus Datei wiederherstellen"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="?attr/colorOutline"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
<!-- Server-Backup Sektion -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Server-Backup"
|
||||||
|
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- Vom Server wiederherstellen Button -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/buttonRestoreFromServer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="🔄 Vom Server wiederherstellen"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Material 3 Card: About Section -->
|
<!-- Material 3 Card: About Section -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 316 B |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 774 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 314 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 319 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 327 B |
|
After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 329 B |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 2.9 KiB |
16
android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<!-- Allow HTTP for all connections during development/testing -->
|
||||||
|
<!-- Production validation happens in UrlValidator.kt to restrict HTTP to:
|
||||||
|
- Private IP ranges: 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 127.x.x.x
|
||||||
|
- .local domains (mDNS/Bonjour)
|
||||||
|
|
||||||
|
This permissive config is necessary because Android's Network Security Config
|
||||||
|
doesn't support IP-based rules, only domain patterns.
|
||||||
|
We handle security through application-level validation instead. -->
|
||||||
|
<base-config cleartextTrafficPermitted="true">
|
||||||
|
<trust-anchors>
|
||||||
|
<certificates src="system" />
|
||||||
|
</trust-anchors>
|
||||||
|
</base-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
🐛 Bugfixes v1.1.1
|
|
||||||
|
|
||||||
✅ Keine Fehler-Notifications mehr in fremden WiFi-Netzwerken!
|
|
||||||
- Server-Erreichbarkeits-Check vor jedem Sync (2s Timeout)
|
|
||||||
- Stiller Abbruch wenn Server nicht erreichbar
|
|
||||||
- 80% schnellerer Abbruch: 2s statt 10+ Sekunden
|
|
||||||
|
|
||||||
✅ Keine Fehler beim WiFi-Connect / Nach-Hause-Kommen!
|
|
||||||
- Pre-Check wartet bis Netzwerk bereit ist (DHCP, Routing, Gateway)
|
|
||||||
- Kein Fehler mehr bei Netzwerk-Initialisierung
|
|
||||||
|
|
||||||
🔧 Notification-Verbesserungen:
|
|
||||||
- Alte Notifications werden beim App-Start gelöscht
|
|
||||||
- Fehler-Notifications verschwinden automatisch nach 30 Sekunden
|
|
||||||
- Bessere Batterie-Effizienz (keine langen Timeouts mehr)
|
|
||||||
|
|
||||||
📱 UI-Fixes:
|
|
||||||
- Sync-Icon wird nicht mehr angezeigt wenn Sync nicht konfiguriert ist
|
|
||||||
- Swipe-to-Delete: Kein Flackern mehr beim schnellen Löschen mehrerer Notizen
|
|
||||||
- Nach dem Speichern einer Notiz landet man automatisch ganz oben in der Liste
|
|
||||||
@@ -1,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
|
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
@@ -1,6 +1,6 @@
|
|||||||
#Sat Dec 20 00:06:31 CET 2025
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
|
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
|
|||||||
6
android/gradlew
vendored
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright © 2015 the original authors.
|
# Copyright © 2015-2021 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -114,7 +114,7 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
|||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-classpath "$CLASSPATH" \
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
4
android/gradlew.bat
vendored
@@ -70,11 +70,11 @@ goto fail
|
|||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
|||||||
18
fastlane/metadata/android/de-DE/changelogs/3.txt
Normal 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
|
||||||
12
fastlane/metadata/android/de-DE/changelogs/4.txt
Normal 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
|
||||||
13
fastlane/metadata/android/de-DE/changelogs/5.txt
Normal 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
|
||||||
BIN
fastlane/metadata/android/de-DE/images/icon.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/1.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/2.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
fastlane/metadata/android/de-DE/images/phoneScreenshots/3.jpg
Normal file
|
After Width: | Height: | Size: 134 KiB |
18
fastlane/metadata/android/en-US/changelogs/3.txt
Normal 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
|
||||||
12
fastlane/metadata/android/en-US/changelogs/4.txt
Normal 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
|
||||||
13
fastlane/metadata/android/en-US/changelogs/5.txt
Normal 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
|
||||||
BIN
fastlane/metadata/android/en-US/images/icon.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg
Normal file
|
After Width: | Height: | Size: 137 KiB |
69
metadata/dev.dettmer.simplenotes.yml
Normal 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
|
||||||