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 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 18:30:50 +01:00
parent 235a355a05
commit c0e0ec8f51
4 changed files with 123 additions and 35 deletions

View File

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

View File

@@ -5,6 +5,7 @@ class Logbook(val name: String) {
const val MAX_SAFE_LOGBOOK_SIZE = 30000
}
val logs = ArrayList<LunaEvent>()
val removedSinceLoad = mutableSetOf<String>()
fun isTooBig(): Boolean {
return logs.size > MAX_SAFE_LOGBOOK_SIZE

View File

@@ -160,6 +160,19 @@ class LunaEvent: Comparable<LunaEvent> {
}
}
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

View File

@@ -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<LunaEvent>
val removedFingerprints: Set<String>
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<LunaEvent>,
remote: Logbook,
removedFingerprints: Set<String>
): 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<LunaEvent>): Boolean {
val ongoingStartTime = ongoingEvent.time
private fun hasMatchingTimerEvent(event: LunaEvent, localEvents: List<LunaEvent>): 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<LunaEvent>): 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
}
}