From 96422da7ebfc0a9f24dd08f42d98a2b28182a447 Mon Sep 17 00:00:00 2001 From: Maximilian von Heyden Date: Sat, 14 Feb 2026 09:43:44 +0100 Subject: [PATCH] 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 --- .../lunatracker/MainActivity.kt | 28 ++++++++++++++++--- .../repository/WebDAVLogbookRepository.kt | 21 +++----------- app/src/main/res/values-de/strings.xml | 1 + app/src/main/res/values-fr/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt index 9fd3db6..50ac273 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/MainActivity.kt @@ -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) 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 86e1a9a..1511e8a 100644 --- a/app/src/main/java/it/danieleverducci/lunatracker/repository/WebDAVLogbookRepository.kt +++ b/app/src/main/java/it/danieleverducci/lunatracker/repository/WebDAVLogbookRepository.kt @@ -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 - val removedFingerprints: Set - 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") diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3e509d1..8a88624 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -104,6 +104,7 @@ Stopp Tippe Stopp wenn fertig Es läuft bereits eine Stillsitzung + Es läuft bereits ein anderer Timer Stilldauer Dauer in Minuten eingeben diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 77d940a..cc09d18 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -104,6 +104,7 @@ Arrêter Appuyez sur Arrêter quand terminé Une session d\'allaitement est déjà en cours + Un autre minuteur est déjà en cours Durée d\'allaitement Entrez la durée en minutes diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 16fe674..ee0755b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -104,6 +104,7 @@ Stop Premi Stop quando hai finito Una sessione di allattamento è già in corso + Un altro timer è già in corso Durata allattamento Inserisci la durata in minuti diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e16373..02eb0e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,6 +162,7 @@ Stop Tap Stop when finished A breastfeeding session is already in progress + Another timer is already running Breastfeeding duration Enter the duration in minutes min