@@ -168,36 +168,31 @@ class CrashLogger private constructor(private val appContext: Context) {
168 /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
168 /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
169 * action gets the same undo window as a single-report delete. Runs on the logger's own
169 * action gets the same undo window as a single-report delete. Runs on the logger's own
170 * scope so it commits even if the user leaves the Diagnostics screen. */
170 * scope so it commits even if the user leaves the Diagnostics screen. */
171− // @Volatile: the flag is read/written from both the UI thread (schedule/cancel calls
171+ // AtomicReference (not a bare @Volatile var): the timer-fired claim must be a CAS,
172− // from the Diagnostics screen) and the IO coroutine (which nulls it when the timer
172+ // not an unconditional null, else a reschedule that installed a newer job loses its
173− // fires). Without @Volatile the IO write isn't guaranteed visible to the UI thread's
173+ // entry to the old coroutine's null and the clear runs immediately instead of after
174− // cancel read — a data race that could let a cancel falsely report "already fired".
174+ // the new delay (and cancelScheduledClearAll then falsely reports "already fired").
175− // The sibling pendingDeletes map uses ConcurrentHashMap for the same reason.
175+ // Mirrors scheduleDelete's ConcurrentHashMap.remove(name, job) claim.
177− private var pendingClearAll: Job? = null
176+ private val pendingClearAll = java.util.concurrent.atomic.AtomicReference<Job?>(null)
179 fun scheduleClearAll(delayMs: Long) {
178 fun scheduleClearAll(delayMs: Long) {
180− pendingClearAll?.cancel()
181− pendingClearAll = scope.launch {
179+ val job = scope.launch {
182 delay(delayMs)
180 delay(delayMs)
183− // Claim the clear by nulling our entry BEFORE deleting, mirroring
181+ // Atomically claim the clear by CAS-ing our own entry to null, mirroring
184− // scheduleDelete's claim pattern: whichever nulls first wins. A concurrent
182+ // scheduleDelete's claim pattern: a reschedule (or cancel) that nulls first
185− // cancelScheduledClearAll that nulls first makes this a no-op.
183+ // makes this a no-op. Without the CAS, an unconditional null would wipe a
186− pendingClearAll = null
184+ // newer job installed by a reschedule.
185+ val mine = coroutineContext[Job]
186+ if (mine == null || !pendingClearAll.compareAndSet(mine, null)) return@launch
187 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
187 crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
188 refresh()
188 refresh()
190+ pendingClearAll.getAndSet(job)?.cancel()
192 /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
193 /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
193 fun cancelScheduledClearAll(): Boolean {
194 fun cancelScheduledClearAll(): Boolean {
194− // Read and null under the volatile read; cancel the captured job after. A
195+ val job = pendingClearAll.getAndSet(null) ?: return false
195− // compareAndSet loop would be ideal but Job identity is enough here: if we read
196− // non-null, null it, and cancel it, the launched coroutine's pendingClearAll =
197− // null is a no-op on an already-nulled field. If the coroutine already nulled it
198− // (timer fired) we read null and return false — the authoritative answer.
199− val job = pendingClearAll ?: return false
200− pendingClearAll = null
201 job.cancel()
196 job.cancel()
202 return true
197 return true