Merge pull request #4 from inventory69/release/v1.3.0

Release v1.3.0: Multi-Device Sync
This commit is contained in:
Inventory69
2026-01-07 12:28:31 +01:00
committed by GitHub
14 changed files with 1177 additions and 170 deletions

View File

@@ -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)**

View File

@@ -6,6 +6,8 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](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**

View File

@@ -6,6 +6,8 @@
[![Material Design 3](https://img.shields.io/badge/Material-Design%203-green.svg)](https://m3.material.io/)
[![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](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)

View File

@@ -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"

View File

@@ -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<Note>) {
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<CheckBox>(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)

View File

@@ -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
*/

View File

@@ -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<DeletionRecord> = 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<DeletionRecord>()
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
}
}
}
}

View File

@@ -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
}

View File

@@ -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<String>() // 🆕 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(

View File

@@ -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

View File

@@ -429,11 +429,11 @@
</com.google.android.material.card.MaterialCardView>
<!-- Markdown Export Toggle -->
<!-- Markdown Auto-Sync Toggle (fusioniert Export + Auto-Import) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginBottom="8dp"
android:orientation="horizontal"
android:gravity="center_vertical">
@@ -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" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switchMarkdownExport"
android:id="@+id/switchMarkdownAutoSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
android:checked="false" />
</LinearLayout>
<!-- Import Markdown Button -->
<Button
android:id="@+id/buttonImportMarkdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="📥 Markdown-Änderungen importieren"
style="@style/Widget.Material3.Button.TonalButton" />
<!-- Import Info Text -->
<!-- Auto-Sync Info Text -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Importiert manuelle Änderungen von Desktop-Apps (.md Dateien vom Server)"
android:layout_marginBottom="16dp"
android:text="Synchronisiert Notizen automatisch als .md Dateien (Upload + Download bei jedem Sync)"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Manual Sync Info (nur sichtbar wenn Auto-Sync OFF) -->
<TextView
android:id="@+id/textViewManualSyncInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text="Oder synchronisiere Markdown-Dateien manuell:"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="?attr/colorOnSurface"
android:visibility="gone" />
<!-- Manual Sync Button (nur sichtbar wenn Auto-Sync OFF) -->
<Button
android:id="@+id/buttonManualMarkdownSync"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Markdown synchronisieren"
android:visibility="gone"
style="@style/Widget.Material3.Button.TonalButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<CheckBox
android:id="@+id/checkboxAlwaysDeleteFromServer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Diese Entscheidung merken"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium" />
</LinearLayout>

View File

@@ -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

View File

@@ -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