From c0e0ec8f5186ccb1ad8c477e0885347ef26354d9 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Fri, 13 Feb 2026 18:30:50 +0100 Subject: [PATCH] Fix merge re-adding deleted/cancelled events and timer sync - Track removed events via removedSinceLoad set in Logbook to prevent merge from re-adding deliberately deleted or cancelled events - Deduplicate finalized timer events (same type + similar start time) to prevent duplicates when both devices stop the same timer - Detect timer cancellation from other device: dismiss local timer dialog when ongoing event disappears from logbook after sync - Fix thread safety: take snapshot of events before background merge Co-Authored-By: Claude Opus 4.6 --- .../lunatracker/MainActivity.kt | 23 +++- .../lunatracker/entities/Logbook.kt | 1 + .../lunatracker/entities/LunaEvent.kt | 13 ++ .../repository/WebDAVLogbookRepository.kt | 121 +++++++++++++----- 4 files changed, 123 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt index 183eaae..9fd3db6 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt @@ -423,6 +423,7 @@ class MainActivity : AppCompatActivity() { // Remove ongoing event from logbook val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) } if (ongoingEvent != null) { + logbook?.removedSinceLoad?.add(ongoingEvent.fingerprint()) logbook?.logs?.remove(ongoingEvent) recyclerView.adapter?.notifyDataSetChanged() saveLogbook() @@ -567,6 +568,7 @@ class MainActivity : AppCompatActivity() { // Remove ongoing event from logbook val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) } if (ongoingEvent != null) { + logbook?.removedSinceLoad?.add(ongoingEvent.fingerprint()) logbook?.logs?.remove(ongoingEvent) recyclerView.adapter?.notifyDataSetChanged() saveLogbook() @@ -1377,25 +1379,36 @@ class MainActivity : AppCompatActivity() { } } - // Check for ongoing breastfeeding from another device + // Breastfeeding timer sync + val ongoingBf = LunaEvent.findOngoingBreastfeeding(logs) if (bfTimerType == null) { - val ongoingBf = LunaEvent.findOngoingBreastfeeding(logs) + // Adopt ongoing timer from another device if (ongoingBf != null) { bfTimerStartTime = ongoingBf.time * 1000 bfTimerType = ongoingBf.type saveBreastfeedingTimerState() showBreastfeedingTimerDialog(ongoingBf.type) } + } else if (ongoingBf == null) { + // Timer running locally but cancelled/stopped on other device + bfTimerHandler?.removeCallbacks(bfTimerRunnable!!) + bfTimerDialog?.dismiss() + clearBreastfeedingTimerState() } - // Check for ongoing sleep from another device + // Sleep timer sync + val ongoingSleep = LunaEvent.findOngoing(logs, LunaEvent.TYPE_SLEEP) if (sleepTimerStartTime == 0L) { - val ongoingSleep = LunaEvent.findOngoing(logs, LunaEvent.TYPE_SLEEP) + // Adopt ongoing timer from another device if (ongoingSleep != null) { sleepTimerStartTime = ongoingSleep.time * 1000 saveSleepTimerState() showSleepTimerDialog() } + } else if (ongoingSleep == null) { + // Timer running locally but cancelled/stopped on other device + sleepTimerDialog?.dismiss() + clearSleepTimerState() } } @@ -1410,6 +1423,7 @@ class MainActivity : AppCompatActivity() { saveLogbook() } d.setNegativeButton(R.string.stale_timer_delete) { _, _ -> + logbook?.removedSinceLoad?.add(event.fingerprint()) logbook?.logs?.remove(event) recyclerView.adapter?.notifyDataSetChanged() saveLogbook() @@ -1440,6 +1454,7 @@ class MainActivity : AppCompatActivity() { // Update data setLoading(true) + logbook?.removedSinceLoad?.add(event.fingerprint()) logbook?.logs?.remove(event) recyclerView.adapter?.notifyDataSetChanged() saveLogbook() diff --git a/app/src/main/java/it/danieleverducci/lunatracker/entities/Logbook.kt b/app/src/main/java/it/danieleverducci/lunatracker/entities/Logbook.kt index 6b4fe90..acad20e 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/entities/Logbook.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/entities/Logbook.kt @@ -5,6 +5,7 @@ class Logbook(val name: String) { const val MAX_SAFE_LOGBOOK_SIZE = 30000 } val logs = ArrayList() + val removedSinceLoad = mutableSetOf() fun isTooBig(): Boolean { return logs.size > MAX_SAFE_LOGBOOK_SIZE diff --git a/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt b/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt index bd359fc..8cf70f3 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/entities/LunaEvent.kt @@ -160,6 +160,19 @@ class LunaEvent: Comparable { } } + fun fingerprint(): String { + return "${time}_${type}_${quantity}_${notes}_${signature}_${ongoing}" + } + + /** + * Returns the approximate start time for timer events. + * For ongoing events: time IS the start time. + * For finalized events: start = time - quantity * 60 (time=end, quantity=duration in min). + */ + fun approximateStartTime(): Long { + return if (ongoing) time else time - quantity * 60L + } + fun finalizeOngoing(endTimeMillis: Long) { if (!ongoing) return val startTimeSeconds = this.time diff --git a/app/src/main/java/it/danieleverducci/lunatracker/repository/WebDAVLogbookRepository.kt b/app/src/main/java/it/danieleverducci/lunatracker/repository/WebDAVLogbookRepository.kt index e016bcf..86e1a9a 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/repository/WebDAVLogbookRepository.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/repository/WebDAVLogbookRepository.kt @@ -133,73 +133,132 @@ class WebDAVLogbookRepository(val webDavURL: String, val username: String, val p } private fun saveLogbook(context: Context, logbook: Logbook) { + // Take a snapshot of local events to avoid thread safety issues + // (UI thread may modify logbook.logs concurrently) + val localSnapshot: List + val removedFingerprints: Set + synchronized(logbook.logs) { + localSnapshot = ArrayList(logbook.logs) + removedFingerprints = HashSet(logbook.removedSinceLoad) + } + // Load remote logbook and merge before saving to avoid overwriting // events added by other devices + val mergedEvents = ArrayList(localSnapshot) try { val remoteLogbook = loadLogbook(logbook.name) - mergeRemoteEvents(logbook, remoteLogbook) - Log.d(TAG, "Merged remote events, logbook now has ${logbook.logs.size} events") + val added = mergeRemoteEvents(mergedEvents, remoteLogbook, removedFingerprints) + if (added > 0) { + // Add merged events back to the actual logbook for UI display + synchronized(logbook.logs) { + val currentFingerprints = logbook.logs.map { it.fingerprint() }.toHashSet() + for (event in mergedEvents) { + if (!currentFingerprints.contains(event.fingerprint())) { + logbook.logs.add(event) + } + } + } + Log.d(TAG, "Merged $added remote events, saving ${mergedEvents.size} total") + } } catch (e: Exception) { // Remote not available (404, network error, etc.) - save local version as-is Log.w(TAG, "Could not load remote logbook for merge: $e") } val ja = JSONArray() - for (l in logbook.logs) { + for (l in mergedEvents) { ja.put(l.toJson()) } sardine.put(getUrl(logbook.name), ja.toString().toByteArray()) } /** - * Merges remote events into the local logbook. - * Events from the remote that don't exist locally are added. - * Ongoing events that were finalized locally are skipped. + * Merges remote events into the local event list. + * - Events already in local (by fingerprint) are skipped + * - Events in removedSinceLoad are skipped (deliberately deleted/cancelled locally) + * - Ongoing events that were finalized locally are skipped + * - Duplicate finalized timer events (same type + similar start time) are skipped + * @return number of events added */ - private fun mergeRemoteEvents(local: Logbook, remote: Logbook) { - val localFingerprints = local.logs.map { eventFingerprint(it) }.toHashSet() + private fun mergeRemoteEvents( + localEvents: ArrayList, + remote: Logbook, + removedFingerprints: Set + ): Int { + val localFingerprints = localEvents.map { it.fingerprint() }.toHashSet() var added = 0 for (remoteEvent in remote.logs) { - val fingerprint = eventFingerprint(remoteEvent) - if (localFingerprints.contains(fingerprint)) { - continue // Already exists locally - } + val fingerprint = remoteEvent.fingerprint() - // If remote event is ongoing, check if it was finalized locally - if (remoteEvent.ongoing && isOngoingSuperseded(remoteEvent, local.logs)) { + // Already exists locally with exact same state + if (localFingerprints.contains(fingerprint)) { continue } - local.logs.add(remoteEvent) + // Was deliberately removed locally (cancel/delete) + if (removedFingerprints.contains(fingerprint)) { + continue + } + + // Remote ongoing event was finalized locally + if (remoteEvent.ongoing && hasMatchingTimerEvent(remoteEvent, localEvents)) { + continue + } + + // Remote finalized timer event that duplicates a local one + // (both devices stopped the same timer → slightly different end times) + if (!remoteEvent.ongoing && isDuplicateTimerEvent(remoteEvent, localEvents)) { + continue + } + + localEvents.add(remoteEvent) localFingerprints.add(fingerprint) added++ } if (added > 0) { - local.sort() - Log.d(TAG, "Added $added events from remote during merge") + localEvents.sortWith(compareByDescending { it.time }) } - } - - private fun eventFingerprint(event: LunaEvent): String { - return "${event.time}_${event.type}_${event.quantity}_${event.notes}_${event.signature}_${event.ongoing}" + return added } /** - * Checks if a remote ongoing event was already finalized locally. - * Compares the ongoing event's start time with the calculated start time - * of finalized events of the same type. + * Checks if there's a local timer event (ongoing or finalized) with + * approximately the same start time as the given event. */ - private fun isOngoingSuperseded(ongoingEvent: LunaEvent, localEvents: List): Boolean { - val ongoingStartTime = ongoingEvent.time + private fun hasMatchingTimerEvent(event: LunaEvent, localEvents: List): Boolean { + val startTime = event.approximateStartTime() + for (local in localEvents) { + if (local.type != event.type) continue + if (Math.abs(startTime - local.approximateStartTime()) < 120) { + return true + } + } + return false + } + + /** + * Checks if a finalized timer event is a duplicate of an existing local event + * (same timer stopped on two devices → slightly different end times). + * Only applies to timer-type events (sleep, breastfeeding) with duration > 0. + */ + private fun isDuplicateTimerEvent(event: LunaEvent, localEvents: List): Boolean { + if (event.quantity <= 0) return false + val timerTypes = setOf( + LunaEvent.TYPE_SLEEP, + LunaEvent.TYPE_BREASTFEEDING_LEFT_NIPPLE, + LunaEvent.TYPE_BREASTFEEDING_BOTH_NIPPLE, + LunaEvent.TYPE_BREASTFEEDING_RIGHT_NIPPLE + ) + if (event.type !in timerTypes) return false + + val startTime = event.approximateStartTime() for (local in localEvents) { if (local.ongoing) continue - if (local.type != ongoingEvent.type) continue - // Finalized events: time = end time, quantity = duration in minutes - // Calculate approximate start time - val localStartTime = local.time - local.quantity * 60 - if (Math.abs(ongoingStartTime - localStartTime) < 120) { + if (local.type != event.type) continue + if (local.quantity <= 0) continue + if (Math.abs(startTime - local.approximateStartTime()) < 120) { return true } }