Skip to content

Commit 213ff95

Browse files
authored
[feat] add a new toolbar button to clear logcat/timber/newtork/custom logs (#242)
## Summary by CodeRabbit * **New Features** * Added a "Clear logs" toolbar button in the debug panel to wipe accumulated Logcat, Timber, and network entries mid-session; underlying sources now support opt-in clearing and accessibility text was added for the control. * **Documentation** * CHANGELOG and README updated to document the new clear-logs capability. * **Tests** * Added unit and integration tests verifying clear behavior and subsequent log/request collection.
1 parent 2d70bb4 commit 213ff95

13 files changed

Lines changed: 206 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Change Log
22

3+
## Version 2.5.0 *(2026-05-12)*
4+
5+
### New Features
6+
7+
* **Clear logs from the debug panel** – New `ClearAll` toolbar button wipes accumulated Logcat / Timber / network entries mid-session so the next bug report covers only the relevant repro window. Built-in sources implement the new public `Clearable` interface; custom `LogSource` / `NetworkRequestSource` implementations can opt in by also implementing `Clearable`. Resolves [#236](https://github.com/Manabu-GT/DebugOverlay-Android/issues/236).
8+
39
## Version 2.4.0 *(2026-05-07)*
410

511
### New Features

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Tap the overlay to open a full-screen diagnostic panel:
5656
- **UI** – View hierarchy via [Radiography](https://github.com/square/radiography)
5757
- **Device** – Hardware specs, OS info, battery, network status
5858
- **Bug Report** – One-tap HTML report with screenshot and diagnostics
59+
- **Clear Logs** – Toolbar button to wipe Logcat/Timber/network entries so the next bug report covers only the repro window
5960

6061
<img src="art/readme_debug_panel.gif" alt="Debug Panel">
6162

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.ms.square.debugoverlay
2+
3+
import androidx.annotation.AnyThread
4+
5+
/**
6+
* Optional capability interface for data sources that can clear their
7+
* accumulated entries. Used by the debug panel's "Clear logs" action so a
8+
* subsequent bug report contains only entries from the relevant repro window.
9+
*
10+
* Any source registered with DebugOverlay (e.g. a [LogSource] or
11+
* [NetworkRequestSource]) can opt in by additionally implementing this
12+
* interface:
13+
*
14+
* ```kotlin
15+
* class MyLogSource : LogSource, Clearable {
16+
* override fun clear() { /* reset internal buffer */ }
17+
* }
18+
* ```
19+
*
20+
* Behavior contract: after [clear] returns, subsequent emissions on the
21+
* source's exposed flow MUST NOT include entries that were present before
22+
* the call. Implementations may achieve this by resetting an in-memory
23+
* buffer or by other means.
24+
*
25+
* Thread-safety: [clear] MUST be safe to call from any thread.
26+
*/
27+
public interface Clearable {
28+
@AnyThread
29+
public fun clear()
30+
}

debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/DebugOverlayDataRepository.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.ms.square.debugoverlay.internal.data
22

33
import android.app.Activity
44
import android.content.Context
5+
import com.ms.square.debugoverlay.Clearable
56
import com.ms.square.debugoverlay.LogSource
67
import com.ms.square.debugoverlay.NetworkRequestSource
78
import com.ms.square.debugoverlay.NoOpNetworkRequestSource
@@ -126,4 +127,16 @@ internal class DebugOverlayDataRepository(context: Context, scope: CoroutineScop
126127
fun stopJankStatsTracking(activity: Activity) {
127128
jankStatsDataSource.stopTracking(activity)
128129
}
130+
131+
/**
132+
* Clears accumulated entries from sources that opt into [Clearable]:
133+
* built-in logcat capture plus the current network and custom log sources
134+
* when they implement [Clearable]. Custom external sources that don't
135+
* implement [Clearable] are silently skipped.
136+
*/
137+
fun clearAllLogs() {
138+
logcatDataSource.clear()
139+
(currentNetworkRequestSource.value as? Clearable)?.clear()
140+
(customLogSource.value as? Clearable)?.clear()
141+
}
129142
}

debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/EvictingQueue.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,12 @@ public class EvictingQueue<T>(private val capacity: Int) {
5656
*/
5757
@Synchronized
5858
public fun toList(): List<T> = queue.toList()
59+
60+
/**
61+
* Removes all elements from the queue.
62+
*/
63+
@Synchronized
64+
public fun clear() {
65+
queue.clear()
66+
}
5967
}

debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/data/source/LogcatDataSource.kt

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.ms.square.debugoverlay.internal.data.source
22

33
import android.os.Build
44
import androidx.annotation.GuardedBy
5+
import com.ms.square.debugoverlay.Clearable
56
import com.ms.square.debugoverlay.LogSource
67
import com.ms.square.debugoverlay.internal.InternalDebugOverlayApi
78
import com.ms.square.debugoverlay.internal.Logger
@@ -10,13 +11,16 @@ import com.ms.square.debugoverlay.internal.util.throttleLatest
1011
import com.ms.square.debugoverlay.model.LogEntry
1112
import kotlinx.coroutines.CoroutineScope
1213
import kotlinx.coroutines.Dispatchers
14+
import kotlinx.coroutines.channels.BufferOverflow
1315
import kotlinx.coroutines.currentCoroutineContext
1416
import kotlinx.coroutines.flow.Flow
17+
import kotlinx.coroutines.flow.MutableSharedFlow
1518
import kotlinx.coroutines.flow.SharingStarted
1619
import kotlinx.coroutines.flow.StateFlow
1720
import kotlinx.coroutines.flow.flow
1821
import kotlinx.coroutines.flow.flowOn
1922
import kotlinx.coroutines.flow.map
23+
import kotlinx.coroutines.flow.merge
2024
import kotlinx.coroutines.flow.stateIn
2125
import kotlinx.coroutines.isActive
2226
import kotlinx.coroutines.withContext
@@ -36,21 +40,40 @@ internal class LogcatDataSource(
3640
private val parser: LogcatEntryParser = LogcatEntryParser(),
3741
private val maxEntries: Int = 300,
3842
) : LogSource,
43+
Clearable,
3944
Closeable {
4045

4146
override val sourceName: String = "Logcat"
4247

43-
private val processLock = Object()
48+
private val processLock = Any()
4449

4550
@GuardedBy("processLock")
4651
private var currentProcess: Process? = null
4752

53+
private val entries = EvictingQueue<LogEntry>(maxEntries)
54+
55+
// Drops OS-replayed entries from before the last clear (e.g. when the producer
56+
// restarts on panel reopen and `-T N` walks the OS ring buffer).
57+
// Wall-clock epoch ms, matching `logcat -v ... epoch`.
58+
@Volatile private var clearMarkerMs: Long = 0L
59+
60+
// Forces a downstream re-read after clear() so the UI sees `[]` instantly,
61+
// even when the producer is idle.
62+
private val clearSignal = MutableSharedFlow<Unit>(
63+
extraBufferCapacity = 1,
64+
onBufferOverflow = BufferOverflow.DROP_OLDEST
65+
)
66+
4867
/**
49-
* Stream logcat entries. Keeps last N entries in memory.
50-
* Private StateFlow for direct .value access in [queryLogcatSnapshot].
68+
* Producer signal flow. Emits Unit ticks (not data) whenever a new entry is
69+
* appended to [entries]. The downstream `.map { entries.toList() }` reads
70+
* the queue's current state — the tick payload is irrelevant.
5171
*/
52-
private val _logs: StateFlow<List<LogEntry>> = flow {
53-
val entries = EvictingQueue<LogEntry>(maxEntries)
72+
private val producerSignal: Flow<Unit> = flow {
73+
// Each subscription session starts fresh — without this, the hoisted queue
74+
// would accumulate duplicates as `logcat -T N` replays the OS ring buffer
75+
// on every resubscribe (panel reopen).
76+
entries.clear()
5477
var reader: BufferedReader? = null
5578
try {
5679
/**
@@ -67,9 +90,14 @@ internal class LogcatDataSource(
6790
while (currentCoroutineContext().isActive) {
6891
// readLine() returns null at end of stream, so exit early if a process dies unexpectedly
6992
val line = reader.readLine() ?: break
70-
parser.parse(line)?.let {
71-
entries.add(it)
72-
emit(entries)
93+
parser.parse(line)?.let { entry ->
94+
// Drop OS-replayed entries from before the last clear. `-T N` replays the
95+
// last N ring-buffer lines on every subprocess start, including when the
96+
// panel reopens after WhileSubscribed cancelled us.
97+
// (Rare caveat: a backward system-clock jump could mis-drop a real entry.)
98+
if (entry.timestampMs < clearMarkerMs) return@let
99+
entries.add(entry)
100+
emit(Unit)
73101
}
74102
}
75103
} catch (e: IOException) {
@@ -81,8 +109,20 @@ internal class LogcatDataSource(
81109
safeDestroyProcess()
82110
}
83111
}
84-
.throttleLatest(500.milliseconds)
85-
.map { it.toList() }
112+
113+
/**
114+
* Stream logcat entries. Keeps last N entries in memory.
115+
* Private StateFlow for direct .value access in [queryLogcatSnapshot].
116+
*
117+
* `throttleLatest` is applied only to `producerSignal` so noisy producers
118+
* are rate-limited, while `clearSignal` flows straight through merge to
119+
* the downstream `.map` — making clear() visually instant.
120+
*/
121+
private val _logs: StateFlow<List<LogEntry>> = merge(
122+
producerSignal.throttleLatest(500.milliseconds),
123+
clearSignal
124+
)
125+
.map { entries.toList() }
86126
.flowOn(Dispatchers.IO)
87127
.stateIn(
88128
scope,
@@ -93,15 +133,22 @@ internal class LogcatDataSource(
93133
/** Public API for [LogSource] interface. */
94134
override val logs: Flow<List<LogEntry>> = _logs
95135

136+
override fun clear() {
137+
clearMarkerMs = System.currentTimeMillis()
138+
entries.clear()
139+
clearSignal.tryEmit(Unit)
140+
}
141+
96142
/**
97143
* Returns a snapshot of logcat logs for bug reports.
98144
* Uses cached value if streaming was active (debug panel was viewed), otherwise captures directly.
99145
*/
100146
suspend fun queryLogcatSnapshot(): List<LogEntry> {
101147
val cached = _logs.value
102148
if (cached.isNotEmpty()) return cached
103-
104-
return captureLogcatOnce()
149+
// drop anything captured before the last clear so a "clear → close panel → bug report" flow
150+
// doesn't resurface pre-clear lines.
151+
return captureLogcatOnce().filter { it.timestampMs >= clearMarkerMs }
105152
}
106153

107154
private suspend fun captureLogcatOnce(): List<LogEntry> = withContext(Dispatchers.IO) {

debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/internal/ui/DebugPanelDialog.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
1010
import androidx.compose.foundation.layout.size
1111
import androidx.compose.material.icons.Icons
1212
import androidx.compose.material.icons.filled.BugReport
13+
import androidx.compose.material.icons.filled.ClearAll
1314
import androidx.compose.material.icons.filled.Close
1415
import androidx.compose.material3.CircularProgressIndicator
1516
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -131,6 +132,7 @@ internal fun DebugPanelDialog(onDismiss: () -> Unit) {
131132
@OptIn(ExperimentalMaterial3Api::class)
132133
@Composable
133134
private fun DebugPanelTopAppBar(
135+
overlayDataRepository: DebugOverlayDataRepository = DebugOverlay.overlayDataRepository,
134136
bugReportGenerator: BugReportGenerator = DebugOverlay.bugReportGenerator,
135137
snackBarHostState: SnackbarHostState,
136138
onDismiss: () -> Unit,
@@ -150,6 +152,9 @@ private fun DebugPanelTopAppBar(
150152
)
151153
},
152154
actions = {
155+
ClearLogsButton(
156+
onClick = { overlayDataRepository.clearAllLogs() }
157+
)
153158
BugReportButton(
154159
isCapturing = isCapturing,
155160
draftCount = draftCount,
@@ -190,6 +195,20 @@ private fun DebugPanelTopAppBar(
190195
)
191196
}
192197

198+
@Composable
199+
private fun ClearLogsButton(onClick: () -> Unit) {
200+
val description = stringResource(R.string.debugoverlay_clear_logs_content_description)
201+
IconButton(
202+
onClick = onClick,
203+
modifier = Modifier.semantics { contentDescription = description }
204+
) {
205+
Icon(
206+
imageVector = Icons.Default.ClearAll,
207+
contentDescription = null // Handled by IconButton semantics
208+
)
209+
}
210+
}
211+
193212
@Composable
194213
private fun BugReportButton(isCapturing: Boolean, draftCount: Int, onClick: () -> Unit) {
195214
// Dynamic accessibility description matching FAB behavior

debugoverlay-core/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<!-- Debug Panel -->
44
<string name="debugoverlay_debug_panel">Debug Panel</string>
55
<string name="debugoverlay_close">Close</string>
6+
<string name="debugoverlay_clear_logs_content_description">Clear collected logs</string>
67

78
<!-- Tab Titles -->
89
<string name="debugoverlay_tab_log">Log</string>

debugoverlay-extension-okhttp/src/main/kotlin/com/ms/square/debugoverlay/extension/okhttp/DebugOverlayNetworkInterceptor.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.ms.square.debugoverlay.extension.okhttp
22

3+
import com.ms.square.debugoverlay.Clearable
34
import com.ms.square.debugoverlay.DebugOverlay
45
import com.ms.square.debugoverlay.NetworkRequestSource
56
import com.ms.square.debugoverlay.extension.okhttp.internal.isProbablyUtf8
@@ -93,7 +94,8 @@ public class DebugOverlayNetworkInterceptor(
9394
private val queryParamsNameToRedact: Set<String> = DEFAULT_QUERY_PARAMS_REDACT,
9495
private val maxBodySize: Long = DEFAULT_MAX_BODY_SIZE,
9596
) : Interceptor,
96-
NetworkRequestSource {
97+
NetworkRequestSource,
98+
Clearable {
9799

98100
private val recentRequests = EvictingQueue<NetworkRequest>(maxStoredRequests)
99101
private val _requests = MutableStateFlow<List<NetworkRequest>>(emptyList())
@@ -436,6 +438,11 @@ public class DebugOverlayNetworkInterceptor(
436438
}
437439
}
438440

441+
override fun clear() {
442+
recentRequests.clear()
443+
_requests.update { emptyList() }
444+
}
445+
439446
private fun redactUrl(url: HttpUrl): HttpUrl {
440447
if (queryParamsNameToRedact.isEmpty() || url.querySize == 0) {
441448
return url

debugoverlay-extension-okhttp/src/test/kotlin/com/ms/square/debugoverlay/extension/okhttp/DebugOverlayNetworkInterceptorTest.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,4 +284,24 @@ class DebugOverlayNetworkInterceptorTest {
284284
val capturedRequest = requests.first()
285285
assertThat(capturedRequest.responseBody).contains("failed to read response body")
286286
}
287+
288+
@Test
289+
fun `clear empties captured requests and subsequent collection still works`() = runTest {
290+
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("first"))
291+
mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody("second"))
292+
293+
client.newCall(Request.Builder().url(mockWebServer.url("/first")).get().build())
294+
.execute().close()
295+
assertThat(interceptor.requests.first()).hasSize(1)
296+
297+
interceptor.clear()
298+
299+
assertThat(interceptor.requests.first()).isEmpty()
300+
301+
client.newCall(Request.Builder().url(mockWebServer.url("/second")).get().build())
302+
.execute().close()
303+
val afterClear = interceptor.requests.first()
304+
assertThat(afterClear).hasSize(1)
305+
assertThat(afterClear.first().url).contains("/second")
306+
}
287307
}

0 commit comments

Comments
 (0)