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:
@@ -334,6 +334,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Toast.makeText(this, R.string.breastfeeding_timer_already_running, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, R.string.breastfeeding_timer_already_running, Toast.LENGTH_SHORT).show()
|
||||||
return
|
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)
|
// Check logbook for existing ongoing breastfeeding event (from another device)
|
||||||
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
|
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoingBreastfeeding(it) }
|
||||||
@@ -431,6 +435,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun askBreastfeedingDuration(eventType: String) {
|
fun askBreastfeedingDuration(eventType: String) {
|
||||||
|
// Cancel any running timer to avoid orphaned ongoing events
|
||||||
|
if (bfTimerType != null) {
|
||||||
|
cancelBreastfeedingTimer()
|
||||||
|
}
|
||||||
|
|
||||||
val d = AlertDialog.Builder(this)
|
val d = AlertDialog.Builder(this)
|
||||||
val dialogView = layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null)
|
val dialogView = layoutInflater.inflate(R.layout.breastfeeding_duration_dialog, null)
|
||||||
d.setTitle(R.string.breastfeeding_duration_title)
|
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()
|
Toast.makeText(this, R.string.sleep_timer_already_running, Toast.LENGTH_SHORT).show()
|
||||||
return
|
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)
|
// Check logbook for existing ongoing sleep event (from another device)
|
||||||
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
|
val existingOngoing = logbook?.logs?.let { LunaEvent.findOngoing(it, LunaEvent.TYPE_SLEEP) }
|
||||||
@@ -576,6 +589,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun askSleepDuration() {
|
fun askSleepDuration() {
|
||||||
|
// Cancel any running timer to avoid orphaned ongoing events
|
||||||
|
if (sleepTimerStartTime > 0) {
|
||||||
|
cancelSleepTimer()
|
||||||
|
}
|
||||||
|
|
||||||
val d = AlertDialog.Builder(this)
|
val d = AlertDialog.Builder(this)
|
||||||
val dialogView = layoutInflater.inflate(R.layout.sleep_duration_dialog, null)
|
val dialogView = layoutInflater.inflate(R.layout.sleep_duration_dialog, null)
|
||||||
d.setTitle(R.string.sleep_duration_title)
|
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 is ongoing, show the appropriate timer dialog instead
|
||||||
if (event.ongoing) {
|
if (event.ongoing) {
|
||||||
if (event.type in LunaEvent.BREASTFEEDING_TYPES) {
|
if (event.type in LunaEvent.BREASTFEEDING_TYPES) {
|
||||||
|
if (bfTimerDialog?.isShowing == true) return
|
||||||
if (bfTimerType == null) {
|
if (bfTimerType == null) {
|
||||||
bfTimerStartTime = event.time * 1000
|
bfTimerStartTime = event.time * 1000
|
||||||
bfTimerType = event.type
|
bfTimerType = event.type
|
||||||
@@ -902,6 +921,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
showBreastfeedingTimerDialog(event.type)
|
showBreastfeedingTimerDialog(event.type)
|
||||||
} else if (event.type == LunaEvent.TYPE_SLEEP) {
|
} else if (event.type == LunaEvent.TYPE_SLEEP) {
|
||||||
|
if (sleepTimerDialog?.isShowing == true) return
|
||||||
if (sleepTimerStartTime == 0L) {
|
if (sleepTimerStartTime == 0L) {
|
||||||
sleepTimerStartTime = event.time * 1000
|
sleepTimerStartTime = event.time * 1000
|
||||||
saveSleepTimerState()
|
saveSleepTimerState()
|
||||||
@@ -939,6 +959,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
TimePickerDialog(this, { _, hour, minute ->
|
TimePickerDialog(this, { _, hour, minute ->
|
||||||
val pickedDateTime = Calendar.getInstance()
|
val pickedDateTime = Calendar.getInstance()
|
||||||
pickedDateTime.set(year, month, day, hour, minute)
|
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
|
// Save event and move it to the right position in the logbook
|
||||||
event.time = pickedDateTime.time.time / 1000 // Seconds since epoch
|
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))
|
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) { _, _ ->
|
pickerDialog.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
val newQuantity = picker.value
|
val newQuantity = picker.value
|
||||||
if (newQuantity != oldQuantity) {
|
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)
|
// Adjust end time based on duration change (duration reduced = end time earlier)
|
||||||
event.time = event.time - (oldQuantity - newQuantity) * 60L
|
event.time = event.time - (oldQuantity - newQuantity) * 60L
|
||||||
event.quantity = newQuantity
|
event.quantity = newQuantity
|
||||||
@@ -1475,10 +1499,6 @@ class MainActivity : AppCompatActivity() {
|
|||||||
runOnUiThread({
|
runOnUiThread({
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
|
||||||
// Refresh list - merge may have added events from other devices
|
|
||||||
logbook?.sort()
|
|
||||||
recyclerView.adapter?.notifyDataSetChanged()
|
|
||||||
|
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
this@MainActivity,
|
this@MainActivity,
|
||||||
if (lastEventAdded != null)
|
if (lastEventAdded != null)
|
||||||
|
|||||||
@@ -133,14 +133,9 @@ 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
|
// Take a snapshot of local events (UI thread may modify logbook.logs concurrently)
|
||||||
// (UI thread may modify logbook.logs concurrently)
|
val localSnapshot = ArrayList(logbook.logs)
|
||||||
val localSnapshot: List<LunaEvent>
|
val removedFingerprints = HashSet(logbook.removedSinceLoad)
|
||||||
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
|
||||||
@@ -149,17 +144,9 @@ class WebDAVLogbookRepository(val webDavURL: String, val username: String, val p
|
|||||||
val remoteLogbook = loadLogbook(logbook.name)
|
val remoteLogbook = loadLogbook(logbook.name)
|
||||||
val added = mergeRemoteEvents(mergedEvents, remoteLogbook, removedFingerprints)
|
val added = mergeRemoteEvents(mergedEvents, remoteLogbook, removedFingerprints)
|
||||||
if (added > 0) {
|
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")
|
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) {
|
} 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")
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="breastfeeding_timer_stop">Stopp</string>
|
<string name="breastfeeding_timer_stop">Stopp</string>
|
||||||
<string name="breastfeeding_timer_hint">Tippe Stopp wenn fertig</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="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_title">Stilldauer</string>
|
||||||
<string name="breastfeeding_duration_description">Dauer in Minuten eingeben</string>
|
<string name="breastfeeding_duration_description">Dauer in Minuten eingeben</string>
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="breastfeeding_timer_stop">Arrêter</string>
|
<string name="breastfeeding_timer_stop">Arrêter</string>
|
||||||
<string name="breastfeeding_timer_hint">Appuyez sur Arrêter quand terminé</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="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_title">Durée d\'allaitement</string>
|
||||||
<string name="breastfeeding_duration_description">Entrez la durée en minutes</string>
|
<string name="breastfeeding_duration_description">Entrez la durée en minutes</string>
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@
|
|||||||
<string name="breastfeeding_timer_stop">Stop</string>
|
<string name="breastfeeding_timer_stop">Stop</string>
|
||||||
<string name="breastfeeding_timer_hint">Premi Stop quando hai finito</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="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_title">Durata allattamento</string>
|
||||||
<string name="breastfeeding_duration_description">Inserisci la durata in minuti</string>
|
<string name="breastfeeding_duration_description">Inserisci la durata in minuti</string>
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,7 @@
|
|||||||
<string name="breastfeeding_timer_stop">Stop</string>
|
<string name="breastfeeding_timer_stop">Stop</string>
|
||||||
<string name="breastfeeding_timer_hint">Tap Stop when finished</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="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_title">Breastfeeding duration</string>
|
||||||
<string name="breastfeeding_duration_description">Enter the duration in minutes</string>
|
<string name="breastfeeding_duration_description">Enter the duration in minutes</string>
|
||||||
<string name="measurement_unit_time_minutes" translatable="false">min</string>
|
<string name="measurement_unit_time_minutes" translatable="false">min</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user