Skip to content

Commit 57b9d70

Browse files
authored
[feat] add a thermal status row to the debug overlay panel (#248)
## Summary by CodeRabbit * **New Features** * Added optional thermal status row to the compact debug overlay (Android 11+ with thermal HAL support). Enable via `OverlayMode.FullMetrics(showThermal = true)`. Displays device thermal throttling states: None, Light, Moderate, Severe, Critical, Emergency, and Shutdown. * **Documentation** * Updated README and CHANGELOG with thermal status row configuration and usage instructions. * **Tests** * Added comprehensive test coverage for thermal status monitoring.
1 parent 2bdfc2e commit 57b9d70

13 files changed

Lines changed: 529 additions & 18 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.6.0 *(2026-05-18)*
4+
5+
### New Features
6+
7+
* **Thermal status row** – Optional thermal-status indicator for the compact overlay, opt-in via `OverlayMode.FullMetrics(showThermal = true)`. Combines `PowerManager.getCurrentThermalStatus()` with `getThermalHeadroom()` per [Google's ADPF guidance](https://developer.android.com/games/optimize/adpf/thermal#device-limitations-of-the-thermal-api) so devices with an incomplete thermal HAL still surface a useful signal. Requires Android 11 (API 30) or above; row stays hidden on older devices. Resolves [#246](https://github.com/Manabu-GT/DebugOverlay-Android/issues/246).
8+
39
## Version 2.5.0 *(2026-05-12)*
410

511
### New Features

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Draggable overlay with real-time metrics and sparklines:
4444
- **Heap** – JVM heap usage percentage
4545
- **PSS** – Proportional Set Size in MB
4646
- **FPS** – Real-time frame rate
47+
- **Thermal***(optional, Android 11+)* Device thermal status — enable via `OverlayMode.FullMetrics(showThermal = true)`
4748

4849
### Debug Panel
4950
Tap the overlay to open a full-screen diagnostic panel:
@@ -178,6 +179,18 @@ DebugOverlay.configure {
178179
}
179180
```
180181

182+
### Thermal status
183+
184+
Opt in to a thermal-status row in the compact overlay:
185+
186+
```kotlin
187+
DebugOverlay.configure {
188+
overlayMode = OverlayMode.FullMetrics(showThermal = true)
189+
}
190+
```
191+
192+
The row shows the current thermal-throttling level with a color-coded dot. Labels are abbreviated to fit the compact panel — `None` / `Light` / `Mod` / `Sev` / `Crit` / `Emer` / `Shut` — mapping to `PowerManager.THERMAL_STATUS_NONE` / `_LIGHT` / `_MODERATE` / `_SEVERE` / `_CRITICAL` / `_EMERGENCY` / `_SHUTDOWN` respectively. Requires Android 11 (API 30) or above with a working thermal HAL — the row stays hidden on older devices and on API 30+ devices whose HAL doesn't expose `getThermalHeadroom` data. If the HAL later starts reporting, the row appears on the next poll.
193+
181194
### Network request tracking
182195

183196
```kotlin

debugoverlay-core/src/debug/kotlin/com/ms/square/debugoverlay/internal/ui/DebugOverlayPanelPreviews.kt

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import androidx.compose.ui.tooling.preview.Preview
2525
import androidx.compose.ui.unit.dp
2626
import com.ms.square.debugoverlay.internal.data.model.DebugOverlayPanelMetrics
2727
import com.ms.square.debugoverlay.internal.data.model.Metrics
28+
import com.ms.square.debugoverlay.internal.data.model.ThermalState
29+
import com.ms.square.debugoverlay.internal.data.model.ThermalStatus
2830
import kotlinx.coroutines.delay
2931

3032
// Composable Previews with static data (no performance monitoring)
@@ -35,6 +37,7 @@ private fun DebugOverlayPanelPreview(
3537
heapPercent: Float = 72f,
3638
pss: Float = 256f,
3739
fps: Float = 60f,
40+
thermalState: ThermalState? = null,
3841
) {
3942
// Mock data that updates for preview
4043
var metrics by remember {
@@ -83,7 +86,8 @@ private fun DebugOverlayPanelPreview(
8386

8487
DebugOverlayPanel(
8588
metrics = metrics,
86-
modifier = modifier
89+
modifier = modifier,
90+
thermalState = thermalState
8791
)
8892
}
8993

@@ -211,3 +215,25 @@ private fun DebugOverlayPreviewHighLoad() {
211215
}
212216
}
213217
}
218+
219+
@Preview(name = "Panel Only - With Thermal (Severe)", showBackground = true, widthDp = 200, heightDp = 200)
220+
@Composable
221+
private fun DebugOverlayPreviewWithThermal() {
222+
MaterialTheme {
223+
Box(
224+
modifier = Modifier
225+
.fillMaxSize()
226+
.background(MaterialTheme.colorScheme.background)
227+
.padding(16.dp),
228+
contentAlignment = Alignment.Center
229+
) {
230+
DebugOverlayPanelPreview(
231+
cpuPercent = 72f,
232+
heapPercent = 65f,
233+
pss = 380f,
234+
fps = 42f,
235+
thermalState = ThermalState(ThermalStatus.SEVERE)
236+
)
237+
}
238+
}
239+
}

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,16 @@ public sealed interface OverlayMode {
2626
* Best for developers during active development/testing.
2727
*
2828
* @param customTabs Custom tabs appended after the built-in tabs in the debug panel.
29+
* @param showThermal When `true`, adds a thermal-status row to the real-time metrics panel.
30+
* Requires Android 11 (API 30) or above with a working thermal HAL — the row stays hidden
31+
* on older devices and on API 30+ devices whose HAL does not expose `getThermalHeadroom`
32+
* data. If a HAL later starts reporting real values, the row appears on the next poll.
33+
* Defaults to `false`.
2934
*/
30-
public data class FullMetrics(override val customTabs: List<DebugTab> = emptyList()) : WithCustomTabs
35+
public data class FullMetrics(
36+
override val customTabs: List<DebugTab> = emptyList(),
37+
public val showThermal: Boolean = false,
38+
) : WithCustomTabs
3139

3240
/**
3341
* Shows a minimal bug reporter FAB.

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.material3.MaterialTheme
1919
import androidx.compose.material3.darkColorScheme
2020
import androidx.compose.material3.lightColorScheme
2121
import androidx.compose.runtime.getValue
22+
import androidx.compose.runtime.remember
2223
import androidx.compose.ui.platform.ComposeView
2324
import androidx.compose.ui.platform.LocalConfiguration
2425
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -30,6 +31,7 @@ import com.ms.square.debugoverlay.core.R
3031
import com.ms.square.debugoverlay.internal.bugreport.ActivityProvider
3132
import com.ms.square.debugoverlay.internal.bugreport.ui.BugReportActivity
3233
import com.ms.square.debugoverlay.internal.bugreport.ui.DraggableBugReporterFab
34+
import com.ms.square.debugoverlay.internal.data.model.ThermalState
3335
import com.ms.square.debugoverlay.internal.data.source.DebugOverlayPanelDataSourceImpl
3436
import com.ms.square.debugoverlay.internal.data.source.OverlayPreferences
3537
import com.ms.square.debugoverlay.internal.data.source.SharedPreferencesOverlayPreferences
@@ -42,9 +44,11 @@ import curtains.OnRootViewsChangedListener
4244
import curtains.phoneWindow
4345
import kotlinx.coroutines.CoroutineScope
4446
import kotlinx.coroutines.Dispatchers
47+
import kotlinx.coroutines.flow.Flow
4548
import kotlinx.coroutines.flow.MutableStateFlow
4649
import kotlinx.coroutines.flow.distinctUntilChanged
4750
import kotlinx.coroutines.flow.drop
51+
import kotlinx.coroutines.flow.flowOf
4852
import kotlinx.coroutines.flow.flowOn
4953
import kotlinx.coroutines.flow.launchIn
5054
import kotlinx.coroutines.flow.onEach
@@ -233,13 +237,18 @@ internal class OverlayViewManager(
233237
}
234238
) {
235239
val currentOverlayMode by overlayMode.collectAsStateWithLifecycle()
236-
when (currentOverlayMode) {
240+
when (val mode = currentOverlayMode) {
237241
is OverlayMode.FullMetrics -> {
238242
val metrics by debugPanelDataSource.debugOverlayPanelMetrics.collectAsStateWithLifecycle(
239243
initialValue = null
240244
)
245+
val thermalFlow: Flow<ThermalState?> = remember(mode.showThermal) {
246+
if (mode.showThermal) debugPanelDataSource.thermalState else flowOf(null)
247+
}
248+
val thermalState by thermalFlow.collectAsStateWithLifecycle(initialValue = null)
241249
DraggableOverlayPanel(
242250
metrics = metrics,
251+
thermalState = thermalState,
243252
initialOffsetX = overlayPreferences.getOverlayX().toFloat(),
244253
initialOffsetY = overlayPreferences.getOverlayY().toFloat(),
245254
onPositionChanged = onPositionChanged,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.ms.square.debugoverlay.internal.data.model
2+
3+
/**
4+
* Current thermal throttling state of the device.
5+
*
6+
* Derived from a combination of [android.os.PowerManager.getCurrentThermalStatus] and
7+
* [android.os.PowerManager.getThermalHeadroom] (API 30+) using the heuristic described in
8+
* the Android ADPF documentation:
9+
* https://developer.android.com/games/optimize/adpf/thermal#device-limitations-of-the-thermal-api
10+
*/
11+
internal data class ThermalState(val status: ThermalStatus)
12+
13+
/**
14+
* Thermal throttling level reported by the platform. Levels [NONE] through [SHUTDOWN] mirror
15+
* the `PowerManager.THERMAL_STATUS_*` constants. [UNSUPPORTED] is a synthetic value emitted
16+
* when the device cannot report thermal data — either because it pre-dates the thermal API
17+
* (Android < 11 / API < 30) or because the thermal HAL is incomplete and
18+
* `getThermalHeadroom()` returns `NaN` per its Javadoc contract. The UI hides the row in
19+
* both cases. If a HAL later begins returning real headroom values, the state self-heals
20+
* to a real level on the next poll.
21+
*/
22+
internal enum class ThermalStatus {
23+
NONE,
24+
LIGHT,
25+
MODERATE,
26+
SEVERE,
27+
CRITICAL,
28+
EMERGENCY,
29+
SHUTDOWN,
30+
UNSUPPORTED,
31+
}

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

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.ms.square.debugoverlay.internal.data.source
33
import android.content.Context
44
import com.ms.square.debugoverlay.internal.data.model.DebugOverlayPanelMetrics
55
import com.ms.square.debugoverlay.internal.data.model.MetricsAccumulator
6+
import com.ms.square.debugoverlay.internal.data.model.ThermalState
67
import kotlinx.coroutines.CoroutineScope
78
import kotlinx.coroutines.flow.Flow
89
import kotlinx.coroutines.flow.SharingStarted
@@ -12,6 +13,14 @@ import kotlinx.coroutines.flow.shareIn
1213

1314
internal sealed interface DebugOverlayPanelDataSource {
1415
val debugOverlayPanelMetrics: Flow<DebugOverlayPanelMetrics?>
16+
17+
/**
18+
* Thermal status flow, exposed separately from [debugOverlayPanelMetrics] so consumers can
19+
* subscribe conditionally (e.g. only when `OverlayMode.FullMetrics.showThermal` is `true`).
20+
* Backed by `shareIn(WhileSubscribed)` so the underlying PowerManager listener / headroom
21+
* polling only runs while something is collecting.
22+
*/
23+
val thermalState: Flow<ThermalState>
1524
}
1625

1726
internal class DebugOverlayPanelDataSourceImpl(context: Context, overlayScope: CoroutineScope) :
@@ -20,14 +29,15 @@ internal class DebugOverlayPanelDataSourceImpl(context: Context, overlayScope: C
2029
private val cpuDataSource = CpuDataSource()
2130
private val memoryDataSource = MemoryDataSource(context)
2231
private val fpsDataSource = FpsDataSource(context)
32+
private val thermalDataSource = ThermalDataSource(context)
2333

2434
// Accumulators for maintaining history across flow collection restarts
2535
private val cpuAccumulator = MetricsAccumulator()
2636
private val heapAccumulator = MetricsAccumulator()
2737
private val pssAccumulator = MetricsAccumulator()
2838
private val fpsAccumulator = MetricsAccumulator()
2939

30-
private val sharedMetrics: Flow<DebugOverlayPanelMetrics?> = combine(
40+
override val debugOverlayPanelMetrics: Flow<DebugOverlayPanelMetrics?> = combine(
3141
cpuDataSource.cpuUsage().map { cpuAccumulator.accumulate(it.value) },
3242
memoryDataSource.heapUsage().map { heapAccumulator.accumulate(it.value) },
3343
memoryDataSource.pss().map { pssAccumulator.accumulate(it) },
@@ -42,13 +52,8 @@ internal class DebugOverlayPanelDataSourceImpl(context: Context, overlayScope: C
4252
targetFps = fpsDataSource.currentTargetFps,
4353
maxFps = fpsDataSource.maxSupportedFps
4454
)
45-
}
46-
.shareIn(
47-
scope = overlayScope,
48-
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 1_000),
49-
replay = 1
50-
)
55+
}.shareIn(overlayScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 1_000), replay = 1)
5156

52-
override val debugOverlayPanelMetrics: Flow<DebugOverlayPanelMetrics?>
53-
get() = sharedMetrics
57+
override val thermalState: Flow<ThermalState> = thermalDataSource.thermalState()
58+
.shareIn(overlayScope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 1_000), replay = 1)
5459
}

0 commit comments

Comments
 (0)