7 Commits

Author SHA1 Message Date
inventory69
dee85233b6 fix: Remove dynamic build date for Reproducible Builds
Fixes #7 - Thanks @IzzySoft for reporting and investigating!

Removed:
- getBuildDate() function from build.gradle.kts
- BUILD_DATE buildConfigField
- Build date display in SettingsActivity

Sorry for the hour of work this caused - will be more careful about RB in the future.
2026-01-10 23:47:57 +01:00
inventory69
fbcca3807d Merge feature/v1.4.0-checklists: Checklists + WiFi permission cleanup 2026-01-10 23:40:10 +01:00
inventory69
e3e64b83e2 feat(v1.4.0): Checklists feature + WiFi permission cleanup
Features:
- Interactive checklists with tap-to-check, drag & drop sorting
- GitHub-flavored Markdown export (- [ ] / - [x])
- FAB menu for note type selection

Fixes:
- Improved Markdown parsing (robust line-based content extraction)
- Better duplicate filename handling (ID suffix)
- Foreground notification suppression

Privacy:
- Removed ACCESS_WIFI_STATE and CHANGE_WIFI_STATE permissions
  (SSID binding was never used, app only checks connectivity state)

Code Quality:
- Fixed 7 Detekt warnings (SwallowedException, MaxLineLength, MagicNumber)
2026-01-10 23:37:22 +01:00
inventory69
2324743f43 Update IzzyOnDroid metadata to v1.3.2 [skip ci] 2026-01-10 08:26:47 +01:00
inventory69
0e96757fab Merge feature/v1.3.2-lint-cleanup into main 2026-01-10 00:57:46 +01:00
inventory69
547c0a1011 v1.3.2: Lint Cleanup & Code Quality
- Complete lint cleanup (Phase 1-7)
- Replace magic numbers with constants
- Remove unused imports/members
- Add Logger.w() for swallowed exceptions
- Custom SyncException for better error handling
- ConstructorParameterNaming with @SerializedName
- ReturnCount & Destructuring with @Suppress
- F-Droid: Add privacy notice for file logging
- Update docs (FEATURES.md, README.md)
- Add fastlane changelogs for versionCode 10
2026-01-10 00:57:28 +01:00
inventory69
b79c0d25e6 [skip ci] fix: simplify workflow for single universal APK per flavor
- Remove APK splits logic from workflow
- Build only universal APKs for both standard and fdroid flavors
- Simplifies release process and fixes F-Droid compatibility
2026-01-09 13:38:43 +01:00
52 changed files with 1551 additions and 388 deletions

View File

@@ -61,33 +61,15 @@ jobs:
run: | run: |
mkdir -p apk-output mkdir -p apk-output
# === Standard Flavor (mit Google Services) === # Standard Flavor - Universal APK
# Universal APK (funktioniert auf allen Geraeten) cp android/app/build/outputs/apk/standard/release/app-standard-release.apk \
cp android/app/build/outputs/apk/standard/release/app-standard-universal-release.apk \ apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk
# ARM64 APK (moderne Geräte 2018+) # F-Droid Flavor - Universal APK
cp android/app/build/outputs/apk/standard/release/app-standard-arm64-v8a-release.apk \ cp android/app/build/outputs/apk/fdroid/release/app-fdroid-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk
# ARMv7 APK (ältere Geräte) echo "✅ APK-Dateien vorbereitet:"
cp android/app/build/outputs/apk/standard/release/app-standard-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk
# === F-Droid Flavor (ohne Google Services) ===
# Universal APK
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-universal-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk
# ARM64 APK
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-arm64-v8a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk
# ARMv7 APK
cp android/app/build/outputs/apk/fdroid/release/app-fdroid-armeabi-v7a-release.apk \
apk-output/simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk
echo "✅ APK-Dateien vorbereitet (Standard + F-Droid):"
ls -lh apk-output/ ls -lh apk-output/
- name: APK-Artefakte hochladen - name: APK-Artefakte hochladen
@@ -138,14 +120,8 @@ jobs:
| Variante | Datei | Info | | Variante | Datei | Info |
|----------|-------|------| |----------|-------|------|
| **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-universal.apk` | Funktioniert auf allen Android-Geraeten | | **🏆 Empfohlen** | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard.apk` | Standard-Version (funktioniert auf allen Geraeten) |
| Modern (2018+) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-arm64-v8a.apk` | Kleinere Dateigröße fuer 64-bit Geräte | | F-Droid | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid.apk` | Fuer F-Droid Store |
| Aelter (<2018) | `simple-notes-sync-v${{ env.VERSION_NAME }}-standard-armeabi-v7a.apk` | Fuer 32-bit ARM Geräte |
| F-Droid Universal | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-universal.apk` | Fuer F-Droid Store |
| F-Droid ARM64 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-arm64-v8a.apk` | F-Droid 64-bit |
| F-Droid ARMv7 | `simple-notes-sync-v${{ env.VERSION_NAME }}-fdroid-armeabi-v7a.apk` | F-Droid 32-bit |
💡 **Nicht sicher?** → Nimm die **Universal** APK!
--- ---

View File

@@ -6,6 +6,100 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
--- ---
## [1.4.0] - 2026-01-10
### 🎉 New Feature: Checklists
- **✅ Checklist Notes**
- New note type: Checklists with tap-to-toggle items
- Add items via dedicated input field with "+" button
- Drag & drop reordering (long-press to activate)
- Swipe-to-delete items
- Visual distinction: Checked items get strikethrough styling
- Type selector when creating new notes (Text or Checklist)
- **📝 Markdown Integration**
- Checklists export as GitHub-style task lists (`- [ ]` / `- [x]`)
- Compatible with Obsidian, Notion, and other Markdown editors
- Full round-trip: Edit in Obsidian → Sync back to app
- YAML frontmatter includes `type: checklist` for identification
### Fixed
- **<2A> Markdown Parsing Robustness**
- Fixed content extraction after title (was returning empty for some formats)
- Now handles single newline after title (was requiring double newline)
- Protection: Skips import if parsed content is empty but local has content
- **📂 Duplicate Filename Handling**
- Notes with identical titles now get unique Markdown filenames
- Format: `title_shortid.md` (e.g., `test_71540ca9.md`)
- Prevents data loss from filename collisions
- **🔔 Notification UX**
- No sync notifications when app is in foreground
- User sees changes directly in UI - no redundant notification
- Background syncs still show notifications as expected
### Privacy Improvements
- **🔒 WiFi Permissions Removed**
- Removed `ACCESS_WIFI_STATE` permission
- Removed `CHANGE_WIFI_STATE` permission
- WiFi binding now works via IP detection instead of SSID matching
- Cleaned up all SSID-related code from codebase and documentation
### Technical Improvements
- **📦 New Data Model**
- `NoteType` enum: `TEXT`, `CHECKLIST`
- `ChecklistItem` data class with id, text, isChecked, order
- `Note.kt` extended with `noteType` and `checklistItems` fields
- **🔄 Sync Protocol v1.4.0**
- JSON format updated to include checklist fields
- Full backward compatibility with v1.3.x notes
- Robust JSON parsing with manual field extraction
---
## [1.3.2] - 2026-01-10
### Changed
- **🧹 Code-Qualität: "Clean Slate" Release**
- Alle einfachen Lint-Issues behoben (Phase 1-7 des Cleanup-Plans)
- Unused Imports und Members entfernt
- Magic Numbers durch benannte Konstanten ersetzt
- SwallowedExceptions mit Logger.w() versehen
- MaxLineLength-Verstöße reformatiert
- ConstructorParameterNaming (snake_case → camelCase mit @SerializedName)
- Custom Exceptions: SyncException.kt und ValidationException.kt erstellt
### Added
- **📝 F-Droid Privacy Notice**
- Datenschutz-Hinweis für die Datei-Logging-Funktion
- Erklärt dass Logs nur lokal gespeichert werden
- Erfüllt F-Droid Opt-in Consent-Anforderungen
### Technical Improvements
- **⚡ Neue Konstanten für bessere Wartbarkeit**
- `SYNC_COMPLETED_DELAY_MS`, `ERROR_DISPLAY_DELAY_MS` (MainActivity)
- `CONNECTION_TIMEOUT_MS` (SettingsActivity)
- `SOCKET_TIMEOUT_MS`, `MAX_FILENAME_LENGTH`, `ETAG_PREVIEW_LENGTH` (WebDavSyncService)
- `AUTO_CANCEL_TIMEOUT_MS` (NotificationHelper)
- RFC 1918 IP-Range Konstanten (UrlValidator)
- `DAYS_THRESHOLD`, `TRUNCATE_SUFFIX_LENGTH` (Extensions)
- **🔒 @Suppress Annotations für legitime Patterns**
- ReturnCount: Frühe Returns für Validierung sind idiomatisch
- LoopWithTooManyJumpStatements: Komplexe Sync-Logik dokumentiert
### Notes
- Komplexe Refactorings (LargeClass, LongMethod) für v1.3.3+ geplant
- Deprecation-Warnungen (LocalBroadcastManager, ProgressDialog) bleiben bestehen
---
## [1.3.1] - 2026-01-08 ## [1.3.1] - 2026-01-08
### Fixed ### Fixed

View File

@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` | | **WebDAV Server URL** | `http://YOUR-SERVER-IP:8080/` |
| **Username** | `noteuser` | | **Username** | `noteuser` |
| **Password** | (your password from `.env`) | | **Password** | (your password from `.env`) |
| **Gateway SSID** | Name of your WiFi network |
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export. > **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
@@ -158,9 +157,8 @@ For reliable auto-sync:
# Should show "Up" # Should show "Up"
``` ```
2. **Same WiFi?** 2. **Same network?**
- Smartphone and server must be on same network - Smartphone and server must be on same network
- Check SSID in app settings
3. **IP address correct?** 3. **IP address correct?**
```bash ```bash
@@ -193,9 +191,9 @@ For reliable auto-sync:
2. **Battery optimization disabled?** 2. **Battery optimization disabled?**
- See [Disable Battery Optimization](#-disable-battery-optimization) - See [Disable Battery Optimization](#-disable-battery-optimization)
3. **On correct WiFi?** 3. **Connected to WiFi?**
- Sync only works when SSID = Gateway SSID - Auto-sync triggers on any WiFi connection
- Check current SSID in Android settings - Check if you're connected to a WiFi network
4. **Test manually:** 4. **Test manually:**
- ⚙️ Settings → "Sync now" - ⚙️ Settings → "Sync now"

View File

@@ -74,7 +74,6 @@ ip addr show | grep "inet " | grep -v 127.0.0.1
| **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` | | **WebDAV Server URL** | `http://DEINE-SERVER-IP:8080/` |
| **Benutzername** | `noteuser` | | **Benutzername** | `noteuser` |
| **Passwort** | (dein Passwort aus `.env`) | | **Passwort** | (dein Passwort aus `.env`) |
| **Gateway SSID** | Name deines WLAN-Netzwerks |
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export. > **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
@@ -158,9 +157,8 @@ Für zuverlässigen Auto-Sync:
# Sollte "Up" zeigen # Sollte "Up" zeigen
``` ```
2. **Gleiche WLAN?** 2. **Gleiches Netzwerk?**
- Smartphone und Server müssen im selben Netzwerk sein - Smartphone und Server müssen im selben Netzwerk sein
- Prüfe SSID in App-Einstellungen
3. **IP-Adresse korrekt?** 3. **IP-Adresse korrekt?**
```bash ```bash
@@ -193,9 +191,9 @@ Für zuverlässigen Auto-Sync:
2. **Akku-Optimierung deaktiviert?** 2. **Akku-Optimierung deaktiviert?**
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren) - Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
3. **Im richtigen WLAN?** 3. **Mit WiFi verbunden?**
- Sync funktioniert nur wenn SSID = Gateway SSID - Auto-Sync triggert bei jeder WiFi-Verbindung
- Prüfe aktuelle SSID in Android-Einstellungen - Prüfe, ob du mit einem WLAN verbunden bist
4. **Manuell testen:** 4. **Manuell testen:**
- ⚙️ Einstellungen → "Jetzt synchronisieren" - ⚙️ Einstellungen → "Jetzt synchronisieren"

View File

@@ -26,11 +26,12 @@
## ✨ Highlights ## ✨ Highlights
-**NEW: Checklists** - Tap-to-check, drag & drop, swipe-to-delete
- 📝 **Offline-first** - Works without internet - 📝 **Offline-first** - Works without internet
- 🔄 **Auto-sync** - Home WiFi only (15/30/60 min) - 🔄 **Auto-sync** - On WiFi connection (15/30/60 min)
- 🔒 **Self-hosted** - Your data stays with you (WebDAV) - 🔒 **Self-hosted** - Your data stays with you (WebDAV)
- 💾 **Local backup** - Export/Import as JSON file - 💾 **Local backup** - Export/Import as JSON file
- 🖥️ **Desktop integration** - Markdown export for VS Code, Typora, etc. - 🖥️ **Desktop integration** - Markdown export for Obsidian, VS Code, Typora
- 🔋 **Battery-friendly** - ~0.2-0.8% per day - 🔋 **Battery-friendly** - ~0.2-0.8% per day
- 🎨 **Material Design 3** - Dark mode & dynamic colors - 🎨 **Material Design 3** - Dark mode & dynamic colors
@@ -85,7 +86,7 @@ cd android
./gradlew assembleStandardRelease ./gradlew assembleStandardRelease
``` ```
➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md) ➡️ **Build guide:** [DOCS.en.md](docs/DOCS.en.md#-build--deployment)
--- ---
@@ -101,4 +102,4 @@ MIT License - see [LICENSE](LICENSE)
--- ---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3 **v1.4.0** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -26,11 +26,12 @@
## ✨ Highlights ## ✨ Highlights
-**NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete
- 📝 **Offline-First** - Funktioniert ohne Internet - 📝 **Offline-First** - Funktioniert ohne Internet
- 🔄 **Auto-Sync** - Nur im Heim-WLAN (15/30/60 Min) - 🔄 **Auto-Sync** - Bei WiFi-Verbindung (15/30/60 Min)
- 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV) - 🔒 **Self-Hosted** - Deine Daten bleiben bei dir (WebDAV)
- 💾 **Lokales Backup** - Export/Import als JSON-Datei - 💾 **Lokales Backup** - Export/Import als JSON-Datei
- 🖥️ **Desktop-Integration** - Markdown-Export für VS Code, Typora, etc. - 🖥️ **Desktop-Integration** - Markdown-Export für Obsidian, VS Code, Typora
- 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag - 🔋 **Akkuschonend** - ~0.2-0.8% pro Tag
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors - 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
@@ -88,7 +89,7 @@ cd android
./gradlew assembleStandardRelease ./gradlew assembleStandardRelease
``` ```
➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md) ➡️ **Build-Anleitung:** [DOCS.md](docs/DOCS.md#-build--deployment)
--- ---
@@ -104,4 +105,4 @@ MIT License - siehe [LICENSE](LICENSE)
--- ---
**v1.2.1** · Built with ❤️ using Kotlin + Material Design 3 **v1.4.0** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -20,13 +20,10 @@ android {
applicationId = "dev.dettmer.simplenotes" applicationId = "dev.dettmer.simplenotes"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 9 // 🚀 v1.3.1: Sync-Performance & Debug-Logging versionCode = 11 // 🚀 v1.4.0: Checklists Feature
versionName = "1.3.1" // 🚀 v1.3.1: Skip unchanged MD-Files, Sync-Mutex, Debug-Logging UI versionName = "1.4.0" // 🚀 v1.4.0: Checklists, Multi-Device Sync Fixes, UX Improvements
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 🔥 NEU: Build Date für About Screen
buildConfigField("String", "BUILD_DATE", "\"${getBuildDate()}\"")
} }
// Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility // Disable Google dependency metadata for F-Droid/IzzyOnDroid compatibility
@@ -144,12 +141,6 @@ dependencies {
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
} }
// 🔥 NEU: Helper function für Build Date
fun getBuildDate(): String {
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
return dateFormat.format(Date())
}
// ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen // ⚡ v1.3.1: ktlint deaktiviert wegen Parser-Problemen
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde // Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
// ktlint { // ktlint {

View File

@@ -5,8 +5,6 @@
<!-- Network & Sync Permissions --> <!-- Network & Sync Permissions -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<!-- Notifications --> <!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -44,6 +44,9 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View import android.view.View
import android.widget.LinearLayout import android.widget.LinearLayout
import android.view.Gravity
import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -76,6 +79,8 @@ class MainActivity : AppCompatActivity() {
private const val REQUEST_SETTINGS = 1002 private const val REQUEST_SETTINGS = 1002
private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute private const val MIN_AUTO_SYNC_INTERVAL_MS = 60_000L // 1 Minute
private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp" private const val PREF_LAST_AUTO_SYNC_TIME = "last_auto_sync_timestamp"
private const val SYNC_COMPLETED_DELAY_MS = 1500L
private const val ERROR_DISPLAY_DELAY_MS = 3000L
} }
/** /**
@@ -152,7 +157,7 @@ class MainActivity : AppCompatActivity() {
// Show completed briefly, then hide // Show completed briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_completed) syncStatusText.text = status.message ?: getString(R.string.sync_status_completed)
lifecycleScope.launch { lifecycleScope.launch {
kotlinx.coroutines.delay(1500) kotlinx.coroutines.delay(SYNC_COMPLETED_DELAY_MS)
syncStatusBanner.visibility = View.GONE syncStatusBanner.visibility = View.GONE
SyncStateManager.reset() SyncStateManager.reset()
} }
@@ -164,7 +169,7 @@ class MainActivity : AppCompatActivity() {
// Show error briefly, then hide // Show error briefly, then hide
syncStatusText.text = status.message ?: getString(R.string.sync_status_error) syncStatusText.text = status.message ?: getString(R.string.sync_status_error)
lifecycleScope.launch { lifecycleScope.launch {
kotlinx.coroutines.delay(3000) kotlinx.coroutines.delay(ERROR_DISPLAY_DELAY_MS)
syncStatusBanner.visibility = View.GONE syncStatusBanner.visibility = View.GONE
SyncStateManager.reset() SyncStateManager.reset()
} }
@@ -518,16 +523,28 @@ class MainActivity : AppCompatActivity() {
val success = webdavService.deleteNoteFromServer(note.id) val success = webdavService.deleteNoteFromServer(note.id)
if (success) { if (success) {
runOnUiThread { runOnUiThread {
Toast.makeText(this@MainActivity, "Vom Server gelöscht", Toast.LENGTH_SHORT).show() Toast.makeText(
this@MainActivity,
"Vom Server gelöscht",
Toast.LENGTH_SHORT
).show()
} }
} else { } else {
runOnUiThread { runOnUiThread {
Toast.makeText(this@MainActivity, "Server-Löschung fehlgeschlagen", Toast.LENGTH_LONG).show() Toast.makeText(
this@MainActivity,
"Server-Löschung fehlgeschlagen",
Toast.LENGTH_LONG
).show()
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
runOnUiThread { runOnUiThread {
Toast.makeText(this@MainActivity, "Server-Fehler: ${e.message}", Toast.LENGTH_LONG).show() Toast.makeText(
this@MainActivity,
"Server-Fehler: ${e.message}",
Toast.LENGTH_LONG
).show()
} }
} }
} }
@@ -537,12 +554,55 @@ class MainActivity : AppCompatActivity() {
}).show() }).show()
} }
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
private fun setupFab() { private fun setupFab() {
fabAddNote.setOnClickListener { fabAddNote.setOnClickListener { view ->
openNoteEditor(null) showNoteTypePopup(view)
} }
} }
/**
* v1.4.0: Zeigt Popup-Menü zur Auswahl des Notiz-Typs
*/
private fun showNoteTypePopup(anchor: View) {
val popupMenu = PopupMenu(this, anchor, Gravity.END)
popupMenu.inflate(R.menu.menu_fab_note_types)
// Icons im Popup anzeigen (via Reflection, da standardmäßig ausgeblendet)
try {
val fields = popupMenu.javaClass.declaredFields
for (field in fields) {
if ("mPopup" == field.name) {
field.isAccessible = true
val menuPopupHelper = field.get(popupMenu)
val classPopupHelper = Class.forName(menuPopupHelper.javaClass.name)
val setForceIcons = classPopupHelper.getMethod("setForceShowIcon", Boolean::class.java)
setForceIcons.invoke(menuPopupHelper, true)
break
}
}
} catch (e: Exception) {
Logger.w(TAG, "Could not force show icons in popup menu: ${e.message}")
}
popupMenu.setOnMenuItemClickListener { menuItem ->
val noteType = when (menuItem.itemId) {
R.id.action_create_text_note -> NoteType.TEXT
R.id.action_create_checklist -> NoteType.CHECKLIST
else -> return@setOnMenuItemClickListener false
}
val intent = Intent(this, NoteEditorActivity::class.java)
intent.putExtra(NoteEditorActivity.EXTRA_NOTE_TYPE, noteType.name)
startActivity(intent)
true
}
popupMenu.show()
}
private fun loadNotes() { private fun loadNotes() {
val notes = storage.loadAllNotes() val notes = storage.loadAllNotes()

View File

@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
import android.os.Bundle import android.os.Bundle
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import dev.dettmer.simplenotes.adapters.ChecklistEditorAdapter
import dev.dettmer.simplenotes.models.ChecklistItem
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.DeviceIdGenerator import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
/**
* Editor Activity für Notizen und Checklisten
*
* v1.4.0: Unterstützt jetzt sowohl TEXT als auch CHECKLIST Notizen
*/
class NoteEditorActivity : AppCompatActivity() { class NoteEditorActivity : AppCompatActivity() {
// Views
private lateinit var toolbar: MaterialToolbar
private lateinit var tilTitle: TextInputLayout
private lateinit var editTextTitle: TextInputEditText private lateinit var editTextTitle: TextInputEditText
private lateinit var tilContent: TextInputLayout
private lateinit var editTextContent: TextInputEditText private lateinit var editTextContent: TextInputEditText
private lateinit var checklistContainer: LinearLayout
private lateinit var rvChecklistItems: RecyclerView
private lateinit var btnAddItem: MaterialButton
private lateinit var storage: NotesStorage private lateinit var storage: NotesStorage
// State
private var existingNote: Note? = null private var existingNote: Note? = null
private var currentNoteType: NoteType = NoteType.TEXT
private val checklistItems = mutableListOf<ChecklistItem>()
private var checklistAdapter: ChecklistEditorAdapter? = null
private var itemTouchHelper: ItemTouchHelper? = null
companion object { companion object {
private const val TAG = "NoteEditorActivity"
const val EXTRA_NOTE_ID = "extra_note_id" const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type"
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -36,39 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
storage = NotesStorage(this) storage = NotesStorage(this)
// Setup toolbar findViews()
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar) setupToolbar()
setSupportActionBar(toolbar) loadNoteOrDetermineType()
supportActionBar?.apply { setupUIForNoteType()
setDisplayHomeAsUpEnabled(true) }
// 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
}
// Find views private fun findViews() {
toolbar = findViewById(R.id.toolbar)
tilTitle = findViewById(R.id.tilTitle)
editTextTitle = findViewById(R.id.editTextTitle) editTextTitle = findViewById(R.id.editTextTitle)
tilContent = findViewById(R.id.tilContent)
editTextContent = findViewById(R.id.editTextContent) editTextContent = findViewById(R.id.editTextContent)
checklistContainer = findViewById(R.id.checklistContainer)
rvChecklistItems = findViewById(R.id.rvChecklistItems)
btnAddItem = findViewById(R.id.btnAddItem)
}
// Load existing note if editing private fun setupToolbar() {
setSupportActionBar(toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
private fun loadNoteOrDetermineType() {
val noteId = intent.getStringExtra(EXTRA_NOTE_ID) val noteId = intent.getStringExtra(EXTRA_NOTE_ID)
if (noteId != null) { if (noteId != null) {
// Existierende Notiz laden
existingNote = storage.loadNote(noteId) existingNote = storage.loadNote(noteId)
existingNote?.let { existingNote?.let { note ->
editTextTitle.setText(it.title) editTextTitle.setText(note.title)
editTextContent.setText(it.content) currentNoteType = note.noteType
supportActionBar?.title = "Notiz bearbeiten"
when (note.noteType) {
NoteType.TEXT -> {
editTextContent.setText(note.content)
supportActionBar?.title = getString(R.string.edit_note)
}
NoteType.CHECKLIST -> {
note.checklistItems?.let { items ->
checklistItems.clear()
checklistItems.addAll(items.sortedBy { it.order })
}
supportActionBar?.title = getString(R.string.edit_checklist)
}
}
} }
} else { } else {
supportActionBar?.title = "Neue Notiz" // Neue Notiz - Typ aus Intent
val typeString = intent.getStringExtra(EXTRA_NOTE_TYPE) ?: NoteType.TEXT.name
currentNoteType = try {
NoteType.valueOf(typeString)
} catch (e: IllegalArgumentException) {
Logger.w(TAG, "Invalid note type '$typeString', defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
when (currentNoteType) {
NoteType.TEXT -> {
supportActionBar?.title = getString(R.string.new_note)
}
NoteType.CHECKLIST -> {
supportActionBar?.title = getString(R.string.new_checklist)
// Erstes leeres Item hinzufügen
checklistItems.add(ChecklistItem.createEmpty(0))
}
}
}
}
private fun setupUIForNoteType() {
when (currentNoteType) {
NoteType.TEXT -> {
tilContent.visibility = View.VISIBLE
checklistContainer.visibility = View.GONE
}
NoteType.CHECKLIST -> {
tilContent.visibility = View.GONE
checklistContainer.visibility = View.VISIBLE
setupChecklistRecyclerView()
}
}
}
private fun setupChecklistRecyclerView() {
checklistAdapter = ChecklistEditorAdapter(
items = checklistItems,
onItemCheckedChanged = { position, isChecked ->
if (position in checklistItems.indices) {
checklistItems[position].isChecked = isChecked
}
},
onItemTextChanged = { position, newText ->
if (position in checklistItems.indices) {
checklistItems[position] = checklistItems[position].copy(text = newText)
}
},
onItemDeleted = { position ->
deleteChecklistItem(position)
},
onAddNewItem = { position ->
addChecklistItemAt(position)
},
onStartDrag = { viewHolder ->
itemTouchHelper?.startDrag(viewHolder)
}
)
rvChecklistItems.apply {
layoutManager = LinearLayoutManager(this@NoteEditorActivity)
adapter = checklistAdapter
}
// Drag & Drop Setup
val callback = object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.UP or ItemTouchHelper.DOWN,
0 // Kein Swipe
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.bindingAdapterPosition
val to = target.bindingAdapterPosition
checklistAdapter?.moveItem(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Nicht verwendet
}
override fun isLongPressDragEnabled(): Boolean = false // Nur via Handle
}
itemTouchHelper = ItemTouchHelper(callback)
itemTouchHelper?.attachToRecyclerView(rvChecklistItems)
// Add Item Button
btnAddItem.setOnClickListener {
addChecklistItemAt(checklistItems.size)
}
}
private fun addChecklistItemAt(position: Int) {
val newItem = ChecklistItem.createEmpty(position)
checklistAdapter?.insertItem(position, newItem)
// Zum neuen Item scrollen und fokussieren
rvChecklistItems.scrollToPosition(position)
checklistAdapter?.focusItem(rvChecklistItems, position)
}
private fun deleteChecklistItem(position: Int) {
checklistAdapter?.removeItem(position)
// Wenn letztes Item gelöscht, automatisch neues hinzufügen
if (checklistItems.isEmpty()) {
addChecklistItemAt(0)
} }
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_editor, menu) menuInflater.inflate(R.menu.menu_editor, menu)
// Delete nur für existierende Notizen
// Show delete only for existing notes
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
return true return true
} }
@@ -92,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
private fun saveNote() { private fun saveNote() {
val title = editTextTitle.text?.toString()?.trim() ?: "" val title = editTextTitle.text?.toString()?.trim() ?: ""
val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) { when (currentNoteType) {
showToast("Notiz ist leer") NoteType.TEXT -> {
return val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) {
showToast(getString(R.string.note_is_empty))
return
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
NoteType.CHECKLIST -> {
// Leere Items filtern
val validItems = checklistItems.filter { it.text.isNotBlank() }
if (title.isEmpty() && validItems.isEmpty()) {
showToast(getString(R.string.note_is_empty))
return
}
// Order neu setzen
val orderedItems = validItems.mapIndexed { index, item ->
item.copy(order = index)
}
val note = if (existingNote != null) {
existingNote!!.copy(
title = title,
content = "", // Leer für Checklisten
noteType = NoteType.CHECKLIST,
checklistItems = orderedItems,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
Note(
title = title,
content = "",
noteType = NoteType.CHECKLIST,
checklistItems = orderedItems,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
}
} }
val note = if (existingNote != null) { showToast(getString(R.string.note_saved))
// Update existing note
existingNote!!.copy(
title = title,
content = content,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
// Create new note
Note(
title = title,
content = content,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
showToast("Notiz gespeichert")
finish() finish()
} }
private fun confirmDelete() { private fun confirmDelete() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("Notiz löschen?") .setTitle(getString(R.string.delete_note_title))
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.") .setMessage(getString(R.string.delete_note_message))
.setPositiveButton("Löschen") { _, _ -> .setPositiveButton(getString(R.string.delete)) { _, _ ->
deleteNote() deleteNote()
} }
.setNegativeButton("Abbrechen", null) .setNegativeButton(getString(R.string.cancel), null)
.show() .show()
} }
private fun deleteNote() { private fun deleteNote() {
existingNote?.let { existingNote?.let {
storage.deleteNote(it.id) storage.deleteNote(it.id)
showToast("Notiz gelöscht") showToast(getString(R.string.note_deleted))
finish() finish()
} }
} }

View File

@@ -25,8 +25,6 @@ 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.color.DynamicColors import com.google.android.material.color.DynamicColors
import com.google.android.material.switchmaterial.SwitchMaterial
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.dettmer.simplenotes.backup.BackupManager import dev.dettmer.simplenotes.backup.BackupManager
@@ -39,7 +37,6 @@ import dev.dettmer.simplenotes.sync.NetworkMonitor
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.showToast import dev.dettmer.simplenotes.utils.showToast
import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -52,6 +49,7 @@ class SettingsActivity : AppCompatActivity() {
private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync" private const val GITHUB_REPO_URL = "https://github.com/inventory69/simple-notes-sync"
private const val GITHUB_PROFILE_URL = "https://github.com/inventory69" private const val GITHUB_PROFILE_URL = "https://github.com/inventory69"
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 const val CONNECTION_TIMEOUT_MS = 3000
} }
private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout private lateinit var textInputLayoutServerUrl: com.google.android.material.textfield.TextInputLayout
@@ -325,7 +323,10 @@ class SettingsActivity : AppCompatActivity() {
*/ */
private fun setupSyncIntervalPicker() { private fun setupSyncIntervalPicker() {
// Load current interval from preferences // Load current interval from preferences
val currentInterval = prefs.getLong(Constants.PREF_SYNC_INTERVAL_MINUTES, Constants.DEFAULT_SYNC_INTERVAL_MINUTES) val currentInterval = prefs.getLong(
Constants.PREF_SYNC_INTERVAL_MINUTES,
Constants.DEFAULT_SYNC_INTERVAL_MINUTES
)
// Set checked radio button based on current interval // Set checked radio button based on current interval
val checkedId = when (currentInterval) { val checkedId = when (currentInterval) {
@@ -370,13 +371,12 @@ class SettingsActivity : AppCompatActivity() {
* Setup about section with version info and clickable cards * Setup about section with version info and clickable cards
*/ */
private fun setupAboutSection() { private fun setupAboutSection() {
// Display app version with build date // Display app version
try { try {
val versionName = BuildConfig.VERSION_NAME val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE val versionCode = BuildConfig.VERSION_CODE
val buildDate = BuildConfig.BUILD_DATE
textViewAppVersion.text = "Version $versionName ($versionCode)\nErstellt am: $buildDate" textViewAppVersion.text = "Version $versionName ($versionCode)"
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "Failed to load version info", e) Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = "Version nicht verfügbar" textViewAppVersion.text = "Version nicht verfügbar"
@@ -654,8 +654,8 @@ class SettingsActivity : AppCompatActivity() {
try { try {
val url = URL(serverUrl) val url = URL(serverUrl)
val connection = url.openConnection() as HttpURLConnection val connection = url.openConnection() as HttpURLConnection
connection.connectTimeout = 3000 connection.connectTimeout = CONNECTION_TIMEOUT_MS
connection.readTimeout = 3000 connection.readTimeout = CONNECTION_TIMEOUT_MS
val code = connection.responseCode val code = connection.responseCode
connection.disconnect() connection.disconnect()
code in 200..299 || code == 401 // 401 = Server da, Auth fehlt code in 200..299 || code == 401 // 401 = Server da, Auth fehlt
@@ -764,7 +764,10 @@ class SettingsActivity : AppCompatActivity() {
.apply() .apply()
updateMarkdownButtonVisibility() updateMarkdownButtonVisibility()
showToast("Markdown Auto-Sync aktiviert - Notizen werden als .md-Dateien exportiert und importiert") showToast(
"Markdown Auto-Sync aktiviert - " +
"Notizen werden als .md-Dateien exportiert und importiert"
)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -818,11 +821,13 @@ class SettingsActivity : AppCompatActivity() {
intent.data = Uri.parse("package:$packageName") intent.data = Uri.parse("package:$packageName")
startActivity(intent) startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to open battery optimization settings: ${e.message}")
// Fallback: Open general battery settings // Fallback: Open general battery settings
try { try {
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
startActivity(intent) startActivity(intent)
} catch (e2: Exception) { } catch (e2: Exception) {
Logger.w(TAG, "Failed to open fallback battery settings: ${e2.message}")
showToast("Bitte Akku-Optimierung manuell deaktivieren") showToast("Bitte Akku-Optimierung manuell deaktivieren")
} }
} }
@@ -841,49 +846,6 @@ class SettingsActivity : AppCompatActivity() {
} }
} }
private fun showRestoreConfirmation() {
android.app.AlertDialog.Builder(this)
.setTitle(R.string.restore_confirmation_title)
.setMessage(R.string.restore_confirmation_message)
.setPositiveButton(R.string.restore_button) { _, _ ->
performRestore()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun performRestore() {
val progressDialog = android.app.ProgressDialog(this).apply {
setMessage(getString(R.string.restore_progress))
setCancelable(false)
show()
}
CoroutineScope(Dispatchers.Main).launch {
try {
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
webdavService.restoreFromServer()
}
progressDialog.dismiss()
if (result.isSuccess) {
showToast(getString(R.string.restore_success, result.restoredCount))
// Refresh MainActivity's note list
setResult(RESULT_OK)
} else {
showToast(getString(R.string.restore_error, result.errorMessage))
}
checkServerStatus()
} catch (e: Exception) {
progressDialog.dismiss()
showToast(getString(R.string.restore_error, e.message))
checkServerStatus()
}
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) { return when (item.itemId) {
android.R.id.home -> { android.R.id.home -> {
@@ -946,7 +908,6 @@ class SettingsActivity : AppCompatActivity() {
} }
// Custom View mit Radio Buttons // Custom View mit Radio Buttons
val dialogView = layoutInflater.inflate(android.R.layout.select_dialog_singlechoice, null)
val radioGroup = android.widget.RadioGroup(this).apply { val radioGroup = android.widget.RadioGroup(this).apply {
orientation = android.widget.RadioGroup.VERTICAL orientation = android.widget.RadioGroup.VERTICAL
setPadding(50, 20, 50, 20) setPadding(50, 20, 50, 20)
@@ -1039,12 +1000,12 @@ class SettingsActivity : AppCompatActivity() {
progressDialog.dismiss() progressDialog.dismiss()
if (result.success) { if (result.success) {
val message = result.message ?: "Wiederhergestellt: ${result.imported_notes} Notizen" val message = result.message ?: "Wiederhergestellt: ${result.importedNotes} Notizen"
showToast("$message") showToast("$message")
// Refresh MainActivity's note list // Refresh MainActivity's note list
setResult(RESULT_OK) setResult(RESULT_OK)
broadcastNotesChanged(result.imported_notes) broadcastNotesChanged(result.importedNotes)
} else { } else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler") showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
} }
@@ -1153,10 +1114,16 @@ class SettingsActivity : AppCompatActivity() {
progressDialog.dismiss() progressDialog.dismiss()
// Erfolgs-Nachricht // Erfolgs-Nachricht
val message = "✅ Sync abgeschlossen\n📤 ${result.exportedCount} exportiert\n📥 ${result.importedCount} importiert" val message = "✅ Sync abgeschlossen\n" +
"📤 ${result.exportedCount} exportiert\n" +
"📥 ${result.importedCount} importiert"
showToast(message) showToast(message)
Logger.d("SettingsActivity", "Manual markdown sync: exported=${result.exportedCount}, imported=${result.importedCount}") Logger.d(
"SettingsActivity",
"Manual markdown sync: exported=${result.exportedCount}, " +
"imported=${result.importedCount}"
)
} catch (e: Exception) { } catch (e: Exception) {
progressDialog?.dismiss() progressDialog?.dismiss()

View File

@@ -0,0 +1,181 @@
package dev.dettmer.simplenotes.adapters
import android.graphics.Paint
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import android.widget.ImageButton
import android.widget.ImageView
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox
import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.ChecklistItem
/**
* Adapter für die Bearbeitung von Checklist-Items im Editor
*
* v1.4.0: Checklisten-Feature
*/
class ChecklistEditorAdapter(
private val items: MutableList<ChecklistItem>,
private val onItemCheckedChanged: (Int, Boolean) -> Unit,
private val onItemTextChanged: (Int, String) -> Unit,
private val onItemDeleted: (Int) -> Unit,
private val onAddNewItem: (Int) -> Unit,
private val onStartDrag: (RecyclerView.ViewHolder) -> Unit
) : RecyclerView.Adapter<ChecklistEditorAdapter.ViewHolder>() {
inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val dragHandle: ImageView = view.findViewById(R.id.ivDragHandle)
val checkbox: MaterialCheckBox = view.findViewById(R.id.cbItem)
val editText: EditText = view.findViewById(R.id.etItemText)
val deleteButton: ImageButton = view.findViewById(R.id.btnDeleteItem)
private var textWatcher: TextWatcher? = null
@Suppress("NestedBlockDepth", "UNUSED_PARAMETER")
fun bind(item: ChecklistItem, position: Int) {
// Vorherigen TextWatcher entfernen um Loops zu vermeiden
textWatcher?.let { editText.removeTextChangedListener(it) }
// Checkbox
checkbox.isChecked = item.isChecked
checkbox.setOnCheckedChangeListener { _, isChecked ->
onItemCheckedChanged(bindingAdapterPosition, isChecked)
updateStrikethrough(isChecked)
}
// Text
editText.setText(item.text)
updateStrikethrough(item.isChecked)
// TextWatcher für Änderungen
textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onItemTextChanged(pos, s?.toString() ?: "")
}
}
}
editText.addTextChangedListener(textWatcher)
// Enter-Taste = neues Item
editText.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_NEXT ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)) {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onAddNewItem(pos + 1)
}
true
} else {
false
}
}
// Delete Button
deleteButton.setOnClickListener {
val pos = bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) {
onItemDeleted(pos)
}
}
// Drag Handle Touch Listener
dragHandle.setOnTouchListener { _, event ->
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
onStartDrag(this)
}
false
}
}
private fun updateStrikethrough(isChecked: Boolean) {
if (isChecked) {
editText.paintFlags = editText.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
editText.alpha = CHECKED_ITEM_ALPHA
} else {
editText.paintFlags = editText.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv()
editText.alpha = UNCHECKED_ITEM_ALPHA
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_checklist_editor, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position], position)
}
override fun getItemCount(): Int = items.size
/**
* Bewegt ein Item von einer Position zu einer anderen (für Drag & Drop)
*/
fun moveItem(fromPosition: Int, toPosition: Int) {
val item = items.removeAt(fromPosition)
items.add(toPosition, item)
notifyItemMoved(fromPosition, toPosition)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
/**
* Entfernt ein Item an der angegebenen Position
*/
fun removeItem(position: Int) {
if (position in items.indices) {
items.removeAt(position)
notifyItemRemoved(position)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
}
/**
* Fügt ein neues Item an der angegebenen Position ein
*/
fun insertItem(position: Int, item: ChecklistItem) {
items.add(position, item)
notifyItemInserted(position)
// Order-Werte aktualisieren
items.forEachIndexed { index, checklistItem ->
checklistItem.order = index
}
}
/**
* Fokussiert das EditText des Items an der angegebenen Position
*/
fun focusItem(recyclerView: RecyclerView, position: Int) {
recyclerView.post {
val viewHolder = recyclerView.findViewHolderForAdapterPosition(position) as? ViewHolder
viewHolder?.editText?.requestFocus()
}
}
companion object {
/** Alpha-Wert für abgehakte Items (durchgestrichen) */
private const val CHECKED_ITEM_ALPHA = 0.6f
/** Alpha-Wert für nicht abgehakte Items */
private const val UNCHECKED_ITEM_ALPHA = 1.0f
}
}

View File

@@ -11,11 +11,17 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dev.dettmer.simplenotes.R import dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.toReadableTime import dev.dettmer.simplenotes.utils.toReadableTime
import dev.dettmer.simplenotes.utils.truncate import dev.dettmer.simplenotes.utils.truncate
/**
* Adapter für die Notizen-Liste
*
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
*/
class NotesAdapter( class NotesAdapter(
private val onNoteClick: (Note) -> Unit private val onNoteClick: (Note) -> Unit
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) { ) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
@@ -31,16 +37,46 @@ class NotesAdapter(
} }
inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { inner class NoteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val ivNoteTypeIcon: ImageView = itemView.findViewById(R.id.ivNoteTypeIcon)
private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle) private val textViewTitle: TextView = itemView.findViewById(R.id.textViewTitle)
private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent) private val textViewContent: TextView = itemView.findViewById(R.id.textViewContent)
private val textViewChecklistPreview: TextView = itemView.findViewById(R.id.textViewChecklistPreview)
private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp) private val textViewTimestamp: TextView = itemView.findViewById(R.id.textViewTimestamp)
private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus) private val imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
fun bind(note: Note) { fun bind(note: Note) {
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" } // Titel
textViewContent.text = note.content.truncate(100) textViewTitle.text = note.title.ifEmpty {
itemView.context.getString(R.string.untitled)
}
textViewTimestamp.text = note.updatedAt.toReadableTime() textViewTimestamp.text = note.updatedAt.toReadableTime()
// v1.4.0: Typ-spezifische Anzeige
when (note.noteType) {
NoteType.TEXT -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_note_24)
textViewContent.text = note.content.truncate(100)
textViewContent.visibility = View.VISIBLE
textViewChecklistPreview.visibility = View.GONE
}
NoteType.CHECKLIST -> {
ivNoteTypeIcon.setImageResource(R.drawable.ic_checklist_24)
textViewContent.visibility = View.GONE
textViewChecklistPreview.visibility = View.VISIBLE
// Fortschritt berechnen
val items = note.checklistItems ?: emptyList()
val checkedCount = items.count { it.isChecked }
val totalCount = items.size
textViewChecklistPreview.text = if (totalCount > 0) {
itemView.context.getString(R.string.checklist_progress, checkedCount, totalCount)
} else {
itemView.context.getString(R.string.empty_checklist)
}
}
}
// Sync Icon nur zeigen wenn Sync konfiguriert ist // Sync Icon nur zeigen wenn Sync konfiguriert ist
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null) val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, null)

View File

@@ -49,10 +49,10 @@ class BackupManager(private val context: Context) {
Logger.d(TAG, " Found ${allNotes.size} notes to backup") Logger.d(TAG, " Found ${allNotes.size} notes to backup")
val backupData = BackupData( val backupData = BackupData(
backup_version = BACKUP_VERSION, backupVersion = BACKUP_VERSION,
created_at = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
notes_count = allNotes.size, notesCount = allNotes.size,
app_version = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
notes = allNotes notes = allNotes
) )
@@ -65,7 +65,7 @@ class BackupManager(private val context: Context) {
BackupResult( BackupResult(
success = true, success = true,
notes_count = allNotes.size, notesCount = allNotes.size,
message = "Backup erstellt: ${allNotes.size} Notizen" message = "Backup erstellt: ${allNotes.size} Notizen"
) )
@@ -99,10 +99,10 @@ class BackupManager(private val context: Context) {
val allNotes = storage.loadAllNotes() val allNotes = storage.loadAllNotes()
val backupData = BackupData( val backupData = BackupData(
backup_version = BACKUP_VERSION, backupVersion = BACKUP_VERSION,
created_at = System.currentTimeMillis(), createdAt = System.currentTimeMillis(),
notes_count = allNotes.size, notesCount = allNotes.size,
app_version = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
notes = allNotes notes = allNotes
) )
@@ -149,7 +149,7 @@ class BackupManager(private val context: Context) {
} }
val backupData = gson.fromJson(jsonString, BackupData::class.java) val backupData = gson.fromJson(jsonString, BackupData::class.java)
Logger.d(TAG, " Backup valid: ${backupData.notes_count} notes, version ${backupData.backup_version}") Logger.d(TAG, " Backup valid: ${backupData.notesCount} notes, version ${backupData.backupVersion}")
// 3. Auto-Backup erstellen (Sicherheitsnetz) // 3. Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = createAutoBackup() val autoBackupUri = createAutoBackup()
@@ -164,7 +164,7 @@ class BackupManager(private val context: Context) {
RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes) RestoreMode.OVERWRITE_DUPLICATES -> restoreOverwriteDuplicates(backupData.notes)
} }
Logger.d(TAG, "✅ Restore completed: ${result.imported_notes} imported, ${result.skipped_notes} skipped") Logger.d(TAG, "✅ Restore completed: ${result.importedNotes} imported, ${result.skippedNotes} skipped")
result result
} catch (e: Exception) { } catch (e: Exception) {
@@ -184,10 +184,11 @@ class BackupManager(private val context: Context) {
val backupData = gson.fromJson(jsonString, BackupData::class.java) val backupData = gson.fromJson(jsonString, BackupData::class.java)
// Version kompatibel? // Version kompatibel?
if (backupData.backup_version > BACKUP_VERSION) { if (backupData.backupVersion > BACKUP_VERSION) {
return ValidationResult( return ValidationResult(
isValid = false, isValid = false,
errorMessage = "Backup-Version nicht unterstützt (v${backupData.backup_version} benötigt v${BACKUP_VERSION}+)" errorMessage = "Backup-Version nicht unterstützt " +
"(v${backupData.backupVersion} benötigt v${BACKUP_VERSION}+)"
) )
} }
@@ -238,8 +239,8 @@ class BackupManager(private val context: Context) {
return RestoreResult( return RestoreResult(
success = true, success = true,
imported_notes = newNotes.size, importedNotes = newNotes.size,
skipped_notes = skippedNotes, skippedNotes = skippedNotes,
message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen" message = "${newNotes.size} neue Notizen importiert, $skippedNotes übersprungen"
) )
} }
@@ -259,8 +260,8 @@ class BackupManager(private val context: Context) {
return RestoreResult( return RestoreResult(
success = true, success = true,
imported_notes = backupNotes.size, importedNotes = backupNotes.size,
skipped_notes = 0, skippedNotes = 0,
message = "Alle Notizen ersetzt: ${backupNotes.size} importiert" message = "Alle Notizen ersetzt: ${backupNotes.size} importiert"
) )
} }
@@ -283,9 +284,9 @@ class BackupManager(private val context: Context) {
return RestoreResult( return RestoreResult(
success = true, success = true,
imported_notes = newNotes.size, importedNotes = newNotes.size,
skipped_notes = 0, skippedNotes = 0,
overwritten_notes = overwrittenNotes.size, overwrittenNotes = overwrittenNotes.size,
message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben" message = "${newNotes.size} neu, ${overwrittenNotes.size} überschrieben"
) )
} }
@@ -312,12 +313,17 @@ class BackupManager(private val context: Context) {
/** /**
* Backup-Daten Struktur (JSON) * Backup-Daten Struktur (JSON)
* NOTE: Property names use @SerializedName for JSON compatibility with snake_case
*/ */
data class BackupData( data class BackupData(
val backup_version: Int, @com.google.gson.annotations.SerializedName("backup_version")
val created_at: Long, val backupVersion: Int,
val notes_count: Int, @com.google.gson.annotations.SerializedName("created_at")
val app_version: String, val createdAt: Long,
@com.google.gson.annotations.SerializedName("notes_count")
val notesCount: Int,
@com.google.gson.annotations.SerializedName("app_version")
val appVersion: String,
val notes: List<Note> val notes: List<Note>
) )
@@ -335,7 +341,7 @@ enum class RestoreMode {
*/ */
data class BackupResult( data class BackupResult(
val success: Boolean, val success: Boolean,
val notes_count: Int = 0, val notesCount: Int = 0,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )
@@ -345,9 +351,9 @@ data class BackupResult(
*/ */
data class RestoreResult( data class RestoreResult(
val success: Boolean, val success: Boolean,
val imported_notes: Int = 0, val importedNotes: Int = 0,
val skipped_notes: Int = 0, val skippedNotes: Int = 0,
val overwritten_notes: Int = 0, val overwrittenNotes: Int = 0,
val message: String? = null, val message: String? = null,
val error: String? = null val error: String? = null
) )

View File

@@ -0,0 +1,34 @@
package dev.dettmer.simplenotes.models
import java.util.UUID
/**
* Repräsentiert ein einzelnes Item in einer Checkliste
*
* v1.4.0: Checklisten-Feature
*
* @property id Eindeutige ID für Sync-Konflikterkennung
* @property text Der Text des Items
* @property isChecked Ob das Item abgehakt ist
* @property order Sortierreihenfolge (0-basiert)
*/
data class ChecklistItem(
val id: String = UUID.randomUUID().toString(),
val text: String = "",
var isChecked: Boolean = false,
var order: Int = 0
) {
companion object {
/**
* Erstellt ein neues leeres ChecklistItem
*/
fun createEmpty(order: Int): ChecklistItem {
return ChecklistItem(
id = UUID.randomUUID().toString(),
text = "",
isChecked = false,
order = order
)
}
}
}

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
@@ -49,6 +50,8 @@ data class DeletionTracker(
} }
companion object { companion object {
private const val TAG = "DeletionTracker"
fun fromJson(json: String): DeletionTracker? { fun fromJson(json: String): DeletionTracker? {
return try { return try {
val jsonObject = JSONObject(json) val jsonObject = JSONObject(json)
@@ -70,6 +73,7 @@ data class DeletionTracker(
DeletionTracker(version, deletedNotes) DeletionTracker(version, deletedNotes)
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse DeletionTracker JSON: ${e.message}")
null null
} }
} }

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.models package dev.dettmer.simplenotes.models
import dev.dettmer.simplenotes.utils.Logger
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -13,53 +14,125 @@ data class Note(
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String, val deviceId: String,
val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY val syncStatus: SyncStatus = SyncStatus.LOCAL_ONLY,
// v1.4.0: Checklisten-Felder
val noteType: NoteType = NoteType.TEXT,
val checklistItems: List<ChecklistItem>? = null
) { ) {
/**
* Serialisiert Note zu JSON (v1.4.0: Nutzt Gson für komplexe Strukturen)
*/
fun toJson(): String { fun toJson(): String {
return """ val gson = com.google.gson.GsonBuilder()
{ .setPrettyPrinting()
"id": "$id", .create()
"title": "${title.escapeJson()}", return gson.toJson(this)
"content": "${content.escapeJson()}",
"createdAt": $createdAt,
"updatedAt": $updatedAt,
"deviceId": "$deviceId",
"syncStatus": "${syncStatus.name}"
}
""".trimIndent()
} }
/** /**
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08) * Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
* Format kompatibel mit Obsidian, Joplin, Typora * Format kompatibel mit Obsidian, Joplin, Typora
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/ */
fun toMarkdown(): String { fun toMarkdown(): String {
return """ val header = """
--- ---
id: $id id: $id
created: ${formatISO8601(createdAt)} created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)} updated: ${formatISO8601(updatedAt)}
device: $deviceId device: $deviceId
type: ${noteType.name.lowercase()}
--- ---
# $title # $title
$content """.trimIndent()
""".trimIndent()
return when (noteType) {
NoteType.TEXT -> header + content
NoteType.CHECKLIST -> {
val checklistMarkdown = checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "[x]" else "[ ]"
"- $checkbox ${item.text}"
} ?: ""
header + checklistMarkdown
}
}
} }
companion object { companion object {
private const val TAG = "Note"
/**
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
*/
fun fromJson(json: String): Note? { fun fromJson(json: String): Note? {
return try { return try {
val gson = com.google.gson.Gson() val gson = com.google.gson.Gson()
gson.fromJson(json, Note::class.java) val jsonObject = com.google.gson.JsonParser.parseString(json).asJsonObject
// Backward Compatibility: Alte Notizen ohne noteType bekommen TEXT
val noteType = if (jsonObject.has("noteType") && !jsonObject.get("noteType").isJsonNull) {
try {
NoteType.valueOf(jsonObject.get("noteType").asString)
} catch (e: Exception) {
Logger.w(TAG, "Unknown noteType, defaulting to TEXT: ${e.message}")
NoteType.TEXT
}
} else {
NoteType.TEXT
}
// Parsen der Basis-Note
val rawNote = gson.fromJson(json, NoteRaw::class.java)
// Checklist-Items parsen (kann null sein)
val checklistItemsType = object : com.google.gson.reflect.TypeToken<List<ChecklistItem>>() {}.type
val checklistItems = if (jsonObject.has("checklistItems") &&
!jsonObject.get("checklistItems").isJsonNull
) {
gson.fromJson<List<ChecklistItem>>(
jsonObject.get("checklistItems"),
checklistItemsType
)
} else {
null
}
// Note mit korrekten Werten erstellen
Note(
id = rawNote.id,
title = rawNote.title,
content = rawNote.content,
createdAt = rawNote.createdAt,
updatedAt = rawNote.updatedAt,
deviceId = rawNote.deviceId,
syncStatus = rawNote.syncStatus ?: SyncStatus.LOCAL_ONLY,
noteType = noteType,
checklistItems = checklistItems
)
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
null null
} }
} }
/**
* Hilfsklasse für Gson-Parsing mit nullable Feldern
*/
private data class NoteRaw(
val id: String = UUID.randomUUID().toString(),
val title: String = "",
val content: String = "",
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
val deviceId: String = "",
val syncStatus: SyncStatus? = null
)
/** /**
* Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09) * Parst Markdown zurück zu Note-Objekt (Task #1.2.0-09)
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
* *
* @param md Markdown-String mit YAML Frontmatter * @param md Markdown-String mit YAML Frontmatter
* @return Note-Objekt oder null bei Parse-Fehler * @return Note-Objekt oder null bei Parse-Fehler
@@ -87,10 +160,47 @@ $content
.firstOrNull { it.startsWith("# ") } .firstOrNull { it.startsWith("# ") }
?.removePrefix("# ")?.trim() ?: "Untitled" ?.removePrefix("# ")?.trim() ?: "Untitled"
// Extract content (everything after heading) // v1.4.0: Prüfe ob type: checklist im Frontmatter
val content = contentBlock val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
.substringAfter("# $title\n\n", "") val noteType = when (noteTypeStr) {
.trim() "checklist" -> NoteType.CHECKLIST
else -> NoteType.TEXT
}
// v1.4.0: Parse Content basierend auf Typ
// FIX: Robusteres Parsing - suche nach dem Titel-Header und extrahiere den Rest
val titleLineIndex = contentBlock.lines().indexOfFirst { it.startsWith("# ") }
val contentAfterTitle = if (titleLineIndex >= 0) {
// Alles nach der Titel-Zeile, überspringe führende Leerzeilen
contentBlock.lines()
.drop(titleLineIndex + 1)
.dropWhile { it.isBlank() }
.joinToString("\n")
.trim()
} else {
// Fallback: Gesamter Content (kein Titel gefunden)
contentBlock.trim()
}
val content: String
val checklistItems: List<ChecklistItem>?
if (noteType == NoteType.CHECKLIST) {
// Parse Checklist Items
val checklistRegex = Regex("^- \\[([ xX])\\] (.*)$", RegexOption.MULTILINE)
checklistItems = checklistRegex.findAll(contentAfterTitle).mapIndexed { index, matchResult ->
ChecklistItem(
id = UUID.randomUUID().toString(),
text = matchResult.groupValues[2].trim(),
isChecked = matchResult.groupValues[1].lowercase() == "x",
order = index
)
}.toList().ifEmpty { null }
content = "" // Checklisten haben keinen "content"
} else {
content = contentAfterTitle
checklistItems = null
}
Note( Note(
id = metadata["id"] ?: UUID.randomUUID().toString(), id = metadata["id"] ?: UUID.randomUUID().toString(),
@@ -99,9 +209,12 @@ $content
createdAt = parseISO8601(metadata["created"] ?: ""), createdAt = parseISO8601(metadata["created"] ?: ""),
updatedAt = parseISO8601(metadata["updated"] ?: ""), updatedAt = parseISO8601(metadata["updated"] ?: ""),
deviceId = metadata["device"] ?: "desktop", deviceId = metadata["device"] ?: "desktop",
syncStatus = SyncStatus.SYNCED // Annahme: Vom Server importiert syncStatus = SyncStatus.SYNCED, // Annahme: Vom Server importiert
noteType = noteType,
checklistItems = checklistItems
) )
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")
null null
} }
} }
@@ -126,6 +239,7 @@ $content
sdf.timeZone = TimeZone.getTimeZone("UTC") sdf.timeZone = TimeZone.getTimeZone("UTC")
sdf.parse(dateString)?.time ?: System.currentTimeMillis() sdf.parse(dateString)?.time ?: System.currentTimeMillis()
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Failed to parse ISO8601 date '$dateString': ${e.message}")
System.currentTimeMillis() // Fallback System.currentTimeMillis() // Fallback
} }
} }

View File

@@ -0,0 +1,11 @@
package dev.dettmer.simplenotes.models
/**
* Definiert die verschiedenen Notiz-Typen
*
* v1.4.0: Checklisten-Feature
*/
enum class NoteType {
TEXT, // Normale Text-Notiz (Standard)
CHECKLIST // Checkliste mit abhakbaren Items
}

View File

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

View File

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.sync package dev.dettmer.simplenotes.sync
import android.app.ActivityManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
@@ -22,6 +23,21 @@ class SyncWorker(
const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED" const val ACTION_SYNC_COMPLETED = "dev.dettmer.simplenotes.SYNC_COMPLETED"
} }
/**
* Prüft ob die App im Vordergrund ist.
* Wenn ja, brauchen wir keine Benachrichtigung - die UI zeigt die Änderungen direkt.
*/
private fun isAppInForeground(): Boolean {
val activityManager = applicationContext.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = applicationContext.packageName
return appProcesses.any { process ->
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
process.processName == packageName
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) { override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════") Logger.d(TAG, "═══════════════════════════════════════")
@@ -117,7 +133,11 @@ class SyncWorker(
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Logger.d(TAG, "📍 Step 4: Processing result") Logger.d(TAG, "📍 Step 4: Processing result")
Logger.d(TAG, "📦 Sync result: success=${result.isSuccess}, count=${result.syncedCount}, error=${result.errorMessage}") Logger.d(
TAG,
"📦 Sync result: success=${result.isSuccess}, " +
"count=${result.syncedCount}, error=${result.errorMessage}"
)
} }
if (result.isSuccess) { if (result.isSuccess) {
@@ -127,14 +147,20 @@ class SyncWorker(
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes") Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// Nur Notification zeigen wenn tatsächlich etwas gesynct wurde // Nur Notification zeigen wenn tatsächlich etwas gesynct wurde
// UND die App nicht im Vordergrund ist (sonst sieht User die Änderungen direkt)
if (result.syncedCount > 0) { if (result.syncedCount > 0) {
if (BuildConfig.DEBUG) { val appInForeground = isAppInForeground()
Logger.d(TAG, " Showing success notification...") if (appInForeground) {
Logger.d(TAG, " App in foreground - skipping notification (UI shows changes)")
} else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Showing success notification...")
}
NotificationHelper.showSyncSuccess(
applicationContext,
result.syncedCount
)
} }
NotificationHelper.showSyncSuccess(
applicationContext,
result.syncedCount
)
} else { } else {
Logger.d(TAG, " No changes to sync - no notification") Logger.d(TAG, " No changes to sync - no notification")
} }

View File

@@ -12,6 +12,7 @@ import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.Logger import dev.dettmer.simplenotes.utils.Logger
import dev.dettmer.simplenotes.utils.SyncException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -20,7 +21,6 @@ import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.Proxy
import java.net.Socket import java.net.Socket
import java.net.URL import java.net.URL
import java.util.Date import java.util.Date
@@ -38,6 +38,10 @@ class WebDavSyncService(private val context: Context) {
companion object { companion object {
private const val TAG = "WebDavSyncService" private const val TAG = "WebDavSyncService"
private const val SOCKET_TIMEOUT_MS = 2000
private const val MAX_FILENAME_LENGTH = 200
private const val ETAG_PREVIEW_LENGTH = 8
private const val CONTENT_PREVIEW_LENGTH = 50
// 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern // 🔒 v1.3.1: Mutex um parallele Syncs zu verhindern
private val syncMutex = Mutex() private val syncMutex = Mutex()
@@ -101,6 +105,7 @@ class WebDavSyncService(private val context: Context) {
/** /**
* Findet WiFi Interface IP-Adresse (um VPN zu umgehen) * Findet WiFi Interface IP-Adresse (um VPN zu umgehen)
*/ */
@Suppress("ReturnCount") // Early returns for network validation checks
private fun getWiFiInetAddressInternal(): InetAddress? { private fun getWiFiInetAddressInternal(): InetAddress? {
try { try {
Logger.d(TAG, "🔍 getWiFiInetAddress() called") Logger.d(TAG, "🔍 getWiFiInetAddress() called")
@@ -145,7 +150,11 @@ class WebDavSyncService(private val context: Context) {
while (addresses.hasMoreElements()) { while (addresses.hasMoreElements()) {
val addr = addresses.nextElement() val addr = addresses.nextElement()
Logger.d(TAG, " Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}") Logger.d(
TAG,
" Address: ${addr.hostAddress}, IPv4=${addr is Inet4Address}, " +
"loopback=${addr.isLoopbackAddress}, linkLocal=${addr.isLinkLocalAddress}"
)
// Nur IPv4, nicht loopback, nicht link-local // Nur IPv4, nicht loopback, nicht link-local
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) { if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) {
@@ -362,6 +371,7 @@ class WebDavSyncService(private val context: Context) {
* 3. If changed → server has updates * 3. If changed → server has updates
* 4. If unchanged → skip sync * 4. If unchanged → skip sync
*/ */
@Suppress("ReturnCount") // Early returns for conditional checks
private suspend fun checkServerForChanges(sardine: Sardine, serverUrl: String): Boolean { private suspend fun checkServerForChanges(sardine: Sardine, serverUrl: String): Boolean {
return try { return try {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
@@ -413,7 +423,11 @@ class WebDavSyncService(private val context: Context) {
resource.modified?.time?.let { resource.modified?.time?.let {
val hasNewer = it > lastSyncTime val hasNewer = it > lastSyncTime
if (hasNewer) { if (hasNewer) {
Logger.d(TAG, " 📄 ${resource.name}: modified=${resource.modified}, lastSync=$lastSyncTime") Logger.d(
TAG,
" 📄 ${resource.name}: modified=${resource.modified}, " +
"lastSync=$lastSyncTime"
)
} }
hasNewer hasNewer
} ?: false } ?: false
@@ -524,10 +538,10 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, "🔍 Checking server reachability: $host:$port") Logger.d(TAG, "🔍 Checking server reachability: $host:$port")
// Socket-Check mit 2s Timeout // Socket-Check mit Timeout
// Gibt dem Netzwerk Zeit für Initialisierung (DHCP, Routing, Gateway) // Gibt dem Netzwerk Zeit für Initialisierung (DHCP, Routing, Gateway)
val socket = Socket() val socket = Socket()
socket.connect(InetSocketAddress(host, port), 2000) socket.connect(InetSocketAddress(host, port), SOCKET_TIMEOUT_MS)
socket.close() socket.close()
Logger.d(TAG, "✅ Server is reachable") Logger.d(TAG, "✅ Server is reachable")
@@ -669,7 +683,11 @@ class WebDavSyncService(private val context: Context) {
) )
syncedCount += downloadResult.downloadedCount syncedCount += downloadResult.downloadedCount
conflictCount += downloadResult.conflictCount conflictCount += downloadResult.conflictCount
Logger.d(TAG, "✅ Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}") Logger.d(
TAG,
"✅ Downloaded: ${downloadResult.downloadedCount} notes, " +
"Conflicts: ${downloadResult.conflictCount}"
)
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e) Logger.e(TAG, "💥 CRASH in downloadRemoteNotes()!", e)
e.printStackTrace() e.printStackTrace()
@@ -790,7 +808,7 @@ class WebDavSyncService(private val context: Context) {
val newETag = uploadedResource?.etag val newETag = uploadedResource?.etag
if (newETag != null) { if (newETag != null) {
prefs.edit().putString("etag_json_${note.id}", newETag).apply() prefs.edit().putString("etag_json_${note.id}", newETag).apply()
Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(8)}") Logger.d(TAG, " ⚡ Cached new E-Tag: ${newETag.take(ETAG_PREVIEW_LENGTH)}")
} else { } else {
// Fallback: invalidate if server doesn't provide E-Tag // Fallback: invalidate if server doesn't provide E-Tag
prefs.edit().remove("etag_json_${note.id}").apply() prefs.edit().remove("etag_json_${note.id}").apply()
@@ -814,6 +832,7 @@ class WebDavSyncService(private val context: Context) {
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
Logger.w(TAG, "Upload failed for note ${note.id}, marking as pending: ${e.message}")
// Mark as pending for retry // Mark as pending for retry
val updatedNote = note.copy(syncStatus = SyncStatus.PENDING) val updatedNote = note.copy(syncStatus = SyncStatus.PENDING)
storage.saveNote(updatedNote) storage.saveNote(updatedNote)
@@ -840,8 +859,31 @@ class WebDavSyncService(private val context: Context) {
} }
// Sanitize Filename (Task #1.2.0-12) // Sanitize Filename (Task #1.2.0-12)
val filename = sanitizeFilename(note.title) + ".md" val baseFilename = sanitizeFilename(note.title)
val noteUrl = "$mdUrl/$filename" var filename = "$baseFilename.md"
var noteUrl = "$mdUrl/$filename"
// Prüfe ob Datei bereits existiert und von anderer Note stammt
try {
if (sardine.exists(noteUrl)) {
// Lese existierende Datei und prüfe ID im YAML-Header
val existingContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val existingIdMatch = Regex("^---\\n.*?\\nid:\\s*([a-f0-9-]+)", RegexOption.DOT_MATCHES_ALL)
.find(existingContent)
val existingId = existingIdMatch?.groupValues?.get(1)
if (existingId != null && existingId != note.id) {
// Andere Note hat gleichen Titel - verwende ID-Suffix
val shortId = note.id.take(8)
filename = "${baseFilename}_$shortId.md"
noteUrl = "$mdUrl/$filename"
Logger.d(TAG, "📝 Duplicate title, using: $filename")
}
}
} catch (e: Exception) {
Logger.w(TAG, "⚠️ Could not check existing file: ${e.message}")
// Continue with default filename
}
// Konvertiere zu Markdown // Konvertiere zu Markdown
val mdContent = note.toMarkdown().toByteArray() val mdContent = note.toMarkdown().toByteArray()
@@ -862,10 +904,33 @@ class WebDavSyncService(private val context: Context) {
return title return title
.replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen .replace(Regex("[<>:\"/\\\\|?*]"), "_") // Ersetze verbotene Zeichen
.replace(Regex("\\s+"), " ") // Normalisiere Whitespace .replace(Regex("\\s+"), " ") // Normalisiere Whitespace
.take(200) // Max 200 Zeichen (Reserve für .md) .take(MAX_FILENAME_LENGTH) // Max Zeichen (Reserve für .md)
.trim('_', ' ') // Trim Underscores/Spaces .trim('_', ' ') // Trim Underscores/Spaces
} }
/**
* Generiert eindeutigen Markdown-Dateinamen für eine Notiz.
* Bei Duplikaten wird die Note-ID als Suffix angehängt.
*
* @param note Die Notiz
* @param usedFilenames Set der bereits verwendeten Dateinamen (ohne .md)
* @return Eindeutiger Dateiname (ohne .md Extension)
*/
private fun getUniqueMarkdownFilename(note: Note, usedFilenames: MutableSet<String>): String {
val baseFilename = sanitizeFilename(note.title)
return if (usedFilenames.contains(baseFilename)) {
// Duplikat - hänge gekürzte ID an
val shortId = note.id.take(8)
val uniqueFilename = "${baseFilename}_$shortId"
usedFilenames.add(uniqueFilename)
uniqueFilename
} else {
usedFilenames.add(baseFilename)
baseFilename
}
}
/** /**
* Exportiert ALLE lokalen Notizen als Markdown (Initial-Export) * Exportiert ALLE lokalen Notizen als Markdown (Initial-Export)
* *
@@ -909,6 +974,9 @@ class WebDavSyncService(private val context: Context) {
val totalCount = allNotes.size val totalCount = allNotes.size
var exportedCount = 0 var exportedCount = 0
// Track used filenames to handle duplicates
val usedFilenames = mutableSetOf<String>()
Logger.d(TAG, "📝 Found $totalCount notes to export") Logger.d(TAG, "📝 Found $totalCount notes to export")
allNotes.forEachIndexed { index, note -> allNotes.forEachIndexed { index, note ->
@@ -916,8 +984,8 @@ class WebDavSyncService(private val context: Context) {
// Progress-Callback // Progress-Callback
onProgress(index + 1, totalCount) onProgress(index + 1, totalCount)
// Sanitize Filename // Eindeutiger Filename (mit Duplikat-Handling)
val filename = sanitizeFilename(note.title) + ".md" val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
val noteUrl = "$mdUrl/$filename" val noteUrl = "$mdUrl/$filename"
// Konvertiere zu Markdown // Konvertiere zu Markdown
@@ -927,7 +995,7 @@ class WebDavSyncService(private val context: Context) {
sardine.put(noteUrl, mdContent, "text/markdown") sardine.put(noteUrl, mdContent, "text/markdown")
exportedCount++ exportedCount++
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}") Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
} catch (e: Exception) { } catch (e: Exception) {
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}") Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
@@ -998,9 +1066,13 @@ class WebDavSyncService(private val context: Context) {
val serverModified = resource.modified?.time ?: 0L val serverModified = resource.modified?.time ?: 0L
// 🐛 DEBUG: Log every file check to diagnose performance // 🐛 DEBUG: Log every file check to diagnose performance
val serverETagPreview = serverETag?.take(8) ?: "null" val serverETagPreview = serverETag?.take(ETAG_PREVIEW_LENGTH) ?: "null"
val cachedETagPreview = cachedETag?.take(8) ?: "null" val cachedETagPreview = cachedETag?.take(ETAG_PREVIEW_LENGTH) ?: "null"
Logger.d(TAG, " 🔍 [$noteId] etag=$serverETagPreview/$cachedETagPreview modified=$serverModified lastSync=$lastSyncTime") Logger.d(
TAG,
" 🔍 [$noteId] etag=$serverETagPreview/$cachedETagPreview " +
"modified=$serverModified lastSync=$lastSyncTime"
)
// PRIMARY: Timestamp check (works on first sync!) // PRIMARY: Timestamp check (works on first sync!)
// Same logic as Markdown sync - skip if not modified since last sync // Same logic as Markdown sync - skip if not modified since last sync
@@ -1101,7 +1173,11 @@ class WebDavSyncService(private val context: Context) {
} }
} }
} }
Logger.d(TAG, " 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), $skippedUnchanged skipped (unchanged)") Logger.d(
TAG,
" 📊 Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted), " +
"$skippedUnchanged skipped (unchanged)"
)
} else { } else {
Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1") Logger.w(TAG, " ⚠️ /notes/ does not exist, skipping Phase 1")
} }
@@ -1306,7 +1382,11 @@ class WebDavSyncService(private val context: Context) {
// 🆕 v1.2.2: Use downloadRemoteNotes() with Root fallback + forceOverwrite // 🆕 v1.2.2: Use downloadRemoteNotes() with Root fallback + forceOverwrite
// 🆕 v1.3.0: Pass FRESH empty tracker to avoid loading stale cached data // 🆕 v1.3.0: Pass FRESH empty tracker to avoid loading stale cached data
Logger.d(TAG, "📡 Calling downloadRemoteNotes() - includeRootFallback: true, forceOverwrite: $forceOverwrite") Logger.d(
TAG,
"📡 Calling downloadRemoteNotes() - " +
"includeRootFallback: true, forceOverwrite: $forceOverwrite"
)
val emptyTracker = DeletionTracker() // Fresh empty tracker after clear val emptyTracker = DeletionTracker() // Fresh empty tracker after clear
val result = downloadRemoteNotes( val result = downloadRemoteNotes(
sardine = sardine, sardine = sardine,
@@ -1509,15 +1589,45 @@ class WebDavSyncService(private val context: Context) {
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null") Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
continue continue
} }
Logger.d(TAG, " Parsed: id=${mdNote.id}, title=${mdNote.title}, updatedAt=${Date(mdNote.updatedAt)}")
// v1.4.0 FIX: Validierung - leere TEXT-Notizen nicht importieren wenn lokal Content existiert
val localNote = storage.loadNote(mdNote.id) val localNote = storage.loadNote(mdNote.id)
Logger.d(TAG, " Local note: ${if (localNote == null) "NOT FOUND" else "exists, updatedAt=${Date(localNote.updatedAt)}, syncStatus=${localNote.syncStatus}"}") if (mdNote.noteType == dev.dettmer.simplenotes.models.NoteType.TEXT &&
mdNote.content.isBlank() &&
localNote != null && localNote.content.isNotBlank()) {
Logger.w(
TAG,
" ⚠️ Skipping ${resource.name}: " +
"MD content empty but local has content - likely parse error!"
)
continue
}
Logger.d(
TAG,
" Parsed: id=${mdNote.id}, title=${mdNote.title}, " +
"updatedAt=${Date(mdNote.updatedAt)}, " +
"content=${mdNote.content.take(CONTENT_PREVIEW_LENGTH)}..."
)
Logger.d(
TAG,
" Local note: " + if (localNote == null) {
"NOT FOUND"
} else {
"exists, updatedAt=${Date(localNote.updatedAt)}, " +
"syncStatus=${localNote.syncStatus}"
}
)
// ⚡ v1.3.1: Content-basierte Erkennung // ⚡ v1.3.1: Content-basierte Erkennung
// Wichtig: Vergleiche IMMER den Inhalt, wenn die Datei seit letztem Sync geändert wurde! // Wichtig: Vergleiche IMMER den Inhalt, wenn die Datei seit letztem Sync geändert wurde!
// Der YAML-Timestamp kann veraltet sein (z.B. bei externer Bearbeitung ohne Obsidian) // Der YAML-Timestamp kann veraltet sein (z.B. bei externer Bearbeitung ohne Obsidian)
Logger.d(TAG, " Comparison: mdUpdatedAt=${mdNote.updatedAt}, localUpdated=${localNote?.updatedAt ?: 0L}") Logger.d(
TAG,
" Comparison: mdUpdatedAt=${mdNote.updatedAt}, " +
"localUpdated=${localNote?.updatedAt ?: 0L}"
)
// Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich? // Content-Vergleich: Ist der Inhalt tatsächlich unterschiedlich?
val contentChanged = localNote != null && ( val contentChanged = localNote != null && (
@@ -1538,10 +1648,16 @@ class WebDavSyncService(private val context: Context) {
Logger.d(TAG, " ✅ Imported new from Markdown: ${mdNote.title}") Logger.d(TAG, " ✅ Imported new from Markdown: ${mdNote.title}")
} }
// ⚡ v1.3.1 FIX: Content-basierter Skip - nur wenn Inhalt UND Timestamp gleich // ⚡ v1.3.1 FIX: Content-basierter Skip - nur wenn Inhalt UND Timestamp gleich
localNote.syncStatus == SyncStatus.SYNCED && !contentChanged && localNote.updatedAt >= mdNote.updatedAt -> { localNote.syncStatus == SyncStatus.SYNCED &&
!contentChanged &&
localNote.updatedAt >= mdNote.updatedAt -> {
// Inhalt identisch UND Timestamps passen → Skip // Inhalt identisch UND Timestamps passen → Skip
skippedCount++ skippedCount++
Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: content identical (local=${localNote.updatedAt}, md=${mdNote.updatedAt})") Logger.d(
TAG,
" ⏭️ Skipped ${mdNote.title}: content identical " +
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
)
} }
// ⚡ v1.3.1 FIX: Content geändert aber YAML-Timestamp nicht aktualisiert → Importieren! // ⚡ v1.3.1 FIX: Content geändert aber YAML-Timestamp nicht aktualisiert → Importieren!
contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> { contentChanged && localNote.syncStatus == SyncStatus.SYNCED -> {
@@ -1571,7 +1687,11 @@ class WebDavSyncService(private val context: Context) {
else -> { else -> {
// Local has pending changes but MD is older - keep local // Local has pending changes but MD is older - keep local
skippedCount++ skippedCount++
Logger.d(TAG, " ⏭️ Skipped ${mdNote.title}: local is newer or pending (local=${localNote.updatedAt}, md=${mdNote.updatedAt})") Logger.d(
TAG,
" ⏭️ Skipped ${mdNote.title}: local is newer or pending " +
"(local=${localNote.updatedAt}, md=${mdNote.updatedAt})"
)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -1719,14 +1839,16 @@ class WebDavSyncService(private val context: Context) {
*/ */
suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) { suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
return@withContext try { return@withContext try {
val sardine = getOrCreateSardine() ?: throw Exception("Sardine client konnte nicht erstellt werden") val sardine = getOrCreateSardine()
val serverUrl = getServerUrl() ?: throw Exception("Server-URL nicht konfiguriert") ?: throw SyncException("Sardine client konnte nicht erstellt werden")
val serverUrl = getServerUrl()
?: throw SyncException("Server-URL nicht konfiguriert")
val username = prefs.getString(Constants.KEY_USERNAME, "") ?: "" val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: "" val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) { if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
throw Exception("WebDAV-Server nicht vollständig konfiguriert") throw SyncException("WebDAV-Server nicht vollständig konfiguriert")
} }
Logger.d(TAG, "🔄 Manual Markdown Sync START") Logger.d(TAG, "🔄 Manual Markdown Sync START")

View File

@@ -5,12 +5,17 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import dev.dettmer.simplenotes.utils.Constants import dev.dettmer.simplenotes.utils.Constants
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
/**
* WiFi-Sync BroadcastReceiver
*
* Triggert Sync wenn WiFi verbunden wird (jedes WiFi, keine SSID-Prüfung mehr)
* Die eigentliche Server-Erreichbarkeitsprüfung erfolgt im SyncWorker.
*/
class WifiSyncReceiver : BroadcastReceiver() { class WifiSyncReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@@ -22,33 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
return return
} }
// Check if connected to home WiFi // Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
if (isConnectedToHomeWifi(context)) { if (isConnectedToWifi(context)) {
scheduleSyncWork(context) scheduleSyncWork(context)
} }
} }
private fun isConnectedToHomeWifi(context: Context): Boolean { /**
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE) * Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false * Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
*/
private fun isConnectedToWifi(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
return false
}
// Get current SSID
val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE)
as WifiManager
val wifiInfo = wifiManager.connectionInfo
val currentSSID = wifiInfo.ssid.replace("\"", "")
return currentSSID == homeSSID
} }
private fun scheduleSyncWork(context: Context) { private fun scheduleSyncWork(context: Context) {

View File

@@ -6,7 +6,6 @@ object Constants {
const val KEY_SERVER_URL = "server_url" const val KEY_SERVER_URL = "server_url"
const val KEY_USERNAME = "username" const val KEY_USERNAME = "username"
const val KEY_PASSWORD = "password" const val KEY_PASSWORD = "password"
const val KEY_HOME_SSID = "home_ssid"
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"

View File

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

View File

@@ -5,7 +5,6 @@ import android.util.Log
import dev.dettmer.simplenotes.BuildConfig import dev.dettmer.simplenotes.BuildConfig
import java.io.File import java.io.File
import java.io.FileWriter import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -15,11 +14,12 @@ import java.util.*
*/ */
object Logger { object Logger {
private const val MAX_LOG_ENTRIES = 500 // Nur letzte 500 Einträge
private var fileLoggingEnabled = false private var fileLoggingEnabled = false
private var logFile: File? = null private var logFile: File? = null
private var appContext: Context? = null private var appContext: Context? = null
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault())
private val maxLogEntries = 500 // Nur letzte 500 Einträge
/** /**
* Setzt den File-Logging Status (für UI Toggle) * Setzt den File-Logging Status (für UI Toggle)
@@ -139,13 +139,13 @@ object Logger {
} }
/** /**
* Begrenzt Log-Datei auf maxLogEntries * Begrenzt Log-Datei auf MAX_LOG_ENTRIES
*/ */
private fun trimLogFile() { private fun trimLogFile() {
try { try {
val lines = logFile?.readLines() ?: return val lines = logFile?.readLines() ?: return
if (lines.size > maxLogEntries) { if (lines.size > MAX_LOG_ENTRIES) {
val trimmed = lines.takeLast(maxLogEntries) val trimmed = lines.takeLast(MAX_LOG_ENTRIES)
logFile?.writeText(trimmed.joinToString("\n") + "\n") logFile?.writeText(trimmed.joinToString("\n") + "\n")
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -20,6 +20,7 @@ object NotificationHelper {
private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status" private const val CHANNEL_DESCRIPTION = "Benachrichtigungen über Sync-Status"
private const val NOTIFICATION_ID = 1001 private const val NOTIFICATION_ID = 1001
private const val SYNC_NOTIFICATION_ID = 2 private const val SYNC_NOTIFICATION_ID = 2
private const val AUTO_CANCEL_TIMEOUT_MS = 30_000L
/** /**
* Erstellt Notification Channel (Android 8.0+) * Erstellt Notification Channel (Android 8.0+)
@@ -286,7 +287,7 @@ object NotificationHelper {
Handler(Looper.getMainLooper()).postDelayed({ Handler(Looper.getMainLooper()).postDelayed({
manager.cancel(SYNC_NOTIFICATION_ID) manager.cancel(SYNC_NOTIFICATION_ID)
Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout") Logger.d(TAG, "🗑️ Auto-cancelled error notification after 30s timeout")
}, 30_000) }, AUTO_CANCEL_TIMEOUT_MS)
} }
/** /**

View File

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

View File

@@ -8,6 +8,16 @@ import java.net.URL
*/ */
object UrlValidator { object UrlValidator {
// RFC 1918 Private IP Ranges
private const val PRIVATE_CLASS_A_FIRST_OCTET = 10
private const val PRIVATE_CLASS_B_FIRST_OCTET = 172
private const val PRIVATE_CLASS_B_SECOND_OCTET_MIN = 16
private const val PRIVATE_CLASS_B_SECOND_OCTET_MAX = 31
private const val PRIVATE_CLASS_C_FIRST_OCTET = 192
private const val PRIVATE_CLASS_C_SECOND_OCTET = 168
private const val LOCALHOST_FIRST_OCTET = 127
private const val OCTET_MAX_VALUE = 255
/** /**
* Prüft ob eine URL eine lokale/private Adresse ist * Prüft ob eine URL eine lokale/private Adresse ist
* Erlaubt: * Erlaubt:
@@ -17,6 +27,7 @@ object UrlValidator {
* - 127.x.x.x (Localhost) * - 127.x.x.x (Localhost)
* - .local domains (mDNS/Bonjour) * - .local domains (mDNS/Bonjour)
*/ */
@Suppress("ReturnCount") // Early returns for validation checks are clearer
fun isLocalUrl(url: String): Boolean { fun isLocalUrl(url: String): Boolean {
return try { return try {
val parsedUrl = URL(url) val parsedUrl = URL(url)
@@ -40,25 +51,29 @@ object UrlValidator {
val octets = match.groupValues.drop(1).map { it.toInt() } val octets = match.groupValues.drop(1).map { it.toInt() }
// Validate octets are in range 0-255 // Validate octets are in range 0-255
if (octets.any { it > 255 }) { if (octets.any { it > OCTET_MAX_VALUE }) {
return false return false
} }
val (o1, o2, o3, o4) = octets // Extract octets individually (destructuring with 4 elements triggers detekt warning)
val o1 = octets[0]
val o2 = octets[1]
// Check RFC 1918 private IP ranges // Check RFC 1918 private IP ranges
return when { return when {
// 10.0.0.0/8 (10.0.0.0 - 10.255.255.255) // 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
o1 == 10 -> true o1 == PRIVATE_CLASS_A_FIRST_OCTET -> true
// 172.16.0.0/12 (172.16.0.0 - 172.31.255.255) // 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
o1 == 172 && o2 in 16..31 -> true o1 == PRIVATE_CLASS_B_FIRST_OCTET &&
o2 in PRIVATE_CLASS_B_SECOND_OCTET_MIN..PRIVATE_CLASS_B_SECOND_OCTET_MAX -> true
// 192.168.0.0/16 (192.168.0.0 - 192.168.255.255) // 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
o1 == 192 && o2 == 168 -> true o1 == PRIVATE_CLASS_C_FIRST_OCTET &&
o2 == PRIVATE_CLASS_C_SECOND_OCTET -> true
// 127.0.0.0/8 (Localhost) // 127.0.0.0/8 (Localhost)
o1 == 127 -> true o1 == LOCALHOST_FIRST_OCTET -> true
else -> false else -> false
} }
@@ -67,7 +82,7 @@ object UrlValidator {
// Not a recognized local address // Not a recognized local address
false false
} catch (e: Exception) { } catch (e: Exception) {
// Invalid URL format Logger.w("UrlValidator", "Failed to parse URL: ${e.message}")
false false
} }
} }

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurfaceVariant">
<path
android:fillColor="@android:color/white"
android:pathData="M20,9H4v2h16V9zM4,15h16v-2H4v2z"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,18H8v-2h8v2zM16,14H8v-2h8v2zM13,9V3.5L18.5,9H13z"/>
</vector>

View File

@@ -2,6 +2,7 @@
<LinearLayout <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
@@ -18,8 +19,9 @@
app:title="@string/edit_note" app:title="@string/edit_note"
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" /> app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
<!-- Material 3 Outlined TextInputLayout with 16dp corners --> <!-- Title Input (für beide Typen) -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_margin="16dp" android:layout_margin="16dp"
@@ -44,8 +46,9 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<!-- Material 3 Outlined TextInputLayout for Content --> <!-- Content Input (nur für TEXT sichtbar) -->
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilContent"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
@@ -74,4 +77,39 @@
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<!-- v1.4.0: Checklist Container (nur für CHECKLIST sichtbar) -->
<LinearLayout
android:id="@+id/checklistContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<!-- Checklist Items RecyclerView -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvChecklistItems"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginHorizontal="8dp"
android:clipToPadding="false"
android:paddingBottom="8dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/item_checklist_editor" />
<!-- Add Item Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAddItem"
style="@style/Widget.Material3.Button.TextButton.Icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="16dp"
android:text="@string/add_item"
app:icon="@android:drawable/ic_input_add" />
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -818,6 +818,17 @@
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
<!-- F-Droid Privacy Notice -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/file_logging_privacy_notice"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<!-- Export Logs Button --> <!-- Export Logs Button -->
<Button <Button
android:id="@+id/buttonExportLogs" android:id="@+id/buttonExportLogs"

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- v1.4.0: Checklist Item Layout für Editor -->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="4dp"
android:paddingHorizontal="8dp"
android:minHeight="48dp">
<!-- Drag Handle -->
<ImageView
android:id="@+id/ivDragHandle"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_drag_handle_24"
android:contentDescription="@string/reorder_item"
android:importantForAccessibility="yes" />
<!-- Checkbox -->
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/cbItem"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp" />
<!-- Text Input (ohne Box, nur transparent) -->
<EditText
android:id="@+id/etItemText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:background="@null"
android:hint="@string/item_placeholder"
android:inputType="text"
android:imeOptions="actionNext"
android:maxLines="3"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
tools:text="Milch kaufen" />
<!-- Delete Button -->
<ImageButton
android:id="@+id/btnDeleteItem"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_delete_24"
android:contentDescription="@string/delete_item"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Material 3: Filled Card Style (Flat, No Shadow) --> <!-- Material 3: Filled Card Style (Flat, No Shadow) -->
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp" android:layout_marginHorizontal="8dp"
@@ -17,17 +19,37 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:padding="20dp">
<!-- Material 3 Typography: TitleMedium --> <!-- v1.4.0: Header Row mit Icon und Titel -->
<TextView <LinearLayout
android:id="@+id/textViewTitle"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/note_title_placeholder" android:orientation="horizontal"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" android:gravity="center_vertical">
android:maxLines="2"
android:ellipsize="end" />
<!-- Material 3 Typography: BodyMedium --> <!-- v1.4.0: Note Type Icon -->
<ImageView
android:id="@+id/ivNoteTypeIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="8dp"
android:src="@drawable/ic_note_24"
app:tint="?attr/colorPrimary"
android:contentDescription="@null" />
<!-- Material 3 Typography: TitleMedium -->
<TextView
android:id="@+id/textViewTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/note_title_placeholder"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
android:maxLines="2"
android:ellipsize="end" />
</LinearLayout>
<!-- Content Preview (für TEXT Notizen) -->
<TextView <TextView
android:id="@+id/textViewContent" android:id="@+id/textViewContent"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -39,6 +61,18 @@
android:maxLines="3" android:maxLines="3"
android:ellipsize="end" /> android:ellipsize="end" />
<!-- v1.4.0: Checklist Preview (für CHECKLIST Notizen) -->
<TextView
android:id="@+id/textViewChecklistPreview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:visibility="gone"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
tools:visibility="visible"
tools:text="2/5 erledigt" />
<!-- Metadata Row mit Timestamp und Sync-Status --> <!-- Metadata Row mit Timestamp und Sync-Status -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/action_create_text_note"
android:icon="@drawable/ic_note_24"
android:title="@string/create_text_note" />
<item
android:id="@+id/action_create_checklist"
android:icon="@drawable/ic_checklist_24"
android:title="@string/create_checklist" />
</menu>

View File

@@ -24,6 +24,7 @@
<string name="note_title_placeholder">Note Title</string> <string name="note_title_placeholder">Note Title</string>
<string name="note_content_placeholder">Note content preview…</string> <string name="note_content_placeholder">Note content preview…</string>
<string name="note_timestamp_placeholder">Vor 2 Std</string> <string name="note_timestamp_placeholder">Vor 2 Std</string>
<string name="untitled">Ohne Titel</string>
<!-- Delete Confirmation Dialog --> <!-- Delete Confirmation Dialog -->
<string name="delete_note_title">Notiz löschen?</string> <string name="delete_note_title">Notiz löschen?</string>
@@ -42,10 +43,9 @@
<!-- Auto-Sync Settings --> <!-- Auto-Sync Settings -->
<string name="sync_settings">Sync-Einstellungen</string> <string name="sync_settings">Sync-Einstellungen</string>
<string name="home_ssid">Heim-WLAN SSID</string>
<string name="auto_sync">Auto-Sync aktiviert</string> <string name="auto_sync">Auto-Sync aktiviert</string>
<string name="sync_status">Sync-Status</string> <string name="sync_status">Sync-Status</string>
<string name="auto_sync_info"> Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert nur im selben Netzwerk\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string> <string name="auto_sync_info"> Auto-Sync:\n\n• Prüft alle 30 Min ob Server erreichbar\n• Funktioniert bei jeder WiFi-Verbindung\n• Läuft auch im Hintergrund\n• Minimaler Akkuverbrauch (~0.4%%/Tag)</string>
<!-- Backup & Restore --> <!-- Backup & Restore -->
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string> <string name="backup_restore_title">Backup &amp; Wiederherstellung</string>
@@ -63,4 +63,30 @@
<string name="sync_status_completed">Synchronisierung abgeschlossen</string> <string name="sync_status_completed">Synchronisierung abgeschlossen</string>
<string name="sync_status_error">Synchronisierung fehlgeschlagen</string> <string name="sync_status_error">Synchronisierung fehlgeschlagen</string>
<string name="sync_already_running">Synchronisierung läuft bereits</string> <string name="sync_already_running">Synchronisierung läuft bereits</string>
<!-- Debug/Logging Section (v1.3.2) -->
<string name="file_logging_privacy_notice"> Datenschutz: Logs werden nur lokal auf deinem Gerät gespeichert und niemals an externe Server gesendet. Die Logs enthalten Sync-Aktivitäten zur Fehlerdiagnose. Du kannst sie jederzeit löschen oder exportieren.</string>
<!-- ========================== -->
<!-- CHECKLIST FEATURE (v1.4.0) -->
<!-- ========================== -->
<!-- FAB Menu -->
<string name="create_text_note">Notiz</string>
<string name="create_checklist">Liste</string>
<!-- Editor -->
<string name="new_checklist">Neue Liste</string>
<string name="edit_checklist">Liste bearbeiten</string>
<string name="add_item">Element hinzufügen</string>
<string name="item_placeholder">Neues Element…</string>
<string name="reorder_item">Element verschieben</string>
<string name="delete_item">Element löschen</string>
<string name="note_is_empty">Notiz ist leer</string>
<string name="note_saved">Notiz gespeichert</string>
<string name="note_deleted">Notiz gelöscht</string>
<!-- List Preview -->
<string name="checklist_progress">%1$d/%2$d erledigt</string>
<string name="empty_checklist">Keine Einträge</string>
</resources> </resources>

View File

@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
### Network Detection ### Network Detection
Instead of SSID-based detection (Android 13+ privacy issues), we use **Gateway IP Comparison**: We use **Gateway IP Comparison** to check if the server is reachable:
```kotlin ```kotlin
fun isInHomeNetwork(): Boolean { fun isInHomeNetwork(): Boolean {
@@ -127,7 +127,7 @@ The app uses **4 different sync triggers** with different use cases:
| **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes | | **1. Manual Sync** | `MainActivity.kt` | `triggerManualSync()` | User clicks sync button in menu | ✅ Yes |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes | | **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes |
| **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes | | **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi enabled/SSID changed | ✅ Yes | | **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi connected | ✅ Yes |
### Server Reachability Check (Pre-Check) ### Server Reachability Check (Pre-Check)
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
| Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None | | Manual Sync | Toast: "Server not reachable" | Toast: "✅ Synced: X notes" | None |
| Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min | | Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min |
| Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min | | Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min |
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | SSID-based | | WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | WiFi-based |
--- ---
@@ -349,7 +349,7 @@ The app requires **minimal permissions**:
``` ```
**No Location Permissions!** **No Location Permissions!**
Earlier versions required `ACCESS_FINE_LOCATION` for SSID detection. Now we use Gateway IP Comparison. We use Gateway IP Comparison instead of SSID detection. No location permission required.
--- ---

View File

@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
### Network Detection ### Network Detection
Statt SSID-basierter Erkennung (Android 13+ Privacy-Probleme) verwenden wir **Gateway IP Comparison**: Wir verwenden **Gateway IP Comparison** um zu prüfen, ob der Server erreichbar ist:
```kotlin ```kotlin
fun isInHomeNetwork(): Boolean { fun isInHomeNetwork(): Boolean {
@@ -127,7 +127,7 @@ Die App verwendet **4 verschiedene Sync-Trigger** mit unterschiedlichen Anwendun
| **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja | | **1. Manueller Sync** | `MainActivity.kt` | `triggerManualSync()` | User klickt auf Sync-Button im Menü | ✅ Ja |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja | | **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja |
| **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja | | **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi an/SSID-Wechsel | ✅ Ja | | **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi verbunden | ✅ Ja |
### Server-Erreichbarkeits-Check (Pre-Check) ### Server-Erreichbarkeits-Check (Pre-Check)
@@ -168,7 +168,7 @@ suspend fun isServerReachable(): Boolean = withContext(Dispatchers.IO) {
| Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins | | Manueller Sync | Toast: "Server nicht erreichbar" | Toast: "✅ Gesynct: X Notizen" | Keins |
| Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min | | Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min |
| Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min | | Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min |
| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | SSID-basiert | | WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | WiFi-basiert |
--- ---
@@ -349,7 +349,7 @@ Die App benötigt **minimale Permissions**:
``` ```
**Keine Location Permissions!** **Keine Location Permissions!**
Frühere Versionen benötigten `ACCESS_FINE_LOCATION` für SSID-Erkennung. Jetzt verwenden wir Gateway IP Comparison. Wir verwenden Gateway IP Comparison statt SSID-Erkennung. Keine Standortberechtigung nötig.
--- ---

View File

@@ -8,8 +8,16 @@
## 📝 Note Management ## 📝 Note Management
### Note Types
-**Text notes** - Classic free-form notes
-**Checklists** _(NEW in v1.4.0)_ - Task lists with tap-to-check
- Add items via input field
- ☑️ Tap to check/uncheck
- 📌 Long-press for drag & drop sorting
- 🗑️ Swipe-to-delete individual items
- ~~Strikethrough~~ for completed entries
### Basic Features ### Basic Features
-**Simple text notes** - Focus on content, no distractions
-**Auto-save** - No manual saving needed -**Auto-save** - No manual saving needed
-**Title + content** - Clear structure for each note -**Title + content** - Clear structure for each note
-**Timestamps** - Creation and modification date automatically -**Timestamps** - Creation and modification date automatically
@@ -52,9 +60,11 @@
### Markdown Export ### Markdown Export
-**Automatic export** - Each note → `.md` file -**Automatic export** - Each note → `.md` file
-**Checklists as task lists** _(NEW)_ - `- [ ]` / `- [x]` format (GitHub-compatible)
-**Dual-format** - JSON (master) + Markdown (mirror) -**Dual-format** - JSON (master) + Markdown (mirror)
-**Filename sanitization** - Safe filenames from titles -**Filename sanitization** - Safe filenames from titles
-**Frontmatter metadata** - YAML with ID, timestamps, tags -**Duplicate handling** _(NEW)_ - ID suffix for same titles
-**Frontmatter metadata** - YAML with ID, timestamps, type
-**WebDAV sync** - Parallel to JSON sync -**WebDAV sync** - Parallel to JSON sync
-**Optional** - Toggle in settings -**Optional** - Toggle in settings
-**Initial export** - All existing notes when activated -**Initial export** - All existing notes when activated
@@ -81,16 +91,16 @@
### Auto-Sync ### Auto-Sync
-**Interval selection** - 15, 30 or 60 minutes -**Interval selection** - 15, 30 or 60 minutes
-**WiFi binding** - Only in configured home WiFi -**WiFi trigger** - Sync on WiFi connection _(no SSID restriction)_
-**Battery-friendly** - ~0.2-0.8% per day -**Battery-friendly** - ~0.2-0.8% per day
-**Smart server check** - No errors on foreign networks -**Smart server check** - Sync only when server is reachable
-**WorkManager** - Reliable background execution -**WorkManager** - Reliable background execution
-**Battery optimization compatible** - Works even with Doze mode -**Battery optimization compatible** - Works even with Doze mode
### Sync Triggers (6 total) ### Sync Triggers (6 total)
1.**Periodic sync** - Automatically after interval 1.**Periodic sync** - Automatically after interval
2.**App-start sync** - When opening the app 2.**App-start sync** - When opening the app
3.**WiFi-connect sync** - When home WiFi connects 3.**WiFi-connect sync** - On any WiFi connection
4.**Manual sync** - Button in settings 4.**Manual sync** - Button in settings
5.**Pull-to-refresh** - Swipe gesture in notes list 5.**Pull-to-refresh** - Swipe gesture in notes list
6.**Settings-save sync** - After server configuration 6.**Settings-save sync** - After server configuration
@@ -109,7 +119,6 @@
-**HTTP/HTTPS** - HTTP only local, HTTPS for external -**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication -**Username/password** - Basic authentication
-**Connection test** - Test in settings -**Connection test** - Test in settings
-**Gateway SSID** - WiFi name for auto-sync
-**Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_ -**Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_
-**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/` -**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
@@ -130,6 +139,11 @@
-**Password storage** - Android SharedPreferences (encrypted) -**Password storage** - Android SharedPreferences (encrypted)
-**No third-party libs** - Only Android SDK + Sardine (WebDAV) -**No third-party libs** - Only Android SDK + Sardine (WebDAV)
### Developer Features
-**File logging** - Optional, only when enabled _(NEW in v1.3.2)_
-**Privacy notice** - Explicit warning on activation
-**Local logs** - Logs stay on device
--- ---
## 🔋 Performance & Optimization ## 🔋 Performance & Optimization
@@ -137,7 +151,7 @@
### Battery Efficiency ### Battery Efficiency
-**Optimized sync intervals** - 15/30/60 min -**Optimized sync intervals** - 15/30/60 min
-**WiFi-only** - No mobile data sync -**WiFi-only** - No mobile data sync
-**Smart server check** - Only in home WiFi -**Smart server check** - Sync only when server is reachable
-**WorkManager** - System-optimized execution -**WorkManager** - System-optimized execution
-**Doze mode compatible** - Sync runs even in standby -**Doze mode compatible** - Sync runs even in standby
-**Measured consumption:** -**Measured consumption:**
@@ -171,6 +185,7 @@
- **Dispatchers.IO** - Background operations - **Dispatchers.IO** - Background operations
- **SharedPreferences** - Settings storage - **SharedPreferences** - Settings storage
- **File-based storage** - JSON files locally - **File-based storage** - JSON files locally
- **Custom exceptions** - Dedicated SyncException for better error handling _(NEW in v1.3.2)_
### Dependencies ### Dependencies
- **AndroidX** - Jetpack libraries - **AndroidX** - Jetpack libraries
@@ -208,27 +223,22 @@
## 🔮 Future Features ## 🔮 Future Features
Planned for upcoming versions (see [TODO.md](project-docs/simple-notes-sync/planning/TODO.md)): Planned for upcoming versions:
### v1.3.0 - Web Editor & Organization ### v1.4.0 - Checklists
- **Browser-based editor** - Edit notes in web browser - **Checklist notes** - New note type with checkboxes
- **WebDAV access via browser** - No mount needed - **Completed items** - Strike-through/check off
- **Mobile-optimized** - Responsive design - **Drag & drop** - Reorder items
- **Offline-capable** - Progressive Web App (PWA)
- **Tags/labels** - Categorize notes
- **Search** - Full-text search in all notes
- **Sorting** - By date, title, tags
- **Filter** - Filter by tags
### v1.4.0 - Sharing & Export ### v1.5.0 - Internationalization
- **Share note** - Via share intent - **Multi-language** - German + English UI
- **Export single note** - As .txt or .md - **Language selection** - Selectable in settings
- **Import from text** - Via share intent - **Full translation** - All strings in both languages
### v1.5.0 - Advanced Editor Features ### v1.6.0 - Modern APIs
- **Markdown preview** - In-app rendering - **Replace LocalBroadcastManager** - Use SharedFlow instead
- **Checklists** - TODO lists in notes - **PackageInfo Flags** - Use PackageInfoFlags.of()
- **Syntax highlighting** - For code snippets - **Complexity refactoring** - Split long functions
--- ---
@@ -271,4 +281,4 @@ A: Yes! Download the APK directly from GitHub or use F-Droid.
--- ---
**Last update:** v1.2.1 (2026-01-05) **Last update:** v1.3.2 (2026-01-10)

View File

@@ -8,8 +8,16 @@
## 📝 Notiz-Verwaltung ## 📝 Notiz-Verwaltung
### Notiz-Typen
-**Textnotizen** - Klassische Freitext-Notizen
-**Checklisten** _(NEU in v1.4.0)_ - Aufgabenlisten mit Tap-to-Check
- Items hinzufügen über Eingabefeld
- ☑️ Tap zum Abhaken/Wieder-Öffnen
- 📌 Long-Press für Drag & Drop Sortierung
- 🗑️ Swipe-to-Delete für einzelne Items
- ~~Durchstreichen~~ bei erledigten Einträgen
### Basis-Funktionen ### Basis-Funktionen
-**Einfache Textnotizen** - Fokus auf Inhalt, keine Ablenkung
-**Automatisches Speichern** - Kein manuelles Speichern nötig -**Automatisches Speichern** - Kein manuelles Speichern nötig
-**Titel + Inhalt** - Klare Struktur für jede Notiz -**Titel + Inhalt** - Klare Struktur für jede Notiz
-**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch -**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
@@ -52,9 +60,11 @@
### Markdown-Export ### Markdown-Export
-**Automatischer Export** - Jede Notiz → `.md` Datei -**Automatischer Export** - Jede Notiz → `.md` Datei
-**Checklisten als Task-Listen** _(NEU)_ - `- [ ]` / `- [x]` Format (GitHub-kompatibel)
-**Dual-Format** - JSON (Master) + Markdown (Mirror) -**Dual-Format** - JSON (Master) + Markdown (Mirror)
-**Dateinamen-Sanitization** - Sichere Dateinamen aus Titeln -**Dateinamen-Sanitization** - Sichere Dateinamen aus Titeln
-**Frontmatter-Metadata** - YAML mit ID, Timestamps, Tags -**Duplikat-Handling** _(NEU)_ - ID-Suffix bei gleichen Titeln
-**Frontmatter-Metadata** - YAML mit ID, Timestamps, Type
-**WebDAV-Sync** - Parallel zum JSON-Sync -**WebDAV-Sync** - Parallel zum JSON-Sync
-**Optional** - In Einstellungen ein/ausschaltbar -**Optional** - In Einstellungen ein/ausschaltbar
-**Initial Export** - Alle bestehenden Notizen beim Aktivieren -**Initial Export** - Alle bestehenden Notizen beim Aktivieren
@@ -81,16 +91,16 @@
### Auto-Sync ### Auto-Sync
-**Intervall-Auswahl** - 15, 30 oder 60 Minuten -**Intervall-Auswahl** - 15, 30 oder 60 Minuten
-**WLAN-Bindung** - Nur im konfigurierten Heim-WLAN -**WiFi-Trigger** - Sync bei WiFi-Verbindung _(keine SSID-Einschränkung)_
-**Akkuschonend** - ~0.2-0.8% pro Tag -**Akkuschonend** - ~0.2-0.8% pro Tag
-**Smart Server-Check** - Keine Fehler in fremden Netzwerken -**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - Zuverlässige Background-Ausführung -**WorkManager** - Zuverlässige Background-Ausführung
-**Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode -**Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode
### Sync-Trigger (6 Stück) ### Sync-Trigger (6 Stück)
1.**Periodic Sync** - Automatisch nach Intervall 1.**Periodic Sync** - Automatisch nach Intervall
2.**App-Start Sync** - Beim Öffnen der App 2.**App-Start Sync** - Beim Öffnen der App
3.**WiFi-Connect Sync** - Wenn Heim-WLAN verbindet 3.**WiFi-Connect Sync** - Bei jeder WiFi-Verbindung
4.**Manual Sync** - Button in Einstellungen 4.**Manual Sync** - Button in Einstellungen
5.**Pull-to-Refresh** - Wisch-Geste in Notizliste 5.**Pull-to-Refresh** - Wisch-Geste in Notizliste
6.**Settings-Save Sync** - Nach Server-Konfiguration 6.**Settings-Save Sync** - Nach Server-Konfiguration
@@ -109,7 +119,6 @@
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern -**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication -**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen -**Connection Test** - In Einstellungen testen
-**Gateway SSID** - WLAN-Name für Auto-Sync
-**Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_ -**Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_
-**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/` -**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
@@ -130,14 +139,19 @@
-**Passwort-Speicherung** - Android SharedPreferences (verschlüsselt) -**Passwort-Speicherung** - Android SharedPreferences (verschlüsselt)
-**Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV) -**Keine Drittanbieter-Libs** - Nur Android SDK + Sardine (WebDAV)
### Entwickler-Features
-**Datei-Logging** - Optional, nur bei Aktivierung _(NEU in v1.3.2)_
-**Datenschutz-Hinweis** - Explizite Warnung bei Aktivierung
-**Lokale Logs** - Logs bleiben auf dem Gerät
--- ---
## 🔋 Performance & Optimierung ## 🔋 Performance & Optimierung
### Akku-Effizienz ### Akku-Effizienz
-**Optimierte Sync-Intervalle** - 15/30/60 Min -**Optimierte Sync-Intervalle** - 15/30/60 Min
-**WLAN-Only** - Kein Mobile Data Sync -**WiFi-Only** - Kein Mobile Data Sync
-**Smart Server-Check** - Nur im Heim-WLAN -**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - System-optimierte Ausführung -**WorkManager** - System-optimierte Ausführung
-**Doze Mode kompatibel** - Sync läuft auch im Standby -**Doze Mode kompatibel** - Sync läuft auch im Standby
-**Gemessener Verbrauch:** -**Gemessener Verbrauch:**
@@ -171,6 +185,7 @@
- **Dispatchers.IO** - Background-Operationen - **Dispatchers.IO** - Background-Operationen
- **SharedPreferences** - Settings-Speicherung - **SharedPreferences** - Settings-Speicherung
- **File-Based Storage** - JSON-Dateien lokal - **File-Based Storage** - JSON-Dateien lokal
- **Custom Exceptions** - Dedizierte SyncException für bessere Fehlerbehandlung _(NEU in v1.3.2)_
### Abhängigkeiten ### Abhängigkeiten
- **AndroidX** - Jetpack Libraries - **AndroidX** - Jetpack Libraries
@@ -208,27 +223,22 @@
## 🔮 Zukünftige Features ## 🔮 Zukünftige Features
Geplant für kommende Versionen (siehe [TODO.md](project-docs/simple-notes-sync/planning/TODO.md)): Geplant für kommende Versionen:
### v1.3.0 - Web Editor & Organisation ### v1.4.0 - Checklisten
- **Browser-basierter Editor** - Notizen im Webbrowser bearbeiten - **Checklisten-Notizen** - Neuer Notiz-Typ mit Checkboxen
- **WebDAV-Zugriff via Browser** - Kein Mount nötig - **Erledigte Items** - Durchstreichen/Abhaken
- **Mobile-optimiert** - Responsive Design - **Drag & Drop** - Items neu anordnen
- **Offline-fähig** - Progressive Web App (PWA)
- **Tags/Labels** - Kategorisierung von Notizen
- **Suche** - Volltextsuche in allen Notizen
- **Sortierung** - Nach Datum, Titel, Tags
- **Filter** - Nach Tags filtern
### v1.4.0 - Sharing & Export ### v1.5.0 - Internationalisierung
- **Notiz teilen** - Via Share-Intent - **Mehrsprachigkeit** - Deutsch + Englisch UI
- **Einzelne Notiz exportieren** - Als .txt oder .md - **Sprachauswahl** - In Einstellungen wählbar
- **Import von Text** - Via Share-Intent - **Vollständige Übersetzung** - Alle Strings in beiden Sprachen
### v1.5.0 - Erweiterte Editor-Features ### v1.6.0 - Modern APIs
- **Markdown-Vorschau** - In-App Rendering - **LocalBroadcastManager ersetzen** - SharedFlow stattdessen
- **Checklisten** - TODO-Listen in Notizen - **PackageInfo Flags** - PackageInfoFlags.of() verwenden
- **Syntax-Highlighting** - Für Code-Snippets - **Komplexitäts-Refactoring** - Lange Funktionen aufteilen
--- ---
@@ -271,4 +281,4 @@ A: Ja! Lade die APK direkt von GitHub oder nutze F-Droid.
--- ---
**Letzte Aktualisierung:** v1.2.1 (2026-01-05) **Letzte Aktualisierung:** v1.3.2 (2026-01-10)

View File

@@ -0,0 +1,5 @@
Unter der Haube haben wir ordentlich aufgeraumt:
- Verbesserte Sync-Performance durch optimierten Code
- Stabilere Fehlerbehandlung bei Verbindungsproblemen
- Speichereffizientere Datenverarbeitung
- Datenschutz-Hinweis fur Datei-Logging hinzugefugt

View File

@@ -0,0 +1,12 @@
NEU: Checklisten!
- Erstelle Checklisten-Notizen mit Tap-to-Check
- Markdown-Export als GitHub-Style Aufgabenlisten
Fixes:
- Robusteres Markdown-Parsing
- Doppelte Dateinamen bekommen ID-Suffix
- Keine Benachrichtigungen wenn App offen ist
Privacy:
- 2 WiFi-Permissions entfernt (ACCESS/CHANGE_WIFI_STATE)
- WiFi-Binding funktioniert bereits ohne SSID-Zugriff!

View File

@@ -2,16 +2,15 @@ Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisatio
HAUPTFUNKTIONEN: HAUPTFUNKTIONEN:
Einfache Notizen erstellen und bearbeiten Text-Notizen und Checklisten erstellen
• Checklisten mit Tap-to-Check, Drag & Drop, Swipe-to-Delete
• WebDAV-Synchronisation mit eigenem Server • WebDAV-Synchronisation mit eigenem Server
• Multi-Device Sync (Handy, Tablet, Desktop) • Multi-Device Sync (Handy, Tablet, Desktop)
• Markdown-Export für Obsidian/Desktop-Editoren • Markdown-Export für Obsidian/Desktop-Editoren
• Checklisten als GitHub-Style Task-Listen exportieren
• Automatische Synchronisation im Heim-WLAN • Automatische Synchronisation im Heim-WLAN
• Konfigurierbares Sync-Interval (15/30/60 Minuten) • Konfigurierbares Sync-Interval (15/30/60 Minuten)
• Transparente Batterie-Verbrauchsanzeige
• Material Design 3 mit Dynamic Colors (Android 12+) • Material Design 3 mit Dynamic Colors (Android 12+)
• Swipe-to-Delete mit Server-Sync
• Server-Backup & Wiederherstellung (Merge/Replace/Overwrite)
• Komplett offline nutzbar • Komplett offline nutzbar
• Keine Werbung, keine Tracker • Keine Werbung, keine Tracker

View File

@@ -1 +1 @@
Einfache Notizen-App mit WebDAV-Synchronisation Notizen & Checklisten mit WebDAV-Sync zu deinem eigenen Server

View File

@@ -0,0 +1,5 @@
Under the hood improvements:
- Improved sync performance through optimized code
- More stable error handling for connection issues
- More memory-efficient data processing
- Added privacy notice for file logging

View File

@@ -0,0 +1,12 @@
NEW: Checklists!
- Create checklist notes with tap-to-check items
- Markdown export as GitHub-style task lists
Fixes:
- More robust Markdown parsing
- Duplicate filenames get ID suffix
- No notifications when app is open
Privacy:
- Removed 2 WiFi permissions (ACCESS/CHANGE_WIFI_STATE)
- WiFi binding already works without SSID access!

View File

@@ -2,16 +2,15 @@ Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization.
KEY FEATURES: KEY FEATURES:
• Create and edit simple notes • Create text notes and checklists
• Checklists with tap-to-check, drag & drop, swipe-to-delete
• WebDAV synchronization with your own server • WebDAV synchronization with your own server
• Multi-device sync (phone, tablet, desktop) • Multi-device sync (phone, tablet, desktop)
• Markdown export for Obsidian/desktop editors • Markdown export for Obsidian/desktop editors
• Checklists export as GitHub-style task lists
• Automatic synchronization on home WiFi • Automatic synchronization on home WiFi
• Configurable sync interval (15/30/60 minutes) • Configurable sync interval (15/30/60 minutes)
• Transparent battery usage display
• Material Design 3 with Dynamic Colors (Android 12+) • Material Design 3 with Dynamic Colors (Android 12+)
• Swipe-to-delete with server sync
• Server backup & restore (Merge/Replace/Overwrite)
• Fully usable offline • Fully usable offline
• No ads, no trackers • No ads, no trackers

View File

@@ -1 +1 @@
Simple note-taking app with WebDAV synchronization Notes & checklists with WebDAV sync to your own server

View File

@@ -119,7 +119,35 @@ Builds:
scandelete: scandelete:
- android/gradle/wrapper - android/gradle/wrapper
- versionName: 1.3.2
versionCode: 10
commit: v1.3.2
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
- versionName: 1.4.0
versionCode: 11
commit: v1.4.0
subdir: android/app
sudo:
- apt-get update
- apt-get install -y openjdk-17-jdk-headless
- update-java-alternatives -a
gradle:
- fdroid
prebuild: sed -i -e '/signingConfig/d' build.gradle.kts
scandelete:
- android/gradle/wrapper
AutoUpdateMode: Version AutoUpdateMode: Version
UpdateCheckMode: Tags UpdateCheckMode: Tags
CurrentVersion: 1.3.1 CurrentVersion: 1.4.0
CurrentVersionCode: 9 CurrentVersionCode: 11