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
|
// Remove ongoing event from logbook
|
||||||
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
|
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
|
||||||
if (ongoingEvent != null) {
|
if (ongoingEvent != null) {
|
||||||
|
logbook?.removedSinceLoad?.add(ongoingEvent.fingerprint())
|
||||||
logbook?.logs?.remove(ongoingEvent)
|
logbook?.logs?.remove(ongoingEvent)
|
||||||
recyclerView.adapter?.notifyDataSetChanged()
|
recyclerView.adapter?.notifyDataSetChanged()
|
||||||
saveLogbook()
|
saveLogbook()
|
||||||
@@ -567,6 +568,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// Remove ongoing event from logbook
|
// Remove ongoing event from logbook
|
||||||
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
|
val ongoingEvent = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
|
||||||
if (ongoingEvent != null) {
|
if (ongoingEvent != null) {
|
||||||
|
logbook?.removedSinceLoad?.add(ongoingEvent.fingerprint())
|
||||||
logbook?.logs?.remove(ongoingEvent)
|
logbook?.logs?.remove(ongoingEvent)
|
||||||
recyclerView.adapter?.notifyDataSetChanged()
|
recyclerView.adapter?.notifyDataSetChanged()
|
||||||
saveLogbook()
|
saveLogbook()
|
||||||
@@ -1377,25 +1379,36 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for ongoing breastfeeding from another device
|
// Breastfeeding timer sync
|
||||||
if (bfTimerType == null) {
|
|
||||||
val ongoingBf = LunaEvent.findOngoingBreastfeeding(logs)
|
val ongoingBf = LunaEvent.findOngoingBreastfeeding(logs)
|
||||||
|
if (bfTimerType == null) {
|
||||||
|
// Adopt ongoing timer from another device
|
||||||
if (ongoingBf != null) {
|
if (ongoingBf != null) {
|
||||||
bfTimerStartTime = ongoingBf.time * 1000
|
bfTimerStartTime = ongoingBf.time * 1000
|
||||||
bfTimerType = ongoingBf.type
|
bfTimerType = ongoingBf.type
|
||||||
saveBreastfeedingTimerState()
|
saveBreastfeedingTimerState()
|
||||||
showBreastfeedingTimerDialog(ongoingBf.type)
|
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
|
||||||
if (sleepTimerStartTime == 0L) {
|
|
||||||
val ongoingSleep = LunaEvent.findOngoing(logs, LunaEvent.TYPE_SLEEP)
|
val ongoingSleep = LunaEvent.findOngoing(logs, LunaEvent.TYPE_SLEEP)
|
||||||
|
if (sleepTimerStartTime == 0L) {
|
||||||
|
// Adopt ongoing timer from another device
|
||||||
if (ongoingSleep != null) {
|
if (ongoingSleep != null) {
|
||||||
sleepTimerStartTime = ongoingSleep.time * 1000
|
sleepTimerStartTime = ongoingSleep.time * 1000
|
||||||
saveSleepTimerState()
|
saveSleepTimerState()
|
||||||
showSleepTimerDialog()
|
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()
|
saveLogbook()
|
||||||
}
|
}
|
||||||
d.setNegativeButton(R.string.stale_timer_delete) { _, _ ->
|
d.setNegativeButton(R.string.stale_timer_delete) { _, _ ->
|
||||||
|
logbook?.removedSinceLoad?.add(event.fingerprint())
|
||||||
logbook?.logs?.remove(event)
|
logbook?.logs?.remove(event)
|
||||||
recyclerView.adapter?.notifyDataSetChanged()
|
recyclerView.adapter?.notifyDataSetChanged()
|
||||||
saveLogbook()
|
saveLogbook()
|
||||||
@@ -1440,6 +1454,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// Update data
|
// Update data
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
logbook?.removedSinceLoad?.add(event.fingerprint())
|
||||||
logbook?.logs?.remove(event)
|
logbook?.logs?.remove(event)
|
||||||
recyclerView.adapter?.notifyDataSetChanged()
|
recyclerView.adapter?.notifyDataSetChanged()
|
||||||
saveLogbook()
|
saveLogbook()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Logbook(val name: String) {
|
|||||||
const val MAX_SAFE_LOGBOOK_SIZE = 30000
|
const val MAX_SAFE_LOGBOOK_SIZE = 30000
|
||||||
}
|
}
|
||||||
val logs = ArrayList<LunaEvent>()
|
val logs = ArrayList<LunaEvent>()
|
||||||
|
val removedSinceLoad = mutableSetOf<String>()
|
||||||
|
|
||||||
fun isTooBig(): Boolean {
|
fun isTooBig(): Boolean {
|
||||||
return logs.size > MAX_SAFE_LOGBOOK_SIZE
|
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) {
|
fun finalizeOngoing(endTimeMillis: Long) {
|
||||||
if (!ongoing) return
|
if (!ongoing) return
|
||||||
val startTimeSeconds = this.time
|
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) {
|
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
|
// Load remote logbook and merge before saving to avoid overwriting
|
||||||
// events added by other devices
|
// events added by other devices
|
||||||
|
val mergedEvents = ArrayList(localSnapshot)
|
||||||
try {
|
try {
|
||||||
val remoteLogbook = loadLogbook(logbook.name)
|
val remoteLogbook = loadLogbook(logbook.name)
|
||||||
mergeRemoteEvents(logbook, remoteLogbook)
|
val added = mergeRemoteEvents(mergedEvents, remoteLogbook, removedFingerprints)
|
||||||
Log.d(TAG, "Merged remote events, logbook now has ${logbook.logs.size} events")
|
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) {
|
} catch (e: Exception) {
|
||||||
// Remote not available (404, network error, etc.) - save local version as-is
|
// Remote not available (404, network error, etc.) - save local version as-is
|
||||||
Log.w(TAG, "Could not load remote logbook for merge: $e")
|
Log.w(TAG, "Could not load remote logbook for merge: $e")
|
||||||
}
|
}
|
||||||
|
|
||||||
val ja = JSONArray()
|
val ja = JSONArray()
|
||||||
for (l in logbook.logs) {
|
for (l in mergedEvents) {
|
||||||
ja.put(l.toJson())
|
ja.put(l.toJson())
|
||||||
}
|
}
|
||||||
sardine.put(getUrl(logbook.name), ja.toString().toByteArray())
|
sardine.put(getUrl(logbook.name), ja.toString().toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merges remote events into the local logbook.
|
* Merges remote events into the local event list.
|
||||||
* Events from the remote that don't exist locally are added.
|
* - Events already in local (by fingerprint) are skipped
|
||||||
* Ongoing events that were finalized locally 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) {
|
private fun mergeRemoteEvents(
|
||||||
val localFingerprints = local.logs.map { eventFingerprint(it) }.toHashSet()
|
localEvents: ArrayList<LunaEvent>,
|
||||||
|
remote: Logbook,
|
||||||
|
removedFingerprints: Set<String>
|
||||||
|
): Int {
|
||||||
|
val localFingerprints = localEvents.map { it.fingerprint() }.toHashSet()
|
||||||
var added = 0
|
var added = 0
|
||||||
|
|
||||||
for (remoteEvent in remote.logs) {
|
for (remoteEvent in remote.logs) {
|
||||||
val fingerprint = eventFingerprint(remoteEvent)
|
val fingerprint = remoteEvent.fingerprint()
|
||||||
if (localFingerprints.contains(fingerprint)) {
|
|
||||||
continue // Already exists locally
|
|
||||||
}
|
|
||||||
|
|
||||||
// If remote event is ongoing, check if it was finalized locally
|
// Already exists locally with exact same state
|
||||||
if (remoteEvent.ongoing && isOngoingSuperseded(remoteEvent, local.logs)) {
|
if (localFingerprints.contains(fingerprint)) {
|
||||||
continue
|
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)
|
localFingerprints.add(fingerprint)
|
||||||
added++
|
added++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (added > 0) {
|
if (added > 0) {
|
||||||
local.sort()
|
localEvents.sortWith(compareByDescending { it.time })
|
||||||
Log.d(TAG, "Added $added events from remote during merge")
|
|
||||||
}
|
}
|
||||||
}
|
return added
|
||||||
|
|
||||||
private fun eventFingerprint(event: LunaEvent): String {
|
|
||||||
return "${event.time}_${event.type}_${event.quantity}_${event.notes}_${event.signature}_${event.ongoing}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a remote ongoing event was already finalized locally.
|
* Checks if there's a local timer event (ongoing or finalized) with
|
||||||
* Compares the ongoing event's start time with the calculated start time
|
* approximately the same start time as the given event.
|
||||||
* of finalized events of the same type.
|
|
||||||
*/
|
*/
|
||||||
private fun isOngoingSuperseded(ongoingEvent: LunaEvent, localEvents: List<LunaEvent>): Boolean {
|
private fun hasMatchingTimerEvent(event: LunaEvent, localEvents: List<LunaEvent>): Boolean {
|
||||||
val ongoingStartTime = ongoingEvent.time
|
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) {
|
for (local in localEvents) {
|
||||||
if (local.ongoing) continue
|
if (local.ongoing) continue
|
||||||
if (local.type != ongoingEvent.type) continue
|
if (local.type != event.type) continue
|
||||||
// Finalized events: time = end time, quantity = duration in minutes
|
if (local.quantity <= 0) continue
|
||||||
// Calculate approximate start time
|
if (Math.abs(startTime - local.approximateStartTime()) < 120) {
|
||||||
val localStartTime = local.time - local.quantity * 60
|
|
||||||
if (Math.abs(ongoingStartTime - localStartTime) < 120) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user