Fix timer edge cases: thread safety, duplicate dialogs, concurrent timers

- Remove background thread mutation of logbook.logs during merge to
  prevent ConcurrentModificationException crashes
- Cancel ongoing timer when user enters manual duration to prevent
  orphaned ongoing events
- Prevent duplicate timer dialogs when tapping ongoing event in list
- Block starting a second timer while another is already running
- Track old fingerprint on event edit to prevent merge re-adding
  the old version

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 09:43:44 +01:00
parent c0e0ec8f51
commit 96422da7eb
6 changed files with 32 additions and 21 deletions

View File

@@ -334,6 +334,10 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, R.string.breastfeeding_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
if (sleepTimerStartTime > 0) {
Toast.makeText(this, R.string.another_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
// Check logbook for existing ongoing breastfeeding event (from another device)
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
@@ -431,6 +435,11 @@ class MainActivity : AppCompatActivity() {
}
fun askBreastfeedingDuration(eventType: String) {
// Cancel any running timer to avoid orphaned ongoing events
if (bfTimerType != null) {
cancelBreastfeedingTimer()
}
val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null)
d.setTitle(R.string.breastfeeding_duration_title)
@@ -479,6 +488,10 @@ class MainActivity : AppCompatActivity() {
Toast.makeText(this, R.string.sleep_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
if (bfTimerType != null) {
Toast.makeText(this, R.string.another_timer_already_running, Toast.LENGTH_SHORT).show()
return
}
// Check logbook for existing ongoing sleep event (from another device)
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
@@ -576,6 +589,11 @@ class MainActivity : AppCompatActivity() {
}
fun askSleepDuration() {
// Cancel any running timer to avoid orphaned ongoing events
if (sleepTimerStartTime > 0) {
cancelSleepTimer()
}
val d = AlertDialog.Builder(this)
val dialogView = layoutInflater.inflate(R.layout.sleep_duration_dialog, null)
d.setTitle(R.string.sleep_duration_title)
@@ -895,6 +913,7 @@ class MainActivity : AppCompatActivity() {
// If event is ongoing, show the appropriate timer dialog instead
if (event.ongoing) {
if (event.type in LunaEvent.BREASTFEEDING_TYPES) {
if (bfTimerDialog?.isShowing == true) return
if (bfTimerType == null) {
bfTimerStartTime = event.time * 1000
bfTimerType = event.type
@@ -902,6 +921,7 @@ class MainActivity : AppCompatActivity() {
}
showBreastfeedingTimerDialog(event.type)
} else if (event.type == LunaEvent.TYPE_SLEEP) {
if (sleepTimerDialog?.isShowing == true) return
if (sleepTimerStartTime == 0L) {
sleepTimerStartTime = event.time * 1000
saveSleepTimerState()
@@ -939,6 +959,8 @@ class MainActivity : AppCompatActivity() {
TimePickerDialog(this, { _, hour, minute ->
val pickedDateTime = Calendar.getInstance()
pickedDateTime.set(year, month, day, hour, minute)
// Track old fingerprint so merge doesn't re-add the old version
logbook?.removedSinceLoad?.add(event.fingerprint())
// Save event and move it to the right position in the logbook
event.time = pickedDateTime.time.time / 1000 // Seconds since epoch
dateTextView.text = String.format(getString(R.string.dialog_event_detail_datetime_icon), DateUtils.formatDateTime(event.time))
@@ -980,6 +1002,8 @@ class MainActivity : AppCompatActivity() {
pickerDialog.setPositiveButton(android.R.string.ok) { _, _ ->
val newQuantity = picker.value
if (newQuantity != oldQuantity) {
// Track old fingerprint so merge doesn't re-add the old version
logbook?.removedSinceLoad?.add(event.fingerprint())
// Adjust end time based on duration change (duration reduced = end time earlier)
event.time = event.time - (oldQuantity - newQuantity) * 60L
event.quantity = newQuantity
@@ -1475,10 +1499,6 @@ class MainActivity : AppCompatActivity() {
runOnUiThread({
setLoading(false)
// Refresh list - merge may have added events from other devices
logbook?.sort()
recyclerView.adapter?.notifyDataSetChanged()
Toast.makeText(
this@MainActivity,
if (lastEventAdded != null)

View File

@@ -133,14 +133,9 @@ 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)
}
// Take a snapshot of local events (UI thread may modify logbook.logs concurrently)
val localSnapshot = ArrayList(logbook.logs)
val removedFingerprints = HashSet(logbook.removedSinceLoad)
// Load remote logbook and merge before saving to avoid overwriting
// events added by other devices
@@ -149,17 +144,9 @@ class WebDAVLogbookRepository(val webDavURL: String, val username: String, val p
val remoteLogbook = loadLogbook(logbook.name)
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")
}
// Merged events will appear in UI on next periodic load (max 60s)
} 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")

View File

@@ -104,6 +104,7 @@
<string name="breastfeeding_timer_stop">Stopp</string>
<string name="breastfeeding_timer_hint">Tippe Stopp wenn fertig</string>
<string name="breastfeeding_timer_already_running">Es läuft bereits eine Stillsitzung</string>
<string name="another_timer_already_running">Es läuft bereits ein anderer Timer</string>
<string name="breastfeeding_duration_title">Stilldauer</string>
<string name="breastfeeding_duration_description">Dauer in Minuten eingeben</string>

View File

@@ -104,6 +104,7 @@
<string name="breastfeeding_timer_stop">Arrêter</string>
<string name="breastfeeding_timer_hint">Appuyez sur Arrêter quand terminé</string>
<string name="breastfeeding_timer_already_running">Une session d\'allaitement est déjà en cours</string>
<string name="another_timer_already_running">Un autre minuteur est déjà en cours</string>
<string name="breastfeeding_duration_title">Durée d\'allaitement</string>
<string name="breastfeeding_duration_description">Entrez la durée en minutes</string>

View File

@@ -104,6 +104,7 @@
<string name="breastfeeding_timer_stop">Stop</string>
<string name="breastfeeding_timer_hint">Premi Stop quando hai finito</string>
<string name="breastfeeding_timer_already_running">Una sessione di allattamento è già in corso</string>
<string name="another_timer_already_running">Un altro timer è già in corso</string>
<string name="breastfeeding_duration_title">Durata allattamento</string>
<string name="breastfeeding_duration_description">Inserisci la durata in minuti</string>

View File

@@ -162,6 +162,7 @@
<string name="breastfeeding_timer_stop">Stop</string>
<string name="breastfeeding_timer_hint">Tap Stop when finished</string>
<string name="breastfeeding_timer_already_running">A breastfeeding session is already in progress</string>
<string name="another_timer_already_running">Another timer is already running</string>
<string name="breastfeeding_duration_title">Breastfeeding duration</string>
<string name="breastfeeding_duration_description">Enter the duration in minutes</string>
<string name="measurement_unit_time_minutes" translatable="false">min</string>