Skip to content

Commit 1d3a5a1

Browse files
committed
fix(sync): heal skipped notes' folder & false deleted-flag (no-ETag servers)
On WebDAV servers that never return ETags the timestamp fallback skips unchanged notes forever. Two stale local states could therefore never correct themselves on the skip path: 1. folderName: the server-path -> folderName mapping was only applied on actual downloads, so a stale local folderName (e.g. null on a freshly enrolled device) was never corrected — the same note appeared in a folder on one device and outside it on another. 2. DELETED_ON_SERVER: during an older folder move a note could drop out of the PROPFIND listing for a single sync pass and get flagged DELETED_ON_SERVER locally; nothing ever cleared that flag, so the note stayed mis-filed even though it still existed on the server. Replace reconcileFolderIfStale with reconcileSkippedNote(), called in both skip branches (ETag-match and timestamp-fallback): - SYNCED: align folderName with the authoritative server path. - DELETED_ON_SERVER: note is present in the current server listing, so the flag is false — restore to SYNCED and adopt the server folder, keeping local content (no download). - PENDING / LOCAL_ONLY / CONFLICT: real local state, left untouched. updatedAt is never bumped and status never becomes PENDING, so the note stays invisible to NoteUploader — no re-upload loop. The heal branch only runs while a server file is being processed, so genuinely deleted notes (absent from the listing) are never touched. Both skip logs now carry [localStatus=...] for field diagnosis. Propagate folderReconciledCount through DownloadResult -> SyncResult (foldersReconciled flag); MainViewModel reloads notes and refreshes folders when set, so the UI heals on the next sync with no manual action. Covered by NoteDownloaderFolderReconcileTest.
1 parent e1801ac commit 1d3a5a1

5 files changed

Lines changed: 195 additions & 4 deletions

File tree

android/app/src/main/java/dev/dettmer/simplenotes/sync/NoteDownloader.kt

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ internal data class DownloadResult(
2828
val downloadedCount: Int,
2929
val conflictCount: Int,
3030
val deletedOnServerCount: Int = 0,
31+
val folderReconciledCount: Int = 0,
3132
val downloadFailed: Boolean = false,
3233
val downloadError: String? = null
3334
)
@@ -88,6 +89,7 @@ internal class NoteDownloader(
8889
var downloadedCount = 0
8990
var conflictCount = 0
9091
var skippedDeleted = 0 // Track skipped deleted notes
92+
var folderReconciledCount = 0
9193
val processedIds = mutableSetOf<String>() // 🆕 v1.2.2: Track already loaded notes
9294

9395
Logger.d(TAG, "📥 downloadAll() called:")
@@ -239,7 +241,8 @@ internal class NoteDownloader(
239241
// PRIMARY: E-Tag check — erkennt Inhaltsänderungen zuverlässig
240242
if (!forceOverwrite && fileExistsLocally && serverETag != null && serverETag == cachedETag) {
241243
skippedUnchanged++
242-
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged)")
244+
Logger.d(TAG, " ⏭️ Skipping $noteId: E-Tag match (content unchanged) [localStatus=${localNote?.syncStatus}]")
245+
if (reconcileSkippedNote(localNote, folderByNoteId[noteId])) folderReconciledCount++
243246
processedIds.add(noteId)
244247
continue
245248
}
@@ -253,7 +256,12 @@ internal class NoteDownloader(
253256
serverModified <= lastSyncTime
254257
if (noETagAndTimestampUnchanged) {
255258
skippedUnchanged++
256-
Logger.d(TAG, " ⏭️ Skipping $noteId: No E-Tag, timestamp unchanged (fallback)")
259+
Logger.d(
260+
TAG,
261+
" ⏭️ Skipping $noteId: No E-Tag, timestamp unchanged (fallback)" +
262+
" [localStatus=${localNote?.syncStatus}]"
263+
)
264+
if (reconcileSkippedNote(localNote, folderByNoteId[noteId])) folderReconciledCount++
257265
processedIds.add(noteId)
258266
continue
259267
}
@@ -615,6 +623,7 @@ internal class NoteDownloader(
615623
downloadedCount = downloadedCount,
616624
conflictCount = conflictCount,
617625
deletedOnServerCount = deletedOnServerCount,
626+
folderReconciledCount = folderReconciledCount,
618627
downloadFailed = downloadException != null,
619628
downloadError = downloadException?.message
620629
)
@@ -810,6 +819,52 @@ internal class NoteDownloader(
810819
}
811820
}
812821

822+
/**
823+
* Heilt eine übersprungene, lokal vorhandene Notiz, deren Server-File gerade
824+
* verarbeitet wird (→ die Notiz ist nachweislich auf dem Server vorhanden), ohne
825+
* Datei-Download:
826+
*
827+
* - DELETED_ON_SERVER: Die Notiz liegt in der aktuellen Server-Liste, wurde aber
828+
* lokal fälschlich als „gelöscht" markiert (z. B. durch einen v2.7.0-Ordner-Move-
829+
* Race auf E-Tag-losen Servern, der den Eintrag für einen Sync-Durchlauf aus der
830+
* PROPFIND-Liste fallen ließ; nichts setzt dieses Flag je zurück). Falsch-Flag
831+
* löschen: zurück auf SYNCED + autoritativen Server-Ordner übernehmen. Lokaler
832+
* Inhalt bleibt erhalten. Echt gelöschte Notizen sind NICHT in der Liste → ihr
833+
* File wird nie verarbeitet → dieser Zweig läuft für sie nie.
834+
* - SYNCED: veraltetes folderName an den Server-Pfad angleichen.
835+
* - PENDING / LOCAL_ONLY / CONFLICT: echter lokaler Zustand → unangetastet.
836+
*
837+
* @return true wenn auf Platte geschrieben wurde (→ UI muss neu laden).
838+
*/
839+
private suspend fun reconcileSkippedNote(localNote: Note?, serverFolder: String?): Boolean {
840+
if (localNote == null) return false
841+
return when (localNote.syncStatus) {
842+
SyncStatus.DELETED_ON_SERVER -> {
843+
// updatedAt NICHT bumpen, Status nicht PENDING → kein Re-Upload, keine Schleife
844+
storage.saveNote(
845+
localNote.copy(syncStatus = SyncStatus.SYNCED, folderName = serverFolder)
846+
)
847+
Logger.d(
848+
TAG,
849+
" 🔧 Cleared false DELETED_ON_SERVER for ${localNote.id}: " +
850+
"present in server listing → SYNCED, folder '$serverFolder'"
851+
)
852+
true
853+
}
854+
SyncStatus.SYNCED -> {
855+
if (localNote.folderName == serverFolder) return false // kein unnötiger Write
856+
storage.saveNote(localNote.copy(folderName = serverFolder))
857+
Logger.d(
858+
TAG,
859+
" 📁 Reconciled folder for ${localNote.id}: " +
860+
"'${localNote.folderName}' → '$serverFolder'"
861+
)
862+
true
863+
}
864+
else -> false // PENDING / LOCAL_ONLY / CONFLICT → lokaler Zustand gewinnt
865+
}
866+
}
867+
813868
/**
814869
* Returns true if the user-visible content of [a] and [b] differs.
815870
* Intentionally excludes [Note.updatedAt] and [Note.syncStatus] — only the

android/app/src/main/java/dev/dettmer/simplenotes/sync/SyncResult.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ data class SyncResult(
1313
val conflictCount: Int = 0,
1414
val deletedOnServerCount: Int = 0, // 🆕 v1.8.0
1515
val foldersChanged: Boolean = false, // 🆕 v2.7.0 (Folders): Ordner-Metadaten haben sich geändert
16+
val foldersReconciled: Boolean = false, // 🆕 v2.7.2: folderName einer Notiz an Server-Pfad geheilt
1617
val errorMessage: String? = null,
1718
val infoMessage: String? = null // 🆕 v1.9.0 Issue #21: Non-error status info
1819
) {

android/app/src/main/java/dev/dettmer/simplenotes/sync/WebDavSyncService.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ class WebDavSyncService(private val context: Context, private val ioDispatcher:
671671
Logger.d(TAG, "📍 Step 5: Downloading remote notes")
672672
// Download remote notes
673673
var deletedOnServerCount = 0 // 🆕 v1.8.0
674+
var folderReconciledCount = 0 // 🆕 v2.7.2
674675
try {
675676
Logger.d(TAG, "⬇️ Downloading remote notes...")
676677
val downloadResult = downloadRemoteNotes(
@@ -695,6 +696,7 @@ class WebDavSyncService(private val context: Context, private val ioDispatcher:
695696
syncedCount += downloadResult.downloadedCount
696697
conflictCount += downloadResult.conflictCount
697698
deletedOnServerCount = downloadResult.deletedOnServerCount // 🆕 v1.8.0
699+
folderReconciledCount = downloadResult.folderReconciledCount // 🆕 v2.7.2
698700
Logger.d(
699701
TAG,
700702
"✅ Downloaded: ${downloadResult.downloadedCount} notes, " +
@@ -809,7 +811,8 @@ class WebDavSyncService(private val context: Context, private val ioDispatcher:
809811
syncedCount = effectiveSyncedCount,
810812
conflictCount = conflictCount,
811813
deletedOnServerCount = deletedOnServerCount, // 🆕 v1.8.0
812-
foldersChanged = foldersChanged // 🆕 v2.7.0 (Folders)
814+
foldersChanged = foldersChanged, // 🆕 v2.7.0 (Folders)
815+
foldersReconciled = folderReconciledCount > 0 // 🆕 v2.7.2
813816
)
814817
} catch (e: Exception) {
815818
Logger.e(TAG, "═══════════════════════════════════════")

android/app/src/main/java/dev/dettmer/simplenotes/ui/main/MainViewModel.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1032,8 +1032,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
10321032
} else if (result.isSuccess) {
10331033
Logger.d(TAG, "ℹ️ Auto-sync ($source): No changes")
10341034
SyncStateManager.markCompleted() // Silent → geht direkt auf IDLE
1035+
// 🆕 v2.7.2: Ordner-Zuordnung wurde lokal geheilt → Notenliste neu laden
1036+
if (result.foldersReconciled) loadNotes(forceReload = true)
10351037
// 🆕 v2.7.0 (Folders): Farbe leerer Ordner auch ohne Note-Sync ins UI laden
1036-
if (result.foldersChanged) refreshFolders()
1038+
if (result.foldersChanged || result.foldersReconciled) refreshFolders()
10371039
} else {
10381040
Logger.e(TAG, "❌ Auto-sync failed ($source): ${result.errorMessage}")
10391041
// Fehler werden IMMER angezeigt (auch bei Silent-Sync)
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package dev.dettmer.simplenotes.sync
2+
3+
import android.content.Context
4+
import android.content.SharedPreferences
5+
import com.thegrizzlylabs.sardineandroid.DavResource
6+
import com.thegrizzlylabs.sardineandroid.Sardine
7+
import dev.dettmer.simplenotes.models.Note
8+
import dev.dettmer.simplenotes.models.SyncStatus
9+
import dev.dettmer.simplenotes.storage.FolderStore
10+
import dev.dettmer.simplenotes.storage.NotesStorage
11+
import dev.dettmer.simplenotes.utils.Constants
12+
import io.mockk.every
13+
import io.mockk.mockk
14+
import io.mockk.verify
15+
import kotlinx.coroutines.Dispatchers
16+
import kotlinx.coroutines.test.runTest
17+
import org.junit.After
18+
import org.junit.Assert.assertEquals
19+
import org.junit.Assert.assertNull
20+
import org.junit.Before
21+
import org.junit.Test
22+
import java.io.File
23+
import java.nio.file.Files
24+
import java.util.Date
25+
26+
class NoteDownloaderFolderReconcileTest {
27+
private lateinit var tmpDir: File
28+
private lateinit var prefs: SharedPreferences
29+
private lateinit var storage: NotesStorage
30+
private lateinit var downloader: NoteDownloader
31+
32+
private val serverUrl = "http://server:8080"
33+
private val noteId = "8aeff7d6-4aa0-447a-960d-572206faeaf5"
34+
private val lastSync = 2_000_000L
35+
private val serverModified = 1_000_000L // <= lastSync → Timestamp-Fallback-Skip
36+
37+
@Before fun setUp() {
38+
tmpDir = Files.createTempDirectory("notedl-test").toFile()
39+
prefs = mockk(relaxed = true)
40+
every { prefs.getLong("last_sync_timestamp", 0L) } returns lastSync
41+
every { prefs.getString(Constants.KEY_SYNC_FOLDER_NAME, any()) } returns "notes"
42+
val context = mockk<Context> {
43+
every { filesDir } returns tmpDir
44+
every { getSharedPreferences(any(), any()) } returns prefs
45+
}
46+
storage = NotesStorage(context)
47+
downloader = NoteDownloader(
48+
prefs = prefs,
49+
storage = storage,
50+
eTagCache = ETagCache(prefs),
51+
urlBuilder = SyncUrlBuilder(prefs),
52+
connectionManager = mockk(relaxed = true),
53+
markdownSyncManager = mockk(relaxed = true),
54+
ioDispatcher = Dispatchers.Unconfined,
55+
folderStore = FolderStore(context)
56+
)
57+
}
58+
59+
@After fun tearDown() { tmpDir.deleteRecursively() }
60+
61+
private fun folderDir(dirName: String): DavResource = mockk(relaxed = true) {
62+
every { isDirectory } returns true
63+
every { name } returns dirName
64+
}
65+
66+
private fun noteFile(id: String, etagValue: String? = null): DavResource = mockk(relaxed = true) {
67+
every { isDirectory } returns false
68+
every { name } returns "$id.json"
69+
every { etag } returns etagValue
70+
every { modified } returns Date(serverModified)
71+
}
72+
73+
private fun mockSardine(): Sardine = mockk(relaxed = true) {
74+
every { list(match { it.endsWith("/notes/") }) } returns listOf(folderDir("Noltenius"))
75+
every { list(match { it.contains("Noltenius") }) } returns listOf(noteFile(noteId))
76+
}
77+
78+
@Test fun `stale folderName is healed to server path without download`() = runTest {
79+
storage.saveNote(
80+
Note(id = noteId, title = "T", content = "C", deviceId = "", syncStatus = SyncStatus.SYNCED, folderName = null)
81+
)
82+
val sardine = mockSardine()
83+
84+
val result = downloader.downloadAll(sardine, serverUrl)
85+
86+
assertEquals(0, result.downloadedCount)
87+
verify(exactly = 0) { sardine.get(any<String>()) }
88+
assertEquals(1, result.folderReconciledCount)
89+
val healed = storage.loadNote(noteId)!!
90+
assertEquals("Noltenius", healed.folderName)
91+
assertEquals(SyncStatus.SYNCED, healed.syncStatus)
92+
}
93+
94+
@Test fun `pending note is not reconciled`() = runTest {
95+
storage.saveNote(
96+
Note(id = noteId, title = "T", content = "C", deviceId = "", syncStatus = SyncStatus.PENDING, folderName = null)
97+
)
98+
val result = downloader.downloadAll(mockSardine(), serverUrl)
99+
assertEquals(0, result.folderReconciledCount)
100+
assertNull(storage.loadNote(noteId)!!.folderName)
101+
}
102+
103+
@Test fun `already correct folder is a no-op`() = runTest {
104+
storage.saveNote(
105+
Note(id = noteId, title = "T", content = "C", deviceId = "", syncStatus = SyncStatus.SYNCED, folderName = "Noltenius")
106+
)
107+
val result = downloader.downloadAll(mockSardine(), serverUrl)
108+
assertEquals(0, result.folderReconciledCount)
109+
assertEquals("Noltenius", storage.loadNote(noteId)!!.folderName)
110+
}
111+
112+
@Test fun `false DELETED_ON_SERVER is cleared when note is still present on server`() = runTest {
113+
storage.saveNote(
114+
Note(
115+
id = noteId, title = "T", content = "C", deviceId = "",
116+
syncStatus = SyncStatus.DELETED_ON_SERVER, folderName = null
117+
)
118+
)
119+
val sardine = mockSardine()
120+
121+
val result = downloader.downloadAll(sardine, serverUrl)
122+
123+
assertEquals(0, result.downloadedCount)
124+
verify(exactly = 0) { sardine.get(any<String>()) } // kein Download — lokaler Inhalt bleibt
125+
assertEquals(1, result.folderReconciledCount)
126+
val healed = storage.loadNote(noteId)!!
127+
assertEquals(SyncStatus.SYNCED, healed.syncStatus)
128+
assertEquals("Noltenius", healed.folderName)
129+
}
130+
}

0 commit comments

Comments
 (0)