Skip to content

Commit 64c927e

Browse files
authored
feat: Custom Tab API for debug panel (#213)
- Add `DebugTab` class for custom debug panel tabs with composable content - Custom tabs configured via `OverlayMode.FullMetrics(customTabs = ...)` — type system prevents misconfiguration with `BugReporterOnly` - Built-in tabs remain unchanged (private enum, same order as v2.1.x) - Sample app demonstrates custom SharedPrefs tab with M3-styled card UI
1 parent 87e48bb commit 64c927e

12 files changed

Lines changed: 314 additions & 97 deletions

File tree

CHANGELOG.md

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

3+
## Version 2.2.0 *(Unreleased)*
4+
5+
### New Features
6+
7+
* **Custom Tab API** – Add app-specific tabs to the debug panel via `DebugTab` class. Custom tabs are appended after built-in tabs and configured via `OverlayMode.FullMetrics(customTabs = ...)`.
8+
9+
### Breaking Changes
10+
11+
* `OverlayMode.FullMetrics` changed from `data object` to `data class`. If you reference it explicitly, change `OverlayMode.FullMetrics` to `OverlayMode.FullMetrics()`. Zero-config users are unaffected.
12+
313
## Version 2.1.1 *(2026-03-26)*
414

515
### Dependencies

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ class MyApp : Application() {
125125

126126
<img src="art/readme_bug_reporter_only.gif" alt="Bug Reporter Only Mode">
127127

128+
### Custom tabs
129+
130+
Add app-specific tabs to the debug panel. Custom tabs appear after the built-in tabs:
131+
132+
```kotlin
133+
DebugOverlay.configure {
134+
overlayMode = OverlayMode.FullMetrics(
135+
customTabs = listOf(
136+
DebugTab(title = "Feature Flags") { FeatureFlagsContent() }
137+
)
138+
)
139+
}
140+
```
141+
128142
### Network request tracking
129143

130144
```kotlin

debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/DebugOverlay.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ public object DebugOverlay {
226226
* @see configure
227227
*/
228228
public data class Config(
229-
val overlayMode: OverlayMode = OverlayMode.FullMetrics,
229+
val overlayMode: OverlayMode = OverlayMode.FullMetrics(),
230230
val networkRequestSource: NetworkRequestSource = NoOpNetworkRequestSource,
231231
val customLogSource: LogSource? = null,
232232
val bugReportExporter: BugReportExporter = IntentShareExporter,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.ms.square.debugoverlay
2+
3+
import androidx.compose.runtime.Composable
4+
5+
/**
6+
* A custom tab displayed in the debug panel.
7+
*
8+
* Custom tabs are appended after the built-in tabs (Logcat, Network, etc.).
9+
* Configure via [OverlayMode.FullMetrics]:
10+
*
11+
* ```kotlin
12+
* DebugOverlay.configure {
13+
* overlayMode = OverlayMode.FullMetrics(
14+
* customTabs = listOf(
15+
* DebugTab(title = "Feature Flags") { FeatureFlagsContent() }
16+
* )
17+
* )
18+
* }
19+
* ```
20+
*
21+
* ## Bug Reports
22+
* Tabs are UI-only. To include custom data in bug reports, use
23+
* [DebugOverlay.addBugReportContributor] separately.
24+
*
25+
* @param title Display title shown in the tab row.
26+
* @param content Composable content rendered when this tab is selected.
27+
*/
28+
public class DebugTab(internal val title: String, internal val content: @Composable () -> Unit)

debugoverlay-core/src/main/kotlin/com/ms/square/debugoverlay/OverlayMode.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ package com.ms.square.debugoverlay
66
* Configure via [DebugOverlay.configure]:
77
* ```kotlin
88
* DebugOverlay.configure {
9-
* copy(overlayMode = OverlayMode.BugReporterOnly)
9+
* overlayMode = OverlayMode.BugReporterOnly
1010
* }
1111
* ```
1212
*/
@@ -15,8 +15,10 @@ public sealed interface OverlayMode {
1515
* Shows real-time metrics panel (CPU, Memory, FPS).
1616
* Tapping opens the debug panel.
1717
* Best for developers during active development/testing.
18+
*
19+
* @param customTabs Custom tabs appended after the built-in tabs in the debug panel.
1820
*/
19-
public data object FullMetrics : OverlayMode
21+
public data class FullMetrics(val customTabs: List<DebugTab> = emptyList()) : OverlayMode
2022

2123
/**
2224
* Shows a minimal bug reporter FAB.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ internal class OverlayViewManager(
214214
) {
215215
val currentOverlayMode by overlayMode.collectAsStateWithLifecycle()
216216
when (currentOverlayMode) {
217-
OverlayMode.FullMetrics -> {
217+
is OverlayMode.FullMetrics -> {
218218
val metrics by debugPanelDataSource.debugOverlayPanelMetrics.collectAsStateWithLifecycle(
219219
initialValue = null
220220
)

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

Lines changed: 59 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import androidx.compose.material3.TopAppBarDefaults
2828
import androidx.compose.runtime.Composable
2929
import androidx.compose.runtime.LaunchedEffect
3030
import androidx.compose.runtime.getValue
31+
import androidx.compose.runtime.key
32+
import androidx.compose.runtime.mutableIntStateOf
3133
import androidx.compose.runtime.mutableStateOf
3234
import androidx.compose.runtime.remember
3335
import androidx.compose.runtime.rememberCoroutineScope
@@ -46,6 +48,8 @@ import androidx.compose.ui.window.Dialog
4648
import androidx.compose.ui.window.DialogProperties
4749
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4850
import com.ms.square.debugoverlay.DebugOverlay
51+
import com.ms.square.debugoverlay.DebugTab
52+
import com.ms.square.debugoverlay.OverlayMode
4953
import com.ms.square.debugoverlay.core.R
5054
import com.ms.square.debugoverlay.internal.bugreport.BugReportGenerator
5155
import com.ms.square.debugoverlay.internal.bugreport.ui.BugReportActivity
@@ -55,7 +59,7 @@ import com.ms.square.debugoverlay.internal.data.DebugOverlayDataRepository
5559
import kotlinx.coroutines.isActive
5660
import kotlinx.coroutines.launch
5761

58-
private enum class DebugTab(@param:StringRes val titleResId: Int) {
62+
private enum class BuiltInTab(@param:StringRes val titleResId: Int) {
5963
LOGCAT(R.string.debugoverlay_tab_logcat),
6064
CUSTOM_LOG(R.string.debugoverlay_tab_custom_log), // Fallback; UI uses dynamic title from source
6165
APP_EXITS(R.string.debugoverlay_tab_app_exits),
@@ -65,6 +69,14 @@ private enum class DebugTab(@param:StringRes val titleResId: Int) {
6569
DEVICE_INFO(R.string.debugoverlay_tab_device_info),
6670
}
6771

72+
/**
73+
* A visible tab in the panel — either a built-in tab or a custom [DebugTab].
74+
*/
75+
private sealed class PanelTab {
76+
data class BuiltIn(val tab: BuiltInTab) : PanelTab()
77+
data class Custom(val tab: DebugTab) : PanelTab()
78+
}
79+
6880
@OptIn(ExperimentalMaterial3Api::class)
6981
@Composable
7082
internal fun DebugPanelDialog(onDismiss: () -> Unit) {
@@ -225,61 +237,58 @@ private fun DebugPanelContent(modifier: Modifier = Modifier) {
225237
val repository = DebugOverlay.overlayDataRepository
226238
val hasCustomLogSource by repository.hasCustomLogSource.collectAsStateWithLifecycle()
227239
val customLogSourceName by repository.customLogSourceName.collectAsStateWithLifecycle()
240+
val customTabs = (DebugOverlay.config.overlayMode as? OverlayMode.FullMetrics)?.customTabs.orEmpty()
228241

229-
// Build visible tabs: hide CUSTOM_LOG when no custom log source is registered
230-
val visibleTabs = remember(hasCustomLogSource) {
231-
DebugTab.entries.filter { tab ->
232-
tab != DebugTab.CUSTOM_LOG || hasCustomLogSource
233-
}
234-
}
235-
236-
// Use rememberSaveable to persist tab selection across configuration changes
237-
var selectedTab by rememberSaveable { mutableStateOf(DebugTab.LOGCAT) }
238-
239-
// Validate selection when tabs change (e.g., custom log source removed)
240-
LaunchedEffect(visibleTabs) {
241-
if (selectedTab !in visibleTabs) {
242-
selectedTab = DebugTab.LOGCAT
243-
}
242+
// Build visible tabs: built-in tabs (with CUSTOM_LOG conditionally shown) + custom tabs
243+
val visibleTabs = remember(hasCustomLogSource, customTabs) {
244+
val builtIn = BuiltInTab.entries
245+
.filter { it != BuiltInTab.CUSTOM_LOG || hasCustomLogSource }
246+
.map { PanelTab.BuiltIn(it) }
247+
builtIn + customTabs.map { PanelTab.Custom(it) }
244248
}
245249

246-
val selectedTabIndex = visibleTabs.indexOf(selectedTab).coerceAtLeast(0)
250+
var selectedIndex by rememberSaveable { mutableIntStateOf(0) }
251+
selectedIndex = selectedIndex.coerceIn(0, visibleTabs.lastIndex)
247252

248253
Column(modifier = modifier.fillMaxSize()) {
249254
DebugPanelTabRow(
250255
visibleTabs = visibleTabs,
251-
selectedTab = selectedTab,
252-
selectedTabIndex = selectedTabIndex,
256+
selectedIndex = selectedIndex,
253257
customLogSourceName = customLogSourceName,
254-
onTabSelected = { selectedTab = it }
258+
onTabSelected = { selectedIndex = it }
259+
)
260+
DebugPanelTabContent(
261+
selectedTab = visibleTabs[selectedIndex],
262+
repository = repository
255263
)
256-
DebugPanelTabContent(selectedTab = selectedTab, repository = repository)
257264
}
258265
}
259266

260267
@Composable
261268
private fun DebugPanelTabRow(
262-
visibleTabs: List<DebugTab>,
263-
selectedTab: DebugTab,
264-
selectedTabIndex: Int,
269+
visibleTabs: List<PanelTab>,
270+
selectedIndex: Int,
265271
customLogSourceName: String?,
266-
onTabSelected: (DebugTab) -> Unit,
272+
onTabSelected: (Int) -> Unit,
267273
) {
268274
PrimaryScrollableTabRow(
269-
selectedTabIndex = selectedTabIndex,
275+
selectedTabIndex = selectedIndex,
270276
modifier = Modifier.fillMaxWidth(),
271277
containerColor = Color.Transparent
272278
) {
273-
visibleTabs.forEach { tab ->
279+
visibleTabs.forEachIndexed { index, panelTab ->
274280
Tab(
275-
selected = selectedTab == tab,
276-
onClick = { onTabSelected(tab) },
281+
selected = index == selectedIndex,
282+
onClick = { onTabSelected(index) },
277283
text = {
278284
Text(
279-
text = if (tab == DebugTab.CUSTOM_LOG) {
280-
customLogSourceName ?: DEFAULT_CUSTOM_LOG_SOURCE_NAME
281-
} else {
282-
stringResource(tab.titleResId)
285+
text = when (panelTab) {
286+
is PanelTab.BuiltIn -> if (panelTab.tab == BuiltInTab.CUSTOM_LOG) {
287+
customLogSourceName ?: DEFAULT_CUSTOM_LOG_SOURCE_NAME
288+
} else {
289+
stringResource(panelTab.tab.titleResId)
290+
}
291+
is PanelTab.Custom -> panelTab.tab.title
283292
},
284293
style = MaterialTheme.typography.labelLarge
285294
)
@@ -290,20 +299,23 @@ private fun DebugPanelTabRow(
290299
}
291300

292301
@Composable
293-
private fun DebugPanelTabContent(selectedTab: DebugTab, repository: DebugOverlayDataRepository) {
302+
private fun DebugPanelTabContent(selectedTab: PanelTab, repository: DebugOverlayDataRepository) {
294303
when (selectedTab) {
295-
DebugTab.LOGCAT -> LogTabContent(logsFlow = repository.logcatLogs)
296-
DebugTab.CUSTOM_LOG -> LogTabContent(logsFlow = repository.customLogSourceLogs)
297-
DebugTab.APP_EXITS -> AppExitTabContent(
298-
exitInfosFlow = repository.appExitInfos,
299-
isSupported = repository.isAppExitSupported
300-
)
301-
DebugTab.NETWORK -> NetworkTabContent(
302-
netStatsFlow = repository.netStats,
303-
networkRequestsFlow = repository.networkRequests
304-
)
305-
DebugTab.JANKSTATS -> JankStatsTabContent(jankStatsFlow = repository.jankStats)
306-
DebugTab.UI -> UiTabContent()
307-
DebugTab.DEVICE_INFO -> DeviceInfoTabContent(deviceInfoFlow = repository.deviceInfo)
304+
is PanelTab.BuiltIn -> when (selectedTab.tab) {
305+
BuiltInTab.LOGCAT -> LogTabContent(logsFlow = repository.logcatLogs)
306+
BuiltInTab.CUSTOM_LOG -> LogTabContent(logsFlow = repository.customLogSourceLogs)
307+
BuiltInTab.APP_EXITS -> AppExitTabContent(
308+
exitInfosFlow = repository.appExitInfos,
309+
isSupported = repository.isAppExitSupported
310+
)
311+
BuiltInTab.NETWORK -> NetworkTabContent(
312+
netStatsFlow = repository.netStats,
313+
networkRequestsFlow = repository.networkRequests
314+
)
315+
BuiltInTab.JANKSTATS -> JankStatsTabContent(jankStatsFlow = repository.jankStats)
316+
BuiltInTab.UI -> UiTabContent()
317+
BuiltInTab.DEVICE_INFO -> DeviceInfoTabContent(deviceInfoFlow = repository.deviceInfo)
318+
}
319+
is PanelTab.Custom -> key(selectedTab.tab) { selectedTab.tab.content() }
308320
}
309321
}

debugoverlay-core/src/test/kotlin/com/ms/square/debugoverlay/DebugOverlayTest.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class DebugOverlayTest {
2121
@After
2222
fun tearDown() {
2323
DebugOverlay.configure {
24-
overlayMode = OverlayMode.FullMetrics
24+
overlayMode = OverlayMode.FullMetrics()
2525
networkRequestSource = NoOpNetworkRequestSource
2626
customLogSource = null
2727
bugReportExporter = IntentShareExporter
@@ -33,7 +33,7 @@ class DebugOverlayTest {
3333
fun `Config has correct defaults`() {
3434
val config = DebugOverlay.Config()
3535

36-
assertThat(config.overlayMode).isEqualTo(OverlayMode.FullMetrics)
36+
assertThat(config.overlayMode).isEqualTo(OverlayMode.FullMetrics())
3737
assertThat(config.networkRequestSource).isEqualTo(NoOpNetworkRequestSource)
3838
assertThat(config.customLogSource).isNull()
3939
assertThat(config.bugReportExporter).isSameInstanceAs(IntentShareExporter)
@@ -83,6 +83,19 @@ class DebugOverlayTest {
8383
assertThat(DebugOverlay.config.networkRequestSource).isSameInstanceAs(networkSource)
8484
}
8585

86+
@Test
87+
fun `configure sets custom tabs on FullMetrics preserving order`() {
88+
val tab1 = DebugTab(title = "Tab 1") {}
89+
val tab2 = DebugTab(title = "Tab 2") {}
90+
91+
DebugOverlay.configure {
92+
overlayMode = OverlayMode.FullMetrics(customTabs = listOf(tab1, tab2))
93+
}
94+
95+
val mode = DebugOverlay.config.overlayMode as OverlayMode.FullMetrics
96+
assertThat(mode.customTabs).containsExactly(tab1, tab2).inOrder()
97+
}
98+
8699
@Test
87100
fun `addBugReportContributor adds contributors to list`() {
88101
val contributor1 = TestBugReportContributor("test1.txt")

docs/ARCHITECTURE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,10 @@ interface BugReportDataContributor {
8282

8383
**Auto-registration pattern:** Extensions self-register in their `init` block by calling `DebugOverlay.configure {}`. AndroidX Startup provides zero-config initialization for core and Timber extension (via manifest-declared initializers); OkHttp extension requires manual interceptor registration.
8484

85+
### Custom Tabs
86+
87+
The debug panel supports custom tabs via the `DebugTab` class. Custom tabs are appended after built-in tabs and configured via `OverlayMode.FullMetrics(customTabs = ...)`. Custom tab content is a `@Composable` lambda provided at construction time.
88+
8589
## UI Architecture
8690

8791
```

docs/TESTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Before writing a new test, verify it tests a **unique behavior**:
5757
// GOOD: One test, multiple assertions for the same behavior
5858
@Test fun `Config has correct defaults`() {
5959
val config = Config()
60-
assertThat(config.overlayMode).isEqualTo(OverlayMode.FullMetrics)
60+
assertThat(config.overlayMode).isEqualTo(OverlayMode.FullMetrics())
6161
assertThat(config.networkRequestSource).isEqualTo(NoOpNetworkRequestSource)
6262
assertThat(config.customLogSource).isNull()
6363
}

0 commit comments

Comments
 (0)