diff --git a/CHANGELOG.md b/CHANGELOG.md
index d22f4a5..2b31b07 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,60 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
-## [1.2.2] - TBD
+## [1.3.0] - 2026-01-07
+
+### Added
+- **π Multi-Device Sync** (Thanks to Thomas from Bielefeld for reporting!)
+ - Automatic download of new notes from other devices
+ - Deletion tracking prevents "zombie notes" (deleted notes don't come back)
+ - Smart cleanup: Re-created notes (newer timestamp) are downloaded
+ - Works with all devices: v1.2.0, v1.2.1, v1.2.2, and v1.3.0
+
+- **ποΈ Server Deletion via Swipe Gesture**
+ - Swipe left on notes to delete from server (requires confirmation)
+ - Prevents duplicate notes on other devices
+ - Works with deletion tracking system
+ - Material Design confirmation dialog
+
+- **β‘ E-Tag Performance Optimization**
+ - Smart server checking with E-Tag caching (~150ms vs 3000ms for "no changes")
+ - 20x faster when server has no updates
+ - E-Tag hybrid approach: E-Tag for JSON (fast), timestamp for Markdown (reliable)
+ - Battery-friendly with minimal server requests
+
+- **π₯ Markdown Auto-Sync Toggle**
+ - NEW: Unified Auto-Sync toggle in Settings (replaces separate Export/Auto-Import toggles)
+ - When enabled: Notes export to Markdown AND import changes automatically
+ - When disabled: Manual sync button appears for on-demand synchronization
+ - Performance: Auto-Sync OFF = 0ms overhead
+
+- **π Manual Markdown Sync Button**
+ - Manual sync button for performance-conscious users
+ - Shows import/export counts after completion
+ - Only visible when Auto-Sync is disabled
+ - On-demand synchronization (~150-200ms only when triggered)
+
+- **βοΈ Server-Restore Modes**
+ - MERGE: Keep local notes + add server notes
+ - REPLACE: Delete all local + download from server
+ - OVERWRITE: Update duplicates, keep non-duplicates
+ - Restore modes now work correctly for WebDAV restore
+
+### Technical
+- New `DeletionTracker` model with JSON persistence
+- `NotesStorage`: Added deletion tracking methods
+- `WebDavSyncService.hasUnsyncedChanges()`: Intelligent server checks with E-Tag caching
+- `WebDavSyncService.downloadRemoteNotes()`: Deletion-aware downloads
+- `WebDavSyncService.restoreFromServer()`: Support for restore modes
+- `WebDavSyncService.deleteNoteFromServer()`: Server deletion with YAML frontmatter scanning
+- `WebDavSyncService.importMarkdownFiles()`: Automatic Markdown import during sync
+- `WebDavSyncService.manualMarkdownSync()`: Manual sync with result counts
+- `MainActivity.setupSwipeToDelete()`: Two-stage swipe deletion with confirmation
+- E-Tag caching in SharedPreferences for performance
+
+---
+
+## [1.2.2] - 2026-01-06
### Fixed
- **Backward Compatibility for v1.2.0 Users (Critical)**
diff --git a/README.en.md b/README.en.md
index c0e5165..7e7c69d 100644
--- a/README.en.md
+++ b/README.en.md
@@ -6,6 +6,8 @@
[](https://m3.material.io/)
[](LICENSE)
+[
](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
+
**π± [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** Β· **π [Documentation](docs/DOCS.en.md)** Β· **π [Quick Start](QUICKSTART.en.md)**
**π Languages:** [Deutsch](README.md) Β· **English**
diff --git a/README.md b/README.md
index 7c3f6e6..b974144 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@
[](https://m3.material.io/)
[](LICENSE)
+[
](https://apt.izzysoft.de/fdroid/index/apk/dev.dettmer.simplenotes)
+
**π± [APK Download](https://github.com/inventory69/simple-notes-sync/releases/latest)** Β· **π [Dokumentation](docs/DOCS.md)** Β· **π [Quick Start](QUICKSTART.md)**
**π Sprachen:** **Deutsch** Β· [English](README.en.md)
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index d985747..16d4b16 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -17,8 +17,8 @@ android {
applicationId = "dev.dettmer.simplenotes"
minSdk = 24
targetSdk = 36
- versionCode = 7 // π§ v1.2.2: Backward compatibility for v1.2.0 migration
- versionName = "1.2.2" // π§ v1.2.2: Dual-mode download (Root + /notes/)
+ versionCode = 8 // π v1.3.0: Multi-Device Sync with deletion tracking
+ versionName = "1.3.0" // π v1.3.0: Multi-Device Sync, E-Tag caching, Markdown auto-import
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt
index 752708d..5c54f85 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/MainActivity.kt
@@ -21,13 +21,19 @@ import com.google.android.material.color.DynamicColors
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.card.MaterialCardView
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.dettmer.simplenotes.adapters.NotesAdapter
+import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.storage.NotesStorage
import dev.dettmer.simplenotes.sync.SyncWorker
import dev.dettmer.simplenotes.utils.NotificationHelper
import dev.dettmer.simplenotes.utils.showToast
import dev.dettmer.simplenotes.utils.Constants
import android.widget.TextView
+import android.widget.CheckBox
+import android.widget.Toast
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.launch
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.Dispatchers
@@ -320,36 +326,20 @@ class MainActivity : AppCompatActivity() {
): Boolean = false
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
- val position = viewHolder.adapterPosition
- val note = adapter.currentList[position]
- val notesCopy = adapter.currentList.toMutableList()
+ val position = viewHolder.bindingAdapterPosition
+ val swipedNote = adapter.currentList[position]
- // Track pending deletion to prevent flicker
- pendingDeletions.add(note.id)
+ // Store original list BEFORE removing note
+ val originalList = adapter.currentList.toList()
- // Remove from list immediately for visual feedback
- notesCopy.removeAt(position)
- adapter.submitList(notesCopy)
+ // Remove from list for visual feedback (NOT from storage yet!)
+ val listWithoutNote = originalList.toMutableList().apply {
+ removeAt(position)
+ }
+ adapter.submitList(listWithoutNote)
- // Show Snackbar with UNDO
- Snackbar.make(
- recyclerViewNotes,
- "Notiz gelΓΆscht",
- Snackbar.LENGTH_LONG
- ).setAction("RΓCKGΓNGIG") {
- // UNDO: Remove from pending deletions and restore
- pendingDeletions.remove(note.id)
- loadNotes()
- }.addCallback(object : Snackbar.Callback() {
- override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
- if (event != DISMISS_EVENT_ACTION) {
- // Snackbar dismissed without UNDO β Actually delete the note
- storage.deleteNote(note.id)
- pendingDeletions.remove(note.id)
- loadNotes()
- }
- }
- }).show()
+ // Show dialog with ability to restore
+ showServerDeletionDialog(swipedNote, originalList)
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
@@ -361,6 +351,104 @@ class MainActivity : AppCompatActivity() {
itemTouchHelper.attachToRecyclerView(recyclerViewNotes)
}
+ private fun showServerDeletionDialog(note: Note, originalList: List) {
+ val alwaysDeleteFromServer = prefs.getBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false)
+
+ if (alwaysDeleteFromServer) {
+ // Auto-delete from server without asking
+ deleteNoteLocally(note, deleteFromServer = true)
+ return
+ }
+
+ val dialogView = layoutInflater.inflate(R.layout.dialog_server_deletion, null)
+ val checkboxAlways = dialogView.findViewById(R.id.checkboxAlwaysDeleteFromServer)
+
+ MaterialAlertDialogBuilder(this)
+ .setTitle("Notiz lΓΆschen")
+ .setMessage("\"${note.title}\" wird lokal gelΓΆscht.\n\nAuch vom Server lΓΆschen?")
+ .setView(dialogView)
+ .setNeutralButton("Abbrechen") { _, _ ->
+ // RESTORE: Re-submit original list (note is NOT deleted from storage)
+ adapter.submitList(originalList)
+ }
+ .setOnCancelListener {
+ // User pressed back - also restore
+ adapter.submitList(originalList)
+ }
+ .setPositiveButton("Nur lokal") { _, _ ->
+ if (checkboxAlways.isChecked) {
+ prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, false).apply()
+ }
+ // NOW actually delete from storage
+ deleteNoteLocally(note, deleteFromServer = false)
+ }
+ .setNegativeButton("Vom Server lΓΆschen") { _, _ ->
+ if (checkboxAlways.isChecked) {
+ prefs.edit().putBoolean(Constants.KEY_ALWAYS_DELETE_FROM_SERVER, true).apply()
+ }
+ deleteNoteLocally(note, deleteFromServer = true)
+ }
+ .setCancelable(true)
+ .show()
+ }
+
+ private fun deleteNoteLocally(note: Note, deleteFromServer: Boolean) {
+ // Track pending deletion to prevent flicker
+ pendingDeletions.add(note.id)
+
+ // Delete from storage
+ storage.deleteNote(note.id)
+
+ // Reload to reflect changes
+ loadNotes()
+
+ // Show Snackbar with UNDO option
+ val message = if (deleteFromServer) {
+ "\"${note.title}\" wird lokal und vom Server gelΓΆscht"
+ } else {
+ "\"${note.title}\" lokal gelΓΆscht (Server bleibt)"
+ }
+
+ Snackbar.make(recyclerViewNotes, message, Snackbar.LENGTH_LONG)
+ .setAction("RΓCKGΓNGIG") {
+ // UNDO: Restore note
+ storage.saveNote(note)
+ pendingDeletions.remove(note.id)
+ loadNotes()
+ }
+ .addCallback(object : Snackbar.Callback() {
+ override fun onDismissed(transientBottomBar: Snackbar?, event: Int) {
+ if (event != DISMISS_EVENT_ACTION) {
+ // Snackbar dismissed without UNDO
+ pendingDeletions.remove(note.id)
+
+ // Delete from server if requested
+ if (deleteFromServer) {
+ lifecycleScope.launch {
+ try {
+ val webdavService = WebDavSyncService(this@MainActivity)
+ val success = webdavService.deleteNoteFromServer(note.id)
+ if (success) {
+ runOnUiThread {
+ Toast.makeText(this@MainActivity, "Vom Server gelΓΆscht", Toast.LENGTH_SHORT).show()
+ }
+ } else {
+ runOnUiThread {
+ Toast.makeText(this@MainActivity, "Server-LΓΆschung fehlgeschlagen", Toast.LENGTH_LONG).show()
+ }
+ }
+ } catch (e: Exception) {
+ runOnUiThread {
+ Toast.makeText(this@MainActivity, "Server-Fehler: ${e.message}", Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+ }
+ }
+ }).show()
+ }
+
private fun setupFab() {
fabAddNote.setOnClickListener {
openNoteEditor(null)
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
index ef3ca23..fc1d449 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/SettingsActivity.kt
@@ -9,6 +9,7 @@ import android.os.PowerManager
import android.provider.Settings
import android.util.Log
import android.view.MenuItem
+import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.RadioButton
@@ -57,14 +58,15 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var editTextUsername: EditText
private lateinit var editTextPassword: EditText
private lateinit var switchAutoSync: SwitchCompat
- private lateinit var switchMarkdownExport: SwitchCompat
+ private lateinit var switchMarkdownAutoSync: SwitchCompat
private lateinit var buttonTestConnection: Button
private lateinit var buttonSyncNow: Button
private lateinit var buttonCreateBackup: Button
private lateinit var buttonRestoreFromFile: Button
private lateinit var buttonRestoreFromServer: Button
- private lateinit var buttonImportMarkdown: Button
+ private lateinit var buttonManualMarkdownSync: Button
private lateinit var textViewServerStatus: TextView
+ private lateinit var textViewManualSyncInfo: TextView
// Protocol Selection UI
private lateinit var protocolRadioGroup: RadioGroup
@@ -130,14 +132,15 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername = findViewById(R.id.editTextUsername)
editTextPassword = findViewById(R.id.editTextPassword)
switchAutoSync = findViewById(R.id.switchAutoSync)
- switchMarkdownExport = findViewById(R.id.switchMarkdownExport)
+ switchMarkdownAutoSync = findViewById(R.id.switchMarkdownAutoSync)
buttonTestConnection = findViewById(R.id.buttonTestConnection)
buttonSyncNow = findViewById(R.id.buttonSyncNow)
buttonCreateBackup = findViewById(R.id.buttonCreateBackup)
buttonRestoreFromFile = findViewById(R.id.buttonRestoreFromFile)
buttonRestoreFromServer = findViewById(R.id.buttonRestoreFromServer)
- buttonImportMarkdown = findViewById(R.id.buttonImportMarkdown)
+ buttonManualMarkdownSync = findViewById(R.id.buttonManualMarkdownSync)
textViewServerStatus = findViewById(R.id.textViewServerStatus)
+ textViewManualSyncInfo = findViewById(R.id.textViewManualSyncInfo)
// Protocol Selection UI
protocolRadioGroup = findViewById(R.id.protocolRadioGroup)
@@ -180,7 +183,14 @@ class SettingsActivity : AppCompatActivity() {
editTextUsername.setText(prefs.getString(Constants.KEY_USERNAME, ""))
editTextPassword.setText(prefs.getString(Constants.KEY_PASSWORD, ""))
switchAutoSync.isChecked = prefs.getBoolean(Constants.KEY_AUTO_SYNC, false)
- switchMarkdownExport.isChecked = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false) // Default: disabled (offline-first)
+
+ // Load Markdown Auto-Sync (backward compatible)
+ val markdownExport = prefs.getBoolean(Constants.KEY_MARKDOWN_EXPORT, false)
+ val markdownAutoImport = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
+ val markdownAutoSync = markdownExport && markdownAutoImport
+ switchMarkdownAutoSync.isChecked = markdownAutoSync
+
+ updateMarkdownButtonVisibility()
// Update hint text based on selected protocol
updateProtocolHint()
@@ -269,17 +279,16 @@ class SettingsActivity : AppCompatActivity() {
showRestoreDialog(RestoreSource.WEBDAV_SERVER, null)
}
- buttonImportMarkdown.setOnClickListener {
- saveSettings()
- importMarkdownChanges()
+ buttonManualMarkdownSync.setOnClickListener {
+ performManualMarkdownSync()
}
switchAutoSync.setOnCheckedChangeListener { _, isChecked ->
onAutoSyncToggled(isChecked)
}
- switchMarkdownExport.setOnCheckedChangeListener { _, isChecked ->
- onMarkdownExportToggled(isChecked)
+ switchMarkdownAutoSync.setOnCheckedChangeListener { _, isChecked ->
+ onMarkdownAutoSyncToggled(isChecked)
}
// Clear error when user starts typing again
@@ -548,7 +557,7 @@ class SettingsActivity : AppCompatActivity() {
}
}
- private fun onMarkdownExportToggled(enabled: Boolean) {
+ private fun onMarkdownAutoSyncToggled(enabled: Boolean) {
if (enabled) {
// Initial-Export wenn Feature aktiviert wird
lifecycleScope.launch {
@@ -559,7 +568,7 @@ class SettingsActivity : AppCompatActivity() {
if (currentNoteCount > 0) {
// Zeige Progress-Dialog
val progressDialog = ProgressDialog(this@SettingsActivity).apply {
- setTitle("Markdown-Export")
+ setTitle("Markdown Auto-Sync")
setMessage("Exportiere Notizen nach Markdown...")
setProgressStyle(ProgressDialog.STYLE_HORIZONTAL)
max = currentNoteCount
@@ -577,7 +586,7 @@ class SettingsActivity : AppCompatActivity() {
if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
progressDialog.dismiss()
showToast("β οΈ Bitte zuerst WebDAV-Server konfigurieren")
- switchMarkdownExport.isChecked = false
+ switchMarkdownAutoSync.isChecked = false
return@launch
}
@@ -597,8 +606,13 @@ class SettingsActivity : AppCompatActivity() {
progressDialog.dismiss()
- // Speichere Einstellung
- prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
+ // Speichere beide Einstellungen
+ prefs.edit()
+ .putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
+ .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
+ .apply()
+
+ updateMarkdownButtonVisibility()
// Erfolgs-Nachricht
showToast("β
$exportedCount Notizen nach Markdown exportiert")
@@ -608,76 +622,35 @@ class SettingsActivity : AppCompatActivity() {
showToast("β Export fehlgeschlagen: ${e.message}")
// Deaktiviere Toggle bei Fehler
- switchMarkdownExport.isChecked = false
+ switchMarkdownAutoSync.isChecked = false
return@launch
}
} else {
- // Keine Notizen vorhanden - speichere Einstellung direkt
- prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
- showToast("Markdown-Export aktiviert - Notizen werden als .md-Dateien exportiert")
+ // Keine Notizen vorhanden - speichere Einstellungen direkt
+ prefs.edit()
+ .putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
+ .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
+ .apply()
+
+ updateMarkdownButtonVisibility()
+ showToast("Markdown Auto-Sync aktiviert - Notizen werden als .md-Dateien exportiert und importiert")
}
} catch (e: Exception) {
- Logger.e(TAG, "Error toggling markdown export: ${e.message}")
+ Logger.e(TAG, "Error toggling markdown auto-sync: ${e.message}")
showToast("Fehler: ${e.message}")
- switchMarkdownExport.isChecked = false
+ switchMarkdownAutoSync.isChecked = false
}
}
} else {
- // Deaktivieren - nur Setting speichern
- prefs.edit().putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled).apply()
- showToast("Markdown-Export deaktiviert - nur JSON-Sync aktiv")
- }
- }
-
- private fun importMarkdownChanges() {
- // PrΓΌfen ob Server konfiguriert ist
- val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "") ?: ""
- val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
- val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
-
- if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
- showToast("Bitte zuerst WebDAV-Server konfigurieren")
- return
- }
-
- // Import-Dialog mit Warnung
- AlertDialog.Builder(this)
- .setTitle("Markdown-Import")
- .setMessage(
- "Importiert Γnderungen aus .md-Dateien vom Server.\n\n" +
- "β οΈ Bei Konflikten: Last-Write-Wins (neuere Zeitstempel gewinnen)\n\n" +
- "Fortfahren?"
- )
- .setPositiveButton("Importieren") { _, _ ->
- performMarkdownImport(serverUrl, username, password)
- }
- .setNegativeButton("Abbrechen", null)
- .show()
- }
-
- private fun performMarkdownImport(serverUrl: String, username: String, password: String) {
- showToast("Importiere Markdown-Dateien...")
-
- lifecycleScope.launch(Dispatchers.IO) {
- try {
- val syncService = WebDavSyncService(this@SettingsActivity)
- val importCount = syncService.syncMarkdownFiles(serverUrl, username, password)
-
- withContext(Dispatchers.Main) {
- if (importCount > 0) {
- showToast("$importCount Notizen aus Markdown importiert")
- // Benachrichtige MainActivity zum Neuladen
- sendBroadcast(Intent("dev.dettmer.simplenotes.NOTES_CHANGED"))
- } else {
- showToast("Keine Markdown-Γnderungen gefunden")
- }
- }
- } catch (e: Exception) {
- withContext(Dispatchers.Main) {
- showToast("Import-Fehler: ${e.message}")
- }
- }
+ // Deaktivieren - Settings speichern
+ prefs.edit()
+ .putBoolean(Constants.KEY_MARKDOWN_EXPORT, enabled)
+ .putBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, enabled)
+ .apply()
+
+ updateMarkdownButtonVisibility()
+ showToast("Markdown Auto-Sync deaktiviert - nur JSON-Sync aktiv")
}
}
@@ -940,7 +913,7 @@ class SettingsActivity : AppCompatActivity() {
// Refresh MainActivity's note list
setResult(RESULT_OK)
- broadcastNotesChanged()
+ broadcastNotesChanged(result.imported_notes)
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.error ?: "Unbekannter Fehler")
}
@@ -953,12 +926,7 @@ class SettingsActivity : AppCompatActivity() {
}
/**
- * FΓΌhrt Restore vom Server durch (Task #1.2.0-05b)
- * Nutzt neues universelles Dialog-System mit Restore-Modi
- *
- * HINWEIS: Die alte WebDavSyncService.restoreFromServer() Funktion
- * unterstΓΌtzt noch keine Restore-Modi. Aktuell wird immer REPLACE verwendet.
- * TODO: WebDavSyncService.restoreFromServer() erweitern fΓΌr v1.2.1+
+ * Server-Restore mit Restore-Modi (v1.3.0)
*/
private fun performRestoreFromServer(mode: RestoreMode) {
lifecycleScope.launch {
@@ -970,7 +938,6 @@ class SettingsActivity : AppCompatActivity() {
try {
Logger.d(TAG, "π₯ Restoring from server (mode: $mode)")
- Logger.w(TAG, "β οΈ Server-Restore nutzt aktuell immer REPLACE Mode (TODO: v1.2.1+)")
// Auto-Backup erstellen (Sicherheitsnetz)
val autoBackupUri = backupManager.createAutoBackup()
@@ -981,8 +948,7 @@ class SettingsActivity : AppCompatActivity() {
// Server-Restore durchfΓΌhren
val webdavService = WebDavSyncService(this@SettingsActivity)
val result = withContext(Dispatchers.IO) {
- // Nutzt alte Funktion (immer REPLACE)
- webdavService.restoreFromServer()
+ webdavService.restoreFromServer(mode) // β
Pass mode parameter
}
progressDialog.dismiss()
@@ -990,7 +956,7 @@ class SettingsActivity : AppCompatActivity() {
if (result.isSuccess) {
showToast("β
Wiederhergestellt: ${result.restoredCount} Notizen")
setResult(RESULT_OK)
- broadcastNotesChanged()
+ broadcastNotesChanged(result.restoredCount)
} else {
showErrorDialog("Wiederherstellung fehlgeschlagen", result.errorMessage ?: "Unbekannter Fehler")
}
@@ -1005,13 +971,70 @@ class SettingsActivity : AppCompatActivity() {
/**
* Sendet Broadcast dass Notizen geΓ€ndert wurden
*/
- private fun broadcastNotesChanged() {
+ private fun broadcastNotesChanged(count: Int = 0) {
val intent = Intent(dev.dettmer.simplenotes.sync.SyncWorker.ACTION_SYNC_COMPLETED)
intent.putExtra("success", true)
- intent.putExtra("syncedCount", 0)
+ intent.putExtra("syncedCount", count)
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
}
+ /**
+ * Updates visibility of manual sync button based on Auto-Sync toggle state
+ */
+ private fun updateMarkdownButtonVisibility() {
+ val autoSyncEnabled = switchMarkdownAutoSync.isChecked
+ val visibility = if (autoSyncEnabled) View.GONE else View.VISIBLE
+
+ textViewManualSyncInfo.visibility = visibility
+ buttonManualMarkdownSync.visibility = visibility
+ }
+
+ /**
+ * Performs manual Markdown sync (Export + Import)
+ * Called when manual sync button is clicked
+ */
+ private fun performManualMarkdownSync() {
+ lifecycleScope.launch {
+ var progressDialog: ProgressDialog? = null
+ try {
+ // Validierung
+ val serverUrl = prefs.getString(Constants.KEY_SERVER_URL, "")
+ val username = prefs.getString(Constants.KEY_USERNAME, "")
+ val password = prefs.getString(Constants.KEY_PASSWORD, "")
+
+ if (serverUrl.isNullOrBlank() || username.isNullOrBlank() || password.isNullOrBlank()) {
+ showToast("β οΈ Bitte zuerst WebDAV-Server konfigurieren")
+ return@launch
+ }
+
+ // Progress-Dialog
+ progressDialog = ProgressDialog(this@SettingsActivity).apply {
+ setTitle("Markdown-Sync")
+ setMessage("Synchronisiere Markdown-Dateien...")
+ setCancelable(false)
+ show()
+ }
+
+ // Sync ausfΓΌhren
+ val syncService = dev.dettmer.simplenotes.sync.WebDavSyncService(this@SettingsActivity)
+ val result = syncService.manualMarkdownSync()
+
+ progressDialog.dismiss()
+
+ // Erfolgs-Nachricht
+ val message = "β
Sync abgeschlossen\nπ€ ${result.exportedCount} exportiert\nπ₯ ${result.importedCount} importiert"
+ showToast(message)
+
+ Logger.d("SettingsActivity", "Manual markdown sync: exported=${result.exportedCount}, imported=${result.importedCount}")
+
+ } catch (e: Exception) {
+ progressDialog?.dismiss()
+ showToast("β Sync fehlgeschlagen: ${e.message}")
+ Logger.e("SettingsActivity", "Manual markdown sync failed", e)
+ }
+ }
+ }
+
/**
* Zeigt Error-Dialog an
*/
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt b/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt
new file mode 100644
index 0000000..d120fce
--- /dev/null
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/models/DeletionTracker.kt
@@ -0,0 +1,77 @@
+package dev.dettmer.simplenotes.models
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+data class DeletionRecord(
+ val id: String,
+ val deletedAt: Long,
+ val deviceId: String
+)
+
+data class DeletionTracker(
+ val version: Int = 1,
+ val deletedNotes: MutableList = mutableListOf()
+) {
+ fun addDeletion(noteId: String, deviceId: String) {
+ if (!deletedNotes.any { it.id == noteId }) {
+ deletedNotes.add(DeletionRecord(noteId, System.currentTimeMillis(), deviceId))
+ }
+ }
+
+ fun isDeleted(noteId: String): Boolean {
+ return deletedNotes.any { it.id == noteId }
+ }
+
+ fun getDeletionTimestamp(noteId: String): Long? {
+ return deletedNotes.find { it.id == noteId }?.deletedAt
+ }
+
+ fun removeDeletion(noteId: String) {
+ deletedNotes.removeIf { it.id == noteId }
+ }
+
+ fun toJson(): String {
+ val jsonObject = JSONObject()
+ jsonObject.put("version", version)
+
+ val notesArray = JSONArray()
+ for (record in deletedNotes) {
+ val recordObj = JSONObject()
+ recordObj.put("id", record.id)
+ recordObj.put("deletedAt", record.deletedAt)
+ recordObj.put("deviceId", record.deviceId)
+ notesArray.put(recordObj)
+ }
+ jsonObject.put("deletedNotes", notesArray)
+
+ return jsonObject.toString(2) // Pretty print with 2-space indent
+ }
+
+ companion object {
+ fun fromJson(json: String): DeletionTracker? {
+ return try {
+ val jsonObject = JSONObject(json)
+ val version = jsonObject.optInt("version", 1)
+ val deletedNotes = mutableListOf()
+
+ val notesArray = jsonObject.optJSONArray("deletedNotes")
+ if (notesArray != null) {
+ for (i in 0 until notesArray.length()) {
+ val recordObj = notesArray.getJSONObject(i)
+ val record = DeletionRecord(
+ id = recordObj.getString("id"),
+ deletedAt = recordObj.getLong("deletedAt"),
+ deviceId = recordObj.getString("deviceId")
+ )
+ deletedNotes.add(record)
+ }
+ }
+
+ DeletionTracker(version, deletedNotes)
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
index 2797a1c..a439735 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/storage/NotesStorage.kt
@@ -1,11 +1,18 @@
package dev.dettmer.simplenotes.storage
import android.content.Context
+import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
+import dev.dettmer.simplenotes.utils.DeviceIdGenerator
+import dev.dettmer.simplenotes.utils.Logger
import java.io.File
class NotesStorage(private val context: Context) {
+ companion object {
+ private const val TAG = "NotesStorage"
+ }
+
private val notesDir: File = File(context.filesDir, "notes").apply {
if (!exists()) mkdirs()
}
@@ -34,19 +41,89 @@ class NotesStorage(private val context: Context) {
fun deleteNote(id: String): Boolean {
val file = File(notesDir, "$id.json")
- return file.delete()
+ val deleted = file.delete()
+
+ if (deleted) {
+ Logger.d(TAG, "ποΈ Deleted note: $id")
+
+ // Track deletion to prevent zombie notes
+ val deviceId = DeviceIdGenerator.getDeviceId(context)
+ trackDeletion(id, deviceId)
+ }
+
+ return deleted
}
fun deleteAllNotes(): Boolean {
return try {
- notesDir.listFiles()
- ?.filter { it.extension == "json" }
- ?.forEach { it.delete() }
+ val notes = loadAllNotes()
+ val deviceId = DeviceIdGenerator.getDeviceId(context)
+
+ for (note in notes) {
+ deleteNote(note.id) // Uses trackDeletion() automatically
+ }
+
+ Logger.d(TAG, "ποΈ Deleted all notes (${notes.size} notes)")
true
} catch (e: Exception) {
+ Logger.e(TAG, "Failed to delete all notes", e)
false
}
}
+ // === Deletion Tracking ===
+
+ private fun getDeletionTrackerFile(): File {
+ return File(context.filesDir, "deleted_notes.json")
+ }
+
+ fun loadDeletionTracker(): DeletionTracker {
+ val file = getDeletionTrackerFile()
+ if (!file.exists()) {
+ return DeletionTracker()
+ }
+
+ return try {
+ val json = file.readText()
+ DeletionTracker.fromJson(json) ?: DeletionTracker()
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to load deletion tracker", e)
+ DeletionTracker()
+ }
+ }
+
+ fun saveDeletionTracker(tracker: DeletionTracker) {
+ try {
+ val file = getDeletionTrackerFile()
+ file.writeText(tracker.toJson())
+
+ if (tracker.deletedNotes.size > 1000) {
+ Logger.w(TAG, "β οΈ Deletion tracker large: ${tracker.deletedNotes.size} entries")
+ }
+
+ Logger.d(TAG, "β
Deletion tracker saved (${tracker.deletedNotes.size} entries)")
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to save deletion tracker", e)
+ }
+ }
+
+ fun trackDeletion(noteId: String, deviceId: String) {
+ val tracker = loadDeletionTracker()
+ tracker.addDeletion(noteId, deviceId)
+ saveDeletionTracker(tracker)
+ Logger.d(TAG, "π Tracked deletion: $noteId")
+ }
+
+ fun isNoteDeleted(noteId: String): Boolean {
+ val tracker = loadDeletionTracker()
+ return tracker.isDeleted(noteId)
+ }
+
+ fun clearDeletionTracker() {
+ saveDeletionTracker(DeletionTracker())
+ Logger.d(TAG, "ποΈ Deletion tracker cleared")
+ }
+
+
fun getNotesDir(): File = notesDir
}
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt
index 2c9b693..89dd99d 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt
@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
import com.thegrizzlylabs.sardineandroid.Sardine
import com.thegrizzlylabs.sardineandroid.impl.OkHttpSardine
import dev.dettmer.simplenotes.BuildConfig
+import dev.dettmer.simplenotes.models.DeletionTracker
import dev.dettmer.simplenotes.models.Note
import dev.dettmer.simplenotes.models.SyncStatus
import dev.dettmer.simplenotes.storage.NotesStorage
@@ -21,8 +22,17 @@ import java.net.NetworkInterface
import java.net.Proxy
import java.net.Socket
import java.net.URL
+import java.util.Date
import javax.net.SocketFactory
+/**
+ * Result of manual Markdown sync operation
+ */
+data class ManualMarkdownSyncResult(
+ val exportedCount: Int,
+ val importedCount: Int
+)
+
class WebDavSyncService(private val context: Context) {
companion object {
@@ -256,6 +266,131 @@ class WebDavSyncService(private val context: Context) {
}
}
+ /**
+ * Checks if server has changes using E-Tag caching
+ *
+ * v1.3.0: Also checks /notes-md/ if Markdown Auto-Import enabled
+ *
+ * Performance: ~100-200ms (E-Tag cache hit)
+ * ~300-500ms (E-Tag miss, needs PROPFIND)
+ *
+ * Strategy:
+ * 1. Store E-Tag of /notes/ collection after each sync
+ * 2. HEAD request to check if E-Tag changed
+ * 3. If changed β server has updates
+ * 4. If unchanged β skip sync
+ */
+ private suspend fun checkServerForChanges(sardine: Sardine, serverUrl: String): Boolean {
+ return try {
+ val startTime = System.currentTimeMillis()
+ val lastSyncTime = getLastSyncTimestamp()
+
+ if (lastSyncTime == 0L) {
+ Logger.d(TAG, "π Never synced - assuming server has changes")
+ return true
+ }
+
+ val notesUrl = getNotesUrl(serverUrl)
+ if (!sardine.exists(notesUrl)) {
+ Logger.d(TAG, "π /notes/ doesn't exist - no server changes")
+ return false
+ }
+
+ // ====== JSON FILES CHECK (/notes/) ======
+
+ // Optimierung 1: E-Tag Check (fastest - ~100ms)
+ val cachedETag = prefs.getString("notes_collection_etag", null)
+ var jsonHasChanges = false
+
+ if (cachedETag != null) {
+ try {
+ val resources = sardine.list(notesUrl, 0) // Depth 0 = only collection itself
+ val currentETag = resources.firstOrNull()?.contentLength?.toString() ?: ""
+
+ if (currentETag == cachedETag) {
+ val elapsed = System.currentTimeMillis() - startTime
+ Logger.d(TAG, "β‘ E-Tag match - no JSON changes (${elapsed}ms)")
+ // Don't return yet - check Markdown too!
+ } else {
+ Logger.d(TAG, "π E-Tag changed - JSON files have updates")
+ return true // Early return if JSON changed
+ }
+ } catch (e: Exception) {
+ Logger.w(TAG, "E-Tag check failed: ${e.message}, falling back to timestamp check")
+ jsonHasChanges = true
+ }
+ } else {
+ jsonHasChanges = true
+ }
+
+ // Optimierung 2: Smart Timestamp Check for JSON (medium - ~300ms)
+ if (jsonHasChanges || cachedETag == null) {
+ val resources = sardine.list(notesUrl, 1) // Depth 1 = collection + children
+
+ val jsonHasNewer = resources.any { resource ->
+ !resource.isDirectory &&
+ resource.name.endsWith(".json") &&
+ resource.modified?.time?.let { it > lastSyncTime } ?: false
+ }
+
+ if (jsonHasNewer) {
+ val elapsed = System.currentTimeMillis() - startTime
+ Logger.d(TAG, "π JSON check: hasNewer=true (${resources.size} resources, ${elapsed}ms)")
+ return true
+ }
+ }
+
+ // ====== MARKDOWN FILES CHECK (/notes-md/) ======
+ // IMPORTANT: E-Tag for collections does NOT work for content changes!
+ // β Use hybrid approach: If-Modified-Since + Timestamp fallback
+
+ val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
+ if (!markdownAutoImportEnabled) {
+ Logger.d(TAG, "βοΈ Markdown check skipped (auto-import disabled)")
+ } else {
+ val mdUrl = getMarkdownUrl(serverUrl)
+
+ if (!sardine.exists(mdUrl)) {
+ Logger.d(TAG, "π /notes-md/ doesn't exist - no markdown changes")
+ } else {
+ Logger.d(TAG, "π Checking Markdown files (hybrid approach)...")
+
+ // Strategy: Timestamp-based check (reliable, always works)
+ // Note: If-Modified-Since support varies by WebDAV server
+ // We use timestamp comparison which is universal
+ val mdResources = sardine.list(mdUrl, 1)
+ val mdHasNewer = mdResources.any { resource ->
+ !resource.isDirectory &&
+ resource.name.endsWith(".md") &&
+ resource.modified?.time?.let {
+ val hasNewer = it > lastSyncTime
+ if (hasNewer) {
+ Logger.d(TAG, " π ${resource.name}: modified=${resource.modified}, lastSync=$lastSyncTime")
+ }
+ hasNewer
+ } ?: false
+ }
+
+ if (mdHasNewer) {
+ val mdCount = mdResources.count { !it.isDirectory && it.name.endsWith(".md") }
+ Logger.d(TAG, "π Markdown files have changes ($mdCount files checked)")
+ return true
+ } else {
+ Logger.d(TAG, "β
Markdown files up-to-date (timestamp check)")
+ }
+ }
+ }
+
+ val elapsed = System.currentTimeMillis() - startTime
+ Logger.d(TAG, "β
No changes detected (JSON + Markdown checked, ${elapsed}ms)")
+ return false
+
+ } catch (e: Exception) {
+ Logger.w(TAG, "Server check failed: ${e.message} - assuming changes exist")
+ true // Safe default: check anyway
+ }
+ }
+
/**
* PrΓΌft ob lokale Γnderungen seit letztem Sync vorhanden sind (v1.1.2)
* Performance-Optimierung: Vermeidet unnΓΆtige Sync-Operationen
@@ -266,31 +401,50 @@ class WebDavSyncService(private val context: Context) {
return@withContext try {
val lastSyncTime = getLastSyncTimestamp()
- // Wenn noch nie gesynct, dann haben wir Γnderungen
+ // Check 1: Never synced
if (lastSyncTime == 0L) {
- Logger.d(TAG, "π Never synced - assuming changes exist")
+ Logger.d(TAG, "π Never synced - has changes: true")
return@withContext true
}
- // PrΓΌfe ob Notizen existieren die neuer sind als letzter Sync
- val storage = dev.dettmer.simplenotes.storage.NotesStorage(context)
+ // Check 2: Local changes
+ val storage = NotesStorage(context)
val allNotes = storage.loadAllNotes()
-
- val hasChanges = allNotes.any { note ->
+ val hasLocalChanges = allNotes.any { note ->
note.updatedAt > lastSyncTime
}
- Logger.d(TAG, "π Unsynced changes check: $hasChanges (${allNotes.size} notes total)")
- if (hasChanges) {
- val unsyncedCount = allNotes.count { note -> note.updatedAt > lastSyncTime }
- Logger.d(TAG, " β $unsyncedCount notes modified since last sync")
+ if (hasLocalChanges) {
+ val unsyncedCount = allNotes.count { it.updatedAt > lastSyncTime }
+ Logger.d(TAG, "π Local changes: $unsyncedCount notes modified")
+ return@withContext true
}
- hasChanges
+ // Check 3: Server changes (respects user preference)
+ val alwaysCheckServer = prefs.getBoolean(Constants.KEY_ALWAYS_CHECK_SERVER, true)
+
+ if (!alwaysCheckServer) {
+ Logger.d(TAG, "βοΈ Server check disabled by user - has changes: false")
+ return@withContext false
+ }
+
+ // Perform intelligent server check
+ val sardine = getSardine()
+ val serverUrl = getServerUrl()
+
+ if (sardine == null || serverUrl == null) {
+ Logger.w(TAG, "β οΈ Cannot check server - no credentials")
+ return@withContext false
+ }
+
+ val hasServerChanges = checkServerForChanges(sardine, serverUrl)
+ Logger.d(TAG, "π Final check: local=$hasLocalChanges, server=$hasServerChanges")
+
+ hasServerChanges
+
} catch (e: Exception) {
- Logger.e(TAG, "Failed to check for unsynced changes - assuming changes exist", e)
- // Bei Fehler lieber sync durchfΓΌhren (safe default)
- true
+ Logger.e(TAG, "Failed to check for unsynced changes", e)
+ true // Safe default
}
}
@@ -452,7 +606,11 @@ class WebDavSyncService(private val context: Context) {
// Download remote notes
try {
Logger.d(TAG, "β¬οΈ Downloading remote notes...")
- val downloadResult = downloadRemoteNotes(sardine, serverUrl)
+ val downloadResult = downloadRemoteNotes(
+ sardine,
+ serverUrl,
+ includeRootFallback = true // β
v1.3.0: Enable for v1.2.0 compatibility
+ )
syncedCount += downloadResult.downloadedCount
conflictCount += downloadResult.conflictCount
Logger.d(TAG, "β
Downloaded: ${downloadResult.downloadedCount} notes, Conflicts: ${downloadResult.conflictCount}")
@@ -462,7 +620,24 @@ class WebDavSyncService(private val context: Context) {
throw e
}
- Logger.d(TAG, "π Step 6: Saving sync timestamp")
+ Logger.d(TAG, "π Step 6: Auto-import Markdown (if enabled)")
+ // Auto-import Markdown files from server
+ var markdownImportedCount = 0
+ try {
+ val markdownAutoImportEnabled = prefs.getBoolean(Constants.KEY_MARKDOWN_AUTO_IMPORT, false)
+ if (markdownAutoImportEnabled) {
+ Logger.d(TAG, "π₯ Auto-importing Markdown files...")
+ markdownImportedCount = importMarkdownFiles(sardine, serverUrl)
+ Logger.d(TAG, "β
Auto-imported: $markdownImportedCount Markdown files")
+ } else {
+ Logger.d(TAG, "βοΈ Markdown auto-import disabled")
+ }
+ } catch (e: Exception) {
+ Logger.e(TAG, "β οΈ Markdown auto-import failed (non-fatal)", e)
+ // Non-fatal, continue
+ }
+
+ Logger.d(TAG, "π Step 7: Saving sync timestamp")
// Update last sync timestamp
try {
saveLastSyncTimestamp()
@@ -473,12 +648,24 @@ class WebDavSyncService(private val context: Context) {
// Non-fatal, continue
}
- Logger.d(TAG, "π Sync completed successfully - Total synced: $syncedCount")
+ // β
v1.3.0: Hybrid counting to prevent double-counting
+ // - If JSON sync occurred, it represents unique notes (JSON is source of truth)
+ // - If ONLY Markdown edits (no JSON), use Markdown count
+ val effectiveSyncedCount = if (syncedCount > 0) {
+ syncedCount // JSON-based count is authoritative
+ } else {
+ markdownImportedCount // Fallback: Markdown-only edits
+ }
+
+ Logger.d(TAG, "π Sync completed successfully: $effectiveSyncedCount notes")
+ if (markdownImportedCount > 0 && syncedCount > 0) {
+ Logger.d(TAG, "π Including $markdownImportedCount Markdown file updates")
+ }
Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
SyncResult(
isSuccess = true,
- syncedCount = syncedCount,
+ syncedCount = effectiveSyncedCount,
conflictCount = conflictCount
)
@@ -678,12 +865,22 @@ class WebDavSyncService(private val context: Context) {
private fun downloadRemoteNotes(
sardine: Sardine,
serverUrl: String,
- includeRootFallback: Boolean = false // π v1.2.2: Only for restore from server
+ includeRootFallback: Boolean = false, // π v1.2.2: Only for restore from server
+ forceOverwrite: Boolean = false, // π v1.3.0: For OVERWRITE_DUPLICATES mode
+ deletionTracker: DeletionTracker = storage.loadDeletionTracker() // π v1.3.0: Allow passing fresh tracker
): DownloadResult {
var downloadedCount = 0
var conflictCount = 0
+ var skippedDeleted = 0 // NEW: Track skipped deleted notes
val processedIds = mutableSetOf() // π v1.2.2: Track already loaded notes
+ Logger.d(TAG, "π₯ downloadRemoteNotes() called:")
+ Logger.d(TAG, " includeRootFallback: $includeRootFallback")
+ Logger.d(TAG, " forceOverwrite: $forceOverwrite")
+
+ // Use provided deletion tracker (allows fresh tracker from restore)
+ var trackerModified = false
+
try {
// π PHASE 1: Download from /notes/ (new structure v1.2.1+)
val notesUrl = getNotesUrl(serverUrl)
@@ -703,6 +900,24 @@ class WebDavSyncService(private val context: Context) {
val jsonContent = sardine.get(noteUrl).bufferedReader().use { it.readText() }
val remoteNote = Note.fromJson(jsonContent) ?: continue
+ // NEW: Check if note was deleted locally
+ if (deletionTracker.isDeleted(remoteNote.id)) {
+ val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id)
+
+ // Smart check: Was note re-created on server after deletion?
+ if (deletedAt != null && remoteNote.updatedAt > deletedAt) {
+ Logger.d(TAG, " π Note re-created on server after deletion: ${remoteNote.id}")
+ deletionTracker.removeDeletion(remoteNote.id)
+ trackerModified = true
+ // Continue with download below
+ } else {
+ Logger.d(TAG, " βοΈ Skipping deleted note: ${remoteNote.id}")
+ skippedDeleted++
+ processedIds.add(remoteNote.id)
+ continue
+ }
+ }
+
processedIds.add(remoteNote.id) // π Mark as processed
val localNote = storage.loadNote(remoteNote.id)
@@ -714,6 +929,12 @@ class WebDavSyncService(private val context: Context) {
downloadedCount++
Logger.d(TAG, " β
Downloaded from /notes/: ${remoteNote.id}")
}
+ forceOverwrite -> {
+ // OVERWRITE mode: Always replace regardless of timestamps
+ storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
+ downloadedCount++
+ Logger.d(TAG, " β»οΈ Overwritten from /notes/: ${remoteNote.id}")
+ }
localNote.updatedAt < remoteNote.updatedAt -> {
// Remote is newer
if (localNote.syncStatus == SyncStatus.PENDING) {
@@ -729,7 +950,7 @@ class WebDavSyncService(private val context: Context) {
}
}
}
- Logger.d(TAG, " π Phase 1 complete: $downloadedCount notes from /notes/")
+ Logger.d(TAG, " π Phase 1: $downloadedCount downloaded, $skippedDeleted skipped (deleted)")
} else {
Logger.w(TAG, " β οΈ /notes/ does not exist, skipping Phase 1")
}
@@ -738,7 +959,7 @@ class WebDavSyncService(private val context: Context) {
// β οΈ ONLY for restore from server! Normal sync should NOT scan Root
if (includeRootFallback) {
val rootUrl = serverUrl.trimEnd('/')
- Logger.d(TAG, "π Phase 2: Checking ROOT at: $rootUrl (Restore mode)")
+ Logger.d(TAG, "π Phase 2: Checking ROOT at: $rootUrl (v1.2.0 compat)")
try {
val rootResources = sardine.list(rootUrl)
@@ -770,6 +991,19 @@ class WebDavSyncService(private val context: Context) {
continue
}
+ // NEW: Check deletion tracker
+ if (deletionTracker.isDeleted(remoteNote.id)) {
+ val deletedAt = deletionTracker.getDeletionTimestamp(remoteNote.id)
+ if (deletedAt != null && remoteNote.updatedAt > deletedAt) {
+ deletionTracker.removeDeletion(remoteNote.id)
+ trackerModified = true
+ } else {
+ Logger.d(TAG, " βοΈ Skipping deleted note: ${remoteNote.id}")
+ skippedDeleted++
+ continue
+ }
+ }
+
processedIds.add(remoteNote.id)
val localNote = storage.loadNote(remoteNote.id)
@@ -779,6 +1013,12 @@ class WebDavSyncService(private val context: Context) {
downloadedCount++
Logger.d(TAG, " β
Downloaded from ROOT: ${remoteNote.id}")
}
+ forceOverwrite -> {
+ // OVERWRITE mode: Always replace regardless of timestamps
+ storage.saveNote(remoteNote.copy(syncStatus = SyncStatus.SYNCED))
+ downloadedCount++
+ Logger.d(TAG, " β»οΈ Overwritten from ROOT: ${remoteNote.id}")
+ }
localNote.updatedAt < remoteNote.updatedAt -> {
if (localNote.syncStatus == SyncStatus.PENDING) {
storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
@@ -812,12 +1052,45 @@ class WebDavSyncService(private val context: Context) {
Logger.e(TAG, "β downloadRemoteNotes failed", e)
}
- Logger.d(TAG, "π Total download result: $downloadedCount notes, $conflictCount conflicts")
+ // NEW: Save deletion tracker if modified
+ if (trackerModified) {
+ storage.saveDeletionTracker(deletionTracker)
+ Logger.d(TAG, "πΎ Deletion tracker updated")
+ }
+
+ Logger.d(TAG, "π Total: $downloadedCount downloaded, $conflictCount conflicts, $skippedDeleted deleted")
return DownloadResult(downloadedCount, conflictCount)
}
private fun saveLastSyncTimestamp() {
val now = System.currentTimeMillis()
+
+ // v1.3.0: Save E-Tag only for JSON (Markdown uses timestamp check)
+ try {
+ val sardine = getSardine()
+ val serverUrl = getServerUrl()
+
+ if (sardine != null && serverUrl != null) {
+ val notesUrl = getNotesUrl(serverUrl)
+
+ // JSON E-Tag only
+ val notesResources = sardine.list(notesUrl, 0)
+ val notesETag = notesResources.firstOrNull()?.contentLength?.toString()
+
+ prefs.edit()
+ .putLong(Constants.KEY_LAST_SYNC, now)
+ .putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now)
+ .putString("notes_collection_etag", notesETag)
+ .apply()
+
+ Logger.d(TAG, "πΎ Saved sync timestamp + JSON E-Tag")
+ return
+ }
+ } catch (e: Exception) {
+ Logger.w(TAG, "Failed to save E-Tag: ${e.message}")
+ }
+
+ // Fallback: Save timestamp only
prefs.edit()
.putLong(Constants.KEY_LAST_SYNC, now)
.putLong(Constants.KEY_LAST_SUCCESSFUL_SYNC, now) // π₯ v1.1.2: Track successful sync
@@ -833,10 +1106,13 @@ class WebDavSyncService(private val context: Context) {
}
/**
- * Restore all notes from server - overwrites local storage
+ * Restore all notes from server with different modes (v1.3.0)
+ * @param mode RestoreMode (REPLACE, MERGE, or OVERWRITE_DUPLICATES)
* @return RestoreResult with count of restored notes
*/
- suspend fun restoreFromServer(): RestoreResult = withContext(Dispatchers.IO) {
+ suspend fun restoreFromServer(
+ mode: dev.dettmer.simplenotes.backup.RestoreMode = dev.dettmer.simplenotes.backup.RestoreMode.REPLACE
+ ): RestoreResult = withContext(Dispatchers.IO) {
return@withContext try {
val sardine = getSardine() ?: return@withContext RestoreResult(
isSuccess = false,
@@ -850,20 +1126,57 @@ class WebDavSyncService(private val context: Context) {
restoredCount = 0
)
- Logger.d(TAG, "π Starting restore from server...")
+ Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
+ Logger.d(TAG, "π restoreFromServer() ENTRY")
+ Logger.d(TAG, "Mode: $mode")
+ Logger.d(TAG, "Thread: ${Thread.currentThread().name}")
- // Clear local storage FIRST
- Logger.d(TAG, "ποΈ Clearing local storage...")
- storage.deleteAllNotes()
+ // β
v1.3.0 FIX: WICHTIG - Deletion Tracker bei ALLEN Modi clearen!
+ // Restore bedeutet: "Server ist die Quelle der Wahrheit"
+ // β Lokale Deletion-History ist irrelevant
+ Logger.d(TAG, "ποΈ Clearing deletion tracker (restore mode)")
+ storage.clearDeletionTracker()
- // π v1.2.2: Use downloadRemoteNotes() with Root fallback enabled
+ // Determine forceOverwrite flag
+ val forceOverwrite = (mode == dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES)
+ Logger.d(TAG, "forceOverwrite: $forceOverwrite")
+
+ // Mode-specific preparation
+ when (mode) {
+ dev.dettmer.simplenotes.backup.RestoreMode.REPLACE -> {
+ // Clear everything
+ Logger.d(TAG, "ποΈ REPLACE mode: Clearing local storage...")
+ storage.deleteAllNotes()
+ // Tracker already cleared above
+ }
+ dev.dettmer.simplenotes.backup.RestoreMode.MERGE -> {
+ // Keep local notes, just add from server
+ Logger.d(TAG, "π MERGE mode: Keeping local notes...")
+ // β
Tracker cleared β Server notes will NOT be skipped
+ }
+ dev.dettmer.simplenotes.backup.RestoreMode.OVERWRITE_DUPLICATES -> {
+ // Will overwrite in downloadRemoteNotes if needed
+ Logger.d(TAG, "β»οΈ OVERWRITE mode: Will force update duplicates...")
+ // β
Tracker cleared β Server notes will NOT be skipped
+ }
+ }
+
+ // π v1.2.2: Use downloadRemoteNotes() with Root fallback + forceOverwrite
+ // π v1.3.0: Pass FRESH empty tracker to avoid loading stale cached data
+ Logger.d(TAG, "π‘ Calling downloadRemoteNotes() - includeRootFallback: true, forceOverwrite: $forceOverwrite")
+ val emptyTracker = DeletionTracker() // Fresh empty tracker after clear
val result = downloadRemoteNotes(
sardine = sardine,
serverUrl = serverUrl,
- includeRootFallback = true // β
Enable backward compatibility for restore
+ includeRootFallback = true, // β
Enable backward compatibility for restore
+ forceOverwrite = forceOverwrite, // β
v1.3.0: Force overwrite for OVERWRITE_DUPLICATES mode
+ deletionTracker = emptyTracker // β
v1.3.0: Use fresh tracker to prevent skipping
)
- if (result.downloadedCount == 0) {
+ Logger.d(TAG, "π Download result: downloaded=${result.downloadedCount}, conflicts=${result.conflictCount}")
+
+ if (result.downloadedCount == 0 && mode == dev.dettmer.simplenotes.backup.RestoreMode.REPLACE) {
+ Logger.w(TAG, "β οΈ No notes found on server!")
return@withContext RestoreResult(
isSuccess = false,
errorMessage = "Keine Notizen auf Server gefunden",
@@ -871,9 +1184,31 @@ class WebDavSyncService(private val context: Context) {
)
}
+ // NOTE: Code that removes restored notes from deletion tracker is now REDUNDANT
+ // because we cleared the tracker at the start. But keep it for safety:
+ if (result.downloadedCount > 0) {
+ val deletionTracker = storage.loadDeletionTracker()
+ val allNotes = storage.loadAllNotes()
+ var trackingModified = false
+
+ allNotes.forEach { note ->
+ if (deletionTracker.isDeleted(note.id)) {
+ deletionTracker.removeDeletion(note.id)
+ trackingModified = true
+ Logger.d(TAG, "π Removed from deletion tracker: ${note.id} (restored from server)")
+ }
+ }
+
+ if (trackingModified) {
+ storage.saveDeletionTracker(deletionTracker)
+ Logger.d(TAG, "πΎ Updated deletion tracker after restore")
+ }
+ }
+
saveLastSyncTimestamp()
Logger.d(TAG, "β
Restore completed: ${result.downloadedCount} notes")
+ Logger.d(TAG, "βββββββββββββββββββββββββββββββββββββββ")
RestoreResult(
isSuccess = true,
@@ -882,7 +1217,12 @@ class WebDavSyncService(private val context: Context) {
)
} catch (e: Exception) {
- Logger.e(TAG, "β Restore failed", e)
+ Logger.e(TAG, "βββββββββββββββββββββββββββββββββββββββ")
+ Logger.e(TAG, "π₯ restoreFromServer() EXCEPTION")
+ Logger.e(TAG, "Exception type: ${e.javaClass.name}")
+ Logger.e(TAG, "Exception message: ${e.message}")
+ e.printStackTrace()
+ Logger.e(TAG, "βββββββββββββββββββββββββββββββββββββββ")
RestoreResult(
isSuccess = false,
errorMessage = e.message ?: "Unbekannter Fehler",
@@ -970,6 +1310,295 @@ class WebDavSyncService(private val context: Context) {
0
}
}
+
+ /**
+ * Auto-import Markdown files during regular sync (v1.3.0)
+ * Called automatically if KEY_MARKDOWN_AUTO_IMPORT is enabled
+ */
+ private fun importMarkdownFiles(sardine: Sardine, serverUrl: String): Int {
+ return try {
+ Logger.d(TAG, "π Importing Markdown files...")
+
+ val mdUrl = getMarkdownUrl(serverUrl)
+
+ // Check if notes-md/ exists
+ if (!sardine.exists(mdUrl)) {
+ Logger.d(TAG, " β οΈ notes-md/ directory not found - skipping")
+ return 0
+ }
+
+ val mdResources = sardine.list(mdUrl).filter { !it.isDirectory && it.name.endsWith(".md") }
+ var importedCount = 0
+
+ Logger.d(TAG, " π Found ${mdResources.size} markdown files")
+
+ for (resource in mdResources) {
+ try {
+ Logger.d(TAG, " π Processing: ${resource.name}, modified=${resource.modified}")
+
+ // Build full URL
+ val mdFileUrl = mdUrl.trimEnd('/') + "/" + resource.name
+
+ // Download MD content
+ val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
+ Logger.d(TAG, " Downloaded ${mdContent.length} chars")
+
+ // Parse to Note
+ val mdNote = Note.fromMarkdown(mdContent)
+ if (mdNote == null) {
+ Logger.w(TAG, " β οΈ Failed to parse ${resource.name} - fromMarkdown returned null")
+ continue
+ }
+ Logger.d(TAG, " Parsed: id=${mdNote.id}, title=${mdNote.title}, updatedAt=${Date(mdNote.updatedAt)}")
+
+ 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}"}")
+
+ // Use server file modification time for reliable change detection
+ val serverModifiedTime = resource.modified?.time ?: 0L
+ Logger.d(TAG, " Comparison: serverModified=$serverModifiedTime, localUpdated=${localNote?.updatedAt ?: 0L}")
+
+ // Conflict resolution: Last-Write-Wins
+ when {
+ localNote == null -> {
+ // New note from desktop
+ storage.saveNote(mdNote.copy(syncStatus = SyncStatus.SYNCED))
+ importedCount++
+ Logger.d(TAG, " β
Imported new from Markdown: ${mdNote.title}")
+ }
+ serverModifiedTime > localNote.updatedAt -> {
+ // Server file is newer (based on modification time)
+ Logger.d(TAG, " Decision: Server is newer!")
+ if (localNote.syncStatus == SyncStatus.PENDING) {
+ // Conflict: local has pending changes
+ storage.saveNote(localNote.copy(syncStatus = SyncStatus.CONFLICT))
+ Logger.w(TAG, " β οΈ Conflict: Markdown vs local pending: ${mdNote.id}")
+ } else {
+ // Content comparison to preserve timestamps on export-only updates
+ val contentChanged = mdNote.content != localNote.content ||
+ mdNote.title != localNote.title
+
+ // Detect if YAML timestamp wasn't updated despite content change
+ val yamlInconsistent = contentChanged && mdNote.updatedAt <= localNote.updatedAt
+
+ // Log inconsistencies for debugging
+ if (yamlInconsistent) {
+ Logger.w(TAG, " β οΈ Inconsistency: ${mdNote.title}")
+ Logger.w(TAG, " Content changed but YAML timestamp not updated")
+ Logger.w(TAG, " YAML: ${mdNote.updatedAt}, Local: ${localNote.updatedAt}")
+ Logger.w(TAG, " Using current time as fallback")
+ }
+
+ // Determine final timestamp with auto-correction
+ val finalUpdatedAt: Long = when {
+ // No content change β preserve local timestamp (export-only)
+ !contentChanged -> localNote.updatedAt
+
+ // Content changed + YAML timestamp properly updated
+ !yamlInconsistent -> mdNote.updatedAt
+
+ // Content changed + YAML timestamp NOT updated β use current time
+ else -> System.currentTimeMillis()
+ }
+
+ storage.saveNote(mdNote.copy(
+ updatedAt = finalUpdatedAt,
+ syncStatus = SyncStatus.SYNCED
+ ))
+ importedCount++
+
+ // Detailed logging
+ when {
+ !contentChanged -> Logger.d(TAG, " β
Re-synced (export-only, timestamp preserved): ${mdNote.title}")
+ yamlInconsistent -> Logger.d(TAG, " β
Updated (content changed, timestamp corrected): ${mdNote.title}")
+ else -> Logger.d(TAG, " β
Updated (content changed, YAML timestamp valid): ${mdNote.title}")
+ }
+ }
+ }
+ else -> {
+ Logger.d(TAG, " βοΈ Skipped ${mdNote.title}: local is newer (server=$serverModifiedTime, local=${localNote.updatedAt})")
+ }
+ }
+ } catch (e: Exception) {
+ Logger.e(TAG, " β οΈ Failed to import ${resource.name}", e)
+ // Continue with other files
+ }
+ }
+
+ Logger.d(TAG, " π Markdown import complete: $importedCount notes")
+ importedCount
+
+ } catch (e: Exception) {
+ Logger.e(TAG, "β Markdown import failed", e)
+ 0
+ }
+ }
+
+ /**
+ * Finds a Markdown file by scanning YAML frontmatter for note ID
+ * Used when local note is deleted and title is unavailable
+ *
+ * @param sardine Sardine client
+ * @param mdUrl Base URL of notes-md/ directory
+ * @param noteId The note ID to search for
+ * @return Filename if found, null otherwise
+ */
+ private suspend fun findMarkdownFileByNoteId(
+ sardine: Sardine,
+ mdUrl: String,
+ noteId: String
+ ): String? = withContext(Dispatchers.IO) {
+ return@withContext try {
+ Logger.d(TAG, "π Scanning MD files for ID: $noteId")
+ val resources = sardine.list(mdUrl)
+
+ for (resource in resources) {
+ if (resource.isDirectory || !resource.name.endsWith(".md")) {
+ continue
+ }
+
+ try {
+ // Download MD content
+ val mdFileUrl = mdUrl.trimEnd('/') + "/" + resource.name
+ val mdContent = sardine.get(mdFileUrl).bufferedReader().use { it.readText() }
+
+ // Parse YAML frontmatter for ID
+ val idMatch = Regex("""^---\s*\n.*?id:\s*([a-f0-9-]+)""", RegexOption.DOT_MATCHES_ALL)
+ .find(mdContent)
+
+ if (idMatch?.groupValues?.get(1) == noteId) {
+ Logger.d(TAG, " β
Found MD file: ${resource.name}")
+ return@withContext resource.name
+ }
+ } catch (e: Exception) {
+ Logger.w(TAG, " β οΈ Failed to parse ${resource.name}: ${e.message}")
+ // Continue with next file
+ }
+ }
+
+ Logger.w(TAG, " β No MD file found for ID: $noteId")
+ null
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to scan MD files: ${e.message}")
+ null
+ }
+ }
+
+ /**
+ * Deletes a note from the server (JSON + Markdown)
+ * Does NOT delete from local storage!
+ *
+ * @param noteId The ID of the note to delete
+ * @return true if at least one file was deleted, false otherwise
+ */
+ suspend fun deleteNoteFromServer(noteId: String): Boolean = withContext(Dispatchers.IO) {
+ return@withContext try {
+ val sardine = getSardine() ?: return@withContext false
+ val serverUrl = getServerUrl() ?: return@withContext false
+
+ var deletedJson = false
+ var deletedMd = false
+
+ // Delete JSON
+ val jsonUrl = getNotesUrl(serverUrl) + "$noteId.json"
+ if (sardine.exists(jsonUrl)) {
+ sardine.delete(jsonUrl)
+ deletedJson = true
+ Logger.d(TAG, "ποΈ Deleted from server: $noteId.json")
+ }
+
+ // Delete Markdown (v1.3.0: YAML-scan based approach)
+ val mdBaseUrl = getMarkdownUrl(serverUrl)
+ val note = storage.loadNote(noteId)
+ var mdFilenameToDelete: String? = null
+
+ if (note != null) {
+ // Fast path: Note still exists locally, use title
+ mdFilenameToDelete = sanitizeFilename(note.title) + ".md"
+ Logger.d(TAG, "π MD deletion: Using title from local note: $mdFilenameToDelete")
+ } else {
+ // Fallback: Note deleted locally, scan YAML frontmatter
+ Logger.d(TAG, "β οΈ MD deletion: Note not found locally, scanning YAML...")
+ mdFilenameToDelete = findMarkdownFileByNoteId(sardine, mdBaseUrl, noteId)
+ }
+
+ if (mdFilenameToDelete != null) {
+ val mdUrl = mdBaseUrl.trimEnd('/') + "/" + mdFilenameToDelete
+ if (sardine.exists(mdUrl)) {
+ sardine.delete(mdUrl)
+ deletedMd = true
+ Logger.d(TAG, "ποΈ Deleted from server: $mdFilenameToDelete")
+ } else {
+ Logger.w(TAG, "β οΈ MD file not found: $mdFilenameToDelete")
+ }
+ } else {
+ Logger.w(TAG, "β οΈ Could not determine MD filename for note $noteId")
+ }
+
+ if (!deletedJson && !deletedMd) {
+ Logger.w(TAG, "β οΈ Note $noteId not found on server")
+ return@withContext false
+ }
+
+ // Remove from deletion tracker (was explicitly deleted from server)
+ val deletionTracker = storage.loadDeletionTracker()
+ if (deletionTracker.isDeleted(noteId)) {
+ deletionTracker.removeDeletion(noteId)
+ storage.saveDeletionTracker(deletionTracker)
+ Logger.d(TAG, "π Removed from deletion tracker: $noteId")
+ }
+
+ true
+ } catch (e: Exception) {
+ Logger.e(TAG, "Failed to delete note from server: $noteId", e)
+ false
+ }
+ }
+
+ /**
+ * Manual Markdown sync: Export all notes + Import all MD files
+ * Used by manual sync button in settings (when Auto-Sync is OFF)
+ *
+ * @return ManualMarkdownSyncResult with export and import counts
+ */
+ suspend fun manualMarkdownSync(): ManualMarkdownSyncResult = withContext(Dispatchers.IO) {
+ return@withContext try {
+ val sardine = getSardine() ?: throw Exception("Sardine client konnte nicht erstellt werden")
+ val serverUrl = getServerUrl() ?: throw Exception("Server-URL nicht konfiguriert")
+
+ val username = prefs.getString(Constants.KEY_USERNAME, "") ?: ""
+ val password = prefs.getString(Constants.KEY_PASSWORD, "") ?: ""
+
+ if (serverUrl.isBlank() || username.isBlank() || password.isBlank()) {
+ throw Exception("WebDAV-Server nicht vollstΓ€ndig konfiguriert")
+ }
+
+ Logger.d(TAG, "π Manual Markdown Sync START")
+
+ // Step 1: Export alle lokalen Notizen nach Markdown
+ val exportedCount = exportAllNotesToMarkdown(
+ serverUrl = serverUrl,
+ username = username,
+ password = password
+ )
+ Logger.d(TAG, " β
Export: $exportedCount notes")
+
+ // Step 2: Import alle Server-Markdown-Dateien
+ val importedCount = importMarkdownFiles(sardine, serverUrl)
+ Logger.d(TAG, " β
Import: $importedCount notes")
+
+ Logger.d(TAG, "π Manual Markdown Sync COMPLETE: exported=$exportedCount, imported=$importedCount")
+
+ ManualMarkdownSyncResult(
+ exportedCount = exportedCount,
+ importedCount = importedCount
+ )
+
+ } catch (e: Exception) {
+ Logger.e(TAG, "β Manual Markdown Sync FAILED", e)
+ throw e
+ }
+ }
}
data class RestoreResult(
diff --git a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
index 9b07ede..27a5a4c 100644
--- a/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
+++ b/android/app/src/main/java/dev/dettmer/simplenotes/utils/Constants.kt
@@ -23,6 +23,10 @@ object Constants {
const val KEY_MARKDOWN_EXPORT = "markdown_export_enabled"
const val KEY_MARKDOWN_AUTO_IMPORT = "markdown_auto_import_enabled"
+ // π₯ v1.3.0: Performance & Multi-Device Sync
+ const val KEY_ALWAYS_CHECK_SERVER = "always_check_server"
+ const val KEY_ALWAYS_DELETE_FROM_SERVER = "always_delete_from_server"
+
// WorkManager
const val SYNC_WORK_TAG = "notes_sync"
const val SYNC_DELAY_SECONDS = 5L
diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml
index 12c7ef0..0876d59 100644
--- a/android/app/src/main/res/layout/activity_settings.xml
+++ b/android/app/src/main/res/layout/activity_settings.xml
@@ -429,11 +429,11 @@
-
+
@@ -441,34 +441,47 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
- android:text="π Markdown Export (Desktop-Zugriff)"
+ android:text="π Markdown Auto-Sync"
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
+ android:checked="false" />
-
-
-
-
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/dialog_server_deletion.xml b/android/app/src/main/res/layout/dialog_server_deletion.xml
new file mode 100644
index 0000000..689ee24
--- /dev/null
+++ b/android/app/src/main/res/layout/dialog_server_deletion.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
diff --git a/fastlane/metadata/android/de-DE/changelogs/8.txt b/fastlane/metadata/android/de-DE/changelogs/8.txt
new file mode 100644
index 0000000..3a9b6ed
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/8.txt
@@ -0,0 +1,12 @@
+v1.3.0 - Multi-Device Sync
+
+NEUE FEATURES:
+β’ Multi-Device Sync mit Deletion Tracking (keine Zombie-Notizen)
+β’ Wisch-Geste zum Server-LΓΆschen (verhindert Duplikate auf anderen GerΓ€ten)
+β’ E-Tag Performance-Optimierung (~150ms statt 3s)
+β’ Markdown Auto-Sync Toggle (Export + Import vereint)
+β’ Manueller Markdown-Sync Button
+β’ Server-Wiederherstellung Modi (Merge/Replace/Overwrite)
+
+Dank an Thomas aus Bielefeld!
+Kompatibel: v1.2.0-v1.3.0
diff --git a/fastlane/metadata/android/en-US/changelogs/8.txt b/fastlane/metadata/android/en-US/changelogs/8.txt
new file mode 100644
index 0000000..02d6f06
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/8.txt
@@ -0,0 +1,12 @@
+v1.3.0 - Multi-Device Sync
+
+NEW FEATURES:
+β’ Multi-Device Sync with deletion tracking (no zombie notes)
+β’ Swipe gesture for server deletion (prevents duplicates on other devices)
+β’ E-Tag performance optimization (~150ms vs 3s)
+β’ Markdown Auto-Sync toggle (unified Export + Import)
+β’ Manual Markdown sync button
+β’ Server restore modes (Merge/Replace/Overwrite)
+
+Thanks to Thomas from Bielefeld!
+Compatible: v1.2.0-v1.3.0