From cf90aaf40b308169a1d83e46e96f653a22704fae Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 17:02:26 -0700 Subject: [PATCH 01/30] fix: honour MemoryUsageWatcher's configured sampling interval The sampling loop called a hardcoded delay(1000), ignoring the updateInterval constructor parameter it was given. Passing a different interval changed nothing, so the sample rate was fixed at one second whatever a caller asked for. NetworkUsageWatcher (ADFA-5489) uses its interval correctly, so the two watchers disagreed. This is the "sample time is fixed" of ADFA-5486, present in the code and not only in the UI. Making the interval configurable from settings is the rest of that ticket; this makes the existing parameter mean something first. Two supporting changes, both needed to test the loop at all: - The dispatchers are injectable, defaulting to the single-thread context and Dispatchers.Main.immediate as before. Tests drive the loop on a TestDispatcher and advance virtual time, so the regression test is deterministic rather than a wall-clock race. A first attempt that slept on the real clock hung the test executor. - readUsages() returns before the ActivityManager lookup when no process is being watched. Behaviour-preserving -- it went on to iterate zero pids -- and it keeps an idle watcher off BaseApplication, which a unit test does not have. Verified the tests fail without the fix: with delay(1000) restored, "the sampling rate follows the configured interval" reports 1 sample where it expects at least 9, for exactly the reason it is named for. The longer-interval test passes either way by construction; it guards the proportionality, not the bug. Verified: :app:testV8DebugUnitTest, 51 tests green across app ui/utils. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/utils/MemoryUsageWatcher.kt | 402 +++++++++--------- .../utils/MemoryUsageWatcherIntervalTest.kt | 87 ++++ 2 files changed, 294 insertions(+), 195 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherIntervalTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index 0e531ae964..21542e582a 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -38,248 +38,260 @@ import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.CoroutineContext /** * Handles memory usage information of the IDE. * * @property updateInterval The interval at which to update the memory usage. + * @property coroutineDispatcher Where sampling runs. Injectable so tests can drive it with virtual + * time rather than waiting on a real clock. + * @property mainDispatcher Where listeners are notified. * @author Akash Yadav */ -class MemoryUsageWatcher( - private val updateInterval: Long = DEFAULT_UPDATE_INTERVAL, -) { +class MemoryUsageWatcher @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) - private val coroutineDispatcher = newSingleThreadContext("MemoryUsageWatcher") - private val coroutineScope = CoroutineScope(coroutineDispatcher) - private val memoryUsage = ConcurrentHashMap() - private val watching = AtomicBoolean(false) - - /** - * Whether the memory usage watcher is watching processes for their memory usage. - */ - val isWatching: Boolean - get() = watching.get() - - /** - * The listener to be notified when the memory usage of a process changes. - */ - var listener: MemoryUsageListener? = null - - companion object { - private val android_os_Debug_getMemoryInfo by lazy { - checkNotNull( - ReflectionUtils.getDeclaredMethod( - Debug::class.java, - "getMemoryInfo", - Int::class.javaPrimitiveType, - MemoryInfo::class.java, - ), - ) { - "Unable to find getMemoryInfo method in android.os.Debug class" - } - } + constructor( + private val updateInterval: Long = DEFAULT_UPDATE_INTERVAL, + private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("MemoryUsageWatcher"), + private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, + ) { + private val coroutineScope = CoroutineScope(coroutineDispatcher) + private val memoryUsage = ConcurrentHashMap() + private val watching = AtomicBoolean(false) - const val MAX_USAGE_ENTRIES = 30 - const val DEFAULT_UPDATE_INTERVAL = 1000L - private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java) - } + /** + * Whether the memory usage watcher is watching processes for their memory usage. + */ + val isWatching: Boolean + get() = watching.get() - /** - * Start watching processes for their memory usage. - */ - fun startWatching() { - if (isWatching) { - log.warn("Processes are already being watched for memory usage") - return - } + /** + * The listener to be notified when the memory usage of a process changes. + */ + var listener: MemoryUsageListener? = null + + companion object { + private val android_os_Debug_getMemoryInfo by lazy { + checkNotNull( + ReflectionUtils.getDeclaredMethod( + Debug::class.java, + "getMemoryInfo", + Int::class.javaPrimitiveType, + MemoryInfo::class.java, + ), + ) { + "Unable to find getMemoryInfo method in android.os.Debug class" + } + } - watching.set(true) + const val MAX_USAGE_ENTRIES = 30 + const val DEFAULT_UPDATE_INTERVAL = 1000L + private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java) + } - coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { - while (isWatching) { - readUsages() + /** + * Start watching processes for their memory usage. + */ + fun startWatching() { + if (isWatching) { + log.warn("Processes are already being watched for memory usage") + return + } - // don't bother to update if no listeners are set - listener?.also { listener -> - val usages = MutableIntObjectMap(memoryUsage.size) - for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { - usages[pid] = usage + watching.set(true) + + coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { + while (isWatching) { + readUsages() + + // don't bother to update if no listeners are set + listener?.also { listener -> + val usages = MutableIntObjectMap(memoryUsage.size) + for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { + usages[pid] = usage + } + withContext(mainDispatcher) { + listener.onMemoryUsageChanged(usages) + } } - withContext(Dispatchers.Main.immediate) { - listener.onMemoryUsageChanged(usages) - } - } - delay(1000) + delay(updateInterval) + } } } - } - private fun readUsages() { - val activityManager = BaseApplication.baseInstance.getSystemService() - if (activityManager == null) { - log.error("ActivityManager is null") - return - } + private fun readUsages() { + if (memoryUsage.isEmpty()) { + // Nothing to sample. Returning before the service lookup keeps an idle watcher off + // BaseApplication, which a unit test does not have. + return + } - val pids = memoryUsage.keys.toIntArray() - pids.forEach { pid -> + val activityManager = BaseApplication.baseInstance.getSystemService() + if (activityManager == null) { + log.error("ActivityManager is null") + return + } - // ActivityManager.getProcessMemoryInfo is rate-limited - // but it internally uses Debug.getMemoryInfo to get the memory info - // we use it directly using reflection to bypass the rate limit - val proc = - memoryUsage[pid] ?: run { - log.warn("Process {} is not being watched, but readUsages() was called for the process", pid) - return@forEach - } + val pids = memoryUsage.keys.toIntArray() + pids.forEach { pid -> + + // ActivityManager.getProcessMemoryInfo is rate-limited + // but it internally uses Debug.getMemoryInfo to get the memory info + // we use it directly using reflection to bypass the rate limit + val proc = + memoryUsage[pid] ?: run { + log.warn("Process {} is not being watched, but readUsages() was called for the process", pid) + return@forEach + } - ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, proc.memInfo) + ReflectionUtils.invokeMethod(android_os_Debug_getMemoryInfo, null, pid, proc.memInfo) - // From https://developer.android.com/tools/dumpsys#meminfo - // "PSS is a good measure for the actual RAM weight of a process and for comparison against - // the RAM use of other processes and the total available RAM." - val usage = proc.memInfo.totalPss + // From https://developer.android.com/tools/dumpsys#meminfo + // "PSS is a good measure for the actual RAM weight of a process and for comparison against + // the RAM use of other processes and the total available RAM." + val usage = proc.memInfo.totalPss - // values are in kB, convert to bytes - val usageBytes = usage * 1024L - memoryUsage[pid]!!.apply { - // we insert the usage entry at the start of the array, then increment the shift amount by 1 - // this makes the newly inserted usage entry the last element in the array - // and the oldest usage entry the first element in the array + // values are in kB, convert to bytes + val usageBytes = usage * 1024L + memoryUsage[pid]!!.apply { + // we insert the usage entry at the start of the array, then increment the shift amount by 1 + // this makes the newly inserted usage entry the last element in the array + // and the oldest usage entry the first element in the array - // this means that _history[_history.size - 1] will be the newest usage entry + // this means that _history[_history.size - 1] will be the newest usage entry - // the "shift" amount basically indicates what is the start index of the array - // for example, if shift is 1, then _history[0] will actually return _history[1] (index shifted by 1 to the right) - // when the shift amount exceeds the size of the array, it will be reset to 0 (wrapped around) + // the "shift" amount basically indicates what is the start index of the array + // for example, if shift is 1, then _history[0] will actually return _history[1] (index shifted by 1 to the right) + // when the shift amount exceeds the size of the array, it will be reset to 0 (wrapped around) - _history[0] = usageBytes - _history.shift(1) + _history[0] = usageBytes + _history.shift(1) + } } } - } - /** - * Watches the memory usage of the given process. - * - * @param pid The process ID. - * @param pname The process name. - * @param unique Whether to unwatch the process with the same process name. - */ - fun watchProcess( - pid: Int, - pname: String, - unique: Boolean = true, - ) { - if (memoryUsage.containsKey(pid)) { - log.warn("Process {} is already being watched", pid) - return - } + /** + * Watches the memory usage of the given process. + * + * @param pid The process ID. + * @param pname The process name. + * @param unique Whether to unwatch the process with the same process name. + */ + fun watchProcess( + pid: Int, + pname: String, + unique: Boolean = true, + ) { + if (memoryUsage.containsKey(pid)) { + log.warn("Process {} is already being watched", pid) + return + } - if (unique) { - // unwatch the process with the given process name - unwatchProcess(pname) + if (unique) { + // unwatch the process with the given process name + unwatchProcess(pname) + } + + memoryUsage[pid] = + ProcessMemoryInfo( + pid, + pname, + MutableShiftedLongArray(MAX_USAGE_ENTRIES), + ) } - memoryUsage[pid] = - ProcessMemoryInfo( - pid, - pname, - MutableShiftedLongArray(MAX_USAGE_ENTRIES), - ) - } + /** + * Returns the memory usage of all the registered processes. + */ + fun getMemoryUsages(): Array = memoryUsage.values.toTypedArray() - /** - * Returns the memory usage of all the registered processes. - */ - fun getMemoryUsages(): Array = memoryUsage.values.toTypedArray() - - /** - * Returns the memory usage of the given process (in bytes). - */ - fun getMemoryUsage(processId: Int): ProcessMemoryInfo? = memoryUsage[processId] - - /** - * Removes the given process from the watch list. - */ - fun unwatchProcess(processId: Int) { - memoryUsage.remove(processId) - } + /** + * Returns the memory usage of the given process (in bytes). + */ + fun getMemoryUsage(processId: Int): ProcessMemoryInfo? = memoryUsage[processId] - /** - * Removes the process with the given process name from the watch list. - */ - fun unwatchProcess(procName: String) { - memoryUsage.values.forEach { - if (it.pname == procName) { - memoryUsage.remove(it.pid) + /** + * Removes the given process from the watch list. + */ + fun unwatchProcess(processId: Int) { + memoryUsage.remove(processId) + } + + /** + * Removes the process with the given process name from the watch list. + */ + fun unwatchProcess(procName: String) { + memoryUsage.values.forEach { + if (it.pname == procName) { + memoryUsage.remove(it.pid) + } } } - } - /** - * Unwatches all the registered processes. - */ - fun unwatchAll() { - memoryUsage.clear() - } + /** + * Unwatches all the registered processes. + */ + fun unwatchAll() { + memoryUsage.clear() + } - /** - * Stop watching processes for their memory usage. - */ - fun stopWatching(unwatchAll: Boolean = true) { - if (unwatchAll) { - unwatchAll() + /** + * Stop watching processes for their memory usage. + */ + fun stopWatching(unwatchAll: Boolean = true) { + if (unwatchAll) { + unwatchAll() + } + watching.set(false) + coroutineScope.cancelIfActive("Cancellation requested") } - watching.set(false) - coroutineScope.cancelIfActive("Cancellation requested") - } - /** - * Registers a listener to be notified when the memory usage of a process changes. - */ - fun interface MemoryUsageListener { /** - * Called when the memory usage of a process changes. - * - * @param memoryUsage The memory usage of all the registered processes. + * Registers a listener to be notified when the memory usage of a process changes. */ - fun onMemoryUsageChanged(memoryUsage: IntObjectMap) - } + fun interface MemoryUsageListener { + /** + * Called when the memory usage of a process changes. + * + * @param memoryUsage The memory usage of all the registered processes. + */ + fun onMemoryUsageChanged(memoryUsage: IntObjectMap) + } - /** - * Represents the memory usage of a process. - * - * @property pid The process ID. - * @property memInfo The latest [MemoryInfo] object. Stored here to ensure that we only allocate - * a single [MemoryInfo] object for a process. - * @property usageHistory The memory usage history of the process. - */ - data class ProcessMemoryInfo( - val pid: Int, - val pname: String, - internal val _history: MutableShiftedLongArray, - ) { - internal val memInfo: MemoryInfo = MemoryInfo() + /** + * Represents the memory usage of a process. + * + * @property pid The process ID. + * @property memInfo The latest [MemoryInfo] object. Stored here to ensure that we only allocate + * a single [MemoryInfo] object for a process. + * @property usageHistory The memory usage history of the process. + */ + data class ProcessMemoryInfo( + val pid: Int, + val pname: String, + internal val _history: MutableShiftedLongArray, + ) { + internal val memInfo: MemoryInfo = MemoryInfo() - val usageHistory: ShiftedLongArray - get() = _history + val usageHistory: ShiftedLongArray + get() = _history - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is ProcessMemoryInfo) return false + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ProcessMemoryInfo) return false - if (pid != other.pid) return false - if (!_history.contentEquals(other._history)) return false + if (pid != other.pid) return false + if (!_history.contentEquals(other._history)) return false - return true - } + return true + } - override fun hashCode(): Int { - var result = pid - result = 31 * result + _history.contentHashCode() - return result + override fun hashCode(): Int { + var result = pid + result = 31 * result + _history.contentHashCode() + return result + } } } -} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherIntervalTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherIntervalTest.kt new file mode 100644 index 0000000000..32828d706c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MemoryUsageWatcherIntervalTest.kt @@ -0,0 +1,87 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Pins that the sampling loop honours [MemoryUsageWatcher]'s configured interval. + * + * The loop used to `delay(1000)` regardless of the constructor argument, so the interval was fixed + * at one second whatever a caller asked for -- the "sample time is fixed" of ADFA-5486, in the code + * rather than only in the UI. + * + * Sampling runs on an injected test dispatcher, so these advance virtual time and never wait on a + * real clock. No process is watched, so a sample does no work and only the interval governs the + * rate. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MemoryUsageWatcherIntervalTest { + @Test + fun `the sampling rate follows the configured interval`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(1_000L) + watcher.stopWatching() + + // One second of virtual time at 100ms. The hardcoded one-second delay this replaced + // would have produced one sample regardless of the interval asked for. + assertThat(samples).isAtLeast(9) + assertThat(samples).isAtMost(11) + } + + @Test + fun `a longer interval samples proportionally less often`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + + val watcher = + MemoryUsageWatcher( + updateInterval = 500L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(1_000L) + watcher.stopWatching() + + // Five times the interval, so a fifth of the samples. With the interval ignored this + // was indistinguishable from the 100ms case. + assertThat(samples).isAtLeast(1) + assertThat(samples).isAtMost(3) + } +} From d7b34a664f3db3774deebf3f0c53afa1afc60e96 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 17:02:40 -0700 Subject: [PATCH 02/30] refactor: extract MetricsChartRenderer, shared by both carousel charts ADFA-5489 gave the metrics carousel a second chart, and with it a second copy of the chart setup: the two renderers had a byte-identical configure() apart from the value formatter, and a byte-identical block in rebuild() applying theme colours and redrawing. ADFA-5486 adds x-axis labels, zoom, event annotations and snapshot export to "the line chart", written when there was only one. All four belong on both charts, and duplicated setup is how they end up on one. This puts the common behaviour in one place before that work starts. MetricsChartRenderer holds the attach/detach lifecycle -- including detachIfAttached, which a recycling carousel page needs -- the shared axis and gesture configuration, and the data/redraw helpers. Subclasses override configure() to add what is theirs (the memory chart's MB formatter; the network chart's byte formatter and per-decade granularity) and call through. Behaviour-neutral: no configuration value changed, only where it lives. The existing renderer tests are the evidence, and both charts were compared on device against the previous build. Verified: :app:testV8DebugUnitTest, 51 tests green across app ui/utils; both carousel pages rendered on a Pixel 6 Pro (arm64, v8 debug), including the network chart under a live Gradle sync. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MemoryUsageChartRenderer.kt | 93 ++---------- .../androidide/ui/MetricsChartRenderer.kt | 143 ++++++++++++++++++ .../ui/NetworkUsageChartRenderer.kt | 79 ++-------- 3 files changed, 168 insertions(+), 147 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index 67d8b94ef3..5ef7160419 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -29,7 +29,6 @@ import com.itsaky.androidide.R import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo import com.itsaky.androidide.utils.ShiftedLongArray -import com.itsaky.androidide.utils.resolveAttr import kotlin.math.roundToLong /** @@ -50,53 +49,26 @@ import kotlin.math.roundToLong class MemoryUsageChartRenderer( private val usagesProvider: () -> Array, private val lineColorFor: (ProcessMemoryInfo) -> Int, -) { - private var chart: SafeLineChart? = null - +) : MetricsChartRenderer() { /** * Maps a watched pid to its dataset index in the attached chart's [LineData]. Empty whenever no * chart is attached. */ private val pidToDatasetIdx = MutableIntIntMap(initialCapacity = 3) - /** - * Attaches [chart], applies the static chart configuration, and renders the full current - * history. Replaces any previously attached chart. - */ - @UiThread - fun attach(chart: SafeLineChart) { - this.chart = chart - configure(chart) - rebuild() - } - - /** - * Detaches the current chart. Sample history is unaffected; a later [attach] renders it in full. - */ @UiThread - fun detach() { - chart = null + override fun detach() { + super.detach() pidToDatasetIdx.clear() } - /** - * Detaches [chart] only if it is the currently attached one. Use from a recycling container, - * where the replacement view can be bound before the view it replaces is recycled. - */ - @UiThread - fun detachIfAttached(chart: SafeLineChart) { - if (this.chart === chart) { - detach() - } - } - /** * Rebuilds the chart's datasets from scratch for the currently watched processes, rendering each * process's complete [ProcessMemoryInfo.usageHistory]. Call when the set of watched processes * changes; [onUsagesChanged] calls it on its own when it detects such a change. */ @UiThread - fun rebuild() { + override fun rebuild() { val chart = this.chart ?: return val processes = usagesProvider() @@ -125,21 +97,7 @@ class MemoryUsageChartRenderer( } } - val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) - val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) - - chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor - - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() - } + setData(chart, datasets) } /** @@ -180,40 +138,19 @@ class MemoryUsageChartRenderer( } if (dataChanged) { - chart.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() - } + redraw(chart) } } - /** - * Applies the configuration that does not depend on the samples. Idempotent. - */ - private fun configure(chart: SafeLineChart) { - chart.apply { - val colorAccent = context.resolveAttr(R.attr.colorAccent) - - isDragEnabled = false - description.isEnabled = false - xAxis.axisLineColor = colorAccent - axisRight.axisLineColor = colorAccent - - setPinchZoom(false) - setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) - setDrawGridBackground(true) - setScaleEnabled(true) - - axisLeft.isEnabled = false - axisRight.valueFormatter = - object : IAxisValueFormatter { - override fun getFormattedValue( - value: Float, - axis: AxisBase?, - ): String = "%dMB".format(value.roundToLong()) - } - } + override fun configure(chart: SafeLineChart) { + super.configure(chart) + chart.axisRight.valueFormatter = + object : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String = "%dMB".format(value.roundToLong()) + } } private fun labelFor( diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt new file mode 100644 index 0000000000..f6e2dd9ddf --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -0,0 +1,143 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import androidx.annotation.CallSuper +import androidx.annotation.UiThread +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.itsaky.androidide.R +import com.itsaky.androidide.utils.resolveAttr + +/** + * Shared behaviour for the charts on the editor's metrics carousel. + * + * A renderer holds no sample state -- the watchers own the history -- so a chart view is attached + * when its carousel page binds and detached when the page is recycled, and [rebuild] can redraw the + * whole series from scratch at any time. That is what makes a chart safe as a recycled page. + * + * Subclasses supply the data and whatever axis configuration is specific to them; everything the + * charts have in common lives here, so a change to how metrics charts look or behave is made once. + * + * All methods must be called on the UI thread. MPAndroidChart is not thread-safe; see + * [SafeLineChart]. + */ +abstract class MetricsChartRenderer { + /** + * The attached chart, or `null` when no carousel page is bound to this renderer. + */ + protected var chart: SafeLineChart? = null + private set + + /** + * Attaches [chart], applies configuration, and renders the full current history. + */ + @UiThread + fun attach(chart: SafeLineChart) { + this.chart = chart + configure(chart) + rebuild() + } + + /** + * Detaches the current chart. Sample history is unaffected; a later [attach] renders it in full. + */ + @UiThread + @CallSuper + open fun detach() { + chart = null + } + + /** + * Detaches [chart] only if it is the currently attached one. + * + * A recycling container needs this: RecyclerView can bind a replacement view before recycling + * the one it replaced, and an unconditional detach would then drop the new chart. + */ + @UiThread + fun detachIfAttached(chart: SafeLineChart) { + if (this.chart === chart) { + detach() + } + } + + /** + * Rebuilds the chart's series from the full current history. + */ + @UiThread + abstract fun rebuild() + + /** + * Applies the configuration every metrics chart shares. Subclasses override to add their own -- + * a value formatter, axis range -- and must call through. + */ + @CallSuper + protected open fun configure(chart: SafeLineChart) { + chart.apply { + val colorAccent = context.resolveAttr(R.attr.colorAccent) + + isDragEnabled = false + description.isEnabled = false + xAxis.axisLineColor = colorAccent + axisRight.axisLineColor = colorAccent + + setPinchZoom(false) + setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) + setDrawGridBackground(true) + setScaleEnabled(true) + + // The right axis carries the labels; the left is unused. + axisLeft.isEnabled = false + } + } + + /** + * Installs [datasets] on [chart] and applies the theme colours, then redraws. + */ + protected fun setData( + chart: SafeLineChart, + datasets: Array, + ) { + val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) + val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) + + chart.apply { + data = LineData(*datasets) + axisRight.textColor = textColor + axisLeft.textColor = textColor + legend.textColor = textColor + + data.setValueTextColor(textColor) + setBackgroundColor(bgColor) + setGridBackgroundColor(bgColor) + notifyDataSetChanged() + invalidate() + } + } + + /** + * Redraws after the attached series have been mutated in place. + */ + protected fun redraw(chart: SafeLineChart) { + chart.apply { + data.notifyDataChanged() + notifyDataSetChanged() + invalidate() + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index 2dae31b2f2..74cd5bcaf2 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -22,13 +22,11 @@ import androidx.annotation.UiThread import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.components.YAxis import com.github.mikephil.charting.data.Entry -import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.itsaky.androidide.R import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.NetworkUsageWatcher.NetworkUsage -import com.itsaky.androidide.utils.resolveAttr import kotlin.math.ceil import kotlin.math.log10 import kotlin.math.max @@ -58,43 +56,15 @@ import kotlin.math.roundToLong */ class NetworkUsageChartRenderer( private val usageProvider: () -> NetworkUsage, -) { - private var chart: SafeLineChart? = null - - @UiThread - fun attach(chart: SafeLineChart) { - this.chart = chart - configure(chart) - rebuild() - } - - @UiThread - fun detach() { - chart = null - } - - /** - * Detaches [chart] only if it is the currently attached one. See - * [MemoryUsageChartRenderer.detachIfAttached]. - */ - @UiThread - fun detachIfAttached(chart: SafeLineChart) { - if (this.chart === chart) { - detach() - } - } - +) : MetricsChartRenderer() { /** * Rebuilds both series from the full sample history. */ @UiThread - fun rebuild() { + override fun rebuild() { val chart = this.chart ?: return val usage = usageProvider() - val textColor = chart.context.resolveAttr(R.attr.colorOnSurface) - val bgColor = chart.context.resolveAttr(R.attr.colorSurfaceDim) - val datasets = arrayOf( dataset(usage.received, chart.context.getString(R.string.metrics_network_received), RECEIVED_COLOR), @@ -102,19 +72,7 @@ class NetworkUsageChartRenderer( ) applyAxisRange(chart, usage) - - chart.apply { - data = LineData(*datasets) - axisRight.textColor = textColor - axisLeft.textColor = textColor - legend.textColor = textColor - - data.setValueTextColor(textColor) - setBackgroundColor(bgColor) - setGridBackgroundColor(bgColor) - notifyDataSetChanged() - invalidate() - } + setData(chart, datasets) } /** @@ -145,12 +103,7 @@ class NetworkUsageChartRenderer( update(transmitted, usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted)) applyAxisRange(chart, usage) - - chart.apply { - data.notifyDataChanged() - notifyDataSetChanged() - invalidate() - } + redraw(chart) } private fun dataset( @@ -212,26 +165,14 @@ class NetworkUsageChartRenderer( chart.axisRight.axisMaximum = ceil(peak.toLogBytes()).coerceAtLeast(MIN_AXIS_DECADES) } - private fun configure(chart: SafeLineChart) { - chart.apply { - val colorAccent = context.resolveAttr(R.attr.colorAccent) - - isDragEnabled = false - description.isEnabled = false - xAxis.axisLineColor = colorAccent - axisRight.axisLineColor = colorAccent - - setPinchZoom(false) - setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) - setDrawGridBackground(true) - setScaleEnabled(true) - - axisLeft.isEnabled = false - axisRight.valueFormatter = BytesAxisFormatter + override fun configure(chart: SafeLineChart) { + super.configure(chart) + chart.axisRight.apply { + valueFormatter = BytesAxisFormatter // One label per decade, so the gridlines read as 1 kB / 1 MB rather than arbitrary // fractions of a logarithm. The range itself is set per sample by applyAxisRange. - axisRight.granularity = 1f - axisRight.isGranularityEnabled = true + granularity = 1f + isGranularityEnabled = true } } From e681aa5e2c6a53456cf947d2658e41b706fd0927 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 17:59:01 -0700 Subject: [PATCH 03/30] feat: retain an hour of samples, in a ViewModel, shown as a moving window Retention goes from 30 samples to 3600 -- an hour at the current one second interval -- so that zoom, pan and event annotations have something to work against. Against 30 samples they are close to meaningless. Three parts, each a consequence of the first: History moves into MetricsViewModel. The watchers were fields on the editor activity and survived rotation only because EditorActivityKt happens to declare orientation in its configChanges. Drop that flag, or add a screen that does not declare it, and an hour of history would vanish silently. An activity-scoped ViewModel makes survival a property of the lifecycle rather than a manifest coincidence. It does not survive process death; that is ADFA-5494. The chart shows a window of 60 samples rather than all 3600. Holding an hour is cheap -- about 29KB of longs per series -- but drawing 3600 points per series into a 200dp strip is not, and it would be illegible anyway. MPAndroidChart clips drawing to the visible x range, so a window keeps the cost independent of how much is retained. This is also the shape the zoom feature needs, arrived at from the other direction. The x axis is labelled by age. Sample indices were already meaningless and would now run to 3599. This pulls forward part of the ticket's x-axis-labels step, because 3600 samples made the old labels actively worse rather than merely uninformative. Two bugs found on device that no unit test would have caught: - A bound callable reference evaluates its receiver where it is written. Passing memoryUsageWatcher::getMemoryUsages from a field initializer therefore reached the ViewModel during the activity constructor, which throws "You can't request ViewModel before onCreate call" and made the editor unlaunchable. The providers are lambdas now, so the watcher is resolved per call. - The visible x range is held as a scale factor, so a layout change left the window pointing at a different part of the history: after a rotation the chart showed samples from half an hour earlier, with the axis reading -1979s. The window is re-applied on every redraw rather than only when data is set. Verified on a Pixel 6 Pro (arm64), v8 debug: - Both charts show a rolling 60-second window, x axis reading -59s to now, over an hour-deep buffer. - History survives rotation: the same traffic burst was still on screen after a portrait/landscape round trip, correctly aged from -14s to -29s, with sampling continuous across the change. - Landscape re-verified after the viewport fix; no crashes throughout. - 66 tests green across app ui/utils/activities. Known and deliberate: sampling still stops in onPause, so a backgrounded editor leaves a gap that the evenly-spaced x axis does not represent. Raised on the ticket. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 21 ++++-- .../androidide/ui/MemoryUsageChartRenderer.kt | 2 +- .../androidide/ui/MetricsChartRenderer.kt | 66 ++++++++++++++++++- .../ui/NetworkUsageChartRenderer.kt | 2 +- .../androidide/utils/MemoryUsageWatcher.kt | 7 +- .../androidide/utils/NetworkUsageWatcher.kt | 7 +- .../androidide/viewmodel/MetricsViewModel.kt | 48 ++++++++++++++ 7 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 05ff076332..6d68c24634 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -154,6 +154,7 @@ import com.itsaky.androidide.viewmodel.DebuggerViewModel import com.itsaky.androidide.viewmodel.EditorViewModel import com.itsaky.androidide.viewmodel.FileManagerViewModel import com.itsaky.androidide.viewmodel.FileOpResult +import com.itsaky.androidide.viewmodel.MetricsViewModel import com.itsaky.androidide.viewmodel.RecentProjectsViewModel import com.itsaky.androidide.viewmodel.WADBConnectionViewModel import com.itsaky.androidide.xml.resources.ResourceTableRegistry @@ -189,17 +190,25 @@ abstract class BaseEditorActivity : protected var editorBottomSheet: BottomSheetBehavior? = null private var drawerToggle: ActionBarDrawerToggle? = null private var bottomSheetCallback: BottomSheetBehavior.BottomSheetCallback? = null - protected val memoryUsageWatcher = MemoryUsageWatcher() + private val metricsViewModel by viewModels() + + /** + * Sample history lives in [MetricsViewModel] so it survives configuration changes and activity + * recreation rather than depending on this activity's configChanges declaration (ADFA-5486). + */ + protected val memoryUsageWatcher get() = metricsViewModel.memoryUsageWatcher + + protected val networkUsageWatcher get() = metricsViewModel.networkUsageWatcher + private var metricsPageCallback: ViewPager2.OnPageChangeCallback? = null private val memUsageChartRenderer = MemoryUsageChartRenderer( - usagesProvider = memoryUsageWatcher::getMemoryUsages, + usagesProvider = { memoryUsageWatcher.getMemoryUsages() }, lineColorFor = ::getMemUsageLineColorFor, ) - protected val networkUsageWatcher = NetworkUsageWatcher() private val networkUsageChartRenderer = - NetworkUsageChartRenderer(usageProvider = networkUsageWatcher::getUsage) + NetworkUsageChartRenderer(usageProvider = { networkUsageWatcher.getUsage() }) private val networkUsageListener = NetworkUsageWatcher.NetworkUsageListener { usage -> @@ -539,9 +548,9 @@ abstract class BaseEditorActivity : _binding = null if (isDestroying) { - memoryUsageWatcher.stopWatching(true) + // Sampling itself is stopped by MetricsViewModel.onCleared; the history has to outlive a + // recreation, so it must not be torn down whenever this activity goes away. memoryUsageWatcher.listener = null - networkUsageWatcher.stopWatching() networkUsageWatcher.listener = null editorActivityScope.cancelIfActive("Activity is being destroyed") diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index 5ef7160419..b2d2eceb71 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -49,7 +49,7 @@ import kotlin.math.roundToLong class MemoryUsageChartRenderer( private val usagesProvider: () -> Array, private val lineColorFor: (ProcessMemoryInfo) -> Int, -) : MetricsChartRenderer() { +) : MetricsChartRenderer(sampleIntervalMillis = MemoryUsageWatcher.DEFAULT_UPDATE_INTERVAL) { /** * Maps a watched pid to its dataset index in the attached chart's [LineData]. Empty whenever no * chart is attached. diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index f6e2dd9ddf..bef8b88d74 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -19,10 +19,13 @@ package com.itsaky.androidide.ui import androidx.annotation.CallSuper import androidx.annotation.UiThread +import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.itsaky.androidide.R import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.roundToLong /** * Shared behaviour for the charts on the editor's metrics carousel. @@ -37,7 +40,9 @@ import com.itsaky.androidide.utils.resolveAttr * All methods must be called on the UI thread. MPAndroidChart is not thread-safe; see * [SafeLineChart]. */ -abstract class MetricsChartRenderer { +abstract class MetricsChartRenderer( + private val sampleIntervalMillis: Long, +) { /** * The attached chart, or `null` when no carousel page is bound to this renderer. */ @@ -103,6 +108,47 @@ abstract class MetricsChartRenderer { // The right axis carries the labels; the left is unused. axisLeft.isEnabled = false + + xAxis.valueFormatter = ElapsedTimeFormatter(sampleIntervalMillis) + // One label per 15 samples keeps the window readable without crowding. + xAxis.granularity = X_LABEL_GRANULARITY_SAMPLES + xAxis.isGranularityEnabled = true + } + } + + /** + * Scrolls the viewport to the newest samples, showing [VISIBLE_SAMPLES] of them. + * + * The watchers retain an hour of history (ADFA-5486), far more than is legible at once in a + * 200dp strip and more than is cheap to draw -- MPAndroidChart clips drawing to the visible x + * range, so a window keeps the cost independent of how much is retained. + */ + private fun showNewestWindow(chart: SafeLineChart) { + // xMax is the newest sample's index. entryCount would be the total across every series -- + // 7200 for the network chart's two -- which would scroll the window off the end of the data. + val newestIndex = chart.data?.xMax ?: return + if (newestIndex < VISIBLE_SAMPLES) { + return + } + + chart.setVisibleXRangeMaximum(VISIBLE_SAMPLES.toFloat()) + chart.moveViewToX(newestIndex - VISIBLE_SAMPLES.toFloat() + 1f) + } + + /** + * Labels the x axis by age rather than by sample index, which is meaningless to a reader and + * would run to 3599 at the current retention. + */ + private class ElapsedTimeFormatter( + private val sampleIntervalMillis: Long, + ) : IAxisValueFormatter { + override fun getFormattedValue( + value: Float, + axis: AxisBase?, + ): String { + val newestIndex = (axis?.mAxisMaximum ?: value) + val secondsAgo = ((newestIndex - value) * sampleIntervalMillis / 1000f).roundToLong() + return if (secondsAgo <= 0L) "now" else "-%ds".format(secondsAgo) } } @@ -126,8 +172,9 @@ abstract class MetricsChartRenderer { setBackgroundColor(bgColor) setGridBackgroundColor(bgColor) notifyDataSetChanged() - invalidate() } + showNewestWindow(chart) + chart.invalidate() } /** @@ -137,7 +184,20 @@ abstract class MetricsChartRenderer { chart.apply { data.notifyDataChanged() notifyDataSetChanged() - invalidate() } + // Re-applied on every redraw, not just when data is set: the visible x range is held as a + // scale factor, so a layout change (a rotation, say) leaves the window pointing at a + // different part of the history. Landscape showed samples from half an hour ago. + showNewestWindow(chart) + chart.invalidate() + } + + private companion object { + /** + * Samples shown at once. An hour is retained; a minute is what fits legibly in the strip. + */ + const val VISIBLE_SAMPLES = 60 + + const val X_LABEL_GRANULARITY_SAMPLES = 15f } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index 74cd5bcaf2..7a8632cf8a 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -56,7 +56,7 @@ import kotlin.math.roundToLong */ class NetworkUsageChartRenderer( private val usageProvider: () -> NetworkUsage, -) : MetricsChartRenderer() { +) : MetricsChartRenderer(sampleIntervalMillis = NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL) { /** * Rebuilds both series from the full sample history. */ diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index 21542e582a..bdb97026d3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -85,7 +85,12 @@ class MemoryUsageWatcher } } - const val MAX_USAGE_ENTRIES = 30 + /** + * Samples retained per series: one hour at [DEFAULT_UPDATE_INTERVAL] (ADFA-5486). + * About 29KB of longs per series, so the cost is in drawing rather than holding -- + * see MetricsChartRenderer, which shows a window of this rather than all of it. + */ + const val MAX_USAGE_ENTRIES = 3600 const val DEFAULT_UPDATE_INTERVAL = 1000L private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java) } diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index 09e2f52e34..f3e30757c3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -212,7 +212,12 @@ class NetworkUsageWatcher( } companion object { - const val MAX_USAGE_ENTRIES = 30 + /** + * Samples retained per series: one hour at [DEFAULT_UPDATE_INTERVAL] (ADFA-5486). + * About 29KB of longs per series, so the cost is in drawing rather than holding -- + * see MetricsChartRenderer, which shows a window of this rather than all of it. + */ + const val MAX_USAGE_ENTRIES = 3600 const val DEFAULT_UPDATE_INTERVAL = 1000L /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt new file mode 100644 index 0000000000..ff14c23c22 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt @@ -0,0 +1,48 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.viewmodel + +import androidx.lifecycle.ViewModel +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.NetworkUsageWatcher + +/** + * Owns the sample history behind the editor's metrics carousel. + * + * The watchers used to be fields on the editor activity, and survived rotation only because + * `EditorActivityKt` happens to declare `orientation` in its `configChanges`. Drop that flag, or add + * a screen that does not declare it, and an hour of history would vanish silently. Holding them here + * makes survival a property of the ViewModel lifecycle instead of a manifest coincidence + * (ADFA-5486). + * + * This survives configuration changes and activity recreation. It does not survive the process being + * killed -- see ADFA-5494. + */ +class MetricsViewModel : ViewModel() { + val memoryUsageWatcher = MemoryUsageWatcher() + + val networkUsageWatcher = NetworkUsageWatcher() + + override fun onCleared() { + super.onCleared() + memoryUsageWatcher.listener = null + memoryUsageWatcher.stopWatching(true) + networkUsageWatcher.listener = null + networkUsageWatcher.stopWatching() + } +} From 6e5e2a20295db758cb8862c58748d0194b2b4e95 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 18:07:57 -0700 Subject: [PATCH 04/30] fix: make the x axis labels visible, and sample while backgrounded Two things, both from looking at the device rather than the tests. The x axis labels were never missing. MPAndroidChart defaults every component's text to Color.BLACK. setData gave the y axis and the legend a themed colour and nobody ever gave one to the x axis, so its labels have been drawn black on a near-black surface for as long as the chart has existed. Brightening a screenshot 3.2x shows them sitting there perfectly well formed. That is the "the line chart x axis has no labels" of ADFA-5486: not absent, invisible. One line fixes it. Sampling now continues while the editor is backgrounded. It used to stop in onPause, which was harmless at 30 samples and is not at 3600: the x axis assumes samples are evenly spaced, so any spell in the background made it misreport how old everything to the left of the gap was. Only the listeners are dropped on pause, so nothing redraws a chart nobody is looking at, and sampling itself now lives as long as MetricsViewModel. onResume rebuilds both charts rather than waiting a tick, and only starts a watcher that is not already running -- otherwise every resume logged a spurious "already being watched" warning. This also makes the chart answer a question it could not before: what memory did while you were not looking. Verified by backgrounding the editor for 25 seconds -- the chart came back showing the drop as the app went away, the plateau while it was gone, and the rise on return, all recorded. Battery: one /proc read and one TrafficStats read per second while backgrounded. Modest, and the platform freezes cached processes anyway, which stops it for free. Verified on a Pixel 6 Pro (arm64), v8 debug: - x axis reads -59s / -44s / -29s / -14s in the same colour as the y axis labels. - Background sampling as described; no gap in the history. - No "already being watched" warnings in logcat; no crashes. - 66 tests green across app ui/utils/activities. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 17 +++++++++++++---- .../androidide/ui/MetricsChartRenderer.kt | 5 +++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 6d68c24634..848772ed3e 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -1052,10 +1052,11 @@ abstract class BaseEditorActivity : override fun onPause() { super.onPause() + // Sampling continues while backgrounded so the hour of history has no gaps; the x axis + // assumes evenly spaced samples and would otherwise misreport their age (ADFA-5486). + // Only the listeners go, so nothing updates a chart nobody is looking at. memoryUsageWatcher.listener = null - memoryUsageWatcher.stopWatching(false) networkUsageWatcher.listener = null - networkUsageWatcher.stopWatching() this.isDestroying = isFinishing getFileTreeFragment()?.saveTreeState() @@ -1073,9 +1074,17 @@ abstract class BaseEditorActivity : } memoryUsageWatcher.listener = memoryUsageListener - memoryUsageWatcher.startWatching() networkUsageWatcher.listener = networkUsageListener - networkUsageWatcher.startWatching() + if (!memoryUsageWatcher.isWatching) { + memoryUsageWatcher.startWatching() + } + if (!networkUsageWatcher.isWatching) { + networkUsageWatcher.startWatching() + } + + // Draw whatever was sampled while we were away, rather than waiting for the next tick. + memUsageChartRenderer.rebuild() + networkUsageChartRenderer.rebuild() apkInstallationViewModel.reloadStatus(this) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index bef8b88d74..292797298a 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -167,6 +167,11 @@ abstract class MetricsChartRenderer( axisRight.textColor = textColor axisLeft.textColor = textColor legend.textColor = textColor + // MPAndroidChart defaults every component's text to Color.BLACK. The y axis and legend + // were given a themed colour and the x axis never was, so its labels have always been + // drawn black on a near-black surface -- which is the "x axis has no labels" of + // ADFA-5486. They were there the whole time, just invisible. + xAxis.textColor = textColor data.setValueTextColor(textColor) setBackgroundColor(bgColor) From bd4f2b544f02f912f8e5cf7c72e04f98cdb4c2aa Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 18:27:26 -0700 Subject: [PATCH 05/30] feat: raise retention to 10000 samples and add the sampling-rate policy Groundwork for the tap-on-x-axis rate dialog, which the ticket description now specifies (0.1s to 60s). The dialog itself is not built yet; this is the machinery it will drive. Retention goes from 3600 to 10000 samples. With the rate variable, a sample count no longer means a fixed span: 10000 covers most of three hours at one second and about seventeen minutes at the 0.1s floor. 80KB of longs per series, and drawing cost is unchanged because the chart shows a window rather than the whole buffer. The sampling interval is now settable, and changing it clears the history. The chart reads a sample's age from its position, which assumes every sample is the same age apart; a buffer holding samples taken at two rates would silently misdate all the older ones. The network watcher also drops its cumulative baseline, otherwise the first sample after a change would report every byte since the previous one as a single delta -- a spike at exactly the moment the user changed the rate. MetricsSamplingRates holds the floors: 0.1s on 64-bit hardware, 0.5s on 32-bit. Sampling costs a Debug.getMemoryInfo call per watched process plus two TrafficStats reads every interval, and ten times a second on a weak device is enough to distort what the chart is measuring. Rates a device cannot use are still listed, marked unavailable, rather than hidden -- Rate.isAvailable is what the chooser should grey out. A chooser that silently omitted them would leave the user assuming the IDE cannot sample faster, rather than seeing that their hardware is what costs them the two fastest rates. The floor is keyed on the device's architecture, not the build flavour: a 32-bit build of the IDE running on a 64-bit phone is still running on hardware that can afford the faster rate. Verified on a Pixel 6 Pro (arm64), v8 debug: both charts render unchanged at the higher retention, no crashes. 60 tests green across app ui/utils, 9 of them new. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/utils/MemoryUsageWatcher.kt | 25 ++++- .../androidide/utils/MetricsSamplingRates.kt | 102 ++++++++++++++++++ .../utils/MutableShiftedLongArray.kt | 73 ++++++++----- .../androidide/utils/NetworkUsageWatcher.kt | 37 ++++++- .../utils/MetricsSamplingRatesTest.kt | 87 +++++++++++++++ .../utils/WatcherIntervalChangeTest.kt | 84 +++++++++++++++ 6 files changed, 372 insertions(+), 36 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index bdb97026d3..db7db14b8f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -52,10 +52,24 @@ import kotlin.coroutines.CoroutineContext class MemoryUsageWatcher @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) constructor( - private val updateInterval: Long = DEFAULT_UPDATE_INTERVAL, + updateInterval: Long = DEFAULT_UPDATE_INTERVAL, private val coroutineDispatcher: CoroutineContext = newSingleThreadContext("MemoryUsageWatcher"), private val mainDispatcher: CoroutineContext = Dispatchers.Main.immediate, ) { + /** + * Milliseconds between samples. Changing it clears the history: the chart reads a sample's + * age from its position, which assumes every sample is the same age apart, and a buffer + * holding samples taken at two rates would silently misdate all the older ones (ADFA-5486). + */ + var updateInterval: Long = updateInterval + set(value) { + if (field == value) { + return + } + field = value + clearHistory() + } + private val coroutineScope = CoroutineScope(coroutineDispatcher) private val memoryUsage = ConcurrentHashMap() private val watching = AtomicBoolean(false) @@ -90,7 +104,7 @@ class MemoryUsageWatcher * About 29KB of longs per series, so the cost is in drawing rather than holding -- * see MetricsChartRenderer, which shows a window of this rather than all of it. */ - const val MAX_USAGE_ENTRIES = 3600 + const val MAX_USAGE_ENTRIES = 10000 const val DEFAULT_UPDATE_INTERVAL = 1000L private val log = LoggerFactory.getLogger(MemoryUsageWatcher::class.java) } @@ -207,6 +221,13 @@ class MemoryUsageWatcher ) } + /** + * Discards every recorded sample, keeping the watched processes. + */ + fun clearHistory() { + memoryUsage.values.forEach { it._history.clear() } + } + /** * Returns the memory usage of all the registered processes. */ diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt new file mode 100644 index 0000000000..3fe50358c0 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt @@ -0,0 +1,102 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.itsaky.androidide.app.configuration.CpuArch +import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider + +/** + * The sampling rates the metrics charts offer, and which of them a given device may use + * (ADFA-5486). + * + * Sampling costs a `Debug.getMemoryInfo` call per watched process plus two `TrafficStats` reads, + * every interval. At the fastest rate that is ten times a second, which on weak hardware is enough + * to distort the very thing the chart is measuring. 32-bit devices are therefore held to a slower + * floor than 64-bit ones. + * + * Rates a device cannot use are still listed, marked unavailable, rather than hidden -- a chooser + * that silently omits them leaves the user wondering whether the IDE simply cannot sample faster. + * [Rate.isAvailable] is what a chooser should grey out; [minimumIntervalMillis] is the floor it + * enforces. + */ +object MetricsSamplingRates { + /** Floor for a 64-bit device: ten samples a second. */ + const val MIN_INTERVAL_64_BIT_MS = 100L + + /** Floor for a 32-bit device: two samples a second. */ + const val MIN_INTERVAL_32_BIT_MS = 500L + + /** The slowest rate offered, from the ticket's 0.1s-to-60s range. */ + const val MAX_INTERVAL_MS = 60_000L + + /** + * Every rate the chooser offers, fastest first. + */ + val OFFERED_INTERVALS_MS = + longArrayOf(100L, 200L, 500L, 1_000L, 2_000L, 5_000L, 10_000L, 30_000L, 60_000L) + + /** + * A rate as a chooser should present it. + * + * @property intervalMillis The sampling interval. + * @property isAvailable Whether this device may select it. + */ + data class Rate( + val intervalMillis: Long, + val isAvailable: Boolean, + ) + + /** + * The fastest interval [arch] may sample at. + */ + fun minimumIntervalMillis(arch: CpuArch): Long = if (arch.is64Bit) MIN_INTERVAL_64_BIT_MS else MIN_INTERVAL_32_BIT_MS + + /** + * The fastest interval this device may sample at. + * + * Keyed on the device's architecture rather than the build flavour: a 32-bit build of the IDE + * running on a 64-bit phone is still running on hardware that can afford the faster rate. + */ + fun minimumIntervalMillis(): Long = minimumIntervalMillis(IDEBuildConfigProvider.getInstance().deviceArch) + + /** + * Every offered rate, each marked with whether [arch] may select it. + */ + fun ratesFor(arch: CpuArch): List { + val minimum = minimumIntervalMillis(arch) + return OFFERED_INTERVALS_MS.map { interval -> Rate(interval, isAvailable = interval >= minimum) } + } + + /** + * Clamps [intervalMillis] into the range [arch] may use. + */ + fun coerceToSupportedRange( + intervalMillis: Long, + arch: CpuArch, + ): Long = intervalMillis.coerceIn(minimumIntervalMillis(arch), MAX_INTERVAL_MS) +} + +/** + * Whether this architecture is 64-bit. + */ +val CpuArch.is64Bit: Boolean + get() = + when (this) { + CpuArch.AARCH64, CpuArch.X86_64 -> true + CpuArch.ARM, CpuArch.X86 -> false + } diff --git a/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt b/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt index 7c2bdb59a5..c64496e7c9 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MutableShiftedLongArray.kt @@ -23,37 +23,52 @@ package com.itsaky.androidide.utils * @author Akash Yadav */ class MutableShiftedLongArray( - array: LongArray, - shift: Int = 0 + array: LongArray, + shift: Int = 0, ) : ShiftedLongArray(array, shift) { + /** + * @param capacity The capacity of the array. + * @param shift The shift amount. + * @param init A function to initialize the values of the array. + */ + constructor(capacity: Int, shift: Int = 0, init: (Int) -> Long = { 0 }) : this( + LongArray(capacity, init), + shift, + ) - /** - * @param capacity The capacity of the array. - * @param shift The shift amount. - * @param init A function to initialize the values of the array. - */ - constructor(capacity: Int, shift: Int = 0, init: (Int) -> Long = { 0 }) : this( - LongArray(capacity, init), - shift) + operator fun set( + index: Int, + value: Long, + ) { + checkIdx(index) + array[getShiftedIndex(index)] = value + } - operator fun set(index: Int, value: Long) { - checkIdx(index) - array[getShiftedIndex(index)] = value - } + /** + * Sets the given value at the specified absolute (un-shifted) index. + */ + fun setAbsolute( + index: Int, + value: Long, + ) { + array[index] = value + } - /** - * Sets the given value at the specified absolute (un-shifted) index. - */ - fun setAbsolute(index: Int, value: Long) { - array[index] = value - } + /** + * Resets every element to zero and returns the shift to its starting position, so the array reads + * as though nothing had ever been recorded. + */ + fun clear() { + array.fill(0L) + shift = 0 + } - /** - * Shifts the array by the specified amount. The shift amount is added to the current shift. - * - * @param shift The shift amount. - */ - fun shift(shift: Int) { - this.shift = (this.shift + shift) % size - } -} \ No newline at end of file + /** + * Shifts the array by the specified amount. The shift amount is added to the current shift. + * + * @param shift The shift amount. + */ + fun shift(shift: Int) { + this.shift = (this.shift + shift) % size + } +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index f3e30757c3..59470420c8 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -51,7 +51,7 @@ import java.util.concurrent.atomic.AtomicBoolean * @param readTxBytes Reads the cumulative transmitted byte count. Injectable for tests. */ class NetworkUsageWatcher( - private val updateInterval: Long = DEFAULT_UPDATE_INTERVAL, + updateInterval: Long = DEFAULT_UPDATE_INTERVAL, private val uid: Int = Process.myUid(), private val readRxBytes: (Int) -> Long = TrafficStats::getUidRxBytes, private val readTxBytes: (Int) -> Long = TrafficStats::getUidTxBytes, @@ -61,6 +61,19 @@ class NetworkUsageWatcher( private val coroutineScope = CoroutineScope(coroutineDispatcher) private val watching = AtomicBoolean(false) + /** + * Milliseconds between samples. Changing it clears the history, for the reason given on + * [MemoryUsageWatcher.updateInterval]. + */ + var updateInterval: Long = updateInterval + set(value) { + if (field == value) { + return + } + field = value + clearHistory() + } + /** Guards the two ring buffers: the sampler writes them, the UI thread snapshots them. */ private val historyLock = Any() @@ -103,6 +116,19 @@ class NetworkUsageWatcher( NetworkUsage(received.snapshot(), transmitted.snapshot()) } + /** + * Discards every recorded sample and drops the cumulative baseline, so the next sample + * re-establishes it rather than reporting everything since the last one as one huge delta. + */ + fun clearHistory() { + synchronized(historyLock) { + received.clear() + transmitted.clear() + lastRx = null + lastTx = null + } + } + fun startWatching() { if (isWatching) { log.warn("Network usage is already being watched") @@ -213,11 +239,12 @@ class NetworkUsageWatcher( companion object { /** - * Samples retained per series: one hour at [DEFAULT_UPDATE_INTERVAL] (ADFA-5486). - * About 29KB of longs per series, so the cost is in drawing rather than holding -- - * see MetricsChartRenderer, which shows a window of this rather than all of it. + * Samples retained per series (ADFA-5486). The span this covers depends on the interval: + * under three hours at one second, about seventeen minutes at the 0.1s minimum. 80KB of + * longs per series, so the cost is in drawing rather than holding -- see + * MetricsChartRenderer, which shows a window of this rather than all of it. */ - const val MAX_USAGE_ENTRIES = 3600 + const val MAX_USAGE_ENTRIES = 10000 const val DEFAULT_UPDATE_INTERVAL = 1000L /** [TrafficStats.UNSUPPORTED] widened to [Long], which is what the getters return. */ diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt new file mode 100644 index 0000000000..3958122ab7 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt @@ -0,0 +1,87 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.configuration.CpuArch +import org.junit.Test + +/** + * Pins the sampling-rate policy of ADFA-5486: 0.1s is the floor on 64-bit hardware, 0.5s on 32-bit, + * and a rate a device cannot use is still offered, marked unavailable, so the user can see what the + * hardware is costing them. + */ +class MetricsSamplingRatesTest { + @Test + fun `64-bit devices may sample ten times a second`() { + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.AARCH64)).isEqualTo(100L) + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.X86_64)).isEqualTo(100L) + } + + @Test + fun `32-bit devices are held to twice a second`() { + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.ARM)).isEqualTo(500L) + assertThat(MetricsSamplingRates.minimumIntervalMillis(CpuArch.X86)).isEqualTo(500L) + } + + @Test + fun `every rate is offered to both, with the fast ones unavailable on 32-bit`() { + val on64 = MetricsSamplingRates.ratesFor(CpuArch.AARCH64) + val on32 = MetricsSamplingRates.ratesFor(CpuArch.ARM) + + // The same list either way: a rate the device cannot use is shown and greyed, not hidden, + // so the user knows what they are missing rather than assuming the IDE cannot go faster. + assertThat(on32.map { it.intervalMillis }).isEqualTo(on64.map { it.intervalMillis }) + + assertThat(on64.filter { !it.isAvailable }).isEmpty() + assertThat(on32.filter { !it.isAvailable }.map { it.intervalMillis }) + .containsExactly(100L, 200L) + .inOrder() + } + + @Test + fun `the offered range spans the ticket's 0_1 to 60 seconds`() { + val intervals = MetricsSamplingRates.OFFERED_INTERVALS_MS.toList() + + assertThat(intervals.first()).isEqualTo(100L) + assertThat(intervals.last()).isEqualTo(MetricsSamplingRates.MAX_INTERVAL_MS) + assertThat(intervals).isInOrder() + } + + @Test + fun `an out-of-range interval is clamped to what the device supports`() { + // Faster than the hardware allows. + assertThat(MetricsSamplingRates.coerceToSupportedRange(50L, CpuArch.ARM)).isEqualTo(500L) + assertThat(MetricsSamplingRates.coerceToSupportedRange(50L, CpuArch.AARCH64)).isEqualTo(100L) + + // Slower than the slowest offered. + assertThat(MetricsSamplingRates.coerceToSupportedRange(120_000L, CpuArch.AARCH64)) + .isEqualTo(60_000L) + + // Already in range. + assertThat(MetricsSamplingRates.coerceToSupportedRange(2_000L, CpuArch.ARM)).isEqualTo(2_000L) + } + + @Test + fun `architectures are classified by word size`() { + assertThat(CpuArch.AARCH64.is64Bit).isTrue() + assertThat(CpuArch.X86_64.is64Bit).isTrue() + assertThat(CpuArch.ARM.is64Bit).isFalse() + assertThat(CpuArch.X86.is64Bit).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt new file mode 100644 index 0000000000..2bc56a8e04 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt @@ -0,0 +1,84 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Pins that changing the sampling rate discards the history (ADFA-5486). + * + * The chart reads a sample's age from its position, which assumes every sample is the same age + * apart. A buffer holding samples taken at two rates would silently misdate all the older ones, so + * the history goes when the rate does. + */ +class WatcherIntervalChangeTest { + private fun networkWatcher(readings: List): Pair Unit> { + var index = -1 + val watcher = + NetworkUsageWatcher( + uid = TEST_UID, + readRxBytes = { readings[index.coerceIn(0, readings.lastIndex)] }, + readTxBytes = { readings[index.coerceIn(0, readings.lastIndex)] }, + ) + return watcher to { + index++ + watcher.sampleOnce() + } + } + + @Test + fun `changing the network interval discards the samples`() { + val (watcher, sample) = networkWatcher(listOf(0L, 1_000L, 3_000L)) + repeat(3) { sample() } + assertThat(watcher.getUsage().received.sum()).isGreaterThan(0L) + + watcher.updateInterval = 5_000L + + assertThat(watcher.getUsage().received.sum()).isEqualTo(0L) + assertThat(watcher.getUsage().transmitted.sum()).isEqualTo(0L) + } + + @Test + fun `setting the same network interval keeps the samples`() { + val (watcher, sample) = networkWatcher(listOf(0L, 1_000L)) + repeat(2) { sample() } + val before = watcher.getUsage().received.sum() + + watcher.updateInterval = watcher.updateInterval + + assertThat(watcher.getUsage().received.sum()).isEqualTo(before) + } + + @Test + fun `the cumulative baseline is dropped too`() { + // Otherwise the first sample after the change would report every byte since the last one as + // a single delta -- a spike at exactly the moment the user changed the rate. + val (watcher, sample) = networkWatcher(listOf(0L, 1_000L, 50_000L)) + repeat(2) { sample() } + + watcher.updateInterval = 2_000L + sample() + + assertThat(watcher.getUsage().received.sum()).isEqualTo(0L) + } + + private companion object { + const val TEST_UID = 10_123 + } +} From d551fdc6ce2cc2eeec9b0c695b5b752b0d91f7f9 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 18:43:01 -0700 Subject: [PATCH 06/30] refactor: extract MetricsCarouselController ahead of undocking The carousel's pages, renderers, page-change callback and watcher listeners were spread across BaseEditorActivity. Undocking (ADFA-5486) needs the same carousel built against a floating window's context, so running one is now a thing an object does rather than something an activity is. The activity keeps what is genuinely its own: the status-bar inset on the pager, when to start and stop sampling, and which colour each watched process is drawn in -- the last passed in as a lambda, because the process names it keys on belong to the activity. Binding also takes over the watcher listeners, which is what makes the controller the single owner of "a carousel that is being looked at". onPause unbinds and onResume rebinds; sampling is untouched by either, so the history stays continuous. Worth recording for the undocking work: only one carousel can be live at a time. MemoryUsageWatcher and NetworkUsageWatcher each hold a single listener, not a list, so a second carousel would silently take the updates from the first. Undocking therefore has to move the carousel out of the editor rather than copy it into the window -- which matches how an editor file tab already undocks, leaving the tab row. Behaviour-neutral. Verified on a Pixel 6 Pro (arm64), v8 debug: both pages render, paging works, and backgrounding for 15 seconds and returning shows continuous history across the gap, exercising the unbind/rebind path. 75 tests green across app ui/utils/activities. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 89 ++--------- .../ui/MetricsCarouselController.kt | 151 ++++++++++++++++++ 2 files changed, 167 insertions(+), 73 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 848772ed3e..c85edafdcb 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -66,7 +66,6 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_COLLAPSED import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN @@ -119,10 +118,7 @@ import com.itsaky.androidide.tasks.cancelIfActive import com.itsaky.androidide.tasks.mainThreadHandler import com.itsaky.androidide.ui.CodeEditorView import com.itsaky.androidide.ui.ContentTranslatingDrawerLayout -import com.itsaky.androidide.ui.MemoryUsageChartRenderer -import com.itsaky.androidide.ui.MetricsCarouselAdapter -import com.itsaky.androidide.ui.MetricsPage -import com.itsaky.androidide.ui.NetworkUsageChartRenderer +import com.itsaky.androidide.ui.MetricsCarouselController import com.itsaky.androidide.ui.SwipeRevealLayout import com.itsaky.androidide.uidesigner.UIDesignerActivity import com.itsaky.androidide.utils.ActionMenuUtils.showPopupWindow @@ -132,7 +128,6 @@ import com.itsaky.androidide.utils.FlashType import com.itsaky.androidide.utils.InstallationResultHandler.onResult import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.MemoryUsageWatcher -import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.StringsInjectionException import com.itsaky.androidide.utils.StringsXmlInjector import com.itsaky.androidide.utils.applyBottomSheetAnchorForOrientation @@ -200,20 +195,13 @@ abstract class BaseEditorActivity : protected val networkUsageWatcher get() = metricsViewModel.networkUsageWatcher - private var metricsPageCallback: ViewPager2.OnPageChangeCallback? = null - private val memUsageChartRenderer = - MemoryUsageChartRenderer( - usagesProvider = { memoryUsageWatcher.getMemoryUsages() }, + private val metricsCarousel by lazy { + MetricsCarouselController( + memoryUsageWatcher = memoryUsageWatcher, + networkUsageWatcher = networkUsageWatcher, lineColorFor = ::getMemUsageLineColorFor, ) - - private val networkUsageChartRenderer = - NetworkUsageChartRenderer(usageProvider = { networkUsageWatcher.getUsage() }) - - private val networkUsageListener = - NetworkUsageWatcher.NetworkUsageListener { usage -> - networkUsageChartRenderer.onUsageChanged(usage) - } + } private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null @@ -334,11 +322,6 @@ abstract class BaseEditorActivity : } } - private val memoryUsageListener = - MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> - memUsageChartRenderer.onUsagesChanged(memoryUsage) - } - private val shizukuBinderReceivedListener = Shizuku.OnBinderReceivedListener { invalidateOptionsMenu() @@ -538,13 +521,7 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null - metricsPageCallback?.let { callback -> - _binding?.memUsageView?.metricsPager?.unregisterOnPageChangeCallback(callback) - } - metricsPageCallback = null - _binding?.memUsageView?.metricsPager?.adapter = null - memUsageChartRenderer.detach() - networkUsageChartRenderer.detach() + metricsCarousel.unbind() _binding = null if (isDestroying) { @@ -889,7 +866,6 @@ abstract class BaseEditorActivity : setupMetricsCarousel() watchMemory() - watchNetwork() observeFileOperations() setupGestureDetector() @@ -989,57 +965,27 @@ abstract class BaseEditorActivity : content.editorAppBarLayout.updatePadding(top = topInset) } - memUsageView.metricsPager.updateLayoutParams { + metricsCarousel.pager?.updateLayoutParams { topMargin = (insetsTop * progress).roundToInt() } } } private fun setupMetricsCarousel() { - val pages = - listOf( - // The memory chart is the default page (ADFA-5487); network traffic is the second - // (ADFA-5489), replacing the brand-mark placeholder that ADFA-5487 shipped. - MetricsPage.MemoryChart(title = string.metrics_title_memory), - MetricsPage.NetworkChart(title = string.metrics_title_network), - ) - - binding.memUsageView.metricsPager.adapter = - MetricsCarouselAdapter(pages, memUsageChartRenderer, networkUsageChartRenderer) - - val showTitleFor = { position: Int -> - pages.getOrNull(position)?.let { page -> - binding.memUsageView.metricsTitle.setText(page.title) - } - } - - metricsPageCallback = - object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - showTitleFor(position) - } - }.also { binding.memUsageView.metricsPager.registerOnPageChangeCallback(it) } - - // onPageSelected does not fire for the page the carousel opens on. - showTitleFor(binding.memUsageView.metricsPager.currentItem) + metricsCarousel.bind(binding.memUsageView) } private fun watchMemory() { - memoryUsageWatcher.listener = memoryUsageListener memoryUsageWatcher.watchProcess(Process.myPid(), PROC_IDE) resetMemUsageChart() } - private fun watchNetwork() { - networkUsageWatcher.listener = networkUsageListener - } - /** * Rebuilds the memory chart for the currently watched processes. Call after starting or stopping * watching a process. */ protected fun resetMemUsageChart() { - memUsageChartRenderer.rebuild() + metricsCarousel.onWatchedProcessesChanged() } private fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = @@ -1052,11 +998,10 @@ abstract class BaseEditorActivity : override fun onPause() { super.onPause() - // Sampling continues while backgrounded so the hour of history has no gaps; the x axis - // assumes evenly spaced samples and would otherwise misreport their age (ADFA-5486). - // Only the listeners go, so nothing updates a chart nobody is looking at. - memoryUsageWatcher.listener = null - networkUsageWatcher.listener = null + // Sampling continues while backgrounded so the history has no gaps; the x axis assumes + // evenly spaced samples and would otherwise misreport their age (ADFA-5486). Only the + // carousel goes, so nothing updates a chart nobody is looking at. + metricsCarousel.unbind() this.isDestroying = isFinishing getFileTreeFragment()?.saveTreeState() @@ -1073,8 +1018,7 @@ abstract class BaseEditorActivity : log.warn("Unable to move debugger overlay to display {}", displayId, err) } - memoryUsageWatcher.listener = memoryUsageListener - networkUsageWatcher.listener = networkUsageListener + _binding?.let { metricsCarousel.bind(it.memUsageView) } if (!memoryUsageWatcher.isWatching) { memoryUsageWatcher.startWatching() } @@ -1083,8 +1027,7 @@ abstract class BaseEditorActivity : } // Draw whatever was sampled while we were away, rather than waiting for the next tick. - memUsageChartRenderer.rebuild() - networkUsageChartRenderer.rebuild() + metricsCarousel.refresh() apkInstallationViewModel.reloadStatus(this) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt new file mode 100644 index 0000000000..1583b0d57d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -0,0 +1,151 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import androidx.annotation.UiThread +import androidx.viewpager2.widget.ViewPager2 +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.NetworkUsageWatcher + +/** + * Drives one metrics carousel: its pages, its renderers, and the title that names the current page. + * + * Split out of the editor activity so the carousel can be hosted somewhere else -- specifically a + * floating window, once ADFA-5486's undocking lands. The host supplies a binding to bind to and the + * watchers to read from; everything else about running a carousel lives here. + * + * Only one controller may be live at a time. [MemoryUsageWatcher] and [NetworkUsageWatcher] each + * hold a single listener, so a second carousel would silently take the updates from the first -- + * which is why undocking has to move the carousel out of the editor rather than copy it there. + * + * @param lineColorFor Supplies the plot colour for a watched process. Passed in because the process + * names it keys on belong to the editor activity. + */ +class MetricsCarouselController( + private val memoryUsageWatcher: MemoryUsageWatcher, + private val networkUsageWatcher: NetworkUsageWatcher, + lineColorFor: (MemoryUsageWatcher.ProcessMemoryInfo) -> Int, +) { + private val memoryRenderer = + MemoryUsageChartRenderer( + usagesProvider = { memoryUsageWatcher.getMemoryUsages() }, + lineColorFor = lineColorFor, + ) + + private val networkRenderer = + NetworkUsageChartRenderer(usageProvider = { networkUsageWatcher.getUsage() }) + + private val pages = + listOf( + // The memory chart is the default page (ADFA-5487); network traffic is the second + // (ADFA-5489), replacing the brand-mark placeholder that ADFA-5487 shipped. + MetricsPage.MemoryChart(title = string.metrics_title_memory), + MetricsPage.NetworkChart(title = string.metrics_title_network), + ) + + private val memoryListener = + MemoryUsageWatcher.MemoryUsageListener { memoryUsage -> + memoryRenderer.onUsagesChanged(memoryUsage) + } + + private val networkListener = + NetworkUsageWatcher.NetworkUsageListener { usage -> + networkRenderer.onUsageChanged(usage) + } + + private var binding: LayoutMemUsageBinding? = null + private var pageCallback: ViewPager2.OnPageChangeCallback? = null + + /** + * The pager of the bound carousel, or `null` when nothing is bound. Exposed so a host can apply + * layout that is its own concern, such as the editor's status-bar inset. + */ + val pager: ViewPager2? + get() = binding?.metricsPager + + /** + * Binds the carousel to [binding] and starts feeding it samples. + */ + @UiThread + fun bind(binding: LayoutMemUsageBinding) { + this.binding = binding + + binding.metricsPager.adapter = MetricsCarouselAdapter(pages, memoryRenderer, networkRenderer) + + val showTitleFor = { position: Int -> + pages.getOrNull(position)?.let { page -> + binding.metricsTitle.setText(page.title) + } + } + + pageCallback = + object : ViewPager2.OnPageChangeCallback() { + override fun onPageSelected(position: Int) { + showTitleFor(position) + } + }.also { binding.metricsPager.registerOnPageChangeCallback(it) } + + // onPageSelected does not fire for the page the carousel opens on. + showTitleFor(binding.metricsPager.currentItem) + + memoryUsageWatcher.listener = memoryListener + networkUsageWatcher.listener = networkListener + } + + /** + * Stops feeding the carousel and releases the bound views. Sampling is unaffected -- the + * watchers keep their history, so re-binding shows it in full. + */ + @UiThread + fun unbind() { + if (memoryUsageWatcher.listener === memoryListener) { + memoryUsageWatcher.listener = null + } + if (networkUsageWatcher.listener === networkListener) { + networkUsageWatcher.listener = null + } + + pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } + pageCallback = null + + binding?.metricsPager?.adapter = null + memoryRenderer.detach() + networkRenderer.detach() + binding = null + } + + /** + * Redraws both charts from the full history, for a host coming back to the foreground with + * samples gathered while it was away. + */ + @UiThread + fun refresh() { + memoryRenderer.rebuild() + networkRenderer.rebuild() + } + + /** + * Rebuilds the memory chart for a changed set of watched processes. + */ + @UiThread + fun onWatchedProcessesChanged() { + memoryRenderer.rebuild() + } +} From 6108434f8cd1b0c2d3e7271df6f40aa07f2d568a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 19:09:46 -0700 Subject: [PATCH 07/30] feat: undock the metrics carousel into a floating window A two-finger tap on the carousel floats it over other apps, and the editor shows "Metrics are in a floating window. Tap to bring them back." in the space it vacates. Tapping that message, or the window's own dock control, brings it back. Undocking moves the carousel rather than copying it. MemoryUsageWatcher and NetworkUsageWatcher hold a single listener each, so two live carousels would mean the second silently taking the first one's updates. MetricsCarouselDockableContent therefore rebinds the editor's own MetricsCarouselController into the window, and the editor shows the message instead. That also matches how an editor file tab undocks, leaving the tab row. The history is untouched by the move: the watchers own it, so the carousel is redrawn in full wherever it binds. Without the message the reveal would open on an empty strip, which reads as broken, and a window dragged off screen would leave no way back. The gesture is recognised in dispatchTouchEvent, not onInterceptTouchEvent. ViewPager2's RecyclerView calls requestDisallowInterceptTouchEvent on its parents the moment a second pointer lands, and a ViewGroup only calls onInterceptTouchEvent while that flag is clear -- so the first version saw the two fingers arrive and never saw them leave. It fired on nothing. dispatchTouchEvent is delivered first and the flag does not affect it. The unit tests did not catch that, because they called onInterceptTouchEvent directly: they proved the recogniser's logic and not that the framework would ever call it. They now drive dispatchTouchEvent, which is what actually happens. Same failure as the chart axis earlier in this ticket -- a green test over a wire that was never connected. Also generalises the project-close teardown. closeAll released resources only for EditorPanelDockableContent, so any other content type would be removed from DockingManager without being told; it now gets onDestroyView, which is how the carousel unbinds its controller. Verified on a Pixel 6 Pro (arm64), v8 debug, the two-finger taps done by hand because adb cannot inject multi-touch and sendevent needs root: - Two-finger tap undocks; the window shows the carousel with its chrome and the editor shows the message. - Tapping the message re-docks, and the chart returns with its history intact across the float. - FloatingTabService starts on undock and stops on re-dock; no leaked service, no crashes. - 508 tests green across the app module, 5 of them new for the gesture. Known gap: the two-finger tap cannot be exercised in CI for the same reason it could not be scripted here. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 51 ++++- .../editor/EditorHandlerActivity.kt | 19 ++ .../floating/IdeFloatingTabController.kt | 50 +++++ .../MetricsCarouselDockableContent.kt | 81 ++++++++ .../androidide/ui/MetricsCarouselLayout.kt | 91 +++++++++ app/src/main/res/layout/layout_mem_usage.xml | 21 ++ .../ui/MetricsCarouselLayoutTest.kt | 182 ++++++++++++++++++ resources/src/main/res/values/strings.xml | 2 + 8 files changed, 493 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index c85edafdcb..1904073279 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -59,6 +59,7 @@ import androidx.core.os.BundleCompat import androidx.core.view.GravityCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment @@ -195,7 +196,7 @@ abstract class BaseEditorActivity : protected val networkUsageWatcher get() = metricsViewModel.networkUsageWatcher - private val metricsCarousel by lazy { + protected val metricsCarousel by lazy { MetricsCarouselController( memoryUsageWatcher = memoryUsageWatcher, networkUsageWatcher = networkUsageWatcher, @@ -973,6 +974,44 @@ abstract class BaseEditorActivity : private fun setupMetricsCarousel() { metricsCarousel.bind(binding.memUsageView) + binding.memUsageView.root.onTwoFingerTap = ::onMetricsCarouselUndockRequested + binding.memUsageView.metricsUndockedMessage.setOnClickListener { + onMetricsCarouselRedockRequested() + } + } + + /** + * A two-finger tap on the carousel asks for it to be floated. Overridden where the floating + * window machinery lives; a no-op here. + */ + protected open fun onMetricsCarouselUndockRequested() = Unit + + /** Whether the carousel is currently floating rather than docked here. */ + protected open fun isMetricsCarouselUndocked(): Boolean = false + + /** A tap on the "tap to bring them back" message asks for the floating carousel to re-dock. */ + protected open fun onMetricsCarouselRedockRequested() = Unit + + /** + * Swaps the carousel for the message explaining where it has gone, or back again. + * + * Only one carousel can be live at a time, so undocking moves it out of the editor. Without the + * message the reveal would open on an empty strip, and a window dragged off screen would leave + * no way back. + */ + @UiThread + protected fun setMetricsCarouselUndocked(undocked: Boolean) { + val view = _binding?.memUsageView ?: return + view.metricsPager.isVisible = !undocked + view.metricsTitle.isVisible = !undocked + view.metricsUndockedMessage.isVisible = undocked + + if (undocked) { + metricsCarousel.unbind() + } else { + metricsCarousel.bind(view) + metricsCarousel.refresh() + } } private fun watchMemory() { @@ -1018,7 +1057,9 @@ abstract class BaseEditorActivity : log.warn("Unable to move debugger overlay to display {}", displayId, err) } - _binding?.let { metricsCarousel.bind(it.memUsageView) } + if (!isMetricsCarouselUndocked()) { + _binding?.let { metricsCarousel.bind(it.memUsageView) } + } if (!memoryUsageWatcher.isWatching) { memoryUsageWatcher.startWatching() } @@ -1026,8 +1067,10 @@ abstract class BaseEditorActivity : networkUsageWatcher.startWatching() } - // Draw whatever was sampled while we were away, rather than waiting for the next tick. - metricsCarousel.refresh() + if (!isMetricsCarouselUndocked()) { + // Draw whatever was sampled while away, rather than waiting for the next tick. + metricsCarousel.refresh() + } apkInstallationViewModel.reloadStatus(this) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 255a18bc08..382fba636c 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -66,6 +66,7 @@ import com.itsaky.androidide.databinding.FileActionPopupWindowBinding import com.itsaky.androidide.databinding.FileActionPopupWindowItemBinding import com.itsaky.androidide.deeplink.PendingDeepLinkOpen import com.itsaky.androidide.di.APPLICATION_SCOPE +import com.itsaky.androidide.editor.floating.MetricsCarouselDockableContent import com.itsaky.androidide.editor.language.treesitter.JavaLanguage import com.itsaky.androidide.editor.language.treesitter.JsonLanguage import com.itsaky.androidide.editor.language.treesitter.KotlinLanguage @@ -903,6 +904,24 @@ open class EditorHandlerActivity : return if (child is CodeEditorView) child else null } + override fun onMetricsCarouselUndockRequested() { + floatingTabController.floatMetricsCarousel( + controller = metricsCarousel, + title = getString(string.metrics_carousel_window_title), + ) { setMetricsCarouselUndocked(true) } + } + + override fun onMetricsCarouselRedockRequested() { + floatingTabController.redockMetricsCarousel() + } + + override fun isMetricsCarouselUndocked(): Boolean = DockingManager.isFloating(MetricsCarouselDockableContent.ID) + + /** The floating carousel has closed or re-docked; put the editor's own carousel back. */ + fun onFloatingMetricsCarouselGone() { + setMetricsCarouselUndocked(false) + } + /** Undock the file tab at [fileIndex] into a floating window over other apps. */ fun undockFileTab(fileIndex: Int) { floatingTabController.undock(fileIndex) diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt index 69a6b95210..37421b5044 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/IdeFloatingTabController.kt @@ -12,6 +12,7 @@ import com.itsaky.androidide.floating.permission.OverlayPermission import com.itsaky.androidide.floating.service.FloatingTabService import com.itsaky.androidide.floating.window.InitialBounds import com.itsaky.androidide.resources.R +import com.itsaky.androidide.ui.MetricsCarouselController import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import org.slf4j.LoggerFactory @@ -62,6 +63,36 @@ class IdeFloatingTabController( } } + /** + * Float the metrics carousel, moving it out of the editor. [MetricsCarouselDockableContent] + * rebinds the same controller, since only one carousel may be live at a time. + */ + fun floatMetricsCarousel( + controller: MetricsCarouselController, + title: String, + onUndocked: () -> Unit, + ) { + if (!OverlayPermission.canDrawOverlays(activity)) { + activity.startActivity(OverlayPermission.requestIntent(activity)) + return + } + if (DockingManager.isFloating(MetricsCarouselDockableContent.ID)) { + return + } + + onUndocked() + DockingManager.undock( + MetricsCarouselDockableContent(controller, title), + InitialBounds.cascaded(activity, undockCounter++), + ) + FloatingTabService.ensureRunning(activity.applicationContext) + } + + /** Bring the floating metrics carousel back into the editor. */ + fun redockMetricsCarousel() { + DockingManager.dock(MetricsCarouselDockableContent.ID) + } + fun floatPluginTab( tabId: String, title: String, @@ -101,6 +132,15 @@ class IdeFloatingTabController( } DockingManager.remove(tab.id) panel?.release() + + // DockingManager.remove does not run the window's teardown -- reconcile only dismisses + // windows it still knows about -- so content that holds resources has to be told + // directly. Editor panels have release() above; everything else gets onDestroyView, + // which is what the metrics carousel uses to unbind its controller. + if (panel == null) { + runCatching { tab.content.onDestroyView() } + .onFailure { log.error("Failed to release floating content {}", tab.id, it) } + } } } @@ -133,6 +173,16 @@ class IdeFloatingTabController( activity.selectPluginTabById(content.tabId) } } + + is MetricsCarouselDockableContent -> { + // onDestroyView has already unbound the controller from the window, so the editor + // only has to put its own carousel back. Done for Close as well as Redock: closing + // the window must not leave the editor showing "tap to bring them back" forever. + if (event is DockingEvent.Redock) { + bringIdeToFront() + } + activity.onFloatingMetricsCarouselGone() + } } } diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt new file mode 100644 index 0000000000..261657686a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt @@ -0,0 +1,81 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.editor.floating + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.floating.model.DockableContent +import com.itsaky.androidide.floating.window.FloatingWindowHost +import com.itsaky.androidide.ui.MetricsCarouselController + +/** + * Adapts the editor's metrics carousel to [DockableContent] so it can float over other apps + * (ADFA-5486). + * + * The window rebinds the editor's own [MetricsCarouselController] rather than building a second + * one. Only one carousel can be live at a time -- the watchers hold a single listener each -- so + * undocking moves the carousel out of the editor rather than copying it, which is also how an + * editor file tab undocks. The editor shows a "tap to bring them back" message in the space it + * vacates. + * + * The sample history is unaffected by the move: the watchers own it, so the carousel is redrawn in + * full wherever it is bound. + * + * @property controller The carousel to rebind into this window. + * @property title Window title, resolved by the caller against the IDE's resources. + */ +class MetricsCarouselDockableContent( + private val controller: MetricsCarouselController, + override val title: String, +) : DockableContent { + override val id: String = ID + + override fun onCreateView( + context: Context, + host: FloatingWindowHost, + ): View { + val binding = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + + // The editor sizes the carousel to a fixed strip; in a window it should fill whatever the + // user has dragged the frame out to. + binding.root.layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + + // A two-finger tap is what undocked it; inside the window the chrome's dock control is the + // way back, so the gesture would only be a second, less discoverable route. + binding.root.onTwoFingerTap = null + + controller.bind(binding) + return binding.root + } + + override fun onDestroyView() { + controller.unbind() + } + + companion object { + /** Stable id, shared with the docked carousel this content was undocked from. */ + const val ID = "ide.metrics.carousel" + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt index 79dd92c872..b0f46b0600 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -20,7 +20,10 @@ package com.itsaky.androidide.ui import android.content.Context import android.util.AttributeSet import android.view.MotionEvent +import android.view.ViewConfiguration import androidx.constraintlayout.widget.ConstraintLayout +import org.slf4j.LoggerFactory +import kotlin.math.hypot /** * Host for the editor's metrics carousel, which claims horizontal gestures that begin inside it. @@ -46,6 +49,29 @@ class MetricsCarouselLayout attrs: AttributeSet? = null, defStyleAttr: Int = 0, ) : ConstraintLayout(context, attrs, defStyleAttr) { + /** + * Invoked on a two-finger tap anywhere in the carousel, which undocks it into a floating + * window (ADFA-5486). + */ + var onTwoFingerTap: (() -> Unit)? = null + + private var twoFingerDownAt = 0L + private var twoFingerDownX = 0f + private var twoFingerDownY = 0f + private var twoFingerTapCandidate = false + + /** + * The gesture is watched here rather than in [onInterceptTouchEvent] because ViewPager2's + * RecyclerView calls `requestDisallowInterceptTouchEvent` on its parents as soon as a second + * pointer lands, and a ViewGroup only calls `onInterceptTouchEvent` while that flag is + * clear. Watching from there saw the two fingers arrive and never saw them leave. + * `dispatchTouchEvent` is delivered first and is unaffected by the flag. + */ + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + trackTwoFingerTap(ev) + return super.dispatchTouchEvent(ev) + } + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (ev.actionMasked == MotionEvent.ACTION_DOWN) { // Cleared by the framework on the next ACTION_DOWN, so this lasts exactly one gesture. @@ -53,4 +79,69 @@ class MetricsCarouselLayout } return super.onInterceptTouchEvent(ev) } + + /** + * Recognises a two-finger tap: a second finger lands, neither travels far, and one lifts + * again quickly. Movement disqualifies it so a pinch is never mistaken for a tap, which + * matters because pinch-to-zoom shares this view. + */ + private fun trackTwoFingerTap(ev: MotionEvent) { + if (log.isDebugEnabled) { + log.debug( + "carousel touch action={} pointers={} candidate={}", + ev.actionMasked, + ev.pointerCount, + twoFingerTapCandidate, + ) + } + when (ev.actionMasked) { + // Start every gesture clean; a truncated one must not leave a candidate behind. + MotionEvent.ACTION_DOWN -> { + twoFingerTapCandidate = false + } + + MotionEvent.ACTION_POINTER_DOWN -> { + if (ev.pointerCount == 2) { + twoFingerTapCandidate = true + twoFingerDownAt = ev.eventTime + twoFingerDownX = ev.getX(0) + twoFingerDownY = ev.getY(0) + } else { + // A third finger is not this gesture. + twoFingerTapCandidate = false + } + } + + MotionEvent.ACTION_MOVE -> { + if (twoFingerTapCandidate && ev.pointerCount >= 1) { + val travel = hypot(ev.getX(0) - twoFingerDownX, ev.getY(0) - twoFingerDownY) + if (travel > touchSlop) { + twoFingerTapCandidate = false + } + } + } + + MotionEvent.ACTION_POINTER_UP -> { + val heldFor = ev.eventTime - twoFingerDownAt + log.debug("carousel two-finger up: candidate={} heldFor={}ms limit={}ms", twoFingerTapCandidate, heldFor, tapTimeout) + if (twoFingerTapCandidate && heldFor <= tapTimeout) { + twoFingerTapCandidate = false + log.debug("carousel two-finger tap recognised") + onTwoFingerTap?.invoke() + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + twoFingerTapCandidate = false + } + } + } + + private val log = LoggerFactory.getLogger(MetricsCarouselLayout::class.java) + + private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop + + // A person's two-finger tap is far slower than the single-finger tap timeout: the two + // fingers land and lift out of step. Anything shorter than a long press counts. + private val tapTimeout = ViewConfiguration.getLongPressTimeout().toLong() } diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index e78b5f9dc9..e87f7f2c65 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -39,4 +39,25 @@ tools:text="Memory usage" xmlns:tools="http://schemas.android.com/tools" /> + + + diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt new file mode 100644 index 0000000000..e39cc4fc1b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt @@ -0,0 +1,182 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewConfiguration +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins the two-finger tap that undocks the metrics carousel (ADFA-5486). + * + * The gesture cannot be injected on an unrooted device -- `adb input` has no multi-touch and + * `sendevent` needs root -- so the recogniser is exercised here with the same MotionEvents it would + * receive, including the pinch it must not mistake for a tap. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCarouselLayoutTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun layout() = MetricsCarouselLayout(context) + + private var downTime = 0L + + private fun event( + action: Int, + vararg points: Pair, + eventTime: Long = downTime, + ): MotionEvent { + val properties = + Array(points.size) { index -> + MotionEvent.PointerProperties().apply { + id = index + toolType = MotionEvent.TOOL_TYPE_FINGER + } + } + val coords = + Array(points.size) { index -> + MotionEvent.PointerCoords().apply { + x = points[index].first + y = points[index].second + pressure = 1f + size = 1f + } + } + return MotionEvent.obtain( + downTime, + eventTime, + action, + points.size, + properties, + coords, + 0, + 0, + 1f, + 1f, + 0, + 0, + 0, + 0, + ) + } + + private fun pointerDown(index: Int): Int = MotionEvent.ACTION_POINTER_DOWN or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + + private fun pointerUp(index: Int): Int = MotionEvent.ACTION_POINTER_UP or (index shl MotionEvent.ACTION_POINTER_INDEX_SHIFT) + + /** + * Drives one gesture through the layout the way the framework does. + * + * Via dispatchTouchEvent, not onInterceptTouchEvent: these tests passed against a recogniser + * that never fired on a device, because ViewPager2 stops the parent's onInterceptTouchEvent + * being called the moment a second pointer lands. Calling the method under test directly proved + * the logic and not the wiring. + */ + private fun MetricsCarouselLayout.dispatch(vararg events: MotionEvent) { + events.forEach { event -> + dispatchTouchEvent(event) + event.recycle() + } + } + + @Test + fun `a two-finger tap fires the callback`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(pointerUp(1), 500f to 450f, 900f to 450f, eventTime = downTime + 40L), + event(MotionEvent.ACTION_UP, 500f to 450f, eventTime = downTime + 50L), + ) + + assertThat(taps).isEqualTo(1) + } + + @Test + fun `a single-finger tap does not fire it`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(MotionEvent.ACTION_UP, 500f to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a pinch is not a tap`() { + // The carousel is also meant to pinch-to-zoom, so movement has to disqualify the tap. + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val travel = ViewConfiguration.get(context).scaledTouchSlop * 4f + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(MotionEvent.ACTION_MOVE, 500f - travel to 450f, 900f + travel to 450f, eventTime = downTime + 20L), + event(pointerUp(1), 500f - travel to 450f, 900f + travel to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a long two-finger hold is not a tap`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val tooLong = ViewConfiguration.getTapTimeout().toLong() * 5 + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(pointerUp(1), 500f to 450f, 900f to 450f, eventTime = downTime + tooLong), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `three fingers are not a two-finger tap`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(pointerDown(2), 500f to 450f, 900f to 450f, 700f to 600f), + event(pointerUp(2), 500f to 450f, 900f to 450f, 700f to 600f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 1b9946fa0c..7cd65f4593 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1677,6 +1677,8 @@ Memory usage Network traffic chart Network traffic + Metrics are in a floating window.\nTap to bring them back. + Metrics Received Sent From 78ef79b3526ae84ee6612f73411e195ddd26b9b7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Fri, 4 Sep 2026 19:51:40 -0700 Subject: [PATCH 08/30] fix: make the sampling loop stoppable, restartable and crash-proof Three defects raised in review of ADFA-5487/5489, all in the same few lines and all present in both watchers. stopWatching() could not stop the sampler. The loop was launched with `launch(context = SupervisorJob() + dispatcher)`, which gives the coroutine its own parent job, so the watcher's scope could not cancel it: it ran on until it next observed the `watching` flag, and it spends almost all of its time asleep in `delay(updateInterval)`. Stop and start inside that window and the old loop woke up, saw the flag set again, and carried on beside the new one -- two samplers writing history and notifying the chart. The window is as wide as the interval, which ADFA-5486 made configurable up to sixty seconds. The job is now stored and cancelled. An exception ended sampling permanently. A throw anywhere in the body killed the coroutine while `watching` stayed true, so every later startWatching() was refused as "already watching" and the chart silently stopped updating for the rest of the session. A misbehaving listener was enough. The body is guarded now: a sample is worth losing, the loop is not. CancellationException is rethrown so cancellation still works. The dispatcher was never closed. `newSingleThreadContext` holds a thread until closed, and nothing closed it. close() is separate from stopWatching() because the watcher is stopped and restarted across the editor's lifecycle; only the terminal teardown should give up the thread. MetricsViewModel.onCleared calls it. startWatching() also uses compareAndSet rather than a check followed by a set, so two callers cannot both pass the guard. Tests: 5 new lifecycle tests. Verified they fail without the fix, though the first one fails by hanging rather than by asserting -- with the loop unstoppable, runTest never drains the scheduler. That is the bug seen from the inside, and it is why each test now closes its watcher. Verified on a Pixel 6 Pro (arm64), v8 debug: chart samples continuously across a background/foreground cycle, no crashes, nothing logged from the new failure guard. 70 tests green across app ui/utils. Addresses CodeRabbit findings on #1784. ADFA-5486 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/utils/MemoryUsageWatcher.kt | 73 +++++-- .../androidide/utils/NetworkUsageWatcher.kt | 70 +++++-- .../androidide/viewmodel/MetricsViewModel.kt | 8 +- .../androidide/utils/WatcherLifecycleTest.kt | 178 ++++++++++++++++++ 4 files changed, 289 insertions(+), 40 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/utils/WatcherLifecycleTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index db7db14b8f..2f35dc28a3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -26,10 +26,13 @@ import androidx.core.content.getSystemService import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.tasks.cancelIfActive import com.termux.shared.reflection.ReflectionUtils +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -70,7 +73,10 @@ class MemoryUsageWatcher clearHistory() } - private val coroutineScope = CoroutineScope(coroutineDispatcher) + private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) + + /** The running sampling loop, so [stopWatching] can actually stop it. */ + private var samplingJob: Job? = null private val memoryUsage = ConcurrentHashMap() private val watching = AtomicBoolean(false) @@ -113,31 +119,40 @@ class MemoryUsageWatcher * Start watching processes for their memory usage. */ fun startWatching() { - if (isWatching) { + if (!watching.compareAndSet(false, true)) { log.warn("Processes are already being watched for memory usage") return } - watching.set(true) - - coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { - while (isWatching) { - readUsages() - - // don't bother to update if no listeners are set - listener?.also { listener -> - val usages = MutableIntObjectMap(memoryUsage.size) - for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { - usages[pid] = usage + samplingJob = + coroutineScope.launch { + while (isWatching) { + // A throw here used to end the coroutine while `watching` stayed true, so + // every later startWatching() was refused as "already watching" and + // sampling stopped for good. A sample is worth losing; the loop is not. + runCatching { + readUsages() + + // don't bother to update if no listeners are set + listener?.also { listener -> + val usages = MutableIntObjectMap(memoryUsage.size) + for ((pid, usage) in this@MemoryUsageWatcher.memoryUsage) { + usages[pid] = usage + } + withContext(mainDispatcher) { + listener.onMemoryUsageChanged(usages) + } + } + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Memory usage sampling failed; continuing", failure) } - withContext(mainDispatcher) { - listener.onMemoryUsageChanged(usages) - } - } - delay(updateInterval) + delay(updateInterval) + } } - } } private fun readUsages() { @@ -271,7 +286,25 @@ class MemoryUsageWatcher unwatchAll() } watching.set(false) - coroutineScope.cancelIfActive("Cancellation requested") + // Cancelled rather than left to notice the flag: the loop spends almost all its time in + // delay(updateInterval), up to a minute at the slowest rate, so a stop followed by a + // start inside that window would leave the old loop running alongside the new one. + samplingJob?.cancel() + samplingJob = null + } + + /** + * Stops sampling and releases the sampling thread. The watcher cannot be started again. + * + * Separate from [stopWatching] because a watcher is stopped and restarted across the + * editor's lifecycle; only a terminal teardown should give up the thread, and + * `newSingleThreadContext` holds one until it is closed. + */ + fun close() { + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() } /** diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index 59470420c8..f11cff2384 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -21,10 +21,13 @@ import android.net.TrafficStats import android.os.Process import androidx.annotation.VisibleForTesting import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExecutorCoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -58,9 +61,12 @@ class NetworkUsageWatcher( ) { @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) private val coroutineDispatcher = newSingleThreadContext("NetworkUsageWatcher") - private val coroutineScope = CoroutineScope(coroutineDispatcher) + private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) private val watching = AtomicBoolean(false) + /** The running sampling loop, so [stopWatching] can actually stop it. */ + private var samplingJob: Job? = null + /** * Milliseconds between samples. Changing it clears the history, for the reason given on * [MemoryUsageWatcher.updateInterval]. @@ -130,32 +136,64 @@ class NetworkUsageWatcher( } fun startWatching() { - if (isWatching) { + if (!watching.compareAndSet(false, true)) { log.warn("Network usage is already being watched") return } - watching.set(true) - - coroutineScope.launch(context = SupervisorJob() + coroutineDispatcher) { - while (isWatching) { - sampleOnce() - - listener?.also { listener -> - val usage = getUsage() - withContext(Dispatchers.Main.immediate) { - listener.onNetworkUsageChanged(usage) + samplingJob = + coroutineScope.launch { + while (isWatching) { + // A throw here used to end the coroutine while `watching` stayed true, so every + // later startWatching() was refused as "already watching" and sampling stopped + // for good. A sample is worth losing; the loop is not. + runCatching { + sampleOnce() + + listener?.also { listener -> + val usage = getUsage() + withContext(Dispatchers.Main.immediate) { + listener.onNetworkUsageChanged(usage) + } + } + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Network usage sampling failed; continuing", failure) } - } - delay(updateInterval) + delay(updateInterval) + } } - } } + /** + * Stops sampling. The watcher can be started again; [close] is what makes it unusable. + * + * The job is cancelled rather than left to notice the flag: it spends almost all its time in + * `delay(updateInterval)`, which is up to a minute at the slowest rate, so a stop followed by a + * start inside that window would leave the old loop running alongside the new one, both + * recording samples and notifying the chart. + */ fun stopWatching() { watching.set(false) - coroutineScope.cancelIfActive("Cancellation requested") + samplingJob?.cancel() + samplingJob = null + } + + /** + * Stops sampling and releases the sampling thread. The watcher cannot be started again. + * + * Separate from [stopWatching] because a watcher is stopped and restarted across the editor's + * lifecycle; only a terminal teardown should give up the thread, and `newSingleThreadContext` + * holds one until it is closed. + */ + fun close() { + stopWatching() + listener = null + coroutineScope.cancelIfActive("Watcher closed") + (coroutineDispatcher as? ExecutorCoroutineDispatcher)?.close() } /** diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt index ff14c23c22..b4261668bd 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt @@ -40,9 +40,9 @@ class MetricsViewModel : ViewModel() { override fun onCleared() { super.onCleared() - memoryUsageWatcher.listener = null - memoryUsageWatcher.stopWatching(true) - networkUsageWatcher.listener = null - networkUsageWatcher.stopWatching() + // close(), not stopWatching(): this is the terminal teardown, and each watcher holds a + // dedicated sampling thread that newSingleThreadContext keeps alive until it is closed. + memoryUsageWatcher.close() + networkUsageWatcher.close() } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/WatcherLifecycleTest.kt b/app/src/test/java/com/itsaky/androidide/utils/WatcherLifecycleTest.kt new file mode 100644 index 0000000000..a0e592680b --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/WatcherLifecycleTest.kt @@ -0,0 +1,178 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Pins the sampling loop's lifecycle, from three defects found in review of ADFA-5487/5489. + * + * The loop used to be launched with its own `SupervisorJob`, which meant the watcher's scope could + * not cancel it: it ran until it next observed the `watching` flag, and it spends nearly all its + * time asleep in `delay(updateInterval)` -- up to a minute at the slowest rate now that the rate is + * configurable. And an exception anywhere in the body ended the coroutine while the flag stayed + * set, so sampling stopped for good and every later restart was refused. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WatcherLifecycleTest { + @Test + fun `restarting inside the sampling interval does not leave two loops running`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 1_000L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(1_500L) + val afterFirstRun = samples + + // Stop and start again while the loop is asleep mid-interval. The old loop used to wake + // up, see the flag set again, and carry on beside the new one. + watcher.stopWatching(unwatchAll = false) + watcher.startWatching() + advanceTimeBy(3_000L) + + // Three more intervals, one sampler: three more samples, not six. + val duringSecondRun = samples - afterFirstRun + assertThat(duringSecondRun).isAtMost(4) + + watcher.close() + } + + @Test + fun `stopping actually stops sampling`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(500L) + watcher.stopWatching(unwatchAll = false) + val atStop = samples + + advanceTimeBy(2_000L) + + assertThat(samples).isEqualTo(atStop) + assertThat(watcher.isWatching).isFalse() + } + + @Test + fun `a listener that throws does not kill sampling`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var notifications = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = + MemoryUsageWatcher.MemoryUsageListener { + notifications++ + throw IllegalStateException("listener blew up") + } + + watcher.startWatching() + advanceTimeBy(1_000L) + + // The loop used to die on the first throw, leaving isWatching true so nothing could + // restart it. It should keep sampling instead. + assertThat(notifications).isAtLeast(5) + assertThat(watcher.isWatching).isTrue() + + // runTest drains the scheduler when the test ends, which an unstopped loop never lets + // it do. + watcher.close() + } + + @Test + fun `a watcher can be restarted after a listener throws`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var notifications = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = + MemoryUsageWatcher.MemoryUsageListener { + notifications++ + throw IllegalStateException("listener blew up") + } + + watcher.startWatching() + advanceTimeBy(300L) + watcher.stopWatching(unwatchAll = false) + + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { notifications++ } + watcher.startWatching() + val beforeRestart = notifications + advanceTimeBy(500L) + + assertThat(watcher.isWatching).isTrue() + assertThat(notifications).isGreaterThan(beforeRestart) + + watcher.close() + } + + @Test + fun `close stops sampling and refuses to restart`() = + runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + var samples = 0 + val watcher = + MemoryUsageWatcher( + updateInterval = 100L, + coroutineDispatcher = dispatcher, + mainDispatcher = dispatcher, + ) + watcher.listener = MemoryUsageWatcher.MemoryUsageListener { samples++ } + + watcher.startWatching() + advanceTimeBy(300L) + watcher.close() + val atClose = samples + + // The scope is cancelled, so a restart launches nothing. + watcher.startWatching() + advanceTimeBy(1_000L) + + assertThat(samples).isEqualTo(atClose) + } +} From ebe687478c6eb3e701947a8bf9593c83b3d2b6c7 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 10:31:09 -0700 Subject: [PATCH 09/30] feat: annotate the metrics charts with Gradle task events Significant events are Gradle task starts and stops, drawn as dashed vertical markers labelled with the task name. Gradle emits those far faster than a chart can show them -- an incremental build blasts through dozens of up-to-date tasks in a second or two -- so MetricsAnnotationStore throttles to at most one every five seconds and keeps the first of each quiet period, since the interesting moment is when work began rather than an arbitrary one from the middle of a burst. Annotations are stored by wall-clock time, not by sample position. The charts hold a ring buffer whose contents shift under them, so a stored index would drift; the renderer converts a timestamp to an x position from its age at draw time, and anything older than the buffer holds falls outside the axis. A marker therefore travels left with the data and leaves the visible window, which is what it should do. The events already reached EditorBuildEventListener.onProgressEvent for the status line, so this needed no new plumbing -- only a second use of the same TaskStartEvent, plus TaskFinishEvent. Worth recording, because it would have shipped silently broken: lastRecordedAt started at Long.MIN_VALUE, so `now - lastRecordedAt` overflowed to a negative gap on the very first call. That reads as "inside the throttle window", so the store swallowed every annotation for its entire life and nothing anywhere reported an error. All seven tests caught it on their first run. It is nullable now. Verified on a Pixel 6 Pro (arm64), v8 debug: a project sync records nothing, correctly -- a sync configures and emits no task events -- and a build draws a marker at :app:preBuild, confirmed on screen. The rest of that build's tasks completed inside the five-second window and were collapsed into that one marker, which is the throttle working as specified. 7 new tests; 77 green across app ui/utils. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 6 + .../handlers/EditorBuildEventListener.kt | 12 +- .../androidide/ui/MemoryUsageChartRenderer.kt | 7 +- .../ui/MetricsCarouselController.kt | 8 +- .../androidide/ui/MetricsChartRenderer.kt | 47 ++++++++ .../ui/NetworkUsageChartRenderer.kt | 7 +- .../utils/MetricsAnnotationStore.kt | 106 +++++++++++++++++ .../androidide/viewmodel/MetricsViewModel.kt | 4 + .../utils/MetricsAnnotationStoreTest.kt | 107 ++++++++++++++++++ 9 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 1904073279..72299abfc4 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -201,9 +201,15 @@ abstract class BaseEditorActivity : memoryUsageWatcher = memoryUsageWatcher, networkUsageWatcher = networkUsageWatcher, lineColorFor = ::getMemUsageLineColorFor, + annotations = metricsViewModel.annotations, ) } + /** Records a significant event for the charts to annotate (ADFA-5486). */ + fun recordMetricsAnnotation(label: String) { + metricsViewModel.annotations.record(label) + } + private val fileManagerViewModel by viewModels() private var feedbackButtonManager: FeedbackButtonManager? = null private var fullscreenManager: FullscreenManager? = null diff --git a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt index ba7a9975b1..53d78988d5 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -28,6 +28,7 @@ import com.itsaky.androidide.services.builder.GradleBuildService import com.itsaky.androidide.tooling.api.messages.result.BuildInfo import com.itsaky.androidide.tooling.events.ProgressEvent import com.itsaky.androidide.tooling.events.configuration.ProjectConfigurationStartEvent +import com.itsaky.androidide.tooling.events.task.TaskFinishEvent import com.itsaky.androidide.tooling.events.task.TaskStartEvent import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess @@ -140,10 +141,17 @@ class EditorBuildEventListener : GradleBuildService.EventListener { } override fun onProgressEvent(event: ProgressEvent) { - checkActivity("onProgressEvent") ?: return + val act = checkActivity("onProgressEvent") ?: return if (event is ProjectConfigurationStartEvent || event is TaskStartEvent) { - activity.setStatus(event.descriptor.displayName) + act.setStatus(event.descriptor.displayName) + } + + // Annotate the metrics charts with task starts and stops (ADFA-5486). Gradle emits these + // far faster than a chart can show them -- dozens a second during configuration -- so the + // store throttles to one every five seconds and keeps the first of each quiet period. + if (event is TaskStartEvent || event is TaskFinishEvent) { + act.recordMetricsAnnotation(event.descriptor.displayName) } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index b2d2eceb71..611d05d738 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -28,6 +28,7 @@ import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.itsaky.androidide.R import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo +import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.ShiftedLongArray import kotlin.math.roundToLong @@ -49,7 +50,11 @@ import kotlin.math.roundToLong class MemoryUsageChartRenderer( private val usagesProvider: () -> Array, private val lineColorFor: (ProcessMemoryInfo) -> Int, -) : MetricsChartRenderer(sampleIntervalMillis = MemoryUsageWatcher.DEFAULT_UPDATE_INTERVAL) { + annotations: MetricsAnnotationStore? = null, +) : MetricsChartRenderer( + sampleIntervalMillis = MemoryUsageWatcher.DEFAULT_UPDATE_INTERVAL, + annotations = annotations, + ) { /** * Maps a watched pid to its dataset index in the attached chart's [LineData]. Empty whenever no * chart is attached. diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 1583b0d57d..3d375d1dda 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -22,6 +22,7 @@ import androidx.viewpager2.widget.ViewPager2 import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher /** @@ -42,15 +43,20 @@ class MetricsCarouselController( private val memoryUsageWatcher: MemoryUsageWatcher, private val networkUsageWatcher: NetworkUsageWatcher, lineColorFor: (MemoryUsageWatcher.ProcessMemoryInfo) -> Int, + annotations: MetricsAnnotationStore? = null, ) { private val memoryRenderer = MemoryUsageChartRenderer( usagesProvider = { memoryUsageWatcher.getMemoryUsages() }, lineColorFor = lineColorFor, + annotations = annotations, ) private val networkRenderer = - NetworkUsageChartRenderer(usageProvider = { networkUsageWatcher.getUsage() }) + NetworkUsageChartRenderer( + usageProvider = { networkUsageWatcher.getUsage() }, + annotations = annotations, + ) private val pages = listOf( diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index 292797298a..de82cd2ab3 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -17,13 +17,16 @@ package com.itsaky.androidide.ui +import android.os.SystemClock import androidx.annotation.CallSuper import androidx.annotation.UiThread import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.components.LimitLine import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.itsaky.androidide.R +import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.resolveAttr import kotlin.math.roundToLong @@ -42,6 +45,8 @@ import kotlin.math.roundToLong */ abstract class MetricsChartRenderer( private val sampleIntervalMillis: Long, + private val annotations: MetricsAnnotationStore? = null, + private val nowMillis: () -> Long = SystemClock::elapsedRealtime, ) { /** * The attached chart, or `null` when no carousel page is bound to this renderer. @@ -178,10 +183,48 @@ abstract class MetricsChartRenderer( setGridBackgroundColor(bgColor) notifyDataSetChanged() } + applyAnnotations(chart) showNewestWindow(chart) chart.invalidate() } + /** + * Draws a vertical marker for each recent significant event (ADFA-5486). + * + * Annotations are stored by wall-clock time, not sample position, because the ring buffer + * shifts under them. Age converts to an x position here: the newest sample sits at the buffer's + * last index, and every [sampleIntervalMillis] before that is one index to the left. Anything + * older than the buffer holds falls outside the axis and is not drawn. + */ + private fun applyAnnotations(chart: SafeLineChart) { + val store = annotations ?: return + val newestIndex = chart.data?.xMax ?: return + + chart.xAxis.removeAllLimitLines() + + val bufferSpanMillis = (newestIndex.toLong() + 1L) * sampleIntervalMillis + val now = nowMillis() + val markerColor = chart.context.resolveAttr(R.attr.colorOnSurface) + + store.recentAnnotations(bufferSpanMillis).forEach { annotation -> + val samplesAgo = (now - annotation.atMillis).toFloat() / sampleIntervalMillis + val x = newestIndex - samplesAgo + if (x < 0f) { + return@forEach + } + + chart.xAxis.addLimitLine( + LimitLine(x, annotation.label).apply { + lineWidth = ANNOTATION_LINE_WIDTH + lineColor = markerColor + textColor = markerColor + enableDashedLine(ANNOTATION_DASH_LENGTH, ANNOTATION_DASH_LENGTH, 0f) + labelPosition = LimitLine.LimitLabelPosition.RIGHT_BOTTOM + }, + ) + } + } + /** * Redraws after the attached series have been mutated in place. */ @@ -193,6 +236,7 @@ abstract class MetricsChartRenderer( // Re-applied on every redraw, not just when data is set: the visible x range is held as a // scale factor, so a layout change (a rotation, say) leaves the window pointing at a // different part of the history. Landscape showed samples from half an hour ago. + applyAnnotations(chart) showNewestWindow(chart) chart.invalidate() } @@ -204,5 +248,8 @@ abstract class MetricsChartRenderer( const val VISIBLE_SAMPLES = 60 const val X_LABEL_GRANULARITY_SAMPLES = 15f + + const val ANNOTATION_LINE_WIDTH = 1f + const val ANNOTATION_DASH_LENGTH = 6f } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index 7a8632cf8a..f1850bf0d0 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -25,6 +25,7 @@ import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.formatter.IAxisValueFormatter import com.itsaky.androidide.R +import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher import com.itsaky.androidide.utils.NetworkUsageWatcher.NetworkUsage import kotlin.math.ceil @@ -56,7 +57,11 @@ import kotlin.math.roundToLong */ class NetworkUsageChartRenderer( private val usageProvider: () -> NetworkUsage, -) : MetricsChartRenderer(sampleIntervalMillis = NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL) { + annotations: MetricsAnnotationStore? = null, +) : MetricsChartRenderer( + sampleIntervalMillis = NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL, + annotations = annotations, + ) { /** * Rebuilds both series from the full sample history. */ diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt new file mode 100644 index 0000000000..195c623471 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -0,0 +1,106 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.os.SystemClock + +/** + * Records significant events for the metrics charts to annotate (ADFA-5486). + * + * Significant means Gradle task starts and stops. A real build emits far too many of those to draw + * -- dozens a second during configuration -- so they are throttled to at most one every + * [THROTTLE_INTERVAL_MS]. The first event in a quiet period is the one kept, since the interesting + * moment is when work *began*, not an arbitrary one from the middle of a burst. + * + * Annotations are stored by wall-clock time rather than by sample position, because the charts hold + * a ring buffer whose contents shift under them; a stored index would drift. The renderer converts + * a timestamp to an x position from its age, and anything older than the buffer falls off. + */ +class MetricsAnnotationStore( + private val nowMillis: () -> Long = SystemClock::elapsedRealtime, +) { + private val annotations = ArrayDeque() + + /** + * When the last annotation was recorded, or `null` if none has been. Nullable rather than a + * sentinel: `now - Long.MIN_VALUE` overflows to a negative gap, which reads as "inside the + * throttle window" and silently swallows every annotation for the life of the store. + */ + private var lastRecordedAt: Long? = null + + /** + * An annotated moment. + * + * @property atMillis When it happened, on the same clock as [nowMillis]. + * @property label What to show against it. + */ + data class Annotation( + val atMillis: Long, + val label: String, + ) + + /** + * Records [label] unless another annotation was recorded within [THROTTLE_INTERVAL_MS]. + * + * @return whether it was recorded. + */ + @Synchronized + fun record(label: String): Boolean { + val now = nowMillis() + val since = lastRecordedAt + if (since != null && now - since < THROTTLE_INTERVAL_MS) { + return false + } + + lastRecordedAt = now + annotations.addLast(Annotation(now, label)) + while (annotations.size > MAX_ANNOTATIONS) { + annotations.removeFirst() + } + return true + } + + /** + * The annotations recorded within [withinMillis] of now, oldest first. + */ + @Synchronized + fun recentAnnotations(withinMillis: Long): List { + val cutoff = nowMillis() - withinMillis + return annotations.filter { it.atMillis >= cutoff } + } + + @Synchronized + fun clear() { + annotations.clear() + lastRecordedAt = null + } + + companion object { + /** + * Gradle emits task events far faster than a chart can show them; one every five seconds is + * what the ticket asks for. + */ + const val THROTTLE_INTERVAL_MS = 5_000L + + /** + * Enough to cover the deepest buffer at the slowest sampling rate, bounded so a long + * session cannot grow this without limit. + */ + const val MAX_ANNOTATIONS = 256 + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt index b4261668bd..bcf61bd48d 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/MetricsViewModel.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.viewmodel import androidx.lifecycle.ViewModel import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.NetworkUsageWatcher /** @@ -38,6 +39,9 @@ class MetricsViewModel : ViewModel() { val networkUsageWatcher = NetworkUsageWatcher() + /** Significant events for the charts to annotate (ADFA-5486). */ + val annotations = MetricsAnnotationStore() + override fun onCleared() { super.onCleared() // close(), not stopWatching(): this is the terminal teardown, and each watcher holds a diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt new file mode 100644 index 0000000000..03dc8b4b46 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsAnnotationStoreTest.kt @@ -0,0 +1,107 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Pins the annotation throttle of ADFA-5486: significant events are Gradle task starts and stops, + * and there are far too many of them to draw, so at most one every five seconds is kept. + */ +class MetricsAnnotationStoreTest { + private var now = 1_000L + private val store = MetricsAnnotationStore(nowMillis = { now }) + + @Test + fun `the first event is always recorded`() { + assertThat(store.record(":app:compileKotlin")).isTrue() + assertThat(store.recentAnnotations(60_000L)).hasSize(1) + } + + @Test + fun `events inside the throttle window are dropped`() { + store.record("first") + now += 1_000L + assertThat(store.record("second")).isFalse() + now += 3_000L + assertThat(store.record("third")).isFalse() + + // A real build emits dozens of these a second; only the first survives. + val labels = store.recentAnnotations(60_000L).map { it.label } + assertThat(labels).containsExactly("first") + } + + @Test + fun `an event after the window is recorded`() { + store.record("first") + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + + assertThat(store.record("second")).isTrue() + assertThat(store.recentAnnotations(60_000L).map { it.label }) + .containsExactly("first", "second") + .inOrder() + } + + @Test + fun `the first event of a quiet period is the one kept`() { + // The interesting moment is when work began, not one from the middle of a burst. + store.record("burst start") + repeat(20) { + now += 100L + store.record("noise") + } + + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("burst start") + } + + @Test + fun `only annotations within the requested age are returned`() { + store.record("old") + now += 30_000L + store.record("recent") + + assertThat(store.recentAnnotations(10_000L).map { it.label }).containsExactly("recent") + assertThat(store.recentAnnotations(60_000L).map { it.label }).containsExactly("old", "recent").inOrder() + } + + @Test + fun `the store is bounded`() { + repeat(MetricsAnnotationStore.MAX_ANNOTATIONS * 2) { + now += MetricsAnnotationStore.THROTTLE_INTERVAL_MS + store.record("task $it") + } + + val all = store.recentAnnotations(Long.MAX_VALUE / 2) + assertThat(all).hasSize(MetricsAnnotationStore.MAX_ANNOTATIONS) + // The oldest are the ones dropped. + assertThat(all.last().label).endsWith( + (MetricsAnnotationStore.MAX_ANNOTATIONS * 2 - 1).toString(), + ) + } + + @Test + fun `clearing forgets the throttle as well as the annotations`() { + store.record("first") + store.clear() + + assertThat(store.recentAnnotations(60_000L)).isEmpty() + // Without resetting the throttle, the next event would be swallowed for five seconds. + assertThat(store.record("second")).isTrue() + } +} From f632892be6ae307027d657417c02cb97756b7940 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 10:36:40 -0700 Subject: [PATCH 10/30] feat: export a chart snapshot as a shareable image Long-pressing the chart title writes the visible chart to a PNG and hands it to the system share sheet, so it can go into a ticket, a chat or a file. Snapshot means an image of the chart, as decided on the ticket. The gestures over the chart itself are all spoken for -- paging, panning a zoomed chart, and the two-finger tap that undocks -- so the title is the target: an unambiguous one that behaves the same whether the carousel is docked or floating. Images go to a directory under the cache, so the platform can reclaim them, and each export clears the previous one. This is a scratch space for handing a single image to another app, not a gallery; the sharing intent grants the receiving app access before the next export matters. Chart titles are translated, so the filename is derived rather than copied: lowercased, everything outside a-z0-9 collapsed to hyphens, and falling back to "metrics" if nothing usable is left. Verified on a Pixel 6 Pro (arm64), v8 debug: long-pressing the title raised the share sheet showing a preview of the real chart, and left memory-usage-20260905-103613.png (37KB) in the cache directory. 5 new tests; 82 green across app ui/utils. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 47 +++++++++ .../androidide/ui/MetricsChartRenderer.kt | 8 ++ .../androidide/utils/MetricsSnapshot.kt | 95 +++++++++++++++++++ .../androidide/utils/MetricsSnapshotTest.kt | 86 +++++++++++++++++ resources/src/main/res/values/strings.xml | 1 + 5 files changed, 237 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 3d375d1dda..54a75beb9d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -17,12 +17,15 @@ package com.itsaky.androidide.ui +import android.widget.Toast import androidx.annotation.UiThread import androidx.viewpager2.widget.ViewPager2 import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsSnapshot import com.itsaky.androidide.utils.NetworkUsageWatcher /** @@ -111,6 +114,14 @@ class MetricsCarouselController( // onPageSelected does not fire for the page the carousel opens on. showTitleFor(binding.metricsPager.currentItem) + // Long-press the title to export the chart. The gestures over the chart itself are spoken + // for -- paging, panning a zoomed chart, and the two-finger tap that undocks -- and the + // title is an unambiguous target that works the same docked or floating. + binding.metricsTitle.setOnLongClickListener { + exportSnapshot() + true + } + memoryUsageWatcher.listener = memoryListener networkUsageWatcher.listener = networkListener } @@ -128,6 +139,7 @@ class MetricsCarouselController( networkUsageWatcher.listener = null } + binding?.metricsTitle?.setOnLongClickListener(null) pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } pageCallback = null @@ -137,6 +149,41 @@ class MetricsCarouselController( binding = null } + /** + * Writes the visible chart to an image and offers it to another app (ADFA-5486). + * + * @return whether a snapshot was produced. + */ + @UiThread + fun exportSnapshot(): Boolean { + val binding = this.binding ?: return false + val context = binding.root.context + val position = binding.metricsPager.currentItem + val page = pages.getOrNull(position) ?: return false + + val renderer = + when (page) { + is MetricsPage.MemoryChart -> memoryRenderer + is MetricsPage.NetworkChart -> networkRenderer + } + + val label = context.getString(page.title) + val bitmap = renderer.snapshot() + if (bitmap == null) { + Toast.makeText(context, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + return false + } + + val file = MetricsSnapshot.write(context, bitmap, label) + if (file == null) { + Toast.makeText(context, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + return false + } + + IntentUtils.shareFile(context, file, MetricsSnapshot.MIME_TYPE) + return true + } + /** * Redraws both charts from the full history, for a host coming back to the foreground with * samples gathered while it was away. diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index de82cd2ab3..47a8051313 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.ui +import android.graphics.Bitmap import android.os.SystemClock import androidx.annotation.CallSuper import androidx.annotation.UiThread @@ -92,6 +93,13 @@ abstract class MetricsChartRenderer( @UiThread abstract fun rebuild() + /** + * An image of the chart as it currently looks, or `null` when nothing is attached + * (ADFA-5486's snapshot export). + */ + @UiThread + fun snapshot(): Bitmap? = chart?.chartBitmap + /** * Applies the configuration every metrics chart shares. Subclasses override to add their own -- * a value formatter, axis range -- and must call through. diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt new file mode 100644 index 0000000000..45a6951323 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -0,0 +1,95 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import android.graphics.Bitmap +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Writes a metrics chart image to a file the IDE can share (ADFA-5486). + * + * Snapshots go to a directory under the cache, so the platform can reclaim them and they never + * accumulate; the sharing intent gives the receiving app a grant on the file before that matters. + */ +object MetricsSnapshot { + private val log = LoggerFactory.getLogger(MetricsSnapshot::class.java) + + private const val DIRECTORY = "metrics-snapshots" + private const val QUALITY = 100 + private const val TIMESTAMP_PATTERN = "yyyyMMdd-HHmmss" + + /** Media type for the written file, for the sharing intent. */ + const val MIME_TYPE = "image/png" + + /** + * Writes [bitmap] as a PNG named after [label] and the current time. + * + * Old snapshots are cleared first: this is a scratch directory for handing one image to another + * app, not a gallery, and an IDE session could otherwise leave a pile of them behind. + * + * @return the file, or `null` if it could not be written. + */ + fun write( + context: Context, + bitmap: Bitmap, + label: String, + ): File? { + val directory = File(context.cacheDir, DIRECTORY) + return try { + if (directory.exists()) { + directory.listFiles()?.forEach { it.delete() } + } else if (!directory.mkdirs()) { + log.error("Could not create the snapshot directory at {}", directory) + return null + } + + val file = File(directory, "${fileNameFor(label)}.png") + file.outputStream().use { output -> + if (!bitmap.compress(Bitmap.CompressFormat.PNG, QUALITY, output)) { + log.error("Could not encode the chart snapshot") + return null + } + } + file + } catch (io: IOException) { + log.error("Could not write the chart snapshot", io) + null + } + } + + /** + * A filename from [label] and the current time, with anything that is not safe in a filename + * replaced. Chart titles are translated, so they can contain spaces and non-ASCII. + */ + private fun fileNameFor(label: String): String { + val stamp = SimpleDateFormat(TIMESTAMP_PATTERN, Locale.US).format(Date()) + val safeLabel = + label + .lowercase(Locale.US) + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + .ifEmpty { "metrics" } + return "$safeLabel-$stamp" + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt new file mode 100644 index 0000000000..ef9639f700 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt @@ -0,0 +1,86 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.content.Context +import android.graphics.Bitmap +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * Pins ADFA-5486's snapshot export: a chart becomes a PNG in the cache, named after the chart, with + * only the newest one kept. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsSnapshotTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun bitmap() = Bitmap.createBitmap(64, 32, Bitmap.Config.ARGB_8888) + + @Test + fun `writes a png into the cache`() { + val file = MetricsSnapshot.write(context, bitmap(), "Memory usage") + + assertThat(file).isNotNull() + assertThat(file!!.exists()).isTrue() + assertThat(file.extension).isEqualTo("png") + assertThat(file.length()).isGreaterThan(0L) + // Under the cache, so the platform can reclaim it. + assertThat(file.absolutePath).startsWith(context.cacheDir.absolutePath) + } + + @Test + fun `names the file after the chart`() { + val file = MetricsSnapshot.write(context, bitmap(), "Network traffic") + + assertThat(file!!.name).startsWith("network-traffic-") + } + + @Test + fun `a title with punctuation or non-ascii still makes a usable filename`() { + // Chart titles are translated, so they are not guaranteed to be filename-safe. + val file = MetricsSnapshot.write(context, bitmap(), "Mémoire / usage (MB)") + + assertThat(file).isNotNull() + assertThat(file!!.name).matches("[a-z0-9-]+\\.png") + } + + @Test + fun `a title with nothing usable still produces a file`() { + val file = MetricsSnapshot.write(context, bitmap(), "***") + + assertThat(file).isNotNull() + assertThat(file!!.name).startsWith("metrics-") + } + + @Test + fun `only the newest snapshot is kept`() { + val first = MetricsSnapshot.write(context, bitmap(), "Memory usage") + val second = MetricsSnapshot.write(context, bitmap(), "Network traffic") + + assertThat(second).isNotNull() + // This is a scratch directory for handing one image to another app, not a gallery. + val directory = File(context.cacheDir, "metrics-snapshots") + assertThat(directory.listFiles()!!.map { it.name }).containsExactly(second!!.name) + assertThat(first!!.exists()).isFalse() + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 7cd65f4593..b9dd7621dd 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1679,6 +1679,7 @@ Network traffic Metrics are in a floating window.\nTap to bring them back. Metrics + Couldn\'t save the chart image. Received Sent From 1e36e1df1dfe9d9685b48066aa5c54e515462c5a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 10:51:37 -0700 Subject: [PATCH 11/30] feat: choose the sampling rate by tapping the x axis A tap on the x axis opens a chooser offering every rate from 0.1s to 60s, as the ticket specifies. Picking one applies it to both watchers and discards the history, because a buffer holding samples taken at two rates would misdate the older ones. Rates the device cannot use are listed and greyed rather than hidden, so the user can see that their hardware is what costs them the two fastest rates instead of assuming the IDE cannot sample faster. On a 64-bit device all nine are selectable; on 32-bit the 0.1s and 0.2s entries read "needs a 64-bit device" and do nothing. The tap is recognised through the chart's own gesture listener rather than a view: the axis is drawn by MPAndroidChart, so there is nothing to attach a click listener to, and only the chart knows where it put the axis. A tap above viewPortHandler.contentTop landed on it. Two bugs found on the device while doing this: The dialog first appeared with no list at all. An AlertDialog shows either a message or a list, never both, and the message silently wins -- so the explanatory line had swallowed the nine rates. The explanation lives on the greyed entries instead. The x axis kept labelling with the old interval after a rate change: ElapsedTimeFormatter captured sampleIntervalMillis at construction, so at 5s per sample it still read -54s where the leftmost sample was really 295 seconds old. Annotation positioning shared the flaw. Both take a provider now and read the live value. I had flagged this risk when making the interval settable and then did not carry it through. Verified on a Pixel 6 Pro (arm64), v8 debug: the chooser opens from an axis tap with the current rate ticked, selecting a slower rate clears the history and refills at the new rate, and the gridlines re-space to match. 82 tests green across app ui/utils. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MemoryUsageChartRenderer.kt | 3 +- .../ui/MetricsCarouselController.kt | 74 ++++++++++++++++++ .../androidide/ui/MetricsChartRenderer.kt | 77 +++++++++++++++++-- .../ui/NetworkUsageChartRenderer.kt | 3 +- resources/src/main/res/values/strings.xml | 3 + 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index 611d05d738..de412b4262 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -51,8 +51,9 @@ class MemoryUsageChartRenderer( private val usagesProvider: () -> Array, private val lineColorFor: (ProcessMemoryInfo) -> Int, annotations: MetricsAnnotationStore? = null, + sampleIntervalMillis: () -> Long = { MemoryUsageWatcher.DEFAULT_UPDATE_INTERVAL }, ) : MetricsChartRenderer( - sampleIntervalMillis = MemoryUsageWatcher.DEFAULT_UPDATE_INTERVAL, + sampleIntervalMillis = sampleIntervalMillis, annotations = annotations, ) { /** diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 54a75beb9d..01911ae627 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -20,11 +20,14 @@ package com.itsaky.androidide.ui import android.widget.Toast import androidx.annotation.UiThread import androidx.viewpager2.widget.ViewPager2 +import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.resources.R.string +import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.IntentUtils import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.MetricsSamplingRates import com.itsaky.androidide.utils.MetricsSnapshot import com.itsaky.androidide.utils.NetworkUsageWatcher @@ -53,12 +56,14 @@ class MetricsCarouselController( usagesProvider = { memoryUsageWatcher.getMemoryUsages() }, lineColorFor = lineColorFor, annotations = annotations, + sampleIntervalMillis = { memoryUsageWatcher.updateInterval }, ) private val networkRenderer = NetworkUsageChartRenderer( usageProvider = { networkUsageWatcher.getUsage() }, annotations = annotations, + sampleIntervalMillis = { networkUsageWatcher.updateInterval }, ) private val pages = @@ -114,6 +119,11 @@ class MetricsCarouselController( // onPageSelected does not fire for the page the carousel opens on. showTitleFor(binding.metricsPager.currentItem) + // A tap on the x axis opens the sampling-rate chooser (ADFA-5486). The axis is drawn by the + // chart, not a view of its own, so the strip of the pager it occupies is the target. + memoryRenderer.onXAxisTap = { showSamplingRateDialog() } + networkRenderer.onXAxisTap = { showSamplingRateDialog() } + // Long-press the title to export the chart. The gestures over the chart itself are spoken // for -- paging, panning a zoomed chart, and the two-finger tap that undocks -- and the // title is an unambiguous target that works the same docked or floating. @@ -139,6 +149,8 @@ class MetricsCarouselController( networkUsageWatcher.listener = null } + memoryRenderer.onXAxisTap = null + networkRenderer.onXAxisTap = null binding?.metricsTitle?.setOnLongClickListener(null) pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } pageCallback = null @@ -149,6 +161,68 @@ class MetricsCarouselController( binding = null } + /** + * Offers the sampling rates this device supports, and shows the ones it does not so the reason + * is visible rather than the faster rates simply being absent (ADFA-5486). + */ + @UiThread + fun showSamplingRateDialog() { + val context = binding?.root?.context ?: return + val rates = MetricsSamplingRates.ratesFor(IDEBuildConfigProvider.getInstance().deviceArch) + val current = memoryUsageWatcher.updateInterval + + val labels = + rates + .map { rate -> + val label = context.getString(string.metrics_sampling_rate_entry, formatInterval(rate.intervalMillis)) + if (rate.isAvailable) label else context.getString(string.metrics_sampling_rate_unavailable, label) + }.toTypedArray() + + val checked = rates.indexOfFirst { it.intervalMillis == current } + + val dialog = + DialogUtils + .newMaterialDialogBuilder(context) + .setTitle(string.metrics_sampling_rate_title) + .setSingleChoiceItems(labels, checked) { dismissable, which -> + val rate = rates[which] + if (rate.isAvailable) { + setSamplingInterval(rate.intervalMillis) + dismissable.dismiss() + } + // An unavailable rate stays listed and does nothing; the message below says why. + } + // No setMessage: an AlertDialog shows either a message or a list, never both, and + // the message silently wins. The unavailable entries carry the explanation instead. + .setNegativeButton(string.cancel) { dismissable, _ -> dismissable.dismiss() } + .show() + + // Grey the rates this device cannot use, so the list shows what the hardware costs. + dialog.listView?.let { list -> + rates.forEachIndexed { index, rate -> + list.getChildAt(index)?.isEnabled = rate.isAvailable + } + } + } + + /** + * Applies a new sampling interval to both watchers. Their histories are discarded, because a + * buffer holding samples taken at two rates would misdate the older ones. + */ + @UiThread + private fun setSamplingInterval(intervalMillis: Long) { + memoryUsageWatcher.updateInterval = intervalMillis + networkUsageWatcher.updateInterval = intervalMillis + refresh() + } + + private fun formatInterval(intervalMillis: Long): String = + if (intervalMillis < 1_000L) { + "%.1fs".format(intervalMillis / 1000.0) + } else { + "%ds".format(intervalMillis / 1_000L) + } + /** * Writes the visible chart to an image and offers it to another app (ADFA-5486). * diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index 47a8051313..4d5e288811 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.ui import android.graphics.Bitmap import android.os.SystemClock +import android.view.MotionEvent import androidx.annotation.CallSuper import androidx.annotation.UiThread import com.github.mikephil.charting.components.AxisBase @@ -26,6 +27,8 @@ import com.github.mikephil.charting.components.LimitLine import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.formatter.IAxisValueFormatter +import com.github.mikephil.charting.listener.ChartTouchListener +import com.github.mikephil.charting.listener.OnChartGestureListener import com.itsaky.androidide.R import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.resolveAttr @@ -45,10 +48,20 @@ import kotlin.math.roundToLong * [SafeLineChart]. */ abstract class MetricsChartRenderer( - private val sampleIntervalMillis: Long, + // A provider, not a value: the sampling rate is user-settable, and a captured interval leaves + // the axis labelling ages with the old spacing -- reading -54s where the sample is really 295 + // seconds old. + private val sampleIntervalMillis: () -> Long, private val annotations: MetricsAnnotationStore? = null, private val nowMillis: () -> Long = SystemClock::elapsedRealtime, ) { + /** + * Invoked when the chart's x axis is tapped, which opens the sampling-rate chooser + * (ADFA-5486). Set by the host; the axis band is worked out here because only the chart knows + * where it drew it. + */ + var onXAxisTap: (() -> Unit)? = null + /** * The attached chart, or `null` when no carousel page is bound to this renderer. */ @@ -122,6 +135,8 @@ abstract class MetricsChartRenderer( // The right axis carries the labels; the left is unused. axisLeft.isEnabled = false + onChartGestureListener = XAxisTapListener(this) + xAxis.valueFormatter = ElapsedTimeFormatter(sampleIntervalMillis) // One label per 15 samples keeps the window readable without crowding. xAxis.granularity = X_LABEL_GRANULARITY_SAMPLES @@ -148,19 +163,70 @@ abstract class MetricsChartRenderer( chart.moveViewToX(newestIndex - VISIBLE_SAMPLES.toFloat() + 1f) } + /** + * Turns a tap in the x-axis band into [onXAxisTap]. + * + * The axis is drawn by the chart rather than being a view of its own, so there is nothing to + * attach a click listener to. `contentTop` is the top of the plotting area, and the axis labels + * sit above it, so a tap higher than that landed on the axis. + */ + private inner class XAxisTapListener( + private val chart: SafeLineChart, + ) : OnChartGestureListener { + override fun onChartSingleTapped(me: MotionEvent?) { + val y = me?.y ?: return + if (y <= chart.viewPortHandler.contentTop()) { + onXAxisTap?.invoke() + } + } + + override fun onChartGestureStart( + me: MotionEvent?, + lastPerformedGesture: ChartTouchListener.ChartGesture?, + ) = Unit + + override fun onChartGestureEnd( + me: MotionEvent?, + lastPerformedGesture: ChartTouchListener.ChartGesture?, + ) = Unit + + override fun onChartLongPressed(me: MotionEvent?) = Unit + + override fun onChartDoubleTapped(me: MotionEvent?) = Unit + + override fun onChartFling( + me1: MotionEvent?, + me2: MotionEvent?, + velocityX: Float, + velocityY: Float, + ) = Unit + + override fun onChartScale( + me: MotionEvent?, + scaleX: Float, + scaleY: Float, + ) = Unit + + override fun onChartTranslate( + me: MotionEvent?, + dX: Float, + dY: Float, + ) = Unit + } + /** * Labels the x axis by age rather than by sample index, which is meaningless to a reader and * would run to 3599 at the current retention. */ private class ElapsedTimeFormatter( - private val sampleIntervalMillis: Long, + private val sampleIntervalMillis: () -> Long, ) : IAxisValueFormatter { override fun getFormattedValue( value: Float, axis: AxisBase?, ): String { val newestIndex = (axis?.mAxisMaximum ?: value) - val secondsAgo = ((newestIndex - value) * sampleIntervalMillis / 1000f).roundToLong() + val secondsAgo = ((newestIndex - value) * sampleIntervalMillis() / 1000f).roundToLong() return if (secondsAgo <= 0L) "now" else "-%ds".format(secondsAgo) } } @@ -210,12 +276,13 @@ abstract class MetricsChartRenderer( chart.xAxis.removeAllLimitLines() - val bufferSpanMillis = (newestIndex.toLong() + 1L) * sampleIntervalMillis + val interval = sampleIntervalMillis() + val bufferSpanMillis = (newestIndex.toLong() + 1L) * interval val now = nowMillis() val markerColor = chart.context.resolveAttr(R.attr.colorOnSurface) store.recentAnnotations(bufferSpanMillis).forEach { annotation -> - val samplesAgo = (now - annotation.atMillis).toFloat() / sampleIntervalMillis + val samplesAgo = (now - annotation.atMillis).toFloat() / interval val x = newestIndex - samplesAgo if (x < 0f) { return@forEach diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index f1850bf0d0..d349fc75a0 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -58,8 +58,9 @@ import kotlin.math.roundToLong class NetworkUsageChartRenderer( private val usageProvider: () -> NetworkUsage, annotations: MetricsAnnotationStore? = null, + sampleIntervalMillis: () -> Long = { NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL }, ) : MetricsChartRenderer( - sampleIntervalMillis = NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL, + sampleIntervalMillis = sampleIntervalMillis, annotations = annotations, ) { /** diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index b9dd7621dd..cdab3e3844 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1679,6 +1679,9 @@ Network traffic Metrics are in a floating window.\nTap to bring them back. Metrics + Sampling rate + Every %1$s + %1$s (needs a 64-bit device) Couldn\'t save the chart image. Received Sent From f5f2d7d7a542530c6aa50267f1c579cb5221e974 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 10:52:48 -0700 Subject: [PATCH 12/30] feat: trigger the chart snapshot from a camera button Replaces the long-press on the chart title with a camera button in the graph's bottom-right corner, at your request. The long-press worked but advertised nothing: a user had no way to discover that the title did anything. A visible control does not have that problem, and it costs no gesture -- every gesture over the chart is already taken by paging, the two-finger tap that undocks, pinch to zoom, and the tap on the x axis for the sampling rate. The icon is small, as asked, and sits as low and as far right as the graph area allows. The button around it keeps a 40dp touch target, since the visual size of a control and its touch target need not match, and a 24dp target would be hard to hit. Verified on a Pixel 6 Pro (arm64), v8 debug: the button sits in the corner of the plot, and tapping it raises the share sheet showing the real chart, leaving memory-usage-20260905-105005.png in the cache. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 12 ++++------ app/src/main/res/drawable/ic_camera.xml | 24 +++++++++++++++++++ app/src/main/res/layout/layout_mem_usage.xml | 15 ++++++++++++ app/src/main/res/values/dimens.xml | 2 ++ resources/src/main/res/values/strings.xml | 1 + 5 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/ic_camera.xml diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 01911ae627..338af00fe6 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -124,13 +124,9 @@ class MetricsCarouselController( memoryRenderer.onXAxisTap = { showSamplingRateDialog() } networkRenderer.onXAxisTap = { showSamplingRateDialog() } - // Long-press the title to export the chart. The gestures over the chart itself are spoken - // for -- paging, panning a zoomed chart, and the two-finger tap that undocks -- and the - // title is an unambiguous target that works the same docked or floating. - binding.metricsTitle.setOnLongClickListener { - exportSnapshot() - true - } + // A camera button in the graph's bottom-right corner exports the chart. The gestures over + // the chart are all spoken for, so this is a control rather than another gesture. + binding.metricsSnapshot.setOnClickListener { exportSnapshot() } memoryUsageWatcher.listener = memoryListener networkUsageWatcher.listener = networkListener @@ -151,7 +147,7 @@ class MetricsCarouselController( memoryRenderer.onXAxisTap = null networkRenderer.onXAxisTap = null - binding?.metricsTitle?.setOnLongClickListener(null) + binding?.metricsSnapshot?.setOnClickListener(null) pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } pageCallback = null diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000000..a31428756f --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index e87f7f2c65..e7795012c8 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -39,6 +39,21 @@ tools:text="Memory usage" xmlns:tools="http://schemas.android.com/tools" /> + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index f1785efd39..2e035ba27f 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -9,6 +9,8 @@ 248dp 16dp 4dp + 40dp + 10dp 28dp 28dp diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index cdab3e3844..8928f1911e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1682,6 +1682,7 @@ Sampling rate Every %1$s %1$s (needs a 64-bit device) + Save chart image Couldn\'t save the chart image. Received Sent From 61d1131cc86ee33689e815db3ef00bb0ba0c691d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 11:00:02 -0700 Subject: [PATCH 13/30] feat: pinch to zoom the chart, with the carousel swipe kept below the axis The time axis zooms and a zoomed chart pans, without taking the swipe that pages the carousel. The x axis moves to the bottom of the plot. Your split -- carousel swipe below the axis, pan above it -- assumed the conventional position, and ours was at the top, where "above the axis" is a sliver against the status bar. At the bottom the split describes real regions: the plot, and the strip of axis labels, legend and title beneath it. Ownership of a horizontal drag is settled once, on the way down, before either the pager or the chart has seen a move: the pager's touch paging is switched off for the gesture when the drag starts inside the plot of a zoomed chart, which lets the drag through to pan it. Everywhere else the carousel keeps the swipe -- the strip below the axis always, and the whole chart while it is at rest, since there is nothing to pan to. Only the time axis scales. Zooming the value axis on a memory or throughput chart just makes the numbers lie about their own scale. Two things that would otherwise make zoom useless: the auto-follow window no longer re-centres while zoomed, which would have dragged the user back to the newest samples once a second; and switching carousel page resets the zoom, so a page left magnified does not go on claiming horizontal drags when it comes back. Not verified on hardware. A pinch cannot be injected on an unrooted device -- adb input has no multi-touch and sendevent needs root -- which is the same limit the two-finger tap hit. The axis position and the absence of regressions are verified; the pinch itself needs a hand. 82 tests green across app ui/utils. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 25 +++++++++ .../androidide/ui/MetricsCarouselLayout.kt | 35 ++++++++++++ .../androidide/ui/MetricsChartRenderer.kt | 55 ++++++++++++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 338af00fe6..a5c2fa263d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -113,6 +113,9 @@ class MetricsCarouselController( object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { showTitleFor(position) + // A page left zoomed would keep claiming horizontal drags when swiped back to. + memoryRenderer.resetZoom() + networkRenderer.resetZoom() } }.also { binding.metricsPager.registerOnPageChangeCallback(it) } @@ -121,6 +124,13 @@ class MetricsCarouselController( // A tap on the x axis opens the sampling-rate chooser (ADFA-5486). The axis is drawn by the // chart, not a view of its own, so the strip of the pager it occupies is the target. + binding.root.horizontalDragBelongsToChart = { rawX, rawY -> + currentRenderer()?.handlesHorizontalDragAt(rawX, rawY) ?: false + } + binding.root.onPagingEnabledChanged = { enabled -> + binding.metricsPager.isUserInputEnabled = enabled + } + memoryRenderer.onXAxisTap = { showSamplingRateDialog() } networkRenderer.onXAxisTap = { showSamplingRateDialog() } @@ -145,6 +155,9 @@ class MetricsCarouselController( networkUsageWatcher.listener = null } + binding?.root?.horizontalDragBelongsToChart = null + binding?.root?.onPagingEnabledChanged = null + binding?.metricsPager?.isUserInputEnabled = true memoryRenderer.onXAxisTap = null networkRenderer.onXAxisTap = null binding?.metricsSnapshot?.setOnClickListener(null) @@ -157,6 +170,18 @@ class MetricsCarouselController( binding = null } + /** + * The renderer behind the page currently on screen, or `null` when nothing is bound. + */ + private fun currentRenderer(): MetricsChartRenderer? { + val binding = this.binding ?: return null + return when (pages.getOrNull(binding.metricsPager.currentItem)) { + is MetricsPage.MemoryChart -> memoryRenderer + is MetricsPage.NetworkChart -> networkRenderer + null -> null + } + } + /** * Offers the sampling rates this device supports, and shows the ones it does not so the reason * is visible rather than the faster rates simply being absent (ADFA-5486). diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt index b0f46b0600..340a88f510 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -55,6 +55,18 @@ class MetricsCarouselLayout */ var onTwoFingerTap: (() -> Unit)? = null + /** + * Asked, at the start of each gesture, whether a horizontal drag from this screen position + * belongs to the chart (panning a zoomed plot) rather than to the carousel (paging). + */ + var horizontalDragBelongsToChart: ((Float, Float) -> Boolean)? = null + + /** + * Called with whether the carousel should accept touch paging for the gesture just + * starting, and again with `true` when it ends. + */ + var onPagingEnabledChanged: ((Boolean) -> Unit)? = null + private var twoFingerDownAt = 0L private var twoFingerDownX = 0f private var twoFingerDownY = 0f @@ -69,9 +81,32 @@ class MetricsCarouselLayout */ override fun dispatchTouchEvent(ev: MotionEvent): Boolean { trackTwoFingerTap(ev) + routeHorizontalDrag(ev) return super.dispatchTouchEvent(ev) } + /** + * Decides, once per gesture, who owns a horizontal drag. + * + * The carousel and a zoomed chart both want horizontal drags, and only one can have them. + * The decision is made on the way down, before either has seen a move, by turning the + * pager's touch paging off for the gesture: with it off the drag reaches the chart and pans + * it. Inside the plot of a zoomed chart the chart wins; everywhere else -- including the + * strip below the x axis, and the whole chart at rest -- the carousel does. + */ + private fun routeHorizontalDrag(ev: MotionEvent) { + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val chartPans = horizontalDragBelongsToChart?.invoke(ev.rawX, ev.rawY) ?: false + onPagingEnabledChanged?.invoke(!chartPans) + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + onPagingEnabledChanged?.invoke(true) + } + } + } + override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (ev.actionMasked == MotionEvent.ACTION_DOWN) { // Cleared by the framework on the next ACTION_DOWN, so this lasts exactly one gesture. diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index 4d5e288811..a1a192d7eb 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -24,6 +24,7 @@ import androidx.annotation.CallSuper import androidx.annotation.UiThread import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.components.LimitLine +import com.github.mikephil.charting.components.XAxis import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet import com.github.mikephil.charting.formatter.IAxisValueFormatter @@ -106,6 +107,38 @@ abstract class MetricsChartRenderer( @UiThread abstract fun rebuild() + /** + * Whether a horizontal drag starting at this screen position should pan the chart rather than + * page the carousel. + * + * True only inside the plot area of a chart that is zoomed in: at rest there is nothing to pan + * to, so the swipe belongs to the carousel, and the strip below the x axis is never the chart's. + */ + @UiThread + fun handlesHorizontalDragAt( + rawX: Float, + rawY: Float, + ): Boolean { + val chart = this.chart ?: return false + if (chart.viewPortHandler.scaleX <= 1f) { + return false + } + + val location = IntArray(2) + chart.getLocationOnScreen(location) + val x = rawX - location[0] + val y = rawY - location[1] + return chart.viewPortHandler.contentRect.contains(x, y) + } + + /** + * Returns the chart to its unzoomed state. + */ + @UiThread + fun resetZoom() { + chart?.fitScreen() + } + /** * An image of the chart as it currently looks, or `null` when nothing is attached * (ADFA-5486's snapshot export). @@ -122,15 +155,27 @@ abstract class MetricsChartRenderer( chart.apply { val colorAccent = context.resolveAttr(R.attr.colorAccent) - isDragEnabled = false description.isEnabled = false xAxis.axisLineColor = colorAccent axisRight.axisLineColor = colorAccent + // Zoom the time axis only. Zooming the value axis on a memory or throughput chart just + // makes the numbers lie about their own scale; time is the axis worth magnifying. + setScaleXEnabled(true) + setScaleYEnabled(false) setPinchZoom(false) + // Panning is what makes zoom usable: without it you magnify and are then stranded. + // MetricsCarouselLayout decides per gesture whether a horizontal drag pans the chart or + // pages the carousel. + isDragEnabled = true + setDoubleTapToZoomEnabled(false) + setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) setDrawGridBackground(true) - setScaleEnabled(true) + + // Below the plot, so the strip under it can be reserved for the carousel swipe and the + // plot itself can pan when zoomed (ADFA-5486). + xAxis.position = XAxis.XAxisPosition.BOTTOM // The right axis carries the labels; the left is unused. axisLeft.isEnabled = false @@ -152,6 +197,12 @@ abstract class MetricsChartRenderer( * range, so a window keeps the cost independent of how much is retained. */ private fun showNewestWindow(chart: SafeLineChart) { + // Once the user has zoomed in, the view is theirs. Re-centring on every redraw would drag + // them back to the newest samples once a second, which makes zooming useless. + if (chart.viewPortHandler.scaleX > 1f) { + return + } + // xMax is the newest sample's index. entryCount would be the total across every series -- // 7200 for the network chart's two -- which would scroll the window off the end of the data. val newestIndex = chart.data?.xMax ?: return From 825ee45aed924f9118c720ad603fe4ce25954bd0 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 13:59:30 -0700 Subject: [PATCH 14/30] fix: restore the carousel swipe and the auto-follow window; add paging arrows Two of the three problems reported from the device turned out to be one bug. Showing a 60-sample window of a 10000-sample buffer *is* a zoom as far as MPAndroidChart is concerned: scaleX sits around 166 at rest. So testing `scaleX > 1f` for "has the user zoomed" was always true, with two consequences. The auto-follow window stopped re-centring after the first draw, which is why a floating window drifted to around -5000s. And the chart claimed every horizontal drag, which is why moving between carousel pages was so hard -- the swipe was being taken to pan a chart nobody had zoomed. Zoom is now recorded from the scale gesture itself rather than inferred from the viewport, which cannot be confused by the window we set. Paging arrows either side of the chart title. Swiping still works, but it competes with panning a zoomed chart and with the editor's drawer gesture, and losing that race intermittently is worse than not having the gesture at all. The arrow for an end of the carousel is dimmed and disabled. Keyboard in the floating window: nothing in the carousel is typed into, so nothing in it should take focus. A focusable child makes an overlay window focusable, and the soft keyboard then opens over the chart on every touch. The content blocks descendant focus, and a touch also dismisses any keyboard already showing. Verified on a Pixel 6 Pro (arm64), v8 debug: the arrows move between pages and dim at each end, and the axis, window and legend are unchanged otherwise. The keyboard fix and the floating-window drift need the window open to confirm. 82 tests green across app ui/utils. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../MetricsCarouselDockableContent.kt | 17 +++++++++ .../ui/MetricsCarouselController.kt | 38 +++++++++++++++++++ .../androidide/ui/MetricsCarouselLayout.kt | 4 ++ .../androidide/ui/MetricsChartRenderer.kt | 21 ++++++++-- app/src/main/res/layout/layout_mem_usage.xml | 36 +++++++++++++++++- app/src/main/res/values/dimens.xml | 2 + resources/src/main/res/values/strings.xml | 2 + 7 files changed, 115 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt b/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt index 261657686a..e35c79b40b 100644 --- a/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt +++ b/app/src/main/java/com/itsaky/androidide/editor/floating/MetricsCarouselDockableContent.kt @@ -21,6 +21,7 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.floating.model.DockableContent import com.itsaky.androidide.floating.window.FloatingWindowHost @@ -66,6 +67,17 @@ class MetricsCarouselDockableContent( // way back, so the gesture would only be a second, less discoverable route. binding.root.onTwoFingerTap = null + // Nothing here is typed into, so nothing here should take focus. A focusable child in an + // overlay window makes the window focusable, and the soft keyboard then opens over the + // chart on every touch. + binding.root.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS + binding.root.isFocusable = false + binding.root.isFocusableInTouchMode = false + + // Belt and braces: if something upstream has already opened the keyboard, a touch on the + // chart puts it away rather than leaving it covering the window. + binding.root.onTouchDown = { hideSoftInput(binding.root) } + controller.bind(binding) return binding.root } @@ -74,6 +86,11 @@ class MetricsCarouselDockableContent( controller.unbind() } + private fun hideSoftInput(view: View) { + val manager = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + manager?.hideSoftInputFromWindow(view.windowToken, 0) + } + companion object { /** Stable id, shared with the docked carousel this content was undocked from. */ const val ID = "ide.metrics.carousel" diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index a5c2fa263d..37bd7b3fe4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -113,6 +113,7 @@ class MetricsCarouselController( object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { showTitleFor(position) + updateArrows(position) // A page left zoomed would keep claiming horizontal drags when swiped back to. memoryRenderer.resetZoom() networkRenderer.resetZoom() @@ -138,6 +139,13 @@ class MetricsCarouselController( // the chart are all spoken for, so this is a control rather than another gesture. binding.metricsSnapshot.setOnClickListener { exportSnapshot() } + // Arrows are the dependable way to move between pages: a swipe has to share the gesture + // with panning a zoomed chart and with the editor's drawer, and loses often enough to be + // annoying. + binding.metricsPrevious.setOnClickListener { step(-1) } + binding.metricsNext.setOnClickListener { step(1) } + updateArrows(binding.metricsPager.currentItem) + memoryUsageWatcher.listener = memoryListener networkUsageWatcher.listener = networkListener } @@ -161,6 +169,8 @@ class MetricsCarouselController( memoryRenderer.onXAxisTap = null networkRenderer.onXAxisTap = null binding?.metricsSnapshot?.setOnClickListener(null) + binding?.metricsPrevious?.setOnClickListener(null) + binding?.metricsNext?.setOnClickListener(null) pageCallback?.let { binding?.metricsPager?.unregisterOnPageChangeCallback(it) } pageCallback = null @@ -170,6 +180,30 @@ class MetricsCarouselController( binding = null } + /** + * Moves the carousel by [delta] pages, stopping at either end. + */ + @UiThread + private fun step(delta: Int) { + val pager = binding?.metricsPager ?: return + val target = (pager.currentItem + delta).coerceIn(0, pages.lastIndex) + if (target != pager.currentItem) { + pager.setCurrentItem(target, true) + } + } + + /** + * Dims the arrow that has nowhere to go, so the ends of the carousel are visible. + */ + @UiThread + private fun updateArrows(position: Int) { + val binding = this.binding ?: return + binding.metricsPrevious.isEnabled = position > 0 + binding.metricsNext.isEnabled = position < pages.lastIndex + binding.metricsPrevious.alpha = if (position > 0) 1f else DISABLED_ARROW_ALPHA + binding.metricsNext.alpha = if (position < pages.lastIndex) 1f else DISABLED_ARROW_ALPHA + } + /** * The renderer behind the page currently on screen, or `null` when nothing is bound. */ @@ -296,4 +330,8 @@ class MetricsCarouselController( fun onWatchedProcessesChanged() { memoryRenderer.rebuild() } + + private companion object { + const val DISABLED_ARROW_ALPHA = 0.35f + } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt index 340a88f510..6a2ffe92e7 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -61,6 +61,9 @@ class MetricsCarouselLayout */ var horizontalDragBelongsToChart: ((Float, Float) -> Boolean)? = null + /** Invoked as each gesture begins. */ + var onTouchDown: (() -> Unit)? = null + /** * Called with whether the carousel should accept touch paging for the gesture just * starting, and again with `true` when it ends. @@ -97,6 +100,7 @@ class MetricsCarouselLayout private fun routeHorizontalDrag(ev: MotionEvent) { when (ev.actionMasked) { MotionEvent.ACTION_DOWN -> { + onTouchDown?.invoke() val chartPans = horizontalDragBelongsToChart?.invoke(ev.rawX, ev.rawY) ?: false onPagingEnabledChanged?.invoke(!chartPans) } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index a1a192d7eb..206760d7f5 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -63,6 +63,16 @@ abstract class MetricsChartRenderer( */ var onXAxisTap: (() -> Unit)? = null + /** + * Whether the user has pinched this chart. + * + * Recorded from the scale gesture rather than read back from the chart. Showing a window of + * [VISIBLE_SAMPLES] out of a buffer of thousands *is* a zoom as far as the chart is concerned -- + * scaleX sits around 166 at rest -- so testing scaleX for "has the user zoomed" is always true, + * which silently disabled the auto-follow window and handed every horizontal drag to the chart. + */ + private var userHasZoomed = false + /** * The attached chart, or `null` when no carousel page is bound to this renderer. */ @@ -85,6 +95,7 @@ abstract class MetricsChartRenderer( @UiThread @CallSuper open fun detach() { + userHasZoomed = false chart = null } @@ -120,7 +131,7 @@ abstract class MetricsChartRenderer( rawY: Float, ): Boolean { val chart = this.chart ?: return false - if (chart.viewPortHandler.scaleX <= 1f) { + if (!userHasZoomed) { return false } @@ -136,7 +147,9 @@ abstract class MetricsChartRenderer( */ @UiThread fun resetZoom() { + userHasZoomed = false chart?.fitScreen() + chart?.let { showNewestWindow(it) } } /** @@ -199,7 +212,7 @@ abstract class MetricsChartRenderer( private fun showNewestWindow(chart: SafeLineChart) { // Once the user has zoomed in, the view is theirs. Re-centring on every redraw would drag // them back to the newest samples once a second, which makes zooming useless. - if (chart.viewPortHandler.scaleX > 1f) { + if (userHasZoomed) { return } @@ -256,7 +269,9 @@ abstract class MetricsChartRenderer( me: MotionEvent?, scaleX: Float, scaleY: Float, - ) = Unit + ) { + userHasZoomed = true + } override fun onChartTranslate( me: MotionEvent?, diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index e7795012c8..f264063587 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -24,6 +24,37 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 2e035ba27f..8ac6c22353 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -11,6 +11,8 @@ 4dp 40dp 10dp + 48dp + 12dp 28dp 28dp diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 8928f1911e..bdba956b6b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1682,6 +1682,8 @@ Sampling rate Every %1$s %1$s (needs a 64-bit device) + Previous metric + Next metric Save chart image Couldn\'t save the chart image. Received From 7511b17b8649e912991ed08b94aa9b615902d352 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 14:01:57 -0700 Subject: [PATCH 15/30] fix: tint the carousel arrows so they are visible on a dark chart The shared arrow drawables carry a hardcoded android:tint="#000000", so the paging arrows were drawn black on the near-black chart surface and could not be seen at all. This is the same failure as the x axis labels earlier in this ticket, which were invisible for the same reason -- MPAndroidChart defaults its text to Color.BLACK -- and it happened again because these icons were reused without checking what colour they came with. Anything drawn on this surface needs its colour asserted at the usage site rather than assumed. Tinted at the usage site rather than by editing the shared drawables, which are used elsewhere on light backgrounds. Verified on a Pixel 6 Pro (arm64), v8 debug: both arrows legible, the one at the end of the carousel dimmed. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- app/src/main/res/layout/layout_mem_usage.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/layout/layout_mem_usage.xml b/app/src/main/res/layout/layout_mem_usage.xml index f264063587..5190312432 100644 --- a/app/src/main/res/layout/layout_mem_usage.xml +++ b/app/src/main/res/layout/layout_mem_usage.xml @@ -35,6 +35,7 @@ android:padding="@dimen/metrics_carousel_arrow_padding" android:scaleType="fitCenter" android:src="@drawable/ic_arrow_left" + app:tint="?attr/colorOnSurface" app:layout_constraintBottom_toBottomOf="@id/metrics_title" app:layout_constraintEnd_toStartOf="@id/metrics_title" app:layout_constraintHorizontal_chainStyle="packed" @@ -50,6 +51,7 @@ android:padding="@dimen/metrics_carousel_arrow_padding" android:scaleType="fitCenter" android:src="@drawable/ic_arrow_right" + app:tint="?attr/colorOnSurface" app:layout_constraintBottom_toBottomOf="@id/metrics_title" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/metrics_title" From 9444417d9bd9180b3e7e1a416d18fc233ec6bd7f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 14:08:26 -0700 Subject: [PATCH 16/30] feat: page the carousel only with the arrows Swiping in the graph area no longer changes page. The arrows either side of the title are the only way. This removes a three-way contention rather than arbitrating it. A horizontal drag in the plot was wanted by the carousel, by a zoomed chart wanting to pan, and by the editor's drawer gesture; deciding between them per gesture worked, but losing the race intermittently made the carousel feel unreliable, and no amount of tuning makes an ambiguous gesture feel deliberate. With touch paging off, a horizontal drag in the plot is unambiguously a pan, and paging is a plain control that cannot be misread. The gesture arbitration goes with it: the router in MetricsCarouselLayout, the paging-enabled callback, and handlesHorizontalDragAt on the renderer are all deleted rather than left switched off. What stays: the layout still asks its ancestors not to intercept, so a horizontal drag in this strip reaches the chart to pan with instead of opening the drawer, and the editor's fling detector still excludes the carousel's bounds. The x axis stays at the bottom. It moved there so the strip beneath it could be reserved for the carousel swipe, which no longer exists, but the bottom is the conventional place for a time axis and moving it back would be churn. Verified on a Pixel 6 Pro (arm64), v8 debug: a swipe across the plot leaves the title on "Memory usage", and the next arrow moves it to "Network traffic". 82 tests green across app ui/utils. ADFA-5486 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 13 ++--- .../androidide/ui/MetricsCarouselLayout.kt | 54 +++---------------- .../androidide/ui/MetricsChartRenderer.kt | 24 --------- 3 files changed, 10 insertions(+), 81 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 37bd7b3fe4..bd756ee7bf 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -125,12 +125,10 @@ class MetricsCarouselController( // A tap on the x axis opens the sampling-rate chooser (ADFA-5486). The axis is drawn by the // chart, not a view of its own, so the strip of the pager it occupies is the target. - binding.root.horizontalDragBelongsToChart = { rawX, rawY -> - currentRenderer()?.handlesHorizontalDragAt(rawX, rawY) ?: false - } - binding.root.onPagingEnabledChanged = { enabled -> - binding.metricsPager.isUserInputEnabled = enabled - } + // Paging is by the arrows only. A swipe in the plot competes with panning a zoomed chart + // and with the editor's drawer gesture, and losing that race intermittently made the + // carousel feel broken; with touch paging off, a horizontal drag is unambiguously a pan. + binding.metricsPager.isUserInputEnabled = false memoryRenderer.onXAxisTap = { showSamplingRateDialog() } networkRenderer.onXAxisTap = { showSamplingRateDialog() } @@ -163,9 +161,6 @@ class MetricsCarouselController( networkUsageWatcher.listener = null } - binding?.root?.horizontalDragBelongsToChart = null - binding?.root?.onPagingEnabledChanged = null - binding?.metricsPager?.isUserInputEnabled = true memoryRenderer.onXAxisTap = null networkRenderer.onXAxisTap = null binding?.metricsSnapshot?.setOnClickListener(null) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt index 6a2ffe92e7..f175b9e798 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -28,11 +28,10 @@ import kotlin.math.hypot /** * Host for the editor's metrics carousel, which claims horizontal gestures that begin inside it. * - * The carousel pages with a horizontal swipe, but a left-to-right swipe elsewhere in the editor - * opens the navigation drawer -- documented behaviour, shown in the editor's own onboarding text. - * Without this, the carousel could only page forwards. Asking every ancestor not to intercept, for - * the rest of the gesture, hands horizontal drags that start in this strip to [ViewPager2] and - * leaves the drawer gesture untouched everywhere else. + * A left-to-right swipe elsewhere in the editor opens the navigation drawer -- documented + * behaviour, shown in the editor's own onboarding text. Asking every ancestor not to intercept, for + * the rest of the gesture, keeps horizontal drags that start in this strip for the chart to pan + * with, and leaves the drawer gesture untouched everywhere else. * * This covers ancestors that intercept through the view hierarchy. The editor also runs an * activity-level [android.view.GestureDetector] from `dispatchTouchEvent`, which never calls @@ -55,21 +54,9 @@ class MetricsCarouselLayout */ var onTwoFingerTap: (() -> Unit)? = null - /** - * Asked, at the start of each gesture, whether a horizontal drag from this screen position - * belongs to the chart (panning a zoomed plot) rather than to the carousel (paging). - */ - var horizontalDragBelongsToChart: ((Float, Float) -> Boolean)? = null - /** Invoked as each gesture begins. */ var onTouchDown: (() -> Unit)? = null - /** - * Called with whether the carousel should accept touch paging for the gesture just - * starting, and again with `true` when it ends. - */ - var onPagingEnabledChanged: ((Boolean) -> Unit)? = null - private var twoFingerDownAt = 0L private var twoFingerDownX = 0f private var twoFingerDownY = 0f @@ -84,39 +71,10 @@ class MetricsCarouselLayout */ override fun dispatchTouchEvent(ev: MotionEvent): Boolean { trackTwoFingerTap(ev) - routeHorizontalDrag(ev) - return super.dispatchTouchEvent(ev) - } - - /** - * Decides, once per gesture, who owns a horizontal drag. - * - * The carousel and a zoomed chart both want horizontal drags, and only one can have them. - * The decision is made on the way down, before either has seen a move, by turning the - * pager's touch paging off for the gesture: with it off the drag reaches the chart and pans - * it. Inside the plot of a zoomed chart the chart wins; everywhere else -- including the - * strip below the x axis, and the whole chart at rest -- the carousel does. - */ - private fun routeHorizontalDrag(ev: MotionEvent) { - when (ev.actionMasked) { - MotionEvent.ACTION_DOWN -> { - onTouchDown?.invoke() - val chartPans = horizontalDragBelongsToChart?.invoke(ev.rawX, ev.rawY) ?: false - onPagingEnabledChanged?.invoke(!chartPans) - } - - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - onPagingEnabledChanged?.invoke(true) - } - } - } - - override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (ev.actionMasked == MotionEvent.ACTION_DOWN) { - // Cleared by the framework on the next ACTION_DOWN, so this lasts exactly one gesture. - parent?.requestDisallowInterceptTouchEvent(true) + onTouchDown?.invoke() } - return super.onInterceptTouchEvent(ev) + return super.dispatchTouchEvent(ev) } /** diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index 206760d7f5..c087e102d2 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -118,30 +118,6 @@ abstract class MetricsChartRenderer( @UiThread abstract fun rebuild() - /** - * Whether a horizontal drag starting at this screen position should pan the chart rather than - * page the carousel. - * - * True only inside the plot area of a chart that is zoomed in: at rest there is nothing to pan - * to, so the swipe belongs to the carousel, and the strip below the x axis is never the chart's. - */ - @UiThread - fun handlesHorizontalDragAt( - rawX: Float, - rawY: Float, - ): Boolean { - val chart = this.chart ?: return false - if (!userHasZoomed) { - return false - } - - val location = IntArray(2) - chart.getLocationOnScreen(location) - val x = rawX - location[0] - val y = rawY - location[1] - return chart.viewPortHandler.contentRect.contains(x, y) - } - /** * Returns the chart to its unzoomed state. */ From c12bc1e9d4ffe99c1b7d6c66bf15efdfa37ddaf0 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 21:55:13 -0700 Subject: [PATCH 17/30] fix(metrics): address four review findings on the carousel (ADFA-5486) Four defects found by review -- one mine, three CodeRabbit's -- fixed in the PR that owns them rather than in a later one in the stack. An activity was reachable from the floating window. The memory chart's line colour came from `::getMemUsageLineColorFor`, a bound reference to a BaseEditorActivity method, stored in MetricsCarouselController, which is handed to MetricsCarouselDockableContent and held by the floating-window host. Across a recreation while undocked -- a rotation is enough -- that pinned the old activity. The function is pure: process name in, colour constant out. It moves to the companion, so the reference binds a singleton instead. The snapshot did disk I/O on the main thread. `MetricsSnapshot.write` lists a directory, deletes its contents, encodes a full-chart PNG and writes it, and the camera button called it inside the click listener. The bitmap still has to be taken on the UI thread, but the encode and the write now run on Dispatchers.IO. The controller gained a scope for that, and a close() so a snapshot in flight is cancelled with the editor. Getting that wrong once is worth recording: moving the *share* onto the application context along with the write crashed on the first tap, because startActivity throws from a context with no task unless it is given FLAG_ACTIVITY_NEW_TASK. Only the write wanted the long-lived context. The share re-reads the host binding instead of capturing it, because the export is no longer instantaneous and the carousel can be docked or undocked while the file is written. MemoryUsageWatcher had no lock on its history. Its two siblings both guard their ring buffers and hand out copies; this one did neither, and ADFA-5486 added a clearHistory() that the rate dialog calls from the UI thread while the sampler is appending. clear() is a fill plus a shift reset, the append is a write plus a shift, and interleaved they leave the shift pointing at data that is no longer there. Now serialised on a lock, matching the other two. A non-positive sampling interval could spin the sampler. delay() does not suspend for one, so the loop would pin a core for as long as the editor is open. MetricsSamplingRates already had a coerce function that nothing ever called; it gains a device-independent sibling for the watchers to guard themselves with, applied in both the constructor and the setter -- the constructor initialiser bypasses the setter, so it needs its own. The two interval tests were confirmed to fail without the clamp. The lock has no test: a data race has no deterministic failing case, and asserting on one would pin the scheduler rather than the behaviour. Verified on a Pixel 6 Pro: the memory chart still draws its lines in the right colours, and the camera button produces a share sheet with the chart image and no crash, with the disk work off the main thread. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 29 ++++++---- .../ui/MetricsCarouselController.kt | 53 ++++++++++++++++--- .../androidide/utils/MemoryUsageWatcher.kt | 29 +++++++--- .../androidide/utils/MetricsSamplingRates.kt | 11 ++++ .../androidide/utils/NetworkUsageWatcher.kt | 7 +-- .../utils/MetricsSamplingRatesTest.kt | 22 ++++++++ .../utils/WatcherIntervalChangeTest.kt | 24 +++++++++ 7 files changed, 150 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 72299abfc4..163f7e1303 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -200,7 +200,7 @@ abstract class BaseEditorActivity : MetricsCarouselController( memoryUsageWatcher = memoryUsageWatcher, networkUsageWatcher = networkUsageWatcher, - lineColorFor = ::getMemUsageLineColorFor, + lineColorFor = Companion::getMemUsageLineColorFor, annotations = metricsViewModel.annotations, ) } @@ -449,7 +449,23 @@ abstract class BaseEditorActivity : companion object { const val DEBUGGER_SERVICE_STOP_DELAY_MS: Long = 60 * 1000 + /** + * The plot colour for a watched process. + * + * Lives on the companion, not on the activity: a bound reference to an activity method is + * handed to [MetricsCarouselController], which is in turn handed to the floating window and + * outlives an activity recreation. A pure function of the process name has no business + * pinning an activity in memory, and this one is exactly that. + */ @JvmStatic + fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = + when (proc.pname) { + PROC_IDE -> Color.BLUE + PROC_GRADLE_TOOLING -> Color.RED + PROC_GRADLE_DAEMON -> Color.GREEN + else -> throw IllegalArgumentException("Unknown process: $proc") + } + protected val PROC_IDE = "IDE" @JvmStatic @@ -529,6 +545,9 @@ abstract class BaseEditorActivity : fullscreenManager = null metricsCarousel.unbind() + if (isDestroying) { + metricsCarousel.close() + } _binding = null if (isDestroying) { @@ -1033,14 +1052,6 @@ abstract class BaseEditorActivity : metricsCarousel.onWatchedProcessesChanged() } - private fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = - when (proc.pname) { - PROC_IDE -> Color.BLUE - PROC_GRADLE_TOOLING -> Color.RED - PROC_GRADLE_DAEMON -> Color.GREEN - else -> throw IllegalArgumentException("Unknown process: $proc") - } - override fun onPause() { super.onPause() // Sampling continues while backgrounded so the history has no gaps; the x axis assumes diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index bd756ee7bf..eeb7c77bd4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -30,6 +30,12 @@ import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.MetricsSamplingRates import com.itsaky.androidide.utils.MetricsSnapshot import com.itsaky.androidide.utils.NetworkUsageWatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Drives one metrics carousel: its pages, its renderers, and the title that names the current page. @@ -84,6 +90,13 @@ class MetricsCarouselController( networkRenderer.onUsageChanged(usage) } + /** + * Runs the snapshot write. Main-dispatched so its result lands back on the UI thread, with the + * disk work pushed to [Dispatchers.IO] inside; a SupervisorJob so one failed export does not + * stop the next. + */ + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private var binding: LayoutMemUsageBinding? = null private var pageCallback: ViewPager2.OnPageChangeCallback? = null @@ -276,7 +289,11 @@ class MetricsCarouselController( /** * Writes the visible chart to an image and offers it to another app (ADFA-5486). * - * @return whether a snapshot was produced. + * The bitmap has to be taken on the UI thread -- it is a copy of what the chart drew -- but + * encoding and writing the PNG must not be. That is a directory listing, a delete and a file + * write behind a full-chart encode, all of which used to run inside the click listener. + * + * @return whether a snapshot could be started. The write itself completes later. */ @UiThread fun exportSnapshot(): Boolean { @@ -298,16 +315,38 @@ class MetricsCarouselController( return false } - val file = MetricsSnapshot.write(context, bitmap, label) - if (file == null) { - Toast.makeText(context, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() - return false + // The write takes the application context because it outlives the click. The share does not: + // it ends in startActivity, which throws from a context with no task of its own unless it is + // given FLAG_ACTIVITY_NEW_TASK, so it keeps the context the carousel is hosted in. + val appContext = context.applicationContext + scope.launch { + val file = withContext(Dispatchers.IO) { MetricsSnapshot.write(appContext, bitmap, label) } + if (file == null) { + Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + return@launch + } + // Re-read the host rather than capturing it: the export is no longer instantaneous, and + // the carousel can be unbound (docked, undocked, recreated) while the file is written. + val host = binding?.root?.context + if (host == null) { + Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + return@launch + } + IntentUtils.shareFile(host, file, MetricsSnapshot.MIME_TYPE) } - - IntentUtils.shareFile(context, file, MetricsSnapshot.MIME_TYPE) return true } + /** + * Releases the controller for good. Distinct from [unbind], which runs on every dock, undock + * and recreation; this is the terminal teardown and cancels any snapshot still being written. + */ + @UiThread + fun close() { + unbind() + scope.cancel() + } + /** * Redraws both charts from the full history, for a host coming back to the foreground with * samples gathered while it was away. diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index 2f35dc28a3..d31e12e498 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -64,12 +64,13 @@ class MemoryUsageWatcher * age from its position, which assumes every sample is the same age apart, and a buffer * holding samples taken at two rates would silently misdate all the older ones (ADFA-5486). */ - var updateInterval: Long = updateInterval + var updateInterval: Long = MetricsSamplingRates.coerceToSafeRange(updateInterval) set(value) { - if (field == value) { + val safe = MetricsSamplingRates.coerceToSafeRange(value) + if (field == safe) { return } - field = value + field = safe clearHistory() } @@ -78,6 +79,13 @@ class MemoryUsageWatcher /** The running sampling loop, so [stopWatching] can actually stop it. */ private var samplingJob: Job? = null private val memoryUsage = ConcurrentHashMap() + + /** + * Guards the per-process ring buffers, matching [NetworkUsageWatcher] and + * [PowerUsageWatcher]. The sampler appends to them; [clearHistory] wipes them from whatever + * thread changed the sampling rate. + */ + private val historyLock = Any() private val watching = AtomicBoolean(false) /** @@ -200,8 +208,10 @@ class MemoryUsageWatcher // for example, if shift is 1, then _history[0] will actually return _history[1] (index shifted by 1 to the right) // when the shift amount exceeds the size of the array, it will be reset to 0 (wrapped around) - _history[0] = usageBytes - _history.shift(1) + synchronized(historyLock) { + _history[0] = usageBytes + _history.shift(1) + } } } } @@ -240,7 +250,14 @@ class MemoryUsageWatcher * Discards every recorded sample, keeping the watched processes. */ fun clearHistory() { - memoryUsage.values.forEach { it._history.clear() } + // Held while clearing because clear() is two writes -- fill the array, reset the shift -- + // and the sampler's append is another two. Interleaved, they leave the buffer's shift + // pointing into data that is no longer there, and the chart plots a scrambled history. + // The rate dialog changes the interval from the UI thread while the sampler is running, + // so this is reachable, not theoretical. + synchronized(historyLock) { + memoryUsage.values.forEach { it._history.clear() } + } } /** diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt index 3fe50358c0..e50dc13fab 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSamplingRates.kt @@ -89,6 +89,17 @@ object MetricsSamplingRates { intervalMillis: Long, arch: CpuArch, ): Long = intervalMillis.coerceIn(minimumIntervalMillis(arch), MAX_INTERVAL_MS) + + /** + * Clamps [intervalMillis] into the range *any* device may run at. + * + * The watchers guard themselves with this rather than with [coerceToSupportedRange], which + * needs to know the architecture and so cannot be called from a plain unit test. It is a safety + * net, not the policy: what the user may pick is still decided by [ratesFor]. Its job is to + * keep a non-positive interval out of `delay()`, which does not suspend for one -- the sampling + * loop would then spin, pinning a core for as long as the editor is open. + */ + fun coerceToSafeRange(intervalMillis: Long): Long = intervalMillis.coerceIn(MIN_INTERVAL_64_BIT_MS, MAX_INTERVAL_MS) } /** diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index f11cff2384..fb9af57684 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -71,12 +71,13 @@ class NetworkUsageWatcher( * Milliseconds between samples. Changing it clears the history, for the reason given on * [MemoryUsageWatcher.updateInterval]. */ - var updateInterval: Long = updateInterval + var updateInterval: Long = MetricsSamplingRates.coerceToSafeRange(updateInterval) set(value) { - if (field == value) { + val safe = MetricsSamplingRates.coerceToSafeRange(value) + if (field == safe) { return } - field = value + field = safe clearHistory() } diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt index 3958122ab7..1b22612c83 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSamplingRatesTest.kt @@ -84,4 +84,26 @@ class MetricsSamplingRatesTest { assertThat(CpuArch.ARM.is64Bit).isFalse() assertThat(CpuArch.X86.is64Bit).isFalse() } + + @Test + fun `the safe range keeps a non-positive interval out of delay`() { + // delay() does not suspend for a non-positive value, so the sampling loop would spin and + // pin a core for as long as the editor is open. + assertThat(MetricsSamplingRates.coerceToSafeRange(0L)).isGreaterThan(0L) + assertThat(MetricsSamplingRates.coerceToSafeRange(-1_000L)).isGreaterThan(0L) + assertThat(MetricsSamplingRates.coerceToSafeRange(Long.MIN_VALUE)).isGreaterThan(0L) + } + + @Test + fun `the safe range caps an absurdly long interval`() { + assertThat(MetricsSamplingRates.coerceToSafeRange(Long.MAX_VALUE)) + .isEqualTo(MetricsSamplingRates.MAX_INTERVAL_MS) + } + + @Test + fun `the safe range leaves a supported interval alone`() { + assertThat(MetricsSamplingRates.coerceToSafeRange(1_000L)).isEqualTo(1_000L) + assertThat(MetricsSamplingRates.coerceToSafeRange(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS)) + .isEqualTo(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS) + } } diff --git a/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt index 2bc56a8e04..35ad6e5e90 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt @@ -81,4 +81,28 @@ class WatcherIntervalChangeTest { private companion object { const val TEST_UID = 10_123 } + + @Test + fun `a watcher refuses a non-positive sampling interval`() { + val watcher = NetworkUsageWatcher(uid = TEST_UID, readRxBytes = { 0L }, readTxBytes = { 0L }) + try { + watcher.updateInterval = -1L + + // Stored raw, this reaches delay(), which does not suspend for it: the loop spins. + assertThat(watcher.updateInterval).isAtLeast(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS) + } finally { + watcher.close() + } + } + + @Test + fun `a watcher constructed with a non-positive interval is clamped too`() { + // The constructor initialiser bypasses the setter, so it needs its own guard. + val watcher = NetworkUsageWatcher(updateInterval = 0L, uid = TEST_UID, readRxBytes = { 0L }, readTxBytes = { 0L }) + try { + assertThat(watcher.updateInterval).isAtLeast(MetricsSamplingRates.MIN_INTERVAL_64_BIT_MS) + } finally { + watcher.close() + } + } } From 8e68679eabcaa5e5996f481965a6d0148a9a92e8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 17:23:05 -0700 Subject: [PATCH 18/30] fix(metrics): put the sampling-rate tap on the edge the x axis is drawn on (ADFA-5486) The chooser was reachable only from a blank strip above the plot, at the opposite end of the chart from the axis labels the gesture is named for. The hit test compared against contentTop while the axis is positioned BOTTOM, so tapping the labels did nothing and the rate could not be changed by anyone who did not already know where the hidden band was. The strip under the plot had been left alone for the carousel swipe. Paging is by the arrows now, so it is free, and the tap moves there. The two have to agree, and nothing said so: a comment on each site now points at the other. MetricsChartAxisTapTest covers all three bands. Confirmed to fail against the old hit test in both directions -- the tap below the plot not registering, and the tap above it still registering -- so it pins the edge rather than merely the existence of the gesture. A guard test asserts the chart was laid out first, without which every coordinate sits on the same edge and the others would pass vacuously. Verified on a Pixel 6 Pro: tapping the "-54s" labels opens the chooser, tapping the band above the plot does nothing, and picking "Every 5s" relabels the axis to -270s and clears the history as intended. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 16 ++- .../androidide/ui/MetricsChartAxisTapTest.kt | 122 ++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index c087e102d2..10301bbe53 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -162,8 +162,9 @@ abstract class MetricsChartRenderer( setBackgroundColor(context.resolveAttr(R.attr.colorSurfaceDim)) setDrawGridBackground(true) - // Below the plot, so the strip under it can be reserved for the carousel swipe and the - // plot itself can pan when zoomed (ADFA-5486). + // Below the plot, which is also where a tap opens the sampling-rate chooser + // (ADFA-5486). The two have to agree: they disagreed once, and the gesture was + // unreachable at the labels it is named for. xAxis.position = XAxis.XAxisPosition.BOTTOM // The right axis carries the labels; the left is unused. @@ -207,15 +208,20 @@ abstract class MetricsChartRenderer( * Turns a tap in the x-axis band into [onXAxisTap]. * * The axis is drawn by the chart rather than being a view of its own, so there is nothing to - * attach a click listener to. `contentTop` is the top of the plotting area, and the axis labels - * sit above it, so a tap higher than that landed on the axis. + * attach a click listener to. `contentBottom` is the bottom of the plotting area and the axis + * is drawn below it (see [configure]), so a tap lower than that landed on the axis. + * + * This used to test `contentTop`, which put the only way to reach the sampling-rate chooser in + * an empty band at the *opposite* end of the chart from the labels it is named for. The strip + * under the plot had been left alone for the carousel swipe; paging is by the arrows now, so it + * is free. */ private inner class XAxisTapListener( private val chart: SafeLineChart, ) : OnChartGestureListener { override fun onChartSingleTapped(me: MotionEvent?) { val y = me?.y ?: return - if (y <= chart.viewPortHandler.contentTop()) { + if (y >= chart.viewPortHandler.contentBottom()) { onXAxisTap?.invoke() } } diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt new file mode 100644 index 0000000000..7e6a9a7a82 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -0,0 +1,122 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.view.MotionEvent +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Pins where the sampling-rate chooser is reached from (ADFA-5486). + * + * The x axis is drawn by the chart rather than being a view of its own, so the tap is recognised by + * comparing coordinates against the plot area. That test and the axis's position have to agree: + * they disagreed once -- the axis at the bottom, the tap band at the top -- which left the only way + * to change the sampling rate in an empty strip at the far end of the chart from the labels the + * gesture is named for. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsChartAxisTapTest { + private val context = ApplicationProvider.getApplicationContext() + + private var taps = 0 + + private fun laidOutChart(): SafeLineChart { + val chart = SafeLineChart(context) + // Any concrete renderer will do -- the tap band is decided by the base class, and every + // page positions its x axis the same way. + val renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + ) + }, + ) + renderer.attach(chart) + renderer.onXAxisTap = { taps++ } + + // Without a layout pass the plot area has no extent, so every coordinate is on its edge. + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + return chart + } + + private fun tapAt( + chart: SafeLineChart, + y: Float, + ) { + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_UP, 10f, y, 0) + chart.onChartGestureListener.onChartSingleTapped(event) + event.recycle() + } + + @Test + fun `the plot area has room for a tap to fall inside or outside it`() { + val chart = laidOutChart() + + // Guards the other tests: on an unlaid-out chart they would all tap the same edge. + assertThat(chart.viewPortHandler.contentBottom()).isGreaterThan(chart.viewPortHandler.contentTop()) + assertThat(chart.viewPortHandler.contentBottom()).isLessThan(HEIGHT.toFloat()) + } + + @Test + fun `a tap below the plot, where the axis is drawn, opens the chooser`() { + val chart = laidOutChart() + + tapAt(chart, chart.viewPortHandler.contentBottom() + 1f) + + assertThat(taps).isEqualTo(1) + } + + @Test + fun `a tap above the plot does not open the chooser`() { + val chart = laidOutChart() + + // Nothing is drawn up there. Answering taps here is what made the gesture unreachable. + tapAt(chart, chart.viewPortHandler.contentTop() - 1f) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a tap inside the plot does not open the chooser`() { + val chart = laidOutChart() + + val handler = chart.viewPortHandler + tapAt(chart, (handler.contentTop() + handler.contentBottom()) / 2f) + + assertThat(taps).isEqualTo(0) + } + + private companion object { + const val WIDTH = 720 + const val HEIGHT = 400 + const val SAMPLES = 60 + } +} From 717f5098739b2df3f21ebd2444afcdc485c35bfd Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 23:13:56 -0700 Subject: [PATCH 19/30] fix(metrics): close out the remaining review findings on the carousel (ADFA-5486) A pinch anchored on one finger undocked the chart. The two-finger tap recogniser measured travel for pointer 0 only, so holding the first finger still and spreading the second registered no movement at all: the gesture stayed a tap candidate and undocked on lift-off instead of zooming. Both fingers' landing positions are now tracked and either one travelling disqualifies the tap. The existing pinch test missed this because it moved both fingers; there are now cases for each finger held still, and they fail against the old check. The sampling-rate chooser greyed its rows by reaching into the list's laid-out children after showing the dialog. getChildAt only sees rows that exist, and a recycled row comes back enabled, so an unavailable rate could look selectable and then silently do nothing when tapped. The state belongs to the adapter, which now answers isEnabled per position and dims the row itself. bind() registered a page callback without releasing the previous binding. Docking, undocking and an activity recreation all route through it, so a re-bind without an intervening unbind accumulated callbacks and listeners on views that were already gone. It now releases first. close() is terminal in both watchers. It cancelled the scope but left nothing to stop a later startWatching() flipping isWatching to true and launching into that cancelled scope -- a watcher reporting it was sampling with no loop behind it. Test watchers are closed in a finally. Each holds a dedicated sampling thread until close(), and a failed assertion skipped it. That is the same omission that, in a coroutine test, left a sampling loop live and sent runTest's advanceUntilIdle spinning virtual time forever -- a synchronous spin no timeout can interrupt, which pinned a core until Gradle's ten-minute task limit. The two-finger tap cannot be exercised by automation -- adb has no multi-touch and sendevent needs root -- so the anchored-pinch behaviour is covered by unit tests and still wants a human hand on a device before this merges. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 48 +++++++++++++++---- .../androidide/ui/MetricsCarouselLayout.kt | 38 +++++++++++---- .../androidide/utils/MemoryUsageWatcher.kt | 13 +++++ .../androidide/utils/NetworkUsageWatcher.kt | 13 +++++ .../ui/MetricsCarouselLayoutTest.kt | 36 ++++++++++++++ .../utils/WatcherIntervalChangeTest.kt | 14 +++++- 6 files changed, 144 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index eeb7c77bd4..9bc96311fc 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -17,6 +17,9 @@ package com.itsaky.androidide.ui +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter import android.widget.Toast import androidx.annotation.UiThread import androidx.viewpager2.widget.ViewPager2 @@ -112,6 +115,13 @@ class MetricsCarouselController( */ @UiThread fun bind(binding: LayoutMemUsageBinding) { + // A carousel can be re-bound without an intervening unbind -- docking, undocking and an + // activity recreation all route through here. Releasing first keeps one page callback and + // one set of listeners alive rather than accumulating them on views that are already gone. + if (this.binding != null) { + unbind() + } + this.binding = binding binding.metricsPager.adapter = MetricsCarouselAdapter(pages, memoryRenderer, networkRenderer) @@ -243,11 +253,37 @@ class MetricsCarouselController( val checked = rates.indexOfFirst { it.intervalMillis == current } + // A choice adapter that knows which rows are selectable, rather than reaching into the + // list's laid-out children afterwards: getChildAt only sees rows that already exist, and a + // recycled row comes back enabled, so an unavailable rate could look selectable and then + // silently do nothing. + val adapter = + object : ArrayAdapter( + context, + android.R.layout.simple_list_item_single_choice, + android.R.id.text1, + labels, + ) { + override fun areAllItemsEnabled(): Boolean = false + + override fun isEnabled(position: Int): Boolean = rates.getOrNull(position)?.isAvailable ?: false + + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup, + ): View = + super.getView(position, convertView, parent).apply { + isEnabled = isEnabled(position) + alpha = if (isEnabled) 1f else UNAVAILABLE_RATE_ALPHA + } + } + val dialog = DialogUtils .newMaterialDialogBuilder(context) .setTitle(string.metrics_sampling_rate_title) - .setSingleChoiceItems(labels, checked) { dismissable, which -> + .setSingleChoiceItems(adapter, checked) { dismissable, which -> val rate = rates[which] if (rate.isAvailable) { setSamplingInterval(rate.intervalMillis) @@ -259,13 +295,6 @@ class MetricsCarouselController( // the message silently wins. The unavailable entries carry the explanation instead. .setNegativeButton(string.cancel) { dismissable, _ -> dismissable.dismiss() } .show() - - // Grey the rates this device cannot use, so the list shows what the hardware costs. - dialog.listView?.let { list -> - rates.forEachIndexed { index, rate -> - list.getChildAt(index)?.isEnabled = rate.isAvailable - } - } } /** @@ -367,5 +396,8 @@ class MetricsCarouselController( private companion object { const val DISABLED_ARROW_ALPHA = 0.35f + + /** Dims a rate this device cannot offer, so the list shows what the hardware costs. */ + const val UNAVAILABLE_RATE_ALPHA = 0.4f } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt index f175b9e798..4feab2881f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -58,8 +58,14 @@ class MetricsCarouselLayout var onTouchDown: (() -> Unit)? = null private var twoFingerDownAt = 0L - private var twoFingerDownX = 0f - private var twoFingerDownY = 0f + + /** + * Where each of the two fingers landed. Both are tracked, not just the first: a pinch that + * keeps one finger still and spreads the other travels no distance at index 0, so watching + * only that finger let a zoom be read as a tap and undock the chart. + */ + private val twoFingerDownX = FloatArray(TWO_FINGERS) + private val twoFingerDownY = FloatArray(TWO_FINGERS) private var twoFingerTapCandidate = false /** @@ -98,11 +104,13 @@ class MetricsCarouselLayout } MotionEvent.ACTION_POINTER_DOWN -> { - if (ev.pointerCount == 2) { + if (ev.pointerCount == TWO_FINGERS) { twoFingerTapCandidate = true twoFingerDownAt = ev.eventTime - twoFingerDownX = ev.getX(0) - twoFingerDownY = ev.getY(0) + for (pointer in 0 until TWO_FINGERS) { + twoFingerDownX[pointer] = ev.getX(pointer) + twoFingerDownY[pointer] = ev.getY(pointer) + } } else { // A third finger is not this gesture. twoFingerTapCandidate = false @@ -110,10 +118,18 @@ class MetricsCarouselLayout } MotionEvent.ACTION_MOVE -> { - if (twoFingerTapCandidate && ev.pointerCount >= 1) { - val travel = hypot(ev.getX(0) - twoFingerDownX, ev.getY(0) - twoFingerDownY) - if (travel > touchSlop) { - twoFingerTapCandidate = false + if (twoFingerTapCandidate) { + // Either finger travelling means this is a pinch, not a tap. + for (pointer in 0 until minOf(ev.pointerCount, TWO_FINGERS)) { + val travel = + hypot( + ev.getX(pointer) - twoFingerDownX[pointer], + ev.getY(pointer) - twoFingerDownY[pointer], + ) + if (travel > touchSlop) { + twoFingerTapCandidate = false + break + } } } } @@ -141,4 +157,8 @@ class MetricsCarouselLayout // A person's two-finger tap is far slower than the single-finger tap timeout: the two // fingers land and lift out of step. Anything shorter than a long press counts. private val tapTimeout = ViewConfiguration.getLongPressTimeout().toLong() + + private companion object { + const val TWO_FINGERS = 2 + } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index d31e12e498..5bf82fe35f 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -88,6 +88,13 @@ class MemoryUsageWatcher private val historyLock = Any() private val watching = AtomicBoolean(false) + /** + * Set by [close] and never cleared. Without it a start after a terminal teardown would flip + * [isWatching] to true and launch into a cancelled scope, leaving the watcher reporting that + * it is sampling when no loop exists. + */ + private val closed = AtomicBoolean(false) + /** * Whether the memory usage watcher is watching processes for their memory usage. */ @@ -127,6 +134,11 @@ class MemoryUsageWatcher * Start watching processes for their memory usage. */ fun startWatching() { + if (closed.get()) { + log.warn("Memory usage watcher is closed and cannot be restarted") + return + } + if (!watching.compareAndSet(false, true)) { log.warn("Processes are already being watched for memory usage") return @@ -318,6 +330,7 @@ class MemoryUsageWatcher * `newSingleThreadContext` holds one until it is closed. */ fun close() { + closed.set(true) stopWatching() listener = null coroutineScope.cancelIfActive("Watcher closed") diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index 1cdb75c8ff..f78a7728f0 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -72,6 +72,13 @@ class NetworkUsageWatcher private val coroutineScope = CoroutineScope(SupervisorJob() + coroutineDispatcher) private val watching = AtomicBoolean(false) + /** + * Set by [close] and never cleared. Without it a start after a terminal teardown would flip + * [isWatching] to true and launch into a cancelled scope, leaving the watcher reporting that + * it is sampling when no loop exists. + */ + private val closed = AtomicBoolean(false) + /** The running sampling loop, so [stopWatching] can actually stop it. */ private var samplingJob: Job? = null @@ -145,6 +152,11 @@ class NetworkUsageWatcher } fun startWatching() { + if (closed.get()) { + log.warn("Network usage watcher is closed and cannot be restarted") + return + } + if (!watching.compareAndSet(false, true)) { log.warn("Network usage is already being watched") return @@ -199,6 +211,7 @@ class NetworkUsageWatcher * holds one until it is closed. */ fun close() { + closed.set(true) stopWatching() listener = null coroutineScope.cancelIfActive("Watcher closed") diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt index e39cc4fc1b..7131a7b6b6 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt @@ -148,6 +148,42 @@ class MetricsCarouselLayoutTest { assertThat(taps).isEqualTo(0) } + @Test + fun `a pinch anchored on the first finger is not a tap`() { + // The awkward case: hold one finger still and spread the other. Watching only pointer 0 + // sees no travel at all, so the zoom was recognised as a tap and undocked the chart. + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val travel = ViewConfiguration.get(context).scaledTouchSlop * 4f + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(MotionEvent.ACTION_MOVE, 500f to 450f, 900f + travel to 450f, eventTime = downTime + 20L), + event(pointerUp(1), 500f to 450f, 900f + travel to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + + @Test + fun `a pinch anchored on the second finger is not a tap either`() { + var taps = 0 + val layout = layout().apply { onTwoFingerTap = { taps++ } } + downTime = SystemClock.uptimeMillis() + val travel = ViewConfiguration.get(context).scaledTouchSlop * 4f + + layout.dispatch( + event(MotionEvent.ACTION_DOWN, 500f to 450f), + event(pointerDown(1), 500f to 450f, 900f to 450f), + event(MotionEvent.ACTION_MOVE, 500f - travel to 450f, 900f to 450f, eventTime = downTime + 20L), + event(pointerUp(1), 500f - travel to 450f, 900f to 450f, eventTime = downTime + 40L), + ) + + assertThat(taps).isEqualTo(0) + } + @Test fun `a long two-finger hold is not a tap`() { var taps = 0 diff --git a/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt index 35ad6e5e90..a87b129de2 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/WatcherIntervalChangeTest.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.utils import com.google.common.truth.Truth.assertThat +import org.junit.After import org.junit.Test /** @@ -28,6 +29,17 @@ import org.junit.Test * the history goes when the rate does. */ class WatcherIntervalChangeTest { + /** Every watcher built here, so the sampling threads they hold are released. */ + private val created = mutableListOf() + + @After + fun tearDown() { + // An @After rather than a close at the end of each test: a watcher holds a dedicated + // sampling thread until close(), and a failed assertion would skip a trailing call. + created.forEach { it.close() } + created.clear() + } + private fun networkWatcher(readings: List): Pair Unit> { var index = -1 val watcher = @@ -35,7 +47,7 @@ class WatcherIntervalChangeTest { uid = TEST_UID, readRxBytes = { readings[index.coerceIn(0, readings.lastIndex)] }, readTxBytes = { readings[index.coerceIn(0, readings.lastIndex)] }, - ) + ).also { created += it } return watcher to { index++ watcher.sampleOnce() From 8f794a3ceeca156af85adc9adea26962a1fe253f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 5 Sep 2026 23:26:08 -0700 Subject: [PATCH 20/30] fix(metrics): hide the whole strip when the carousel undocks (ADFA-5486) Undocking hid the pager and the title but left the two arrows and the camera button behind, so a dead camera icon sat above the "Metrics are in a floating window" message. Dead in two senses: there is no chart in the strip to photograph, and undocking unbinds the controller that listens to the button, so tapping it did nothing. Reported from a device: "the message had a camera icon above it". The visibility now belongs to MetricsCarouselLayout, which owns those children, rather than to a list of fields at the call site in the activity. That is the actual defect -- the call site enumerated two of the five controls and had no way to notice the arrows and the camera were added later. A control added after this one will be hidden by construction. Tests inflate the real strip and assert every control, so the set is pinned rather than described. They needed the app theme: the controls resolve Material attributes and will not inflate against a bare application context. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../activities/editor/BaseEditorActivity.kt | 4 +- .../androidide/ui/MetricsCarouselLayout.kt | 27 +++++++++++ .../ui/MetricsCarouselLayoutTest.kt | 47 +++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 163f7e1303..229ac9d1c0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -1027,9 +1027,7 @@ abstract class BaseEditorActivity : @UiThread protected fun setMetricsCarouselUndocked(undocked: Boolean) { val view = _binding?.memUsageView ?: return - view.metricsPager.isVisible = !undocked - view.metricsTitle.isVisible = !undocked - view.metricsUndockedMessage.isVisible = undocked + view.root.setUndocked(undocked) if (undocked) { metricsCarousel.unbind() diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt index 4feab2881f..5a6037853c 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -20,8 +20,11 @@ package com.itsaky.androidide.ui import android.content.Context import android.util.AttributeSet import android.view.MotionEvent +import android.view.View import android.view.ViewConfiguration import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import com.itsaky.androidide.R import org.slf4j.LoggerFactory import kotlin.math.hypot @@ -57,6 +60,30 @@ class MetricsCarouselLayout /** Invoked as each gesture begins. */ var onTouchDown: (() -> Unit)? = null + /** + * Shows either the carousel or the "it is in a floating window" message, never a mix. + * + * The whole strip switches, not just the pager. The arrows and the snapshot button are + * chrome for a chart that is not here: left behind they sit over the message, and the + * camera is inert anyway because undocking unbinds the controller that listens to it. + * Keeping the set here rather than at the call site is what stops a control added later + * from being forgotten again. + */ + fun setUndocked(undocked: Boolean) { + val carouselIds = + intArrayOf( + R.id.metrics_pager, + R.id.metrics_title, + R.id.metrics_previous, + R.id.metrics_next, + R.id.metrics_snapshot, + ) + carouselIds.forEach { id -> + findViewById(id)?.isVisible = !undocked + } + findViewById(R.id.metrics_undocked_message)?.isVisible = undocked + } + private var twoFingerDownAt = 0L /** diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt index 7131a7b6b6..9697966d47 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselLayoutTest.kt @@ -19,10 +19,15 @@ package com.itsaky.androidide.ui import android.content.Context import android.os.SystemClock +import android.view.LayoutInflater import android.view.MotionEvent import android.view.ViewConfiguration +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.view.isVisible import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -40,6 +45,17 @@ class MetricsCarouselLayoutTest { private fun layout() = MetricsCarouselLayout(context) + /** + * The real layout, inflated against the app's theme. + * + * The theme is not optional: the strip's controls resolve Material attributes, and a bare + * application context fails to inflate them. + */ + private fun inflatedStrip(): LayoutMemUsageBinding { + val themed = ContextThemeWrapper(context, R.style.Theme_AndroidIDE) + return LayoutMemUsageBinding.inflate(LayoutInflater.from(themed)) + } + private var downTime = 0L private fun event( @@ -100,6 +116,37 @@ class MetricsCarouselLayoutTest { } } + @Test + fun `undocking hides every carousel control, not just the chart`() { + val binding = inflatedStrip() + + binding.root.setUndocked(true) + + // The arrows and the camera are chrome for a chart that is not here. Left visible they sit + // over the message, and the camera is inert anyway because undocking unbinds its listener. + assertThat(binding.metricsPager.isVisible).isFalse() + assertThat(binding.metricsTitle.isVisible).isFalse() + assertThat(binding.metricsPrevious.isVisible).isFalse() + assertThat(binding.metricsNext.isVisible).isFalse() + assertThat(binding.metricsSnapshot.isVisible).isFalse() + assertThat(binding.metricsUndockedMessage.isVisible).isTrue() + } + + @Test + fun `re-docking brings every control back`() { + val binding = inflatedStrip() + + binding.root.setUndocked(true) + binding.root.setUndocked(false) + + assertThat(binding.metricsPager.isVisible).isTrue() + assertThat(binding.metricsTitle.isVisible).isTrue() + assertThat(binding.metricsPrevious.isVisible).isTrue() + assertThat(binding.metricsNext.isVisible).isTrue() + assertThat(binding.metricsSnapshot.isVisible).isTrue() + assertThat(binding.metricsUndockedMessage.isVisible).isFalse() + } + @Test fun `a two-finger tap fires the callback`() { var taps = 0 From aa7c7198888e8e0910fc3b877e95eeb83aee4eac Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 00:16:37 -0700 Subject: [PATCH 21/30] fix(metrics): scale the network axis to the visible window (ADFA-5486) The log axis took its peak from the whole retained buffer -- ten thousand samples, hours of history -- while the chart shows sixty of them. One early download raised the ceiling for the rest of the session and nothing ever brought it back down, so every later sample was squashed against the baseline. That is the opposite of what a logarithmic axis is for: it exists so a large transfer and quiet chatter can be read on one chart, and instead the large transfer permanently hid the chatter. The range now comes from the samples actually on screen. Deciding which those are belongs in the base renderer, since it owns both the window and the flag saying whether the user has taken the viewport over: while the chart is following the newest samples the window is the last VISIBLE_SAMPLES by definition, and only once the user has pinched or panned is the chart itself asked where it is looking. Asking the chart unconditionally does not work, and the failure is quiet. MPAndroidChart reports the full data range as visible until it has been laid out and drawn, and the scroll to the newest samples is queued as a job that only runs during a draw pass -- so in a unit test the chart cheerfully claims the oldest samples are on screen. Two of the three tests here passed backwards against that before the flag replaced it. The range is also applied after the viewport is updated rather than before it, so the axis reflects the window the user is about to see rather than the one they were looking at a sample ago. Confirmed to fail without the fix: with a gigabyte burst at the start of a 200-sample history and 500-byte chatter after it, the axis reaches nine decades instead of three. The paired test keeps the fix honest by putting the burst at the end, where it must still raise the axis. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 33 ++++++++++ .../ui/NetworkUsageChartRenderer.kt | 19 +++++- .../ui/NetworkUsageChartRendererTest.kt | 60 +++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index 10301bbe53..81604fa3b7 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -22,6 +22,7 @@ import android.os.SystemClock import android.view.MotionEvent import androidx.annotation.CallSuper import androidx.annotation.UiThread +import androidx.annotation.VisibleForTesting import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.components.LimitLine import com.github.mikephil.charting.components.XAxis @@ -33,6 +34,8 @@ import com.github.mikephil.charting.listener.OnChartGestureListener import com.itsaky.androidide.R import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.resolveAttr +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.roundToLong /** @@ -204,6 +207,36 @@ abstract class MetricsChartRenderer( chart.moveViewToX(newestIndex - VISIBLE_SAMPLES.toFloat() + 1f) } + /** + * The sample indices currently on screen, for a series of [sampleCount] samples. + * + * The buffer holds thousands of samples and the window shows sixty of them, so anything derived + * from "all the data" -- an axis range, a peak -- describes a chart the user is not looking at. + * + * While the chart is following the newest samples this is [VISIBLE_SAMPLES] at the end of the + * buffer by definition; only once the user has pinched or panned is the chart itself asked. + */ + @VisibleForTesting + internal fun visibleSampleRange( + chart: SafeLineChart, + sampleCount: Int, + ): IntRange { + if (sampleCount <= 0) { + return IntRange.EMPTY + } + + // Until the user drives the viewport themselves, the window is exactly what + // showNewestWindow put there, and saying so is both cheaper and more reliable than asking + // the chart -- which reports the whole data range until it has been laid out and drawn. + if (!userHasZoomed) { + return (sampleCount - VISIBLE_SAMPLES).coerceAtLeast(0)..(sampleCount - 1) + } + + val from = floor(chart.lowestVisibleX).toInt().coerceIn(0, sampleCount - 1) + val to = ceil(chart.highestVisibleX).toInt().coerceIn(from, sampleCount - 1) + return from..to + } + /** * Turns a tap in the x-axis band into [onXAxisTap]. * diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index d349fc75a0..890828a037 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -77,8 +77,11 @@ class NetworkUsageChartRenderer( dataset(usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted), TRANSMITTED_COLOR), ) - applyAxisRange(chart, usage) setData(chart, datasets) + // After, not before: setData is what scrolls the window to the newest samples, and the + // range is derived from what that window ends up showing. + applyAxisRange(chart, usage) + chart.invalidate() } /** @@ -108,8 +111,9 @@ class NetworkUsageChartRenderer( update(received, usage.received, chart.context.getString(R.string.metrics_network_received)) update(transmitted, usage.transmitted, chart.context.getString(R.string.metrics_network_transmitted)) - applyAxisRange(chart, usage) redraw(chart) + applyAxisRange(chart, usage) + chart.invalidate() } private fun dataset( @@ -166,7 +170,16 @@ class NetworkUsageChartRenderer( chart: SafeLineChart, usage: NetworkUsage, ) { - val peak = max(usage.received.maxOrNull() ?: 0L, usage.transmitted.maxOrNull() ?: 0L) + // The peak of what is on screen, not of the whole buffer. Scaled to the buffer, one early + // burst raised the ceiling for the rest of the session and never let it back down -- + // flattening everything after it, which is the opposite of what the log axis is for. + val samples = minOf(usage.received.size, usage.transmitted.size) + val visible = visibleSampleRange(chart, samples) + var peak = 0L + for (index in visible) { + peak = max(peak, max(usage.received[index], usage.transmitted[index])) + } + chart.axisRight.axisMinimum = 0f chart.axisRight.axisMaximum = ceil(peak.toLogBytes()).coerceAtLeast(MIN_AXIS_DECADES) } diff --git a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt index d24b027cd8..f73506b111 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -18,6 +18,9 @@ package com.itsaky.androidide.ui import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.components.YAxis import com.github.mikephil.charting.data.LineDataSet @@ -34,6 +37,14 @@ import kotlin.math.log10 */ @RunWith(RobolectricTestRunner::class) class NetworkUsageChartRendererTest { + private companion object { + const val WIDTH = 720 + const val HEIGHT = 400 + + /** Longer than the visible window, so the start of the history scrolls off screen. */ + const val SAMPLE_COUNT = 200 + } + private val context = ApplicationProvider.getApplicationContext() private fun usage( @@ -87,6 +98,55 @@ class NetworkUsageChartRendererTest { assertThat(ys[2] - ys[1]).isLessThan(4f) } + /** + * Lays the chart out and draws it once. + * + * The draw is not decoration: MPAndroidChart queues the scroll to the newest samples as a job + * that only runs during a draw pass, so without one the chart still reports the *oldest* + * samples as visible and every assertion here would read the wrong window. + */ + private fun laidOut(chart: SafeLineChart) { + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + } + + @Test + fun `the axis is scaled to what is on screen, not to the whole buffer`() { + // A one-off gigabyte burst near the start of a long history, then quiet chatter. + val samples = LongArray(SAMPLE_COUNT) { 500L } + samples[0] = 1_000_000_000L + + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage(samples) }) + renderer.attach(chart) + laidOut(chart) + // A second pass, now that the chart has a viewport to report. + renderer.rebuild() + + // Scaled to the burst the axis would reach 9 decades and flatten the 500 B chatter onto the + // baseline for the rest of the session -- the opposite of what the log axis is for. + assertThat(chart.axisRight.axisMaximum).isLessThan(4f) + } + + @Test + fun `a burst still on screen does raise the axis`() { + // Guards the test above: it must not pass by ignoring bursts altogether. + val samples = LongArray(SAMPLE_COUNT) { 500L } + samples[SAMPLE_COUNT - 1] = 1_000_000_000L + + val chart = SafeLineChart(context) + val renderer = NetworkUsageChartRenderer(usageProvider = { usage(samples) }) + renderer.attach(chart) + laidOut(chart) + renderer.rebuild() + + assertThat(chart.axisRight.axisMaximum).isAtLeast(9f) + } + @Test fun `received and transmitted are separate series`() { val (_, chart) = From aeac2bb0080c2b7c59b63ba1dcce5f152d55f787 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 01:09:41 -0700 Subject: [PATCH 22/30] fix(metrics): guard the snapshot share, and clamp the rate where the arch is known Three review findings on PR #1785. The snapshot coroutine could crash the IDE. Its scope has no exception handler, so anything escaping reached the global crash reporter and was filed as a crash. MetricsSnapshot.write converts only IOException, and IntentUtils.shareFile ends in startActivity, which throws ActivityNotFoundException on a device with nothing able to receive an image. The whole body is now guarded, logs the failure, and shows the same toast the other failure paths use. The share used a stale host. exportSnapshot opens with `val binding = this.binding`, so inside the coroutine `binding` resolved to that local rather than to the property -- and the comment above it claimed the opposite, which is worse than having no comment. It now reads through the property, so a carousel unbound or rebound while the file is written does not leave the share pointed at a dead host. The sampling rate is clamped where the architecture is known. The watchers keep an absolute floor, which exists to stop a non-positive interval spinning delay(); that floor is the 64-bit minimum and would let a programmatic 100ms through on a 32-bit device, where 500ms is the lowest supported. Policy now lives in the controller, which resolves the arch through IDEBuildConfigProvider and coerces to the supported range. Deliberately not in the watchers: they must stay constructible in a plain JVM test, and resolving the arch there would make them depend on a provider a unit test cannot satisfy. Font scale checked at 1.0 and 2.0 on a Pixel 6 Pro: the title, both arrows and the camera icon grow without clipping, the chart is shorter but readable, and the panel keeps its height. The undocked message cannot be reached without a two-finger tap, which no automation on this device can produce, so its 2.0 check stays a human step and is in Steps to QA. Co-Authored-By: Claude Opus 5 --- .../ui/MetricsCarouselController.kt | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 9bc96311fc..d3c36e7128 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -33,12 +33,14 @@ import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.MetricsSamplingRates import com.itsaky.androidide.utils.MetricsSnapshot import com.itsaky.androidide.utils.NetworkUsageWatcher +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory /** * Drives one metrics carousel: its pages, its renderers, and the title that names the current page. @@ -303,8 +305,16 @@ class MetricsCarouselController( */ @UiThread private fun setSamplingInterval(intervalMillis: Long) { - memoryUsageWatcher.updateInterval = intervalMillis - networkUsageWatcher.updateInterval = intervalMillis + // Clamped to what this device supports, which is decided here rather than in the watchers: + // the arch comes from IDEBuildConfigProvider, which a plain JVM test cannot resolve, so the + // watchers keep only an absolute floor to stop delay() spinning. This is the policy. + val supported = + MetricsSamplingRates.coerceToSupportedRange( + intervalMillis, + IDEBuildConfigProvider.getInstance().deviceArch, + ) + memoryUsageWatcher.updateInterval = supported + networkUsageWatcher.updateInterval = supported refresh() } @@ -349,19 +359,28 @@ class MetricsCarouselController( // given FLAG_ACTIVITY_NEW_TASK, so it keeps the context the carousel is hosted in. val appContext = context.applicationContext scope.launch { - val file = withContext(Dispatchers.IO) { MetricsSnapshot.write(appContext, bitmap, label) } - if (file == null) { - Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() - return@launch - } - // Re-read the host rather than capturing it: the export is no longer instantaneous, and - // the carousel can be unbound (docked, undocked, recreated) while the file is written. - val host = binding?.root?.context - if (host == null) { + // Everything here is guarded: the scope has no exception handler, so anything escaping + // reaches the global crash reporter and is filed as a crash. MetricsSnapshot.write + // converts only IOException, and shareFile ends in startActivity, which throws + // ActivityNotFoundException on a device with nothing able to receive an image. + runCatching { + val file = withContext(Dispatchers.IO) { MetricsSnapshot.write(appContext, bitmap, label) } + // Read through the property, not the local captured above: the export is no longer + // instantaneous, and the carousel can be unbound or rebound while the file is + // written, which would leave the share pointed at a dead host. + val host = this@MetricsCarouselController.binding?.root?.context + if (file == null || host == null) { + Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + return@runCatching + } + IntentUtils.shareFile(host, file, MetricsSnapshot.MIME_TYPE) + }.onFailure { failure -> + if (failure is CancellationException) { + throw failure + } + log.error("Could not share the chart snapshot", failure) Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() - return@launch } - IntentUtils.shareFile(host, file, MetricsSnapshot.MIME_TYPE) } return true } @@ -395,6 +414,8 @@ class MetricsCarouselController( } private companion object { + private val log = LoggerFactory.getLogger(MetricsCarouselController::class.java) + const val DISABLED_ARROW_ALPHA = 0.35f /** Dims a rate this device cannot offer, so the list shows what the hardware costs. */ From eb4335bbb4f9fd6cc02c78b599e47686e4afd8a5 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 05:27:56 -0700 Subject: [PATCH 23/30] fix(metrics): make the carousel work in the floating window (ADFA-5486) Three review findings, all of them the same shape: the carousel gained a floating host, and three code paths still assume an activity. The sampling-rate chooser crashed there. A floating window's context is a window context with no activity token, so adding an ordinary application window against it throws BadTokenException -- and nothing caught it, because the axis tap is wired for both hosts. The dialog is now created rather than shown by the builder, and handed to OverlayDialogs.show, which raises it to the overlay window type when anything is floating. That also fixes a second-order problem: even docked, the dialog previously rendered *behind* any open floating window, because the platform stacks overlays above an activity's own windows. onPause and preDestroy unbound the carousel unconditionally, which killed the floating one. The controller is then bound to the window's views, so unbinding cleared the watcher listeners and detached the renderers, leaving the overlay showing a chart that never updated again -- the one state undocking exists for. onResume already guarded its rebind this way; the two teardown paths did not. preDestroy still releases the controller on a real teardown, when the window goes with the editor. Snapshot sharing failed from the floating window. startActivity needs FLAG_ACTIVITY_NEW_TASK from a context with no task of its own, so every export from the overlay reported "couldn't save the chart image" although the PNG had been written. IntentUtils.startIntent/shareFile take an optional extraFlags, defaulting to zero so an activity-hosted share is untouched, and the controller passes NEW_TASK only when the host has no activity above it. The failure toast had the same problem in miniature: a toast's window is added against whatever context built it, and a floating window's context fixes a type a toast cannot use, so it now uses the application context -- the reason PluginWindows.showToast exists. Co-Authored-By: Claude Opus 5 --- .../activities/editor/BaseEditorActivity.kt | 15 +++++++-- .../ui/MetricsCarouselController.kt | 32 +++++++++++++++++-- .../itsaky/androidide/utils/IntentUtils.kt | 9 ++++-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 229ac9d1c0..c31f7f7912 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -544,7 +544,12 @@ abstract class BaseEditorActivity : fullscreenManager?.destroy() fullscreenManager = null - metricsCarousel.unbind() + // Same reasoning as onPause: a floating carousel is bound to the window, not to these + // views. On a real teardown the window goes with the editor, so releasing the controller + // then is correct. + if (!isMetricsCarouselUndocked() || isDestroying) { + metricsCarousel.unbind() + } if (isDestroying) { metricsCarousel.close() } @@ -1055,7 +1060,13 @@ abstract class BaseEditorActivity : // Sampling continues while backgrounded so the history has no gaps; the x axis assumes // evenly spaced samples and would otherwise misreport their age (ADFA-5486). Only the // carousel goes, so nothing updates a chart nobody is looking at. - metricsCarousel.unbind() + // Not while it is floating: the controller is then bound to the window's own views, and + // unbinding would clear the watcher listeners and detach the renderers -- leaving the + // overlay showing a chart that never updates again, which is the one state undocking + // exists for. onResume already guards its rebind the same way. + if (!isMetricsCarouselUndocked()) { + metricsCarousel.unbind() + } this.isDestroying = isFinishing getFileTreeFragment()?.saveTreeState() diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index d3c36e7128..098a5f8aa4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -17,6 +17,10 @@ package com.itsaky.androidide.ui +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter @@ -25,6 +29,7 @@ import androidx.annotation.UiThread import androidx.viewpager2.widget.ViewPager2 import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.floating.window.OverlayDialogs import com.itsaky.androidide.resources.R.string import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.IntentUtils @@ -296,7 +301,13 @@ class MetricsCarouselController( // No setMessage: an AlertDialog shows either a message or a list, never both, and // the message silently wins. The unavailable entries carry the explanation instead. .setNegativeButton(string.cancel) { dismissable, _ -> dismissable.dismiss() } - .show() + .create() + + // Not builder.show(): while the carousel is floating, `context` is the overlay window's + // context, which carries no activity token -- adding an ordinary application window + // against it throws BadTokenException. OverlayDialogs raises the dialog to the overlay + // window type first, which also puts it above the floating windows instead of behind them. + OverlayDialogs.show(dialog) } /** @@ -350,7 +361,10 @@ class MetricsCarouselController( val label = context.getString(page.title) val bitmap = renderer.snapshot() if (bitmap == null) { - Toast.makeText(context, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() + // The application context, not the host: a toast's window is added against whatever + // context built it, and a floating window's context fixes a window type a toast + // cannot use. + Toast.makeText(context.applicationContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() return false } @@ -373,7 +387,11 @@ class MetricsCarouselController( Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() return@runCatching } - IntentUtils.shareFile(host, file, MetricsSnapshot.MIME_TYPE) + // A floating window's context has no task, so startActivity needs NEW_TASK there. + // Docked, the host is the activity and the flag would change its task affinity. + val extraFlags = + if (host.findActivityOrNull() == null) Intent.FLAG_ACTIVITY_NEW_TASK else 0 + IntentUtils.shareFile(host, file, MetricsSnapshot.MIME_TYPE, extraFlags) }.onFailure { failure -> if (failure is CancellationException) { throw failure @@ -416,6 +434,14 @@ class MetricsCarouselController( private companion object { private val log = LoggerFactory.getLogger(MetricsCarouselController::class.java) + /** The nearest [Activity] up the context chain, or `null` for a window context. */ + private tailrec fun Context.findActivityOrNull(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivityOrNull() + else -> null + } + const val DISABLED_ARROW_ALPHA = 0.35f /** Dims a rate this device cannot offer, so the list shows what the hardware costs. */ diff --git a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt index 0bcf662ba4..5d16b59858 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt @@ -71,12 +71,14 @@ object IntentUtils { } @JvmStatic + @JvmOverloads fun shareFile( context: Context, file: File, mimeType: String, + extraFlags: Int = 0, ) { - startIntent(context = context, file = file, mimeType = mimeType) + startIntent(context = context, file = file, mimeType = mimeType, extraFlags = extraFlags) } @JvmStatic @@ -86,6 +88,9 @@ object IntentUtils { file: File, mimeType: String = MIME_ANY, intentAction: String = Intent.ACTION_SEND, + // For a context with no task of its own -- a floating window's -- where startActivity + // needs FLAG_ACTIVITY_NEW_TASK. Zero leaves an activity-hosted share exactly as it was. + extraFlags: Int = 0, ) { val uri = context.fileProviderUriFor(file) val intent = @@ -96,7 +101,7 @@ object IntentUtils { .intent .setAction(intentAction) .setDataAndType(uri, mimeType) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or extraFlags) context.startActivity(Intent.createChooser(intent, null)) } From 8fad3ad886d5c8f451870c9ff6bbb04038064a7c Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 06:50:08 -0700 Subject: [PATCH 24/30] fix(metrics): scale the memory axis to the visible window too (ADFA-5486) The sibling of the network-axis fix, and the one I should have found when I made that one. CLAUDE.md asks for exactly this sweep -- "grep for the other places the same pattern lives" -- and I fixed one site and stopped. MemoryUsageChartRenderer never bounded its value axis at all, so MPAndroidChart ranged it over every entry in the data: the whole retained buffer, ten thousand samples, while sixty are visible. One early Gradle daemon peak set a ceiling that nothing brought back down, pressing every later reading into the bottom of the plot for the hours the buffer takes to turn over. Raising retention from 30 samples to 10000 made it 333 times worse. The axis now takes its maximum from the samples on screen, using the same visibleSampleRange the network chart uses, with a little headroom so the tallest line is not drawn on the frame and a floor so an idle chart has a readable scale instead of a zero-height axis. Confirmed to fail without the fix: a 1.5 GB peak at the start of a 200-sample history puts the axis maximum above 1600 MB instead of under 400. The paired test keeps it honest by moving the peak to the end, where it must still raise the axis. Co-Authored-By: Claude Opus 5 --- .../androidide/ui/MemoryUsageChartRenderer.kt | 36 +++++++++++ .../ui/MemoryUsageChartRendererTest.kt | 64 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index de412b4262..64bb30493b 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -30,6 +30,7 @@ import com.itsaky.androidide.utils.MemoryUsageWatcher import com.itsaky.androidide.utils.MemoryUsageWatcher.ProcessMemoryInfo import com.itsaky.androidide.utils.MetricsAnnotationStore import com.itsaky.androidide.utils.ShiftedLongArray +import kotlin.math.max import kotlin.math.roundToLong /** @@ -103,9 +104,35 @@ class MemoryUsageChartRenderer( } } + applyAxisRange(chart, processes) setData(chart, datasets) } + /** + * Scales the value axis to the samples on screen (ADFA-5486). + * + * Left to itself MPAndroidChart ranges over every entry in the data, which is the whole + * retained buffer -- ten thousand samples, hours of it -- while sixty are visible. One early + * Gradle daemon peak then flattened every later reading into the bottom of the plot and nothing + * ever brought the ceiling back down. The network chart was fixed first; this is the sibling. + */ + private fun applyAxisRange( + chart: SafeLineChart, + processes: Array, + ) { + var peak = 0f + for (proc in processes) { + for (index in visibleSampleRange(chart, proc.usageHistory.size)) { + peak = max(peak, proc.usageHistory.megabytesAt(index)) + } + } + + chart.axisRight.axisMinimum = 0f + // A little headroom so the tallest line is not drawn on the frame, and a floor so an idle + // chart does not collapse onto a zero-height axis before the first samples land. + chart.axisRight.axisMaximum = max(peak * AXIS_HEADROOM, MIN_AXIS_MEGABYTES) + } + /** * Renders a fresh set of samples into the attached chart, mutating the existing entries in place. * @@ -144,6 +171,7 @@ class MemoryUsageChartRenderer( } if (dataChanged) { + applyAxisRange(chart, usagesProvider()) redraw(chart) } } @@ -159,6 +187,14 @@ class MemoryUsageChartRenderer( } } + private companion object { + /** Keeps the tallest line off the top frame of the plot. */ + const val AXIS_HEADROOM = 1.1f + + /** Floor for the axis, so an idle chart has a readable scale rather than a flat zero. */ + const val MIN_AXIS_MEGABYTES = 64f + } + private fun labelFor( pname: String, megabytes: Float, diff --git a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt index a2959fc4d8..031c59167c 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -17,7 +17,10 @@ package com.itsaky.androidide.ui +import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Color +import android.view.View import androidx.collection.MutableIntObjectMap import androidx.test.core.app.ApplicationProvider import com.github.mikephil.charting.data.LineDataSet @@ -46,6 +49,30 @@ class MemoryUsageChartRendererTest { lineColorFor = { Color.BLUE }, ) + /** + * A chart showing one process with the given byte history, laid out and drawn once. + * + * The draw matters: MPAndroidChart queues the scroll to the newest samples as a job that only + * runs during a draw pass, so without one the chart reports the oldest samples as visible. + */ + private fun laidOutChart(history: LongArray): SafeLineChart { + val chart = chart() + val process = + ProcessMemoryInfo( + PID_IDE, + "IDE", + MutableShiftedLongArray(LongArray(history.size) { history[it] }), + ) + renderer { arrayOf(process) }.attach(chart) + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + return chart + } + /** A process whose history ramps from [firstMegabytes] by 1MB per sample. */ private fun proc( pid: Int, @@ -151,7 +178,44 @@ class MemoryUsageChartRendererTest { assertThat(datasetFor(rebound, 0).entries.first().y).isEqualTo(100f) } + @Test + fun `the axis is scaled to what is on screen, not to the whole buffer`() { + // An early 1.5 GB daemon peak, then a long quiet stretch around 200 MB. + val history = LongArray(SAMPLE_COUNT) { 200L * BYTES_PER_MB } + history[0] = 1_500L * BYTES_PER_MB + val chart = laidOutChart(history) + + // Ranged over the whole buffer the axis reaches 1650 MB and presses every later reading + // into the bottom eighth of the plot for the hours the buffer takes to turn over. + assertThat(chart.axisRight.axisMaximum).isLessThan(400f) + } + + @Test + fun `a peak still on screen does raise the axis`() { + // Guards the test above: it must not pass by ignoring peaks altogether. + val history = LongArray(SAMPLE_COUNT) { 200L * BYTES_PER_MB } + history[SAMPLE_COUNT - 1] = 1_500L * BYTES_PER_MB + val chart = laidOutChart(history) + + assertThat(chart.axisRight.axisMaximum).isAtLeast(1_500f) + } + + @Test + fun `an idle chart still has a readable scale`() { + val chart = laidOutChart(LongArray(SAMPLE_COUNT)) + + // Zero everywhere would otherwise collapse the axis to no height at all. + assertThat(chart.axisRight.axisMaximum).isGreaterThan(0f) + assertThat(chart.axisRight.axisMinimum).isEqualTo(0f) + } + private companion object { const val BYTES_PER_MB = 1024L * 1024L + const val WIDTH = 720 + const val HEIGHT = 400 + const val PID_IDE = 1 + + /** Longer than the visible window, so the start of the history scrolls off screen. */ + const val SAMPLE_COUNT = 200 } } From c34b648e8cf61273dff81e7e85ba834578e53d87 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 07:51:37 -0700 Subject: [PATCH 25/30] fix(metrics): report network traffic as the rate the legend claims (ADFA-5486) The samples are bytes per sampling interval and the legend says "/s", and nothing divided one by the other. That was harmless while the interval was fixed at a second -- and this PR is what makes it settable, so picking "Every 5s" from the new rate chooser overstated throughput fivefold, with the axis agreeing because it is derived from the same numbers. The renderer already received the interval for its time-axis labels; it now uses it for the legend too. Nominal rather than measured: the loop's real period is the interval plus the sample and the main-thread hop, so a saturated UI thread still understates slightly. Timestamping each sample would fix that properly and would also remove the need to wipe the history on a rate change, which is worth doing but is a larger change than this. Confirmed with a five-second interval: 10 kB in one interval now reads 2.0 kB/s. Co-Authored-By: Claude Opus 5 --- .../ui/MetricsCarouselController.kt | 2 +- .../ui/NetworkUsageChartRenderer.kt | 19 ++++++++++++++++--- .../ui/NetworkUsageChartRendererTest.kt | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 098a5f8aa4..c03292e351 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -79,7 +79,7 @@ class MetricsCarouselController( NetworkUsageChartRenderer( usageProvider = { networkUsageWatcher.getUsage() }, annotations = annotations, - sampleIntervalMillis = { networkUsageWatcher.updateInterval }, + sampleInterval = { networkUsageWatcher.updateInterval }, ) private val pages = diff --git a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt index 890828a037..f85cfc8ed3 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/NetworkUsageChartRenderer.kt @@ -58,9 +58,9 @@ import kotlin.math.roundToLong class NetworkUsageChartRenderer( private val usageProvider: () -> NetworkUsage, annotations: MetricsAnnotationStore? = null, - sampleIntervalMillis: () -> Long = { NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL }, + private val sampleInterval: () -> Long = { NetworkUsageWatcher.DEFAULT_UPDATE_INTERVAL }, ) : MetricsChartRenderer( - sampleIntervalMillis = sampleIntervalMillis, + sampleIntervalMillis = sampleInterval, annotations = annotations, ) { /** @@ -153,10 +153,21 @@ class NetworkUsageChartRenderer( dataset.notifyDataSetChanged() } + /** + * The legend entry for a series, as a rate. + * + * The stored samples are bytes per sampling interval, and the legend says "/s", so the delta + * has to be divided by that interval. It was not, which was harmless only while the interval + * was fixed at one second: once ADFA-5486 let the user choose, picking "Every 5s" overstated + * throughput fivefold, with the axis agreeing. + */ private fun labelFor( label: String, bytes: Long, - ): String = "%s - %s/s".format(label, formatBytes(bytes.toDouble(), decimals = 1)) + ): String = "%s - %s/s".format(label, formatBytes(bytesPerSecond(bytes), decimals = 1)) + + /** A per-interval byte count as a per-second rate. */ + private fun bytesPerSecond(bytes: Long): Double = bytes.toDouble() * MILLIS_PER_SECOND / sampleInterval().coerceAtLeast(1L) /** * Pins the axis to whole decades, from zero up to at least [MIN_AXIS_DECADES]. @@ -226,6 +237,8 @@ class NetworkUsageChartRenderer( */ const val MIN_AXIS_DECADES = 3f + const val MILLIS_PER_SECOND = 1_000.0 + const val SERIES_COUNT = 2 const val RECEIVED_INDEX = 0 const val TRANSMITTED_INDEX = 1 diff --git a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt index f73506b111..85cd37298c 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/NetworkUsageChartRendererTest.kt @@ -162,6 +162,21 @@ class NetworkUsageChartRendererTest { assertThat(dataset(chart, 1).entries.last().y).isEqualTo(1f) } + @Test + fun `the legend reports a rate, so a slower sampling rate does not overstate it`() { + val chart = SafeLineChart(context) + // 10 kB in a five-second interval is 2 kB/s, not 10 kB/s. + val renderer = + NetworkUsageChartRenderer( + usageProvider = { usage(longArrayOf(0L, 10_000L)) }, + sampleInterval = { 5_000L }, + ) + renderer.attach(chart) + + // Undivided, choosing "Every 5s" in the rate chooser overstated throughput fivefold. + assertThat(dataset(chart, 0).label).endsWith("2.0 kB/s") + } + @Test fun `the legend reports the latest sample in byte units`() { val (_, chart) = rendererFor(usage(longArrayOf(0L, 2_000L))) From 7cdbfa9446b134f72226a00129157d6a239cf76d Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 14:28:28 -0700 Subject: [PATCH 26/30] ADFA-5486: fix the undocked carousel's invisible arrows and lost page Both reported from a Samsung SM-N986U running 1440c4a51, and neither had a test. The arrows were black on a near-black strip in the floating window. app:tint is applied by AppCompat, and only when AppCompat's factory is on the inflater. The editor inflates through the Activity, so the arrows become AppCompatImageButtons and the tint applies. The floating window inflates from a plain window context, so they came out as ordinary ImageButtons, app:tint was ignored, and the vector's own android:tint="#000000" took over. The colour is now set in code, which works whichever inflater built the view, and falls back to the title's own colour if the attribute does not resolve -- a window context carrying a different theme is exactly the case this guards, and an unresolved colour attribute comes back as 0, transparent, rather than as an error. This is the third time in this stack that a colour has come out black on a dark surface: the x axis labels, the snapshot arrows, and now these. Undocking also dropped the user back on the first page. Docking and undocking rebind the same controller into a freshly inflated layout, and that layout's ViewPager2 starts at zero; nothing remembered where the user was, so undocking while reading the network chart showed them the memory chart. The controller now keeps the page across bind and unbind and restores it before the page callback is registered, so the restore does not fire a spurious selection. Four tests, all of which fail without the fixes: the page and its title survive a rebind, and both arrows carry a tint that is neither black nor different between them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 49 +++++++ .../ui/MetricsCarouselRebindTest.kt | 128 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index cf712554fd..8fca44075e 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -21,12 +21,16 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.content.Intent +import android.content.res.ColorStateList +import android.util.TypedValue import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Toast import androidx.annotation.UiThread +import androidx.core.widget.ImageViewCompat import androidx.viewpager2.widget.ViewPager2 +import com.itsaky.androidide.R import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider import com.itsaky.androidide.databinding.LayoutMemUsageBinding import com.itsaky.androidide.floating.window.OverlayDialogs @@ -110,6 +114,15 @@ class MetricsCarouselController( private var binding: LayoutMemUsageBinding? = null private var pageCallback: ViewPager2.OnPageChangeCallback? = null + /** + * The page the user is on, kept across bind and unbind. + * + * The pager itself cannot hold it: docking and undocking inflate a fresh layout and a fresh + * ViewPager2, which starts at zero. Without this, undocking while reading the network chart + * put the floating window on the memory chart. + */ + private var currentPage = 0 + /** * The pager of the bound carousel, or `null` when nothing is bound. Exposed so a host can apply * layout that is its own concern, such as the editor's status-bar inset. @@ -133,6 +146,18 @@ class MetricsCarouselController( binding.metricsPager.adapter = MetricsCarouselAdapter(pages, memoryRenderer, networkRenderer) + // The arrows carry their colour from app:tint, which only AppCompat applies -- and only + // when AppCompat's factory is on the inflater. The floating window inflates from a plain + // window context, so there it produced an ordinary ImageButton, app:tint was ignored, and + // the vector's own android:tint="#000000" took over: black arrows on a near-black strip. + // Setting the tint here works whichever inflater built the view. + tintArrows(binding) + + // Before the page callback is registered, so restoring does not fire it. Docking and + // undocking rebind the carousel, and a rebind used to drop the user back on the first + // page: undocking while reading the network chart showed them the memory chart instead. + binding.metricsPager.setCurrentItem(currentPage, false) + val showTitleFor = { position: Int -> pages.getOrNull(position)?.let { page -> binding.metricsTitle.setText(page.title) @@ -142,6 +167,7 @@ class MetricsCarouselController( pageCallback = object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { + currentPage = position showTitleFor(position) updateArrows(position) // A page left zoomed would keep claiming horizontal drags when swiped back to. @@ -217,6 +243,29 @@ class MetricsCarouselController( } } + /** + * Colours both arrows from the theme, rather than trusting the layout's `app:tint`. + * + * Falls back to the title's own colour if the attribute does not resolve: a window context + * carrying a different theme is exactly the case this is here for, and an unresolved colour + * attribute comes back as 0 -- transparent -- rather than as an error. + */ + @UiThread + private fun tintArrows(binding: LayoutMemUsageBinding) { + val fallback = binding.metricsTitle.currentTextColor + val value = TypedValue() + val color = + if (binding.root.context.theme + .resolveAttribute(R.attr.colorOnSurface, value, true) + ) { + value.data + } else { + fallback + } + ImageViewCompat.setImageTintList(binding.metricsPrevious, ColorStateList.valueOf(color)) + ImageViewCompat.setImageTintList(binding.metricsNext, ColorStateList.valueOf(color)) + } + /** * Dims the arrow that has nowhere to go, so the ends of the carousel are visible. */ diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt new file mode 100644 index 0000000000..0dbd42cf79 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt @@ -0,0 +1,128 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.view.LayoutInflater +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.widget.ImageViewCompat +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R +import com.itsaky.androidide.databinding.LayoutMemUsageBinding +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * What has to survive the carousel moving between the editor and its floating window. + * + * Both cases here were reported from a device and neither had a test. Undocking inflates a fresh + * layout from a plain window context and rebinds the same controller into it, which is a different + * enough environment from the editor that things correct in one are wrong in the other. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsCarouselRebindTest { + private val context: Context = + ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.Theme_AndroidIDE) + + private val controllers = mutableListOf() + + @After + fun tearDown() { + controllers.forEach { it.unbind() } + controllers.clear() + } + + private fun controller() = + MetricsCarouselController( + memoryUsageWatcher = MemoryUsageWatcher(), + networkUsageWatcher = NetworkUsageWatcher(uid = TEST_UID), + lineColorFor = { android.graphics.Color.BLUE }, + ).also(controllers::add) + + private fun strip() = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + + @Test + fun `the page survives a rebind`() { + val controller = controller() + val docked = strip() + controller.bind(docked) + docked.metricsPager.setCurrentItem(1, false) + + // Undocking rebinds the same controller into a freshly inflated layout, whose ViewPager2 + // starts at zero. Undocking while reading the network chart put the floating window on + // the memory chart. + val floating = strip() + controller.bind(floating) + + assertThat(floating.metricsPager.currentItem).isEqualTo(1) + } + + @Test + fun `the page title follows the restored page, not the first one`() { + val controller = controller() + val docked = strip() + controller.bind(docked) + docked.metricsPager.setCurrentItem(1, false) + val title = docked.metricsTitle.text.toString() + + val floating = strip() + controller.bind(floating) + + // A restored page with the first page's title would be worse than not restoring at all. + assertThat(floating.metricsTitle.text.toString()).isEqualTo(title) + } + + @Test + fun `both arrows are tinted, whatever inflated them`() { + val binding = strip() + controller().bind(binding) + + // app:tint is applied by AppCompat, and only when its factory is on the inflater. The + // floating window inflates from a plain window context, so there the arrows came out as + // ordinary ImageButtons and the vector's own android:tint="#000000" won -- black arrows + // on a near-black strip, reported from a device as "the arrows are not visible". + val previous = ImageViewCompat.getImageTintList(binding.metricsPrevious) + val next = ImageViewCompat.getImageTintList(binding.metricsNext) + + assertThat(previous).isNotNull() + assertThat(next).isNotNull() + assertThat(previous!!.defaultColor).isNotEqualTo(BLACK) + assertThat(next!!.defaultColor).isNotEqualTo(BLACK) + assertThat(previous.defaultColor).isEqualTo(next.defaultColor) + } + + @Test + fun `the arrow tint is the colour the title uses`() { + val binding = strip() + controller().bind(binding) + + // The arrows sit either side of the title and should read as the same control surface. + val tint = ImageViewCompat.getImageTintList(binding.metricsPrevious)!!.defaultColor + assertThat(tint).isEqualTo(binding.metricsTitle.currentTextColor) + } + + private companion object { + const val TEST_UID = 10_123 + const val BLACK = 0xFF000000.toInt() + } +} From bbf3ab184812f0118488a51d6533224e58a65643 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 15:56:14 -0700 Subject: [PATCH 27/30] ADFA-5486: one snapshot at a time, and track tap pointers by id Three findings from reviewing this PR. Two quick taps on the camera raced. The button is not debounced and each tap launched its own coroutine over the same scratch directory -- and, within the same second, the same filename, since the name is the chart label plus a whole-second timestamp. MetricsSnapshot.write also cleared the directory before writing, so the second export could delete the file the first was still about to hand to another app, leaving the receiver a URI with nothing behind it. There is now one export at a time, and the cleanup runs after the write and spares the file it returns. The snapshot bitmap is recycled once it has been encoded. getChartBitmap hands back a fresh full-size ARGB_8888 copy of the plot on every tap, which is megabytes left for the collector to notice. The two-finger tap tracked pointers by index. An index is a slot in the current event and shifts when another pointer lifts; an id belongs to the finger for the life of the gesture. Keyed by index, the travel check could measure one finger's position against the other's starting point. It also left a candidate set when a pointer lifted after the tap timeout, so the rest of the gesture was still measured against starting points that no longer meant anything. On the tests: the ordering change turns out not to be unit-testable here. A test that wrote twice and asserted the end state passed just as well against the old delete-first code, because both orderings end up with the same single file -- the difference only shows under concurrency. It is removed rather than kept as reassurance, and what is pinned instead is the guard that actually closes the race: a second export is refused while the first is still being written. That one fails without the guard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../ui/MetricsCarouselController.kt | 32 ++++++++++++++++++- .../androidide/ui/MetricsCarouselLayout.kt | 32 +++++++++++++++---- .../androidide/utils/MetricsSnapshot.kt | 23 ++++++++++--- .../ui/MetricsCarouselRebindTest.kt | 24 ++++++++++++++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt index 8fca44075e..822e29916e 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselController.kt @@ -123,6 +123,16 @@ class MetricsCarouselController( */ private var currentPage = 0 + /** + * Whether an export is already running. + * + * One at a time. The camera button is not debounced and each tap launched its own coroutine, + * so two quick taps raced over the same scratch directory -- and, within the same second, over + * the same filename, since the name is the chart label and a whole-second timestamp. Touched + * only on the main thread, which is where both the tap and the coroutine's continuations run. + */ + private var exportInFlight = false + /** * The pager of the bound carousel, or `null` when nothing is bound. Exposed so a host can apply * layout that is its own concern, such as the editor's status-bar inset. @@ -389,6 +399,10 @@ class MetricsCarouselController( @UiThread fun exportSnapshot(): Boolean { val binding = this.binding ?: return false + if (exportInFlight) { + log.debug("Ignoring a snapshot request while one is already being written") + return false + } val context = binding.root.context val position = binding.metricsPager.currentItem val page = pages.getOrNull(position) ?: return false @@ -413,13 +427,24 @@ class MetricsCarouselController( // it ends in startActivity, which throws from a context with no task of its own unless it is // given FLAG_ACTIVITY_NEW_TASK, so it keeps the context the carousel is hosted in. val appContext = context.applicationContext + exportInFlight = true scope.launch { // Everything here is guarded: the scope has no exception handler, so anything escaping // reaches the global crash reporter and is filed as a crash. MetricsSnapshot.write // converts only IOException, and shareFile ends in startActivity, which throws // ActivityNotFoundException on a device with nothing able to receive an image. runCatching { - val file = withContext(Dispatchers.IO) { MetricsSnapshot.write(appContext, bitmap, label) } + val file = + withContext(Dispatchers.IO) { + // Recycled as soon as it has been encoded: getChartBitmap hands back a + // fresh full-size ARGB_8888 copy of the plot on every tap, which is + // megabytes that would otherwise sit around until the collector noticed. + try { + MetricsSnapshot.write(appContext, bitmap, label) + } finally { + bitmap.recycle() + } + } // Read through the property, not the local captured above: the export is no longer // instantaneous, and the carousel can be unbound or rebound while the file is // written, which would leave the share pointed at a dead host. @@ -435,11 +460,16 @@ class MetricsCarouselController( IntentUtils.shareFile(host, file, MetricsSnapshot.MIME_TYPE, extraFlags) }.onFailure { failure -> if (failure is CancellationException) { + // Cleared before rethrowing: a cancelled export is finished either way, and + // leaving the flag set would refuse every later one for the life of the + // carousel. + exportInFlight = false throw failure } log.error("Could not share the chart snapshot", failure) Toast.makeText(appContext, string.msg_metrics_snapshot_failed, Toast.LENGTH_SHORT).show() } + exportInFlight = false } return true } diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt index 5d4d8e675d..c55989037d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsCarouselLayout.kt @@ -93,6 +93,15 @@ class MetricsCarouselLayout */ private val twoFingerDownX = FloatArray(TWO_FINGERS) private val twoFingerDownY = FloatArray(TWO_FINGERS) + + /** + * The pointers being tracked, by id rather than by index. + * + * A pointer's index is its slot in the current event and shifts when another pointer + * lifts; its id is stable for the life of that finger. Keyed by index, the travel check + * could compare one finger's current position against the other's starting point. + */ + private val twoFingerIds = IntArray(TWO_FINGERS) { MotionEvent.INVALID_POINTER_ID } private var twoFingerTapCandidate = false /** @@ -135,6 +144,7 @@ class MetricsCarouselLayout twoFingerTapCandidate = true twoFingerDownAt = ev.eventTime for (pointer in 0 until TWO_FINGERS) { + twoFingerIds[pointer] = ev.getPointerId(pointer) twoFingerDownX[pointer] = ev.getX(pointer) twoFingerDownY[pointer] = ev.getY(pointer) } @@ -146,12 +156,18 @@ class MetricsCarouselLayout MotionEvent.ACTION_MOVE -> { if (twoFingerTapCandidate) { - // Either finger travelling means this is a pinch, not a tap. - for (pointer in 0 until minOf(ev.pointerCount, TWO_FINGERS)) { + // Either finger travelling means this is a pinch, not a tap. Each is found + // by its id: a finger that has lifted is simply absent, rather than + // silently standing in for the other one. + for (pointer in 0 until TWO_FINGERS) { + val index = ev.findPointerIndex(twoFingerIds[pointer]) + if (index < 0) { + continue + } val travel = hypot( - ev.getX(pointer) - twoFingerDownX[pointer], - ev.getY(pointer) - twoFingerDownY[pointer], + ev.getX(index) - twoFingerDownX[pointer], + ev.getY(index) - twoFingerDownY[pointer], ) if (travel > touchSlop) { twoFingerTapCandidate = false @@ -171,8 +187,12 @@ class MetricsCarouselLayout tapTimeout, ) } - if (twoFingerTapCandidate && heldFor <= tapTimeout) { - twoFingerTapCandidate = false + // Cleared either way: a candidate that has outlasted the tap timeout is over, + // and leaving it set let a later part of the same gesture be measured against + // starting points that no longer mean anything. + val recognised = twoFingerTapCandidate && heldFor <= tapTimeout + twoFingerTapCandidate = false + if (recognised) { log.debug("carousel two-finger tap recognised") onTwoFingerTap?.invoke() } diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt index 45a6951323..e61e6a600d 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -45,8 +45,10 @@ object MetricsSnapshot { /** * Writes [bitmap] as a PNG named after [label] and the current time. * - * Old snapshots are cleared first: this is a scratch directory for handing one image to another - * app, not a gallery, and an IDE session could otherwise leave a pile of them behind. + * Old snapshots are cleared afterwards, not first: this is a scratch directory for handing one + * image to another app, not a gallery, and an IDE session could otherwise leave a pile of them + * behind. Clearing first meant a second export could delete the file a first was still about + * to hand over, so the receiving app was given a URI with nothing behind it. * * @return the file, or `null` if it could not be written. */ @@ -57,9 +59,7 @@ object MetricsSnapshot { ): File? { val directory = File(context.cacheDir, DIRECTORY) return try { - if (directory.exists()) { - directory.listFiles()?.forEach { it.delete() } - } else if (!directory.mkdirs()) { + if (!directory.exists() && !directory.mkdirs()) { log.error("Could not create the snapshot directory at {}", directory) return null } @@ -71,6 +71,7 @@ object MetricsSnapshot { return null } } + deleteAllExcept(directory, file) file } catch (io: IOException) { log.error("Could not write the chart snapshot", io) @@ -78,6 +79,18 @@ object MetricsSnapshot { } } + /** Removes every other snapshot, leaving only the one just written. */ + private fun deleteAllExcept( + directory: File, + keep: File, + ) { + directory.listFiles()?.forEach { file -> + if (file != keep && !file.delete()) { + log.warn("Could not delete the stale chart snapshot at {}", file) + } + } + } + /** * A filename from [label] and the current time, with anything that is not safe in a filename * replaced. Chart titles are translated, so they can contain spaces and non-ASCII. diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt index 0dbd42cf79..70f8ee588a 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsCarouselRebindTest.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.ui import android.content.Context import android.view.LayoutInflater +import android.view.View import androidx.appcompat.view.ContextThemeWrapper import androidx.core.widget.ImageViewCompat import androidx.test.core.app.ApplicationProvider @@ -61,6 +62,14 @@ class MetricsCarouselRebindTest { private fun strip() = LayoutMemUsageBinding.inflate(LayoutInflater.from(context)) + /** The pager needs a size before a chart page can produce a bitmap to export. */ + private fun laidOut(binding: LayoutMemUsageBinding) { + val width = View.MeasureSpec.makeMeasureSpec(720, View.MeasureSpec.EXACTLY) + val height = View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY) + binding.root.measure(width, height) + binding.root.layout(0, 0, 720, 400) + } + @Test fun `the page survives a rebind`() { val controller = controller() @@ -92,6 +101,21 @@ class MetricsCarouselRebindTest { assertThat(floating.metricsTitle.text.toString()).isEqualTo(title) } + @Test + fun `a second snapshot is refused while the first is still being written`() { + val controller = controller() + val binding = strip() + controller.bind(binding) + laidOut(binding) + + // The camera button is not debounced, and each tap used to launch its own coroutine over + // the same scratch directory -- and, within the same second, the same filename, since the + // name is the chart label plus a whole-second timestamp. The first export could then hand + // another app a URI whose file the second had already replaced. + assertThat(controller.exportSnapshot()).isTrue() + assertThat(controller.exportSnapshot()).isFalse() + } + @Test fun `both arrows are tinted, whatever inflated them`() { val binding = strip() From 26e298d3ffbdc6e69fcc30789e7fb041e8666626 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 17:21:05 -0700 Subject: [PATCH 28/30] ADFA-5486: fix the axis the memory lines use, and make panning stick Six findings from CodeRabbit's re-review of this PR. Memory lines were scaled by the wrong axis. configure() disables axisLeft and this renderer ranges and formats only axisRight, but MPAndroidChart defaults a dataset to LEFT -- so the lines were scaled by an axis nobody had configured while the labels beside them came from another. The network renderer already set this; the memory one never did. This is the worst of the six, because the chart looks right while plotting against a scale the labels do not describe. Panning did not stick. onChartTranslate was a no-op, so only a pinch set userHasZoomed; a pan left the renderer ranging and annotating against the newest samples rather than the ones on screen, and showNewestWindow scrolled the chart back on the next tick -- once a second. Zoom and pan are both in this ticket's scope, and pan was effectively broken. updateInterval and listener are @Volatile on both watchers. The UI thread writes them and each watcher's own sampling thread reads them, the interval inside delay(). historyLock does not order that read. I had already fixed this on ADFA-5499, which is above this PR in the stack -- so the branch that introduces the watchers still had it, and a reviewer of this PR was looking at unsafe code. Fixed where the watchers live. MAX_ANNOTATIONS is derived rather than picked. At the slowest sampling rate the renderer asks for just over an hour of annotations and the throttle admits one every five seconds, so a busy hour could fill the window with more markers than a flat 256 held, and eviction cut into what was being drawn. Derived from utils-local values: reading MetricsChartRenderer.VISIBLE_SAMPLES would be an upward dependency, and as a const it also would not compile. Tests for the two gaps: MetricsViewModel.onCleared closing both watchers for good, reached through ViewModelStore.clear() since onCleared is protected, and which progress events are annotated. The latter needed the decision pulled out of onProgressEvent, which returns early without a live activity -- the same move as helpTagAt. Every one of these tests was checked against the bug it covers. Two needed rewriting to be worth anything: the pan test first asserted on the chart's viewport, which was identical either way because an unzoomed chart cannot pan and because sixty samples is exactly the window showNewestWindow declines to scroll. It now asserts on visibleSampleRange, which is what the flag actually decides. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../handlers/EditorBuildEventListener.kt | 19 +++- .../androidide/ui/MemoryUsageChartRenderer.kt | 6 ++ .../androidide/ui/MetricsChartRenderer.kt | 7 +- .../androidide/utils/MemoryUsageWatcher.kt | 8 ++ .../utils/MetricsAnnotationStore.kt | 23 ++++- .../androidide/utils/NetworkUsageWatcher.kt | 4 + .../EditorBuildEventListenerAnnotationTest.kt | 90 +++++++++++++++++++ .../ui/MemoryUsageChartRendererTest.kt | 15 ++++ .../androidide/ui/MetricsChartAxisTapTest.kt | 52 ++++++++++- .../viewmodel/MetricsViewModelTest.kt | 75 ++++++++++++++++ 10 files changed, 290 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt index 53d78988d5..03671a8e12 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/EditorBuildEventListener.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.handlers import android.os.SystemClock +import androidx.annotation.VisibleForTesting import com.itsaky.androidide.R import com.itsaky.androidide.activities.editor.EditorHandlerActivity import com.itsaky.androidide.preferences.internal.GeneralPreferences @@ -147,14 +148,24 @@ class EditorBuildEventListener : GradleBuildService.EventListener { act.setStatus(event.descriptor.displayName) } - // Annotate the metrics charts with task starts and stops (ADFA-5486). Gradle emits these - // far faster than a chart can show them -- dozens a second during configuration -- so the - // store throttles to one every five seconds and keeps the first of each quiet period. - if (event is TaskStartEvent || event is TaskFinishEvent) { + if (isAnnotated(event)) { act.recordMetricsAnnotation(event.descriptor.displayName) } } + /** + * Whether [event] is one the metrics charts annotate (ADFA-5486). + * + * Task starts and stops, and nothing else. Gradle emits these far faster than a chart can show + * them -- dozens a second during configuration -- so the store throttles to one every five + * seconds and keeps the first of each quiet period. + * + * Separated from [onProgressEvent] so the decision can be tested: that method needs a live + * activity before it reaches this point, and returns early without one. + */ + @VisibleForTesting + internal fun isAnnotated(event: ProgressEvent): Boolean = event is TaskStartEvent || event is TaskFinishEvent + override fun onBuildFailed(tasks: List) { val act = checkActivity("onBuildFailed") ?: return diff --git a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt index fd00fa02bc..7e9d48bac5 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MemoryUsageChartRenderer.kt @@ -21,6 +21,7 @@ import androidx.annotation.UiThread import androidx.collection.IntObjectMap import androidx.collection.MutableIntIntMap import com.github.mikephil.charting.components.AxisBase +import com.github.mikephil.charting.components.YAxis import com.github.mikephil.charting.data.Entry import com.github.mikephil.charting.data.LineData import com.github.mikephil.charting.data.LineDataSet @@ -92,6 +93,11 @@ class MemoryUsageChartRenderer( }, proc.pname, ).apply { + // The right axis is the one configure() leaves enabled and the one this + // renderer ranges and formats. MPAndroidChart defaults a dataset to LEFT, so + // without this the lines were scaled by an axis nobody had configured while + // the labels beside them came from another. + axisDependency = YAxis.AxisDependency.RIGHT color = lineColorFor(proc) setDrawIcons(false) setDrawCircles(false) diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index c6bda7453f..099b7a8061 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -293,7 +293,12 @@ abstract class MetricsChartRenderer( me: MotionEvent?, dX: Float, dY: Float, - ) = Unit + ) { + // A pan is the user driving the viewport just as much as a pinch is. Left unrecorded, + // showNewestWindow dragged them back to the newest samples on the next tick -- once a + // second -- so panning a zoomed chart appeared not to work at all. + userHasZoomed = true + } } /** diff --git a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt index 31ca11b78b..229a103e27 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MemoryUsageWatcher.kt @@ -63,7 +63,11 @@ class MemoryUsageWatcher * Milliseconds between samples. Changing it clears the history: the chart reads a sample's * age from its position, which assumes every sample is the same age apart, and a buffer * holding samples taken at two rates would silently misdate all the older ones (ADFA-5486). + * + * Volatile: written on the UI thread and read on the watcher's own sampling thread. + * Without it the reader can go on seeing a stale value indefinitely. */ + @Volatile var updateInterval: Long = MetricsSamplingRates.coerceToSafeRange(updateInterval) set(value) { val safe = MetricsSamplingRates.coerceToSafeRange(value) @@ -103,7 +107,11 @@ class MemoryUsageWatcher /** * The listener to be notified when the memory usage of a process changes. + * + * Volatile: written on the UI thread and read on the watcher's own sampling thread. + * Without it the reader can go on seeing a stale value indefinitely. */ + @Volatile var listener: MemoryUsageListener? = null companion object { diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt index 195c623471..c684ed0c81 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsAnnotationStore.kt @@ -98,9 +98,26 @@ class MetricsAnnotationStore( const val THROTTLE_INTERVAL_MS = 5_000L /** - * Enough to cover the deepest buffer at the slowest sampling rate, bounded so a long - * session cannot grow this without limit. + * Enough to cover the whole visible window at the slowest sampling rate. + * + * Derived rather than picked. The renderer asks for the annotations within + * `(VISIBLE_SAMPLES + 1) * interval`, which at [MetricsSamplingRates.MAX_INTERVAL_MS] is + * just over an hour, and the throttle admits one task marker every + * [THROTTLE_INTERVAL_MS] -- so a busy hour can fill the window with more markers than a + * flat 256 could hold, and eviction then dropped markers that still had samples on + * screen beside them. The bound still exists: a session cannot grow this without limit, + * it just no longer cuts into what is being drawn. */ - const val MAX_ANNOTATIONS = 256 + val MAX_ANNOTATIONS = + (VISIBLE_WINDOW_SAMPLES * MetricsSamplingRates.MAX_INTERVAL_MS / THROTTLE_INTERVAL_MS).toInt() + + /** + * How many samples a chart shows at once, plus the one the renderer allows for. + * + * Held here rather than read from MetricsChartRenderer.VISIBLE_SAMPLES: this class is in + * `utils` and the renderer is in `ui`, so reaching for it would be an upward dependency. + * If the renderer's window changes, this follows. + */ + private const val VISIBLE_WINDOW_SAMPLES = 61L } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index b7d5ef640f..4c74508b20 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -85,7 +85,11 @@ class NetworkUsageWatcher /** * Milliseconds between samples. Changing it clears the history, for the reason given on * [MemoryUsageWatcher.updateInterval]. + * + * Volatile: written on the UI thread and read on the watcher's own sampling thread. + * Without it the reader can go on seeing a stale value indefinitely. */ + @Volatile var updateInterval: Long = MetricsSamplingRates.coerceToSafeRange(updateInterval) set(value) { val safe = MetricsSamplingRates.coerceToSafeRange(value) diff --git a/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt new file mode 100644 index 0000000000..8eb6db3252 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/handlers/EditorBuildEventListenerAnnotationTest.kt @@ -0,0 +1,90 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.handlers + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.tooling.events.ProgressEvent +import com.itsaky.androidide.tooling.events.internal.DefaultOperationDescriptor +import com.itsaky.androidide.tooling.events.internal.DefaultProgressEvent +import com.itsaky.androidide.tooling.events.task.TaskFailureResult +import com.itsaky.androidide.tooling.events.task.TaskFinishEvent +import com.itsaky.androidide.tooling.events.task.TaskOperationDescriptor +import com.itsaky.androidide.tooling.events.task.TaskStartEvent +import com.itsaky.androidide.tooling.model.PluginIdentifier +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Which Gradle progress events the metrics charts annotate (ADFA-5486). + * + * Task starts and stops, and nothing else. Asserted against the predicate rather than through + * `onProgressEvent`, which needs a live activity before it gets this far. + */ +@RunWith(RobolectricTestRunner::class) +class EditorBuildEventListenerAnnotationTest { + private val listener = EditorBuildEventListener() + + private fun taskDescriptor() = + TaskOperationDescriptor( + dependencies = emptySet(), + originPlugin = PluginIdentifier("org.gradle"), + taskPath = ":app:compileKotlin", + name = "compileKotlin", + displayName = "Task :app:compileKotlin", + ) + + private fun taskStart(): ProgressEvent = + TaskStartEvent( + displayName = "Task :app:compileKotlin", + eventTime = 0L, + descriptor = taskDescriptor(), + ) + + private fun taskFinish(): ProgressEvent = + TaskFinishEvent( + displayName = "Task :app:compileKotlin", + eventTime = 0L, + descriptor = taskDescriptor(), + result = TaskFailureResult(startTime = 0L, endTime = 1L), + ) + + private fun plainEvent(): ProgressEvent = + DefaultProgressEvent( + displayName = "Configure project :app", + eventTime = 0L, + descriptor = DefaultOperationDescriptor(name = "configure", displayName = "Configure"), + ) + + @Test + fun `a task starting is annotated`() { + assertThat(listener.isAnnotated(taskStart())).isTrue() + } + + @Test + fun `a task finishing is annotated`() { + assertThat(listener.isAnnotated(taskFinish())).isTrue() + } + + @Test + fun `an unrelated progress event is not annotated`() { + // Gradle emits far more than task events. Annotating everything would bury the markers + // that matter under configuration noise. + assertThat(listener.isAnnotated(plainEvent())).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt index 2c7b519e3a..e6feae8618 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MemoryUsageChartRendererTest.kt @@ -23,6 +23,7 @@ import android.graphics.Color import android.view.View import androidx.collection.MutableIntObjectMap import androidx.test.core.app.ApplicationProvider +import com.github.mikephil.charting.components.YAxis import com.github.mikephil.charting.data.LineDataSet import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.utils.MemoryUsageWatcher @@ -89,6 +90,20 @@ class MemoryUsageChartRendererTest { index: Int, ) = chart.data.getDataSetByIndex(index) as LineDataSet + @Test + fun `every memory line is scaled by the axis that labels it`() { + val chart = laidOutChart(LongArray(SAMPLE_COUNT) { 100L * BYTES_PER_MB }) + + // configure() disables axisLeft and this renderer ranges and formats only axisRight, but + // MPAndroidChart defaults a dataset to LEFT -- so the lines were scaled by an axis nobody + // had configured while the labels beside them came from another. + val datasets = (0 until chart.data.dataSetCount).map { chart.data.getDataSetByIndex(it) } + assertThat(datasets).isNotEmpty() + for (dataset in datasets) { + assertThat(dataset.axisDependency).isEqualTo(YAxis.AxisDependency.RIGHT) + } + } + @Test fun `attach renders the complete existing history, not a flat line`() { val processes = arrayOf(proc(pid = 1, pname = "IDE", firstMegabytes = 100)) diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt index 7e6a9a7a82..9944eec6ad 100644 --- a/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsChartAxisTapTest.kt @@ -18,6 +18,8 @@ package com.itsaky.androidide.ui import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas import android.view.MotionEvent import android.view.View import androidx.test.core.app.ApplicationProvider @@ -42,6 +44,9 @@ class MetricsChartAxisTapTest { private var taps = 0 + /** Set by [laidOutChart], for the tests that need to ask the renderer something. */ + private lateinit var attachedRenderer: NetworkUsageChartRenderer + private fun laidOutChart(): SafeLineChart { val chart = SafeLineChart(context) // Any concrete renderer will do -- the tap band is decided by the base class, and every @@ -57,6 +62,7 @@ class MetricsChartAxisTapTest { ) renderer.attach(chart) renderer.onXAxisTap = { taps++ } + attachedRenderer = renderer // Without a layout pass the plot area has no extent, so every coordinate is on its edge. chart.measure( @@ -76,6 +82,39 @@ class MetricsChartAxisTapTest { event.recycle() } + @Test + fun `a panned viewport is what the renderer reads, not the newest window`() { + val chart = laidOutChart() + drawOnce(chart) + + // Zoom first: an unzoomed chart shows everything, so there is nothing a pan could move. + chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) + chart.moveViewToX(0f) + drawOnce(chart) + assertThat(chart.lowestVisibleX).isLessThan(10f) + assertThat(chart.highestVisibleX).isLessThan(SAMPLES / 2f) + + // Until the user drives the viewport, the renderer says what showNewestWindow put there + // rather than asking the chart -- so it reports the newest samples even though the chart + // is showing the oldest. + assertThat(attachedRenderer.visibleSampleRange(chart, SAMPLES).last).isEqualTo(SAMPLES - 1) + + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, 10f, 0) + chart.onChartGestureListener.onChartTranslate(event, -50f, 0f) + event.recycle() + + // A pan is the user driving the viewport just as much as a pinch. Only a pinch used to + // count, so a pan left the renderer ranging and annotating against the wrong samples -- + // and showNewestWindow scrolled the chart back on the next tick. + assertThat(attachedRenderer.visibleSampleRange(chart, SAMPLES).last) + .isLessThan(SAMPLES - 1) + } + + /** MPAndroidChart runs its viewport jobs during a draw, so a pan is not real until one. */ + private fun drawOnce(chart: SafeLineChart) { + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + } + @Test fun `the plot area has room for a tap to fall inside or outside it`() { val chart = laidOutChart() @@ -117,6 +156,17 @@ class MetricsChartAxisTapTest { private companion object { const val WIDTH = 720 const val HEIGHT = 400 - const val SAMPLES = 60 + + /** + * Longer than the chart's visible window. + * + * It was exactly the window, and showNewestWindow returns early when the newest index is + * below it -- so the pan test could not tell the fix from the bug, because nothing was + * scrolling the viewport either way. + */ + const val SAMPLES = 200 + + /** The renderer's own visible window, which is what it scrolls to the newest samples. */ + const val VISIBLE_WINDOW = 60 } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt new file mode 100644 index 0000000000..a2eb18665d --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/MetricsViewModelTest.kt @@ -0,0 +1,75 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.viewmodel + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The terminal teardown of the metrics watchers (ADFA-5486). + * + * The watchers each own a dedicated sampling thread that `newSingleThreadContext` keeps alive + * until it is closed, so this is the one place that has to close rather than merely stop them. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsViewModelTest { + /** onCleared is protected, so it is reached the way the framework reaches it. */ + private fun cleared() = store.clear() + + private val store = ViewModelStore() + + private fun viewModel(): MetricsViewModel { + val provider = ViewModelProvider(store, ViewModelProvider.NewInstanceFactory()) + return provider[MetricsViewModel::class.java] + } + + @Test + fun `clearing the view model closes both watchers for good`() { + val model = viewModel() + model.memoryUsageWatcher.startWatching() + model.networkUsageWatcher.startWatching() + assertThat(model.memoryUsageWatcher.isWatching).isTrue() + + cleared() + + // close(), not stopWatching(): a closed watcher gives up its sampling thread and refuses + // to restart, which is what makes this the terminal teardown rather than a pause. + assertThat(model.memoryUsageWatcher.isWatching).isFalse() + assertThat(model.networkUsageWatcher.isWatching).isFalse() + + model.memoryUsageWatcher.startWatching() + model.networkUsageWatcher.startWatching() + assertThat(model.memoryUsageWatcher.isWatching).isFalse() + assertThat(model.networkUsageWatcher.isWatching).isFalse() + } + + @Test + fun `the watchers and the annotation store are the same instances across reads`() { + val model = viewModel() + + // The history lives here precisely so it survives an activity being recreated; handing + // back a new watcher per read would quietly defeat that. + assertThat(model.memoryUsageWatcher).isSameInstanceAs(model.memoryUsageWatcher) + assertThat(model.networkUsageWatcher).isSameInstanceAs(model.networkUsageWatcher) + assertThat(model.annotations).isSameInstanceAs(model.annotations) + } +} From 2d4453c87772238b9c8ad6357553a9415b152de0 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 6 Sep 2026 18:51:15 -0700 Subject: [PATCH 29/30] ADFA-5486: put the share flags on the chooser, and widen the marker span Four findings from CodeRabbit's re-review. Two are in fixes I made earlier today and called done. The share flags never reached the intent that was started. Intent.createChooser copies only the URI-grant flags outwards, and the chooser is what startActivity launches -- so the FLAG_ACTIVITY_NEW_TASK passed for a floating window went onto the inner send intent and nowhere useful, and the share still threw from a context with no task of its own. The test for this fails by throwing that exact exception without the fix. Annotations are now queried back to the oldest sample on screen rather than a fixed sixty-one samples from now. This is fallout from making panning stick earlier today: once the viewport can show older samples, their markers were dropped before their x was worked out -- invisible in the one view that was looking at them. I changed what "visible" means and did not sweep the other place that depends on it. Snapshots are pruned to the five most recent instead of one. The earlier fix made the concurrent case safe and left the deletion policy wrong: a share hands the recipient a FileProvider URI and the chooser returns long before the recipient opens it, so the next export pulled the image out from under an app that had not read it. The test asserting the old "only the newest is kept" behaviour is rewritten rather than deleted, since the contract deliberately changed. NetworkUsageWatcher records both deltas and updates both baselines in one synchronized block. Between the two it used, clearHistory() could null the baselines -- it runs on a sampling-rate change precisely so no delta straddles the change -- and the second block then restored the pre-reset values, so the next sample counted traffic from before the change. Not covered by a test: that race, which needs an injected hook between the two blocks to provoke; and the mirror of the chooser case, since Robolectric routes Activity.startActivity through ContextImpl and applies the no-task check regardless. Both are said so in the code rather than left as apparent gaps. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QeW3M24yD6HNhEnbuNW7Hz --- .../androidide/ui/MetricsChartRenderer.kt | 14 ++- .../itsaky/androidide/utils/IntentUtils.kt | 6 +- .../androidide/utils/MetricsSnapshot.kt | 42 +++++-- .../androidide/utils/NetworkUsageWatcher.kt | 7 +- .../ui/MetricsAnnotationSpanTest.kt | 117 ++++++++++++++++++ .../androidide/utils/IntentUtilsShareTest.kt | 76 ++++++++++++ .../androidide/utils/MetricsSnapshotTest.kt | 40 ++++-- 7 files changed, 274 insertions(+), 28 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/utils/IntentUtilsShareTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt index 099b7a8061..1d2cbdf79a 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/MetricsChartRenderer.kt @@ -364,14 +364,18 @@ abstract class MetricsChartRenderer( chart.xAxis.removeAllLimitLines() val interval = sampleIntervalMillis() - // The visible window, not the whole buffer. Spanning the buffer meant asking for every - // annotation the store holds -- up to MAX_ANNOTATIONS -- and building a LimitLine and a - // DashPathEffect for each one on every redraw, almost all of them clipped off screen. - val bufferSpanMillis = (VISIBLE_SAMPLES.toLong() + 1L) * interval + // Back as far as the oldest sample on screen, and no further. Spanning the whole buffer + // meant building a LimitLine and a DashPathEffect for every annotation the store holds on + // every redraw, almost all of them clipped off screen; spanning a fixed sixty-one samples + // from now was wrong in the other direction, because a panned viewport shows older + // samples than that and their markers were dropped before their x was worked out. + val visible = visibleSampleRange(chart, newestIndex.toInt() + 1) + val oldestVisibleIndex = if (visible.isEmpty()) newestIndex else visible.first.toFloat() + val spanMillis = ((newestIndex - oldestVisibleIndex).toLong() + 1L) * interval val now = nowMillis() val markerColor = chart.context.resolveAttr(R.attr.colorOnSurface) - store.recentAnnotations(bufferSpanMillis).forEach { annotation -> + store.recentAnnotations(spanMillis).forEach { annotation -> val samplesAgo = (now - annotation.atMillis).toFloat() / interval val x = newestIndex - samplesAgo if (x < 0f) { diff --git a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt index 5d16b59858..ce619dffc3 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt @@ -103,7 +103,11 @@ object IntentUtils { .setDataAndType(uri, mimeType) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or extraFlags) - context.startActivity(Intent.createChooser(intent, null)) + // extraFlags on the chooser as well as on the intent it wraps. createChooser copies only + // the URI-grant flags outwards, and the chooser is what startActivity launches -- so a + // FLAG_ACTIVITY_NEW_TASK passed for a window context never reached the intent that needed + // it, and the share threw from a context with no task of its own. + context.startActivity(Intent.createChooser(intent, null).addFlags(extraFlags)) } /** diff --git a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt index e61e6a600d..8f1f07d0b5 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/MetricsSnapshot.kt @@ -19,6 +19,7 @@ package com.itsaky.androidide.utils import android.content.Context import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting import org.slf4j.LoggerFactory import java.io.File import java.io.IOException @@ -37,6 +38,15 @@ object MetricsSnapshot { private const val DIRECTORY = "metrics-snapshots" private const val QUALITY = 100 + + /** + * How many snapshots to keep. + * + * Enough that a share still has its file when the recipient gets round to reading it, few + * enough that a long session cannot fill the cache. These are a few hundred kilobytes each. + */ + @VisibleForTesting + internal const val KEEP_RECENT = 5 private const val TIMESTAMP_PATTERN = "yyyyMMdd-HHmmss" /** Media type for the written file, for the sharing intent. */ @@ -45,10 +55,11 @@ object MetricsSnapshot { /** * Writes [bitmap] as a PNG named after [label] and the current time. * - * Old snapshots are cleared afterwards, not first: this is a scratch directory for handing one - * image to another app, not a gallery, and an IDE session could otherwise leave a pile of them - * behind. Clearing first meant a second export could delete the file a first was still about - * to hand over, so the receiving app was given a URI with nothing behind it. + * A few recent snapshots are kept rather than only the newest. This is a scratch directory for + * handing an image to another app, not a gallery, so it stays bounded -- but a share hands the + * recipient a FileProvider URI and the chooser returns long before the recipient opens it. + * Deleting the previous file on the next export therefore pulled an image out from under an + * app that had not read it yet. [KEEP_RECENT] is the slack that buys. * * @return the file, or `null` if it could not be written. */ @@ -71,7 +82,7 @@ object MetricsSnapshot { return null } } - deleteAllExcept(directory, file) + pruneTo(directory, KEEP_RECENT, file) file } catch (io: IOException) { log.error("Could not write the chart snapshot", io) @@ -79,13 +90,24 @@ object MetricsSnapshot { } } - /** Removes every other snapshot, leaving only the one just written. */ - private fun deleteAllExcept( + /** + * Trims [directory] to the [limit] most recent snapshots, always keeping [newest]. + * + * Oldest first, by last-modified. The file just written is protected explicitly rather than + * trusted to sort newest: two exports in the same second share a timestamp, and the filename + * carries only whole seconds. + */ + private fun pruneTo( directory: File, - keep: File, + limit: Int, + newest: File, ) { - directory.listFiles()?.forEach { file -> - if (file != keep && !file.delete()) { + val files = directory.listFiles()?.sortedBy { it.lastModified() } ?: return + if (files.size <= limit) { + return + } + files.take(files.size - limit).forEach { file -> + if (file != newest && !file.delete()) { log.warn("Could not delete the stale chart snapshot at {}", file) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt index 4c74508b20..10370b00fe 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/NetworkUsageWatcher.kt @@ -260,12 +260,13 @@ class NetworkUsageWatcher return } + // One block, not two. Between them clearHistory() could null the baselines -- it runs + // when the sampling rate changes, precisely so that no delta straddles the change -- + // and the second block then put the pre-reset values straight back, so the next + // sample counted traffic from before the change. synchronized(historyLock) { record(received, previous = lastRx, current = rx) record(transmitted, previous = lastTx, current = tx) - } - - synchronized(historyLock) { lastRx = rx lastTx = tx } diff --git a/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt new file mode 100644 index 0000000000..16c953db28 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/ui/MetricsAnnotationSpanTest.kt @@ -0,0 +1,117 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.ui + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.MotionEvent +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MetricsAnnotationStore +import com.itsaky.androidide.utils.NetworkUsageWatcher +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * How far back the renderer asks the annotation store to look (ADFA-5486). + * + * It asked for a fixed sixty-one samples' worth of time from now, which is right only while the + * viewport is following the newest samples. Once panning began to stick, a viewport showing older + * samples had its markers dropped before their x was worked out -- invisible in the one view that + * was looking at them. + */ +@RunWith(RobolectricTestRunner::class) +class MetricsAnnotationSpanTest { + private val context = ApplicationProvider.getApplicationContext() + + private var now = 1_000_000L + + private val store = MetricsAnnotationStore(nowMillis = { now }) + + private fun chartWithAnnotations(): Pair { + val chart = SafeLineChart(context) + val renderer = + NetworkUsageChartRenderer( + usageProvider = { + NetworkUsageWatcher.NetworkUsage( + LongArray(SAMPLES) { 1_000L }, + LongArray(SAMPLES) { 500L }, + ) + }, + annotations = store, + sampleInterval = { INTERVAL_MS }, + ) + renderer.attach(chart) + chart.measure( + View.MeasureSpec.makeMeasureSpec(WIDTH, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(HEIGHT, View.MeasureSpec.EXACTLY), + ) + chart.layout(0, 0, WIDTH, HEIGHT) + draw(chart) + return renderer to chart + } + + private fun draw(chart: SafeLineChart) { + chart.draw(Canvas(Bitmap.createBitmap(WIDTH, HEIGHT, Bitmap.Config.ARGB_8888))) + } + + @Test + fun `a marker outside the newest window is drawn once the viewport is panned to it`() { + // One annotation, then enough elapsed time to push it far outside the newest 61 samples. + store.record("an old task") + now += INTERVAL_MS * 200L + + val (renderer, chart) = chartWithAnnotations() + val whileFollowing = chart.xAxis.limitLines.size + + // Pan back to where that marker lives, and record that the user drove the viewport. + chart.setVisibleXRangeMaximum(VISIBLE_WINDOW.toFloat()) + chart.moveViewToX(0f) + draw(chart) + val event = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 10f, 10f, 0) + chart.onChartGestureListener.onChartTranslate(event, -50f, 0f) + event.recycle() + + renderer.rebuild() + + assertThat(whileFollowing).isEqualTo(0) + assertThat(chart.xAxis.limitLines.size).isEqualTo(1) + } + + @Test + fun `following the newest samples still asks for only the visible window`() { + // The other half: the span must not quietly become the whole buffer, which would build a + // LimitLine and a DashPathEffect per stored annotation on every redraw. + store.record("a recent task") + + val (_, chart) = chartWithAnnotations() + + assertThat(chart.xAxis.limitLines.size).isEqualTo(1) + } + + private companion object { + const val WIDTH = 720 + const val HEIGHT = 400 + const val SAMPLES = 400 + const val VISIBLE_WINDOW = 60 + const val INTERVAL_MS = 1_000L + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/IntentUtilsShareTest.kt b/app/src/test/java/com/itsaky/androidide/utils/IntentUtilsShareTest.kt new file mode 100644 index 0000000000..7df352cfa5 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/utils/IntentUtilsShareTest.kt @@ -0,0 +1,76 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +import android.app.Application +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.io.File + +/** + * Which flags reach the intent that is actually started (ADFA-5486). + * + * The metrics carousel shares a chart image, and while it is floating it does so from a window + * context with no task of its own -- where startActivity needs FLAG_ACTIVITY_NEW_TASK. The flag + * was added to the send intent, but `Intent.createChooser` copies only the URI-grant flags + * outwards and the chooser is what gets started, so the flag never reached the intent that needed + * it and the share threw. + * + * The mirror case -- that a share from an activity is left alone, with no NEW_TASK added -- is not + * covered here. Robolectric routes Activity.startActivity down to ContextImpl, which applies the + * "outside of an Activity context" check regardless, so the assertion would fail for reasons that + * have nothing to do with this code. + */ +@RunWith(RobolectricTestRunner::class) +class IntentUtilsShareTest { + private val context = ApplicationProvider.getApplicationContext() + + private fun file(): File = + File(context.cacheDir, "chart.png").apply { + parentFile?.mkdirs() + writeBytes(byteArrayOf(1, 2, 3)) + } + + private fun lastStarted(): Intent? = shadowOf(context as Application).nextStartedActivity + + @Test + fun `the started chooser carries the extra flags it was given`() { + IntentUtils.shareFile(context, file(), "image/png", Intent.FLAG_ACTIVITY_NEW_TASK) + + val started = lastStarted() + assertThat(started).isNotNull() + assertThat(started!!.flags and Intent.FLAG_ACTIVITY_NEW_TASK).isNotEqualTo(0) + } + + @Test + fun `the wrapped send intent still grants read access to the image`() { + IntentUtils.shareFile(context, file(), "image/png", Intent.FLAG_ACTIVITY_NEW_TASK) + + @Suppress("DEPRECATION") + val inner = lastStarted()!!.getParcelableExtra(Intent.EXTRA_INTENT) + assertThat(inner).isNotNull() + assertThat(inner!!.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION).isNotEqualTo(0) + assertThat(inner.type).isEqualTo("image/png") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt index ef9639f700..53ca31b780 100644 --- a/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt +++ b/app/src/test/java/com/itsaky/androidide/utils/MetricsSnapshotTest.kt @@ -73,14 +73,36 @@ class MetricsSnapshotTest { } @Test - fun `only the newest snapshot is kept`() { - val first = MetricsSnapshot.write(context, bitmap(), "Memory usage") - val second = MetricsSnapshot.write(context, bitmap(), "Network traffic") - - assertThat(second).isNotNull() - // This is a scratch directory for handing one image to another app, not a gallery. - val directory = File(context.cacheDir, "metrics-snapshots") - assertThat(directory.listFiles()!!.map { it.name }).containsExactly(second!!.name) - assertThat(first!!.exists()).isFalse() + fun `a shared snapshot survives the next few exports`() { + val shared = MetricsSnapshot.write(context, bitmap(), "Memory usage")!! + + // A share hands the recipient a FileProvider URI and the chooser returns long before the + // recipient opens it. Deleting the previous file on the next export pulled the image out + // from under an app that had not read it yet. + repeat(3) { index -> MetricsSnapshot.write(context, bitmap(), "Chart $index") } + + assertThat(shared.exists()).isTrue() + } + + @Test + fun `the directory stays bounded across many exports`() { + repeat(20) { index -> MetricsSnapshot.write(context, bitmap(), "Chart $index") } + + // Bounded, not unbounded: this is a scratch directory, not a gallery. + val directory = MetricsSnapshot.write(context, bitmap(), "Last")!!.parentFile!! + assertThat(directory.listFiles()!!.size).isAtMost(MetricsSnapshot.KEEP_RECENT) + } + + @Test + fun `the newest snapshot is the one handed back, and it is on disk`() { + MetricsSnapshot.write(context, bitmap(), "Memory usage") + val newest = MetricsSnapshot.write(context, bitmap(), "Network traffic") + + // This used to assert that the previous file was gone. It is not, deliberately: a share + // can still be reading it. What has to hold is that the file returned exists and is in + // the scratch directory, which stays bounded -- see the two tests above. + assertThat(newest).isNotNull() + assertThat(newest!!.exists()).isTrue() + assertThat(newest.parentFile).isEqualTo(File(context.cacheDir, "metrics-snapshots")) } } From 7a17f2c9db670755811bed07594006852cdbebbf Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 7 Sep 2026 07:20:03 -0700 Subject: [PATCH 30/30] ADFA-5486: ask where the carousel is before binding one, and stop throwing over a colour Two review findings, both about this branch undoing something. setupMetricsCarousel bound the strip unconditionally. Only one carousel can be live at a time and the floating one outlives the activity, so an activity recreated while it was floating -- a night-mode or locale change, or leaving the editor and coming back -- bound a second one into the strip and left the floating one attached to a destroyed activity, frozen, with nothing in the strip to say it had gone anywhere. It now goes through setMetricsCarouselUndocked, the same call the undock request makes, so the strip shows the "tap to bring them back" message and tapping it re-docks onto this activity. getMemUsageLineColorFor threw for an unknown process name again. 5d00a796a replaced that with a grey fallback and said why: it is reached from the once-a-second sample listener and from RecyclerView's bind pass, so throwing takes the editor down from a timer callback or mid-layout. 7becd06ce removed it, 4c65554e5 put it back, and 567667773 on this branch removed it again. Restored, with that history in the KDoc so it is not rediscovered a fourth time, and a test that fails against the throw. The carousel half has no automated test: reaching the undocked state needs a two-finger tap, which adb input cannot inject and which no harness here can build a BaseEditorActivity to drive. It redirects to a path the redock flow already exercises. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017CCQUU7tBzZL61EmQhJP8j --- .../activities/editor/BaseEditorActivity.kt | 20 ++++++- .../editor/MemUsageLineColorTest.kt | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 584502f129..0287967d4d 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -456,6 +456,12 @@ abstract class BaseEditorActivity : * handed to [MetricsCarouselController], which is in turn handed to the floating window and * outlives an activity recreation. A pure function of the process name has no business * pinning an activity in memory, and this one is exactly that. + * + * An unrecognised name falls back rather than throwing. This is reached from the + * once-a-second sample listener and from RecyclerView's bind pass, so a name nobody added a + * colour for would take the editor down from a timer callback or mid-layout -- a crash for + * the sake of a line colour. 5d00a796a and 4c65554e5 each established that; this branch + * removed it again, so it is written down here rather than rediscovered a fourth time. */ @JvmStatic fun getMemUsageLineColorFor(proc: MemoryUsageWatcher.ProcessMemoryInfo): Int = @@ -463,7 +469,7 @@ abstract class BaseEditorActivity : PROC_IDE -> Color.BLUE PROC_GRADLE_TOOLING -> Color.RED PROC_GRADLE_DAEMON -> Color.GREEN - else -> throw IllegalArgumentException("Unknown process: $proc") + else -> Color.GRAY } protected val PROC_IDE = "IDE" @@ -1003,11 +1009,21 @@ abstract class BaseEditorActivity : } private fun setupMetricsCarousel() { - metricsCarousel.bind(binding.memUsageView) binding.memUsageView.root.onTwoFingerTap = ::onMetricsCarouselUndockRequested binding.memUsageView.metricsUndockedMessage.setOnClickListener { onMetricsCarouselRedockRequested() } + + // Ask where the carousel is before binding one here. Only one can be live at a time, and + // the floating one outlives this activity -- so an activity recreated while it is floating + // (a night-mode or locale change, or leaving the editor and coming back) used to bind a + // second carousel into the strip and leave the floating one attached to a destroyed + // activity's views, frozen, with the strip showing no sign that it had gone anywhere. + // + // [setMetricsCarouselUndocked] is the same call the undock request makes, so the strip + // shows the "tap to bring them back" message and tapping it re-docks onto *this* + // activity's controller. + setMetricsCarouselUndocked(isMetricsCarouselUndocked()) } /** diff --git a/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt new file mode 100644 index 0000000000..d82bb0b9aa --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/activities/editor/MemUsageLineColorTest.kt @@ -0,0 +1,58 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.activities.editor + +import android.graphics.Color +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.utils.MemoryUsageWatcher +import com.itsaky.androidide.utils.MutableShiftedLongArray +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * That an unnamed process costs a line colour rather than the editor. + * + * This fallback has been established twice and removed twice. It is reached from the once-a-second + * sample listener and from RecyclerView's bind pass, so throwing here takes the editor down from a + * timer callback or mid-layout -- for the sake of a colour. + */ +@RunWith(RobolectricTestRunner::class) +class MemUsageLineColorTest { + private fun process(name: String) = + MemoryUsageWatcher.ProcessMemoryInfo( + pid = 1234, + pname = name, + _history = MutableShiftedLongArray(4), + ) + + @Test + fun `the three watched processes keep their colours`() { + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("IDE"))).isEqualTo(Color.BLUE) + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("Gradle Tooling"))).isEqualTo(Color.RED) + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("Gradle Daemon"))).isEqualTo(Color.GREEN) + } + + @Test + fun `a process nobody gave a colour gets one anyway`() { + // Not a throw. The names are only ever supplied by watchProcess call sites today, so this + // is a guard rather than a live path -- but the cost of being wrong is a crash from a + // timer callback, and the cost of the guard is one grey line. + assertThat(BaseEditorActivity.getMemUsageLineColorFor(process("Kotlin Daemon"))).isEqualTo(Color.GRAY) + } +}