7 Commits

Author SHA1 Message Date
inventory69
7128c25bd5 Merge feature/v1.4.1-bugfixes: Bugfixes + Checklist improvements 2026-01-11 21:59:24 +01:00
inventory69
356ccde627 feat(v1.4.1): Bugfixes + Checklist auto line wrap
Fixed:
- Delete notes from older app versions (v1.2.0 compatibility)
- Checklist sync backwards compatibility (v1.3.x)
  - Fallback content in GitHub task list format
  - Recovery mode for lost checklistItems

Improved:
- Checklist auto line wrap (no maxLines limit)
- Enter key creates new item (via TextWatcher)

Metadata:
- Changelogs for versionCode 12
- IzzyOnDroid metadata updated
2026-01-11 21:59:09 +01:00
inventory69
9b37078cce Change repository URL in CONTRIBUTING.md
Updated repository URL in Quick Start section.
2026-01-11 16:23:51 +01:00
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
43 changed files with 1390 additions and 191 deletions

1
.gitignore vendored
View File

@@ -42,3 +42,4 @@ Thumbs.db
*.tmp
*.swp
*~
test-apks/

View File

@@ -6,6 +6,91 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [1.4.1] - 2026-01-11
### Fixed
- **🗑️ Löschen älterer Notizen (v1.2.0 Kompatibilität)**
- Notizen aus App-Version v1.2.0 oder früher werden jetzt korrekt vom Server gelöscht
- Behebt Problem bei Multi-Device-Nutzung mit älteren Notizen
- **🔄 Checklisten-Sync Abwärtskompatibilität**
- Checklisten werden jetzt auch als Text-Fallback im `content`-Feld gespeichert
- Ältere App-Versionen (v1.3.x) zeigen Checklisten als lesbaren Text
- Format: GitHub-Style Task-Listen (`[ ] Item` / `[x] Item`)
- Recovery-Mode: Falls Checklisten-Items verloren gehen, werden sie aus dem Content wiederhergestellt
### Improved
- **📝 Checklisten Auto-Zeilenumbruch**
- Lange Checklisten-Texte werden jetzt automatisch umgebrochen
- Keine Begrenzung auf 3 Zeilen mehr
- Enter-Taste erstellt weiterhin ein neues Item
### Looking Ahead
> 🚀 **v1.5.0** wird das nächste größere Release. Wir sammeln Ideen und Feedback!
> Feature-Requests gerne als [GitHub Issue](https://github.com/inventory69/simple-notes-sync/issues) einreichen.
---
## [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

View File

@@ -14,7 +14,7 @@ Danke, dass du zu Simple Notes Sync beitragen möchtest!
1. **Fork & Clone**
```bash
git clone https://github.com/DEIN-USERNAME/simple-notes-sync.git
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync
```
@@ -139,7 +139,7 @@ Thanks for wanting to contribute to Simple Notes Sync!
1. **Fork & Clone**
```bash
git clone https://github.com/YOUR-USERNAME/simple-notes-sync.git
git clone https://github.com/inventory69/simple-notes-sync.git
cd simple-notes-sync
```

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/` |
| **Username** | `noteuser` |
| **Password** | (your password from `.env`) |
| **Gateway SSID** | Name of your WiFi network |
> **💡 Note:** Enter only the base URL (without `/notes`). The app automatically creates `/notes/` for JSON files and `/notes-md/` for Markdown export.
@@ -158,9 +157,8 @@ For reliable auto-sync:
# Should show "Up"
```
2. **Same WiFi?**
2. **Same network?**
- Smartphone and server must be on same network
- Check SSID in app settings
3. **IP address correct?**
```bash
@@ -193,9 +191,9 @@ For reliable auto-sync:
2. **Battery optimization disabled?**
- See [Disable Battery Optimization](#-disable-battery-optimization)
3. **On correct WiFi?**
- Sync only works when SSID = Gateway SSID
- Check current SSID in Android settings
3. **Connected to WiFi?**
- Auto-sync triggers on any WiFi connection
- Check if you're connected to a WiFi network
4. **Test manually:**
- ⚙️ 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/` |
| **Benutzername** | `noteuser` |
| **Passwort** | (dein Passwort aus `.env`) |
| **Gateway SSID** | Name deines WLAN-Netzwerks |
> **💡 Hinweis:** Gib nur die Base-URL ein (ohne `/notes`). Die App erstellt automatisch `/notes/` für JSON-Dateien und `/notes-md/` für Markdown-Export.
@@ -158,9 +157,8 @@ Für zuverlässigen Auto-Sync:
# Sollte "Up" zeigen
```
2. **Gleiche WLAN?**
2. **Gleiches Netzwerk?**
- Smartphone und Server müssen im selben Netzwerk sein
- Prüfe SSID in App-Einstellungen
3. **IP-Adresse korrekt?**
```bash
@@ -193,9 +191,9 @@ Für zuverlässigen Auto-Sync:
2. **Akku-Optimierung deaktiviert?**
- Siehe [Akku-Optimierung](#-akku-optimierung-deaktivieren)
3. **Im richtigen WLAN?**
- Sync funktioniert nur wenn SSID = Gateway SSID
- Prüfe aktuelle SSID in Android-Einstellungen
3. **Mit WiFi verbunden?**
- Auto-Sync triggert bei jeder WiFi-Verbindung
- Prüfe, ob du mit einem WLAN verbunden bist
4. **Manuell testen:**
- ⚙️ Einstellungen → "Jetzt synchronisieren"

View File

@@ -26,11 +26,12 @@
## ✨ Highlights
-**NEW: Checklists** - Tap-to-check, drag & drop, swipe-to-delete
- 📝 **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)
- 💾 **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
- 🎨 **Material Design 3** - Dark mode & dynamic colors
@@ -101,4 +102,4 @@ MIT License - see [LICENSE](LICENSE)
---
**v1.3.2** · Built with ❤️ using Kotlin + Material Design 3
**v1.4.0** · Built with ❤️ using Kotlin + Material Design 3

View File

@@ -26,11 +26,12 @@
## ✨ Highlights
-**NEU: Checklisten** - Tap-to-Check, Drag & Drop, Swipe-to-Delete
- 📝 **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)
- 💾 **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
- 🎨 **Material Design 3** - Dark Mode & Dynamic Colors
@@ -104,4 +105,4 @@ MIT License - siehe [LICENSE](LICENSE)
---
**v1.3.2** · 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"
minSdk = 24
targetSdk = 36
versionCode = 10 // 🚀 v1.3.2: Lint-Cleanup "Clean Slate"
versionName = "1.3.2" // 🚀 v1.3.2: Code-Qualität-Release (alle einfachen Lint-Issues behoben)
versionCode = 12 // 🔧 v1.4.1: Bugfixes (Root-Delete, Checklist Compat)
versionName = "1.4.1" // 🔧 v1.4.1: Root-Folder Delete Fix, Checklisten-Sync Abwärtskompatibilität
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
@@ -144,12 +141,6 @@ dependencies {
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
// Aktivieren in v1.4.0 wenn Code-Stil bereinigt wurde
// ktlint {

View File

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

View File

@@ -44,6 +44,9 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import android.view.View
import android.widget.LinearLayout
import android.view.Gravity
import android.widget.PopupMenu
import dev.dettmer.simplenotes.models.NoteType
class MainActivity : AppCompatActivity() {
@@ -128,6 +131,9 @@ class MainActivity : AppCompatActivity() {
setupRecyclerView()
setupFab()
// v1.4.1: Migrate checklists for backwards compatibility
migrateChecklistsForBackwardsCompat()
loadNotes()
// 🔄 v1.3.1: Observe sync state for UI updates
@@ -551,12 +557,55 @@ class MainActivity : AppCompatActivity() {
}).show()
}
/**
* v1.4.0: Setup FAB mit Dropdown für Notiz-Typ Auswahl
*/
private fun setupFab() {
fabAddNote.setOnClickListener {
openNoteEditor(null)
fabAddNote.setOnClickListener { view ->
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() {
val notes = storage.loadAllNotes()
@@ -684,6 +733,54 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* v1.4.1: Migriert bestehende Checklisten für Abwärtskompatibilität.
*
* Problem: v1.4.0 Checklisten haben leeren "content", was auf älteren
* App-Versionen (v1.3.x) als leere Notiz angezeigt wird.
*
* Lösung: Alle Checklisten ohne Fallback-Content als PENDING markieren,
* damit sie beim nächsten Sync mit Fallback-Content hochgeladen werden.
*
* TODO: Diese Migration kann entfernt werden, sobald v1.4.0 nicht mehr
* im Umlauf ist (ca. 6 Monate nach v1.4.1 Release, also ~Juli 2026).
* Tracking: https://github.com/inventory69/simple-notes-sync/issues/XXX
*/
private fun migrateChecklistsForBackwardsCompat() {
val migrationKey = "v1.4.1_checklist_migration_done"
// Nur einmal ausführen
if (prefs.getBoolean(migrationKey, false)) {
return
}
val allNotes = storage.loadAllNotes()
val checklistsToMigrate = allNotes.filter { note ->
note.noteType == NoteType.CHECKLIST &&
note.content.isBlank() &&
note.checklistItems?.isNotEmpty() == true
}
if (checklistsToMigrate.isNotEmpty()) {
Logger.d(TAG, "🔄 v1.4.1 Migration: Found ${checklistsToMigrate.size} checklists without fallback content")
for (note in checklistsToMigrate) {
// Als PENDING markieren, damit beim nächsten Sync der Fallback-Content
// generiert und hochgeladen wird
val updatedNote = note.copy(
syncStatus = dev.dettmer.simplenotes.models.SyncStatus.PENDING
)
storage.saveNote(updatedNote)
Logger.d(TAG, " 📝 Marked for re-sync: ${note.title}")
}
Logger.d(TAG, "✅ v1.4.1 Migration: ${checklistsToMigrate.size} checklists marked for re-sync")
}
// Migration als erledigt markieren
prefs.edit().putBoolean(migrationKey, true).apply()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,

View File

@@ -3,27 +3,58 @@ package dev.dettmer.simplenotes
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.LinearLayout
import androidx.appcompat.app.AlertDialog
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.button.MaterialButton
import com.google.android.material.color.DynamicColors
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.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.utils.DeviceIdGenerator
import dev.dettmer.simplenotes.utils.Logger
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() {
// Views
private lateinit var toolbar: MaterialToolbar
private lateinit var tilTitle: TextInputLayout
private lateinit var editTextTitle: TextInputEditText
private lateinit var tilContent: TextInputLayout
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
// State
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 {
private const val TAG = "NoteEditorActivity"
const val EXTRA_NOTE_ID = "extra_note_id"
const val EXTRA_NOTE_TYPE = "extra_note_type"
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -36,39 +67,172 @@ class NoteEditorActivity : AppCompatActivity() {
storage = NotesStorage(this)
// Setup toolbar
val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
supportActionBar?.apply {
setDisplayHomeAsUpEnabled(true)
// 🔥 v1.1.2: Use default back arrow (Material Design) instead of X icon
// Icon is set in XML: app:navigationIcon="?attr/homeAsUpIndicator"
findViews()
setupToolbar()
loadNoteOrDetermineType()
setupUIForNoteType()
}
// Find views
private fun findViews() {
toolbar = findViewById(R.id.toolbar)
tilTitle = findViewById(R.id.tilTitle)
editTextTitle = findViewById(R.id.editTextTitle)
tilContent = findViewById(R.id.tilContent)
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)
if (noteId != null) {
// Existierende Notiz laden
existingNote = storage.loadNote(noteId)
existingNote?.let {
editTextTitle.setText(it.title)
editTextContent.setText(it.content)
supportActionBar?.title = "Notiz bearbeiten"
existingNote?.let { note ->
editTextTitle.setText(note.title)
currentNoteType = note.noteType
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 {
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 {
menuInflater.inflate(R.menu.menu_editor, menu)
// Show delete only for existing notes
// Delete nur für existierende Notizen
menu.findItem(R.id.action_delete)?.isVisible = existingNote != null
return true
}
@@ -92,51 +256,96 @@ class NoteEditorActivity : AppCompatActivity() {
private fun saveNote() {
val title = editTextTitle.text?.toString()?.trim() ?: ""
when (currentNoteType) {
NoteType.TEXT -> {
val content = editTextContent.text?.toString()?.trim() ?: ""
if (title.isEmpty() && content.isEmpty()) {
showToast("Notiz ist leer")
showToast(getString(R.string.note_is_empty))
return
}
val note = if (existingNote != null) {
// Update existing note
existingNote!!.copy(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
updatedAt = System.currentTimeMillis(),
syncStatus = SyncStatus.PENDING
)
} else {
// Create new note
Note(
title = title,
content = content,
noteType = NoteType.TEXT,
checklistItems = null,
deviceId = DeviceIdGenerator.getDeviceId(this),
syncStatus = SyncStatus.LOCAL_ONLY
)
}
storage.saveNote(note)
showToast("Notiz gespeichert")
}
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)
}
}
showToast(getString(R.string.note_saved))
finish()
}
private fun confirmDelete() {
AlertDialog.Builder(this)
.setTitle("Notiz löschen?")
.setMessage("Diese Aktion kann nicht rückgängig gemacht werden.")
.setPositiveButton("Löschen") { _, _ ->
.setTitle(getString(R.string.delete_note_title))
.setMessage(getString(R.string.delete_note_message))
.setPositiveButton(getString(R.string.delete)) { _, _ ->
deleteNote()
}
.setNegativeButton("Abbrechen", null)
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
private fun deleteNote() {
existingNote?.let {
storage.deleteNote(it.id)
showToast("Notiz gelöscht")
showToast(getString(R.string.note_deleted))
finish()
}
}

View File

@@ -371,13 +371,12 @@ class SettingsActivity : AppCompatActivity() {
* Setup about section with version info and clickable cards
*/
private fun setupAboutSection() {
// Display app version with build date
// Display app version
try {
val versionName = BuildConfig.VERSION_NAME
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) {
Logger.e(TAG, "Failed to load version info", e)
textViewAppVersion.text = "Version nicht verfügbar"

View File

@@ -0,0 +1,177 @@
package dev.dettmer.simplenotes.adapters
import android.graphics.Paint
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
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)
// v1.4.1: TextWatcher für Änderungen + Enter-Erkennung für neues Item
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) return
val text = s?.toString() ?: ""
// Prüfe ob ein Newline eingegeben wurde
if (text.contains("\n")) {
// Newline entfernen und neues Item erstellen
val cleanText = text.replace("\n", "")
editText.setText(cleanText)
editText.setSelection(cleanText.length)
onItemTextChanged(pos, cleanText)
onAddNewItem(pos + 1)
} else {
onItemTextChanged(pos, text)
}
}
}
editText.addTextChangedListener(textWatcher)
// 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 dev.dettmer.simplenotes.R
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.NoteType
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.utils.Constants
import dev.dettmer.simplenotes.utils.toReadableTime
import dev.dettmer.simplenotes.utils.truncate
/**
* Adapter für die Notizen-Liste
*
* v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen
*/
class NotesAdapter(
private val onNoteClick: (Note) -> Unit
) : ListAdapter<Note, NotesAdapter.NoteViewHolder>(NoteDiffCallback()) {
@@ -31,16 +37,46 @@ class NotesAdapter(
}
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 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 imageViewSyncStatus: ImageView = itemView.findViewById(R.id.imageViewSyncStatus)
fun bind(note: Note) {
textViewTitle.text = note.title.ifEmpty { "Ohne Titel" }
textViewContent.text = note.content.truncate(100)
// Titel
textViewTitle.text = note.title.ifEmpty {
itemView.context.getString(R.string.untitled)
}
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
val prefs = itemView.context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, 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

@@ -14,56 +14,195 @@ data class Note(
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(),
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
* v1.4.1: Für Checklisten wird ein Fallback-Content generiert, damit ältere
* App-Versionen (v1.3.x) die Notiz als Text anzeigen können.
*/
fun toJson(): String {
return """
{
"id": "$id",
"title": "${title.escapeJson()}",
"content": "${content.escapeJson()}",
"createdAt": $createdAt,
"updatedAt": $updatedAt,
"deviceId": "$deviceId",
"syncStatus": "${syncStatus.name}"
val gson = com.google.gson.GsonBuilder()
.setPrettyPrinting()
.create()
// v1.4.1: Für Checklisten den Fallback-Content generieren
val noteToSerialize = if (noteType == NoteType.CHECKLIST && checklistItems != null) {
this.copy(content = generateChecklistFallbackContent())
} else {
this
}
""".trimIndent()
return gson.toJson(noteToSerialize)
}
/**
* v1.4.1: Generiert einen lesbaren Text-Fallback aus Checklist-Items.
* Format: GitHub-Style Task-Listen (kompatibel mit Markdown)
*
* Beispiel:
* [ ] Milch kaufen
* [x] Brot gekauft
* [ ] Eier
*
* Wird von älteren App-Versionen (v1.3.x) als normaler Text angezeigt.
*/
private fun generateChecklistFallbackContent(): String {
return checklistItems?.sortedBy { it.order }?.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "[x]" else "[ ]"
"$checkbox ${item.text}"
} ?: ""
}
/**
* Konvertiert Note zu Markdown mit YAML Frontmatter (Task #1.2.0-08)
* Format kompatibel mit Obsidian, Joplin, Typora
* v1.4.0: Unterstützt jetzt auch Checklisten-Format
*/
fun toMarkdown(): String {
return """
val header = """
---
id: $id
created: ${formatISO8601(createdAt)}
updated: ${formatISO8601(updatedAt)}
device: $deviceId
type: ${noteType.name.lowercase()}
---
# $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 {
private const val TAG = "Note"
/**
* Parst JSON zu Note-Objekt mit Backward Compatibility für alte Notizen ohne noteType
*/
fun fromJson(json: String): Note? {
return try {
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
var checklistItems: List<ChecklistItem>? = if (jsonObject.has("checklistItems") &&
!jsonObject.get("checklistItems").isJsonNull
) {
gson.fromJson<List<ChecklistItem>>(
jsonObject.get("checklistItems"),
checklistItemsType
)
} else {
null
}
// v1.4.1: Recovery-Mode - Falls Checkliste aber keine Items,
// versuche Content als Fallback zu parsen
if (noteType == NoteType.CHECKLIST &&
(checklistItems == null || checklistItems.isEmpty()) &&
rawNote.content.isNotBlank()) {
val recoveredItems = parseChecklistFromContent(rawNote.content)
if (recoveredItems.isNotEmpty()) {
Logger.d(TAG, "🔄 Recovered ${recoveredItems.size} checklist items from content fallback")
checklistItems = recoveredItems
}
}
// 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) {
Logger.w(TAG, "Failed to parse JSON: ${e.message}")
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
)
/**
* v1.4.1: Parst GitHub-Style Checklisten aus Text (Recovery-Mode).
*
* Unterstützte Formate:
* - [ ] Unchecked item
* - [x] Checked item
* - [X] Checked item (case insensitive)
*
* Wird verwendet, wenn eine v1.4.0 Checkliste von einer älteren
* App-Version (v1.3.x) bearbeitet wurde und die checklistItems verloren gingen.
*
* @param content Der Text-Content der Notiz
* @return Liste von ChecklistItems oder leere Liste
*/
private fun parseChecklistFromContent(content: String): List<ChecklistItem> {
val pattern = Regex("""^\s*\[([ xX])\]\s*(.+)$""", RegexOption.MULTILINE)
return pattern.findAll(content).mapIndexed { index, match ->
val checked = match.groupValues[1].lowercase() == "x"
val text = match.groupValues[2].trim()
ChecklistItem(
id = UUID.randomUUID().toString(),
text = text,
isChecked = checked,
order = index
)
}.toList()
}
/**
* 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
* @return Note-Objekt oder null bei Parse-Fehler
@@ -91,10 +230,47 @@ $content
.firstOrNull { it.startsWith("# ") }
?.removePrefix("# ")?.trim() ?: "Untitled"
// Extract content (everything after heading)
val content = contentBlock
.substringAfter("# $title\n\n", "")
// v1.4.0: Prüfe ob type: checklist im Frontmatter
val noteTypeStr = metadata["type"]?.lowercase() ?: "text"
val noteType = when (noteTypeStr) {
"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(
id = metadata["id"] ?: UUID.randomUUID().toString(),
@@ -103,7 +279,9 @@ $content
createdAt = parseISO8601(metadata["created"] ?: ""),
updatedAt = parseISO8601(metadata["updated"] ?: ""),
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) {
Logger.w(TAG, "Failed to parse Markdown: ${e.message}")

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

@@ -1,5 +1,6 @@
package dev.dettmer.simplenotes.sync
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import androidx.localbroadcastmanager.content.LocalBroadcastManager
@@ -22,6 +23,21 @@ class SyncWorker(
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) {
if (BuildConfig.DEBUG) {
Logger.d(TAG, "═══════════════════════════════════════")
@@ -131,7 +147,12 @@ class SyncWorker(
Logger.i(TAG, "✅ Sync successful: ${result.syncedCount} notes")
// 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) {
val appInForeground = isAppInForeground()
if (appInForeground) {
Logger.d(TAG, " App in foreground - skipping notification (UI shows changes)")
} else {
if (BuildConfig.DEBUG) {
Logger.d(TAG, " Showing success notification...")
}
@@ -139,6 +160,7 @@ class SyncWorker(
applicationContext,
result.syncedCount
)
}
} else {
Logger.d(TAG, " No changes to sync - no notification")
}

View File

@@ -41,6 +41,7 @@ class WebDavSyncService(private val context: Context) {
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
private val syncMutex = Mutex()
@@ -858,8 +859,31 @@ class WebDavSyncService(private val context: Context) {
}
// Sanitize Filename (Task #1.2.0-12)
val filename = sanitizeFilename(note.title) + ".md"
val noteUrl = "$mdUrl/$filename"
val baseFilename = sanitizeFilename(note.title)
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
val mdContent = note.toMarkdown().toByteArray()
@@ -884,6 +908,29 @@ class WebDavSyncService(private val context: Context) {
.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)
*
@@ -927,6 +974,9 @@ class WebDavSyncService(private val context: Context) {
val totalCount = allNotes.size
var exportedCount = 0
// Track used filenames to handle duplicates
val usedFilenames = mutableSetOf<String>()
Logger.d(TAG, "📝 Found $totalCount notes to export")
allNotes.forEachIndexed { index, note ->
@@ -934,8 +984,8 @@ class WebDavSyncService(private val context: Context) {
// Progress-Callback
onProgress(index + 1, totalCount)
// Sanitize Filename
val filename = sanitizeFilename(note.title) + ".md"
// Eindeutiger Filename (mit Duplikat-Handling)
val filename = getUniqueMarkdownFilename(note, usedFilenames) + ".md"
val noteUrl = "$mdUrl/$filename"
// Konvertiere zu Markdown
@@ -945,7 +995,7 @@ class WebDavSyncService(private val context: Context) {
sardine.put(noteUrl, mdContent, "text/markdown")
exportedCount++
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title}")
Logger.d(TAG, " ✅ Exported [${index + 1}/$totalCount]: ${note.title} -> $filename")
} catch (e: Exception) {
Logger.e(TAG, "❌ Failed to export ${note.title}: ${e.message}")
@@ -1539,13 +1589,27 @@ class WebDavSyncService(private val context: Context) {
Logger.w(TAG, " ⚠️ Failed to parse ${resource.name} - fromMarkdown returned null")
continue
}
// v1.4.0 FIX: Validierung - leere TEXT-Notizen nicht importieren wenn lokal Content existiert
val localNote = storage.loadNote(mdNote.id)
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)}"
"updatedAt=${Date(mdNote.updatedAt)}, " +
"content=${mdNote.content.take(CONTENT_PREVIEW_LENGTH)}..."
)
val localNote = storage.loadNote(mdNote.id)
Logger.d(
TAG,
" Local note: " + if (localNote == null) {
@@ -1700,6 +1764,9 @@ class WebDavSyncService(private val context: Context) {
* Deletes a note from the server (JSON + Markdown)
* Does NOT delete from local storage!
*
* v1.4.1: Now supports v1.2.0 compatibility mode - also checks ROOT folder
* for notes that were created before the /notes/ directory structure.
*
* @param noteId The ID of the note to delete
* @return true if at least one file was deleted, false otherwise
*/
@@ -1711,12 +1778,21 @@ class WebDavSyncService(private val context: Context) {
var deletedJson = false
var deletedMd = false
// Delete JSON
// v1.4.1: Try to delete JSON from /notes/ first (standard path)
val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json"
if (sardine.exists(jsonUrl)) {
sardine.delete(jsonUrl)
deletedJson = true
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json")
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from /notes/)")
} else {
// v1.4.1: Fallback - check ROOT folder for v1.2.0 compatibility
val rootJsonUrl = serverUrl.trimEnd('/') + "/$noteId.json"
Logger.d(TAG, "🔍 JSON not in /notes/, checking ROOT: $rootJsonUrl")
if (sardine.exists(rootJsonUrl)) {
sardine.delete(rootJsonUrl)
deletedJson = true
Logger.d(TAG, "🗑️ Deleted from server: $noteId.json (from ROOT - v1.2.0 compat)")
}
}
// Delete Markdown (v1.3.0: YAML-scan based approach)

View File

@@ -5,12 +5,17 @@ import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.wifi.WifiManager
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import dev.dettmer.simplenotes.utils.Constants
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() {
override fun onReceive(context: Context, intent: Intent) {
@@ -22,34 +27,24 @@ class WifiSyncReceiver : BroadcastReceiver() {
return
}
// Check if connected to home WiFi
if (isConnectedToHomeWifi(context)) {
// Check if connected to any WiFi (SSID-Prüfung entfernt in v1.4.0)
if (isConnectedToWifi(context)) {
scheduleSyncWork(context)
}
}
@Suppress("ReturnCount") // Early returns for WiFi validation checks
private fun isConnectedToHomeWifi(context: Context): Boolean {
val prefs = context.getSharedPreferences(Constants.PREFS_NAME, Context.MODE_PRIVATE)
val homeSSID = prefs.getString(Constants.KEY_HOME_SSID, null) ?: return false
/**
* Prüft ob ein WiFi-Netzwerk verbunden ist (beliebiges WiFi)
* Die Server-Erreichbarkeitsprüfung erfolgt erst im SyncWorker.
*/
private fun isConnectedToWifi(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
if (!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
return capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
private fun scheduleSyncWork(context: Context) {

View File

@@ -6,7 +6,6 @@ object Constants {
const val KEY_SERVER_URL = "server_url"
const val KEY_USERNAME = "username"
const val KEY_PASSWORD = "password"
const val KEY_HOME_SSID = "home_ssid"
const val KEY_AUTO_SYNC = "auto_sync_enabled"
const val KEY_LAST_SYNC = "last_sync_timestamp"

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
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="match_parent"
android:orientation="vertical"
@@ -18,8 +19,9 @@
app:title="@string/edit_note"
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
android:id="@+id/tilTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
@@ -44,8 +46,9 @@
</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
android:id="@+id/tilContent"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
@@ -74,4 +77,39 @@
</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>

View File

@@ -0,0 +1,56 @@
<?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) -->
<!-- v1.4.1: Auto-Zeilenumbruch für lange Texte -->
<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="textMultiLine|textCapSentences"
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"?>
<!-- Material 3: Filled Card Style (Flat, No Shadow) -->
<!-- v1.4.0: Unterstützt jetzt TEXT und CHECKLIST Notizen -->
<com.google.android.material.card.MaterialCardView
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:layout_marginHorizontal="8dp"
@@ -17,17 +19,37 @@
android:orientation="vertical"
android:padding="20dp">
<!-- v1.4.0: Header Row mit Icon und Titel -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<!-- 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="match_parent"
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" />
<!-- Material 3 Typography: BodyMedium -->
</LinearLayout>
<!-- Content Preview (für TEXT Notizen) -->
<TextView
android:id="@+id/textViewContent"
android:layout_width="match_parent"
@@ -39,6 +61,18 @@
android:maxLines="3"
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 -->
<LinearLayout
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_content_placeholder">Note content preview…</string>
<string name="note_timestamp_placeholder">Vor 2 Std</string>
<string name="untitled">Ohne Titel</string>
<!-- Delete Confirmation Dialog -->
<string name="delete_note_title">Notiz löschen?</string>
@@ -42,10 +43,9 @@
<!-- Auto-Sync Settings -->
<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="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 -->
<string name="backup_restore_title">Backup &amp; Wiederherstellung</string>
@@ -66,4 +66,27 @@
<!-- 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>

View File

@@ -78,7 +78,7 @@ val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
### 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
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 |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App opened/resumed | ✅ Yes |
| **3. Background Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Every 15/30/60 minutes (configurable) | ✅ Yes |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi enabled/SSID changed | ✅ Yes |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi connected | ✅ Yes |
### 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 |
| Auto-Sync (onResume) | Silent abort (no toast) | Toast: "✅ Synced: X notes" | Max. 1x/min |
| Background Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | 15/30/60 min |
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | SSID-based |
| WiFi-Connect Sync | Silent abort (no toast) | Silent (LocalBroadcast only) | WiFi-based |
---
@@ -349,7 +349,7 @@ The app requires **minimal 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
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
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 |
| **2. Auto-Sync (onResume)** | `MainActivity.kt` | `triggerAutoSync()` | App wird geöffnet/fortgesetzt | ✅ Ja |
| **3. Hintergrund-Sync (Periodic)** | `SyncWorker.kt` | `doWork()` | Alle 15/30/60 Minuten (konfigurierbar) | ✅ Ja |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi an/SSID-Wechsel | ✅ Ja |
| **4. WiFi-Connect Sync** | `NetworkMonitor.kt``SyncWorker.kt` | `triggerWifiConnectSync()` | WiFi verbunden | ✅ Ja |
### 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 |
| Auto-Sync (onResume) | Silent abort (kein Toast) | Toast: "✅ Gesynct: X Notizen" | Max. 1x/Min |
| Hintergrund-Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | 15/30/60 Min |
| WiFi-Connect Sync | Silent abort (kein Toast) | Silent (LocalBroadcast only) | SSID-basiert |
| 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!**
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 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
-**Simple text notes** - Focus on content, no distractions
-**Auto-save** - No manual saving needed
-**Title + content** - Clear structure for each note
-**Timestamps** - Creation and modification date automatically
@@ -52,9 +60,11 @@
### Markdown Export
-**Automatic export** - Each note → `.md` file
-**Checklists as task lists** _(NEW)_ - `- [ ]` / `- [x]` format (GitHub-compatible)
-**Dual-format** - JSON (master) + Markdown (mirror)
-**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
-**Optional** - Toggle in settings
-**Initial export** - All existing notes when activated
@@ -81,16 +91,16 @@
### Auto-Sync
-**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
-**Smart server check** - No errors on foreign networks
-**Smart server check** - Sync only when server is reachable
-**WorkManager** - Reliable background execution
-**Battery optimization compatible** - Works even with Doze mode
### Sync Triggers (6 total)
1.**Periodic sync** - Automatically after interval
2.**App-start sync** - When opening the app
3.**WiFi-connect sync** - When home WiFi connects
3.**WiFi-connect sync** - On any WiFi connection
4.**Manual sync** - Button in settings
5.**Pull-to-refresh** - Swipe gesture in notes list
6.**Settings-save sync** - After server configuration
@@ -109,7 +119,6 @@
-**HTTP/HTTPS** - HTTP only local, HTTPS for external
-**Username/password** - Basic authentication
-**Connection test** - Test in settings
-**Gateway SSID** - WiFi name for auto-sync
-**Server URL normalization** - Automatic `/notes/` and `/notes-md/` _(NEW in v1.2.1)_
-**Flexible URL input** - Both variants work: `http://server/` and `http://server/notes/`
@@ -142,7 +151,7 @@
### Battery Efficiency
-**Optimized sync intervals** - 15/30/60 min
-**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
-**Doze mode compatible** - Sync runs even in standby
-**Measured consumption:**

View File

@@ -8,8 +8,16 @@
## 📝 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
-**Einfache Textnotizen** - Fokus auf Inhalt, keine Ablenkung
-**Automatisches Speichern** - Kein manuelles Speichern nötig
-**Titel + Inhalt** - Klare Struktur für jede Notiz
-**Zeitstempel** - Erstellungs- und Änderungsdatum automatisch
@@ -52,9 +60,11 @@
### Markdown-Export
-**Automatischer Export** - Jede Notiz → `.md` Datei
-**Checklisten als Task-Listen** _(NEU)_ - `- [ ]` / `- [x]` Format (GitHub-kompatibel)
-**Dual-Format** - JSON (Master) + Markdown (Mirror)
-**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
-**Optional** - In Einstellungen ein/ausschaltbar
-**Initial Export** - Alle bestehenden Notizen beim Aktivieren
@@ -81,16 +91,16 @@
### Auto-Sync
-**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
-**Smart Server-Check** - Keine Fehler in fremden Netzwerken
-**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - Zuverlässige Background-Ausführung
-**Battery-Optimierung kompatibel** - Funktioniert auch mit Doze Mode
### Sync-Trigger (6 Stück)
1.**Periodic Sync** - Automatisch nach Intervall
2.**App-Start Sync** - Beim Öffnen der App
3.**WiFi-Connect Sync** - Wenn Heim-WLAN verbindet
3.**WiFi-Connect Sync** - Bei jeder WiFi-Verbindung
4.**Manual Sync** - Button in Einstellungen
5.**Pull-to-Refresh** - Wisch-Geste in Notizliste
6.**Settings-Save Sync** - Nach Server-Konfiguration
@@ -109,7 +119,6 @@
-**HTTP/HTTPS** - HTTP nur lokal, HTTPS für extern
-**Username/Password** - Basic Authentication
-**Connection Test** - In Einstellungen testen
-**Gateway SSID** - WLAN-Name für Auto-Sync
-**Server-URL Normalisierung** - Automatisches `/notes/` und `/notes-md/` _(NEU in v1.2.1)_
-**Flexible URL-Eingabe** - Beide Varianten funktionieren: `http://server/` und `http://server/notes/`
@@ -141,8 +150,8 @@
### Akku-Effizienz
-**Optimierte Sync-Intervalle** - 15/30/60 Min
-**WLAN-Only** - Kein Mobile Data Sync
-**Smart Server-Check** - Nur im Heim-WLAN
-**WiFi-Only** - Kein Mobile Data Sync
-**Smart Server-Check** - Sync nur wenn Server erreichbar
-**WorkManager** - System-optimierte Ausführung
-**Doze Mode kompatibel** - Sync läuft auch im Standby
-**Gemessener Verbrauch:**

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

@@ -0,0 +1,4 @@
• Bugfix: Löschen von Notizen aus älteren App-Versionen (v1.2.0)
• Bugfix: Checklisten-Sync mit älteren App-Versionen (v1.3.x)
• Checklisten werden jetzt auch als Text-Fallback gespeichert
• Neu: Checklisten-Texte werden automatisch umgebrochen

View File

@@ -2,16 +2,15 @@ Simple Notes Sync ist eine minimalistische Notizen-App mit WebDAV-Synchronisatio
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
• Multi-Device Sync (Handy, Tablet, Desktop)
• Markdown-Export für Obsidian/Desktop-Editoren
• Checklisten als GitHub-Style Task-Listen exportieren
• Automatische Synchronisation im Heim-WLAN
• Konfigurierbares Sync-Interval (15/30/60 Minuten)
• Transparente Batterie-Verbrauchsanzeige
• Material Design 3 mit Dynamic Colors (Android 12+)
• Swipe-to-Delete mit Server-Sync
• Server-Backup & Wiederherstellung (Merge/Replace/Overwrite)
• Komplett offline nutzbar
• 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,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

@@ -0,0 +1,5 @@
• Bugfix: Deleting notes from older app versions (v1.2.0)
• Bugfix: Checklist sync with older app versions (v1.3.x)
• Checklists are now also saved as text fallback
• New: Checklist texts now wrap automatically
p automatically

View File

@@ -2,16 +2,15 @@ Simple Notes Sync is a minimalist note-taking app with WebDAV synchronization.
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
• Multi-device sync (phone, tablet, desktop)
• Markdown export for Obsidian/desktop editors
• Checklists export as GitHub-style task lists
• Automatic synchronization on home WiFi
• Configurable sync interval (15/30/60 minutes)
• Transparent battery usage display
• Material Design 3 with Dynamic Colors (Android 12+)
• Swipe-to-delete with server sync
• Server backup & restore (Merge/Replace/Overwrite)
• Fully usable offline
• 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,49 @@ Builds:
scandelete:
- 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
- versionName: 1.4.1
versionCode: 12
commit: v1.4.1
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
UpdateCheckMode: Tags
CurrentVersion: 1.3.1
CurrentVersionCode: 9
CurrentVersion: 1.4.1
CurrentVersionCode: 12