forked from penguin86/luna-tracker
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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user